Make Setup Command Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add a root make setup bootstrap flow for new git worktrees that installs dependencies, prepares local env files, starts infra, waits for PostgreSQL, runs backend migrations, and seeds demo data only when the shared local database has no organizations yet.

Architecture: Keep the root Makefile as a thin command surface and move all orchestration into one Bash entrypoint at scripts/setup.sh. The script should use Docker health checks plus a PostgreSQL sentinel query against organizations so rerunning setup in another worktree remains fail-fast and idempotent.

Tech Stack: GNU Make, Bash, Bun workspaces, Docker Compose v2, PostgreSQL/TimescaleDB, Biome, Markdown docs.


File Structure (planned changes)

Create

  • scripts/setup.sh - single owner for worktree bootstrap orchestration and operator messaging.

Modify

  • Makefile - expose setup as a root command and delegate to scripts/setup.sh.
  • README.md - make make setup the primary onboarding path and document the manual fallback.

Validate / reference while implementing

  • docker-compose.infra.yml - source of truth for service names, container names, and health checks.
  • package.json - root migrate and seed:demo scripts that scripts/setup.sh should call.
  • backend/.env.example
  • frontend/.env.example
  • backend/src/database/migrations.ts - confirms the backend reads DATABASE_* env vars used by local setup.
  • backend/src/database/seeds/run-seed.ts
  • backend/src/database/seeds/demo-data.seed.ts - confirms the demo seed creates organizations, making SELECT COUNT(*) FROM organizations the correct empty-database sentinel.

Task 1: Add the root setup command and first-pass bootstrap script

Files:

  • Create: scripts/setup.sh

  • Modify: Makefile

  • Test: command-level verification with make setup

  • Step 1: Confirm the root command does not exist yet

Run: make setup
Expected: FAIL with make: *** No rule to make target 'setup'. Stop.

  • Step 2: Add setup to the root Makefile and keep it thin
.PHONY: setup lint build build-containers check release dev dev\:infra dev\:stop test backend\:test frontend\:test
.ONESHELL:

setup:
 bash ./scripts/setup.sh

lint:
 bun run backend:lint
 bun run frontend:lint

build:
 bun run backend:build
 bun run frontend:build

build-containers:
 bash ./build.sh

check: lint build
 bun run check

release: check
 bash ./release.sh

dev\:infra:
 docker compose -f docker-compose.infra.yml up -d

dev\:stop:
 docker compose -f docker-compose.infra.yml down

dev: dev\:infra
 bun run backend:start:dev & BE_PID=$$!; \
 bun run frontend:start:dev & FE_PID=$$!; \
 trap "kill $$BE_PID $$FE_PID 2>/dev/null" INT TERM EXIT; \
 wait

backend\:test:
 cd backend && if [ -n "$(FILE)" ]; then npx jest "$(FILE)"; else bun run test; fi

frontend\:test:
 cd frontend && if [ -n "$(FILE)" ]; then bun run test -- "$(FILE)"; else bun run test; fi

test:
 $(MAKE) backend:test
 $(MAKE) frontend:test
  • Step 3: Create the first-pass scripts/setup.sh with Docker checks, env bootstrap, infra readiness, migrations, and unconditional seed
#!/usr/bin/env bash
set -euo pipefail

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"

COMPOSE_FILE="docker-compose.infra.yml"
POSTGRES_CONTAINER="hmpiot_postgres_dev"
REDIS_CONTAINER="hmpiot_redis_dev"
EMQX_CONTAINER="hmpiot_emqx_dev"
MINIO_CONTAINER="hmpiot_minio_dev"
WAIT_TIMEOUT_SECONDS=90
POLL_INTERVAL_SECONDS=3

BACKEND_ENV_STATUS="preserved"
FRONTEND_ENV_STATUS="preserved"
MIGRATION_STATUS="not-run"
SEED_STATUS="not-run"

log_stage() {
  printf '\n==> %s\n' "$1"
}

log_info() {
  printf '   %s\n' "$1"
}

fail() {
  printf 'ERROR: %s\n' "$1" >&2
  exit 1
}

check_docker_cli() {
  log_stage "Checking Docker CLI"
  command -v docker >/dev/null 2>&1 || fail "Docker CLI not found. Install Docker before running make setup."
  docker compose version >/dev/null 2>&1 || fail "Docker Compose v2 is not available from the docker CLI."
  log_info "Docker CLI is available."
}

check_docker_daemon() {
  log_stage "Checking Docker daemon"
  docker info >/dev/null 2>&1 || fail "Docker daemon is not available. Start Docker and rerun make setup."
  log_info "Docker daemon is available."
}

install_dependencies() {
  log_stage "Installing workspace dependencies"
  bun install
  log_info "Workspace dependencies installed."
}

ensure_env_files() {
  log_stage "Ensuring local environment files"

  if [ -f "backend/.env" ]; then
    BACKEND_ENV_STATUS="preserved"
    log_info "Preserved existing backend/.env"
  else
    cp "backend/.env.example" "backend/.env"
    BACKEND_ENV_STATUS="created"
    log_info "Created backend/.env from backend/.env.example"
  fi

  if [ -f "frontend/.env" ]; then
    FRONTEND_ENV_STATUS="preserved"
    log_info "Preserved existing frontend/.env"
  else
    cp "frontend/.env.example" "frontend/.env"
    FRONTEND_ENV_STATUS="created"
    log_info "Created frontend/.env from frontend/.env.example"
  fi
}

start_infra() {
  log_stage "Starting infrastructure"
  docker compose -f "$COMPOSE_FILE" up -d
  log_info "Infrastructure start command completed."
}

container_health() {
  docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$1" 2>/dev/null || true
}

wait_for_infra() {
  log_stage "Waiting for PostgreSQL readiness"

  local elapsed=0
  while [ "$elapsed" -lt "$WAIT_TIMEOUT_SECONDS" ]; do
    local postgres_status
    postgres_status="$(container_health "$POSTGRES_CONTAINER")"

    if [ "$postgres_status" = "healthy" ]; then
      log_info "PostgreSQL is healthy."
      return 0
    fi

    log_info "postgres status: ${postgres_status:-starting} (${elapsed}s/${WAIT_TIMEOUT_SECONDS}s)"
    sleep "$POLL_INTERVAL_SECONDS"
    elapsed=$((elapsed + POLL_INTERVAL_SECONDS))
  done

  fail "PostgreSQL did not become healthy within ${WAIT_TIMEOUT_SECONDS}s. Inspect with: docker compose -f ${COMPOSE_FILE} ps"
}

run_migrations() {
  log_stage "Running backend migrations"
  bun run migrate
  MIGRATION_STATUS="applied"
  log_info "Backend migrations applied."
}

seed_demo_data() {
  log_stage "Seeding demo data"
  bun run seed:demo
  SEED_STATUS="seeded"
  log_info "Demo seed completed."
}

print_success_message() {
  printf '\nSetup complete.\n'
  printf 'backend env: %s\n' "$BACKEND_ENV_STATUS"
  printf 'frontend env: %s\n' "$FRONTEND_ENV_STATUS"
  printf 'postgres: %s\n' "$(container_health "$POSTGRES_CONTAINER")"
  printf 'redis: %s\n' "$(container_health "$REDIS_CONTAINER")"
  printf 'emqx: %s\n' "$(container_health "$EMQX_CONTAINER")"
  printf 'minio: %s\n' "$(container_health "$MINIO_CONTAINER")"
  printf 'migrations: %s\n' "$MIGRATION_STATUS"
  printf 'demo data: %s\n' "$SEED_STATUS"
  printf 'next: make dev\n'
}

main() {
  check_docker_cli
  check_docker_daemon
  install_dependencies
  ensure_env_files
  start_infra
  wait_for_infra
  run_migrations
  seed_demo_data
  print_success_message
}

main "$@"
  • Step 4: Run the new command and verify the first-pass bootstrap succeeds

Run: make setup
Expected: PASS with stage output for Docker checks, bun install, env-file creation or preservation, docker compose -f docker-compose.infra.yml up -d, PostgreSQL readiness, bun run migrate, bun run seed:demo, and a final summary ending with next: make dev.

  • Step 5: Commit
git add Makefile scripts/setup.sh
git commit -m "feat: add make setup bootstrap command"

Task 2: Make demo seeding conditional on an empty local database

Files:

  • Modify: scripts/setup.sh

  • Test: command-level verification with make setup plus a direct PostgreSQL sentinel query

  • Step 1: Prove the first-pass script violates the shared-database contract

Run: docker compose -f docker-compose.infra.yml exec -T postgres psql -U hmpiot -d hmpiot -tAc "SELECT COUNT(*) FROM organizations;"
Expected: PASS with a non-zero count such as 1.

Run: make setup
Expected: PASS, but contract is still wrong because the script runs the Seeding demo data stage again instead of skipping when organizations already has rows.

  • Step 2: Replace unconditional seed logic with an organizations sentinel check
#!/usr/bin/env bash
set -euo pipefail

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"

COMPOSE_FILE="docker-compose.infra.yml"
POSTGRES_CONTAINER="hmpiot_postgres_dev"
REDIS_CONTAINER="hmpiot_redis_dev"
EMQX_CONTAINER="hmpiot_emqx_dev"
MINIO_CONTAINER="hmpiot_minio_dev"
POSTGRES_USER="hmpiot"
POSTGRES_DB="hmpiot"
WAIT_TIMEOUT_SECONDS=90
POLL_INTERVAL_SECONDS=3

BACKEND_ENV_STATUS="preserved"
FRONTEND_ENV_STATUS="preserved"
MIGRATION_STATUS="not-run"
SEED_STATUS="not-run"

log_stage() {
  printf '\n==> %s\n' "$1"
}

log_info() {
  printf '   %s\n' "$1"
}

fail() {
  printf 'ERROR: %s\n' "$1" >&2
  exit 1
}

check_docker_cli() {
  log_stage "Checking Docker CLI"
  command -v docker >/dev/null 2>&1 || fail "Docker CLI not found. Install Docker before running make setup."
  docker compose version >/dev/null 2>&1 || fail "Docker Compose v2 is not available from the docker CLI."
  log_info "Docker CLI is available."
}

check_docker_daemon() {
  log_stage "Checking Docker daemon"
  docker info >/dev/null 2>&1 || fail "Docker daemon is not available. Start Docker and rerun make setup."
  log_info "Docker daemon is available."
}

install_dependencies() {
  log_stage "Installing workspace dependencies"
  bun install
  log_info "Workspace dependencies installed."
}

ensure_env_files() {
  log_stage "Ensuring local environment files"

  if [ -f "backend/.env" ]; then
    BACKEND_ENV_STATUS="preserved"
    log_info "Preserved existing backend/.env"
  else
    cp "backend/.env.example" "backend/.env"
    BACKEND_ENV_STATUS="created"
    log_info "Created backend/.env from backend/.env.example"
  fi

  if [ -f "frontend/.env" ]; then
    FRONTEND_ENV_STATUS="preserved"
    log_info "Preserved existing frontend/.env"
  else
    cp "frontend/.env.example" "frontend/.env"
    FRONTEND_ENV_STATUS="created"
    log_info "Created frontend/.env from frontend/.env.example"
  fi
}

start_infra() {
  log_stage "Starting infrastructure"
  docker compose -f "$COMPOSE_FILE" up -d
  log_info "Infrastructure start command completed."
}

container_health() {
  docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$1" 2>/dev/null || true
}

wait_for_infra() {
  log_stage "Waiting for PostgreSQL readiness"

  local elapsed=0
  while [ "$elapsed" -lt "$WAIT_TIMEOUT_SECONDS" ]; do
    local postgres_status
    postgres_status="$(container_health "$POSTGRES_CONTAINER")"

    if [ "$postgres_status" = "healthy" ]; then
      log_info "PostgreSQL is healthy."
      return 0
    fi

    log_info "postgres status: ${postgres_status:-starting} (${elapsed}s/${WAIT_TIMEOUT_SECONDS}s)"
    sleep "$POLL_INTERVAL_SECONDS"
    elapsed=$((elapsed + POLL_INTERVAL_SECONDS))
  done

  fail "PostgreSQL did not become healthy within ${WAIT_TIMEOUT_SECONDS}s. Inspect with: docker compose -f ${COMPOSE_FILE} ps"
}

run_migrations() {
  log_stage "Running backend migrations"
  bun run migrate
  MIGRATION_STATUS="applied"
  log_info "Backend migrations applied."
}

query_postgres_scalar() {
  local sql="$1"
  docker compose -f "$COMPOSE_FILE" exec -T postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc "$sql"
}

get_organization_count() {
  local count
  count="$(query_postgres_scalar 'SELECT COUNT(*) FROM organizations;' | tr -d '[:space:]')" || fail "Unable to query organizations count from PostgreSQL."

  case "$count" in
    ''|*[!0-9]*)
      fail "Unexpected organizations count returned from PostgreSQL: ${count:-<empty>}"
      ;;
  esac

  printf '%s' "$count"
}

seed_demo_data_if_database_empty() {
  log_stage "Checking whether demo seed should run"

  local organization_count
  organization_count="$(get_organization_count)"

  if [ "$organization_count" = "0" ]; then
    SEED_STATUS="seeded"
    log_info "organizations table is empty; running demo seed."
    bun run seed:demo
    log_info "Demo seed completed."
    return 0
  fi

  SEED_STATUS="skipped"
  log_info "Detected existing organizations (${organization_count}); skipping demo seed."
}

print_success_message() {
  printf '\nSetup complete.\n'
  printf 'backend env: %s\n' "$BACKEND_ENV_STATUS"
  printf 'frontend env: %s\n' "$FRONTEND_ENV_STATUS"
  printf 'postgres: %s\n' "$(container_health "$POSTGRES_CONTAINER")"
  printf 'redis: %s\n' "$(container_health "$REDIS_CONTAINER")"
  printf 'emqx: %s\n' "$(container_health "$EMQX_CONTAINER")"
  printf 'minio: %s\n' "$(container_health "$MINIO_CONTAINER")"
  printf 'migrations: %s\n' "$MIGRATION_STATUS"
  printf 'demo data: %s\n' "$SEED_STATUS"
  printf 'next: make dev\n'
}

main() {
  check_docker_cli
  check_docker_daemon
  install_dependencies
  ensure_env_files
  start_infra
  wait_for_infra
  run_migrations
  seed_demo_data_if_database_empty
  print_success_message
}

main "$@"
  • Step 3: Re-run setup and verify the seed is skipped when the database is already initialized

Run: make setup
Expected: PASS. The output should include Checking whether demo seed should run, then Detected existing organizations (1); skipping demo seed. and the final summary should include demo data: skipped.

  • Step 4: Verify the sentinel query still reports the existing initialized database

Run: docker compose -f docker-compose.infra.yml exec -T postgres psql -U hmpiot -d hmpiot -tAc "SELECT COUNT(*) FROM organizations;"
Expected: PASS with the same non-zero organization count as before, confirming setup skipped seeding rather than resetting data.

  • Step 5: Commit
git add scripts/setup.sh
git commit -m "fix: skip demo seed when local database is initialized"

Task 3: Update onboarding docs and run repo verification

Files:

  • Modify: README.md

  • Test: README discoverability checks plus repo-level verification

  • Step 1: Confirm the current README still documents the old manual onboarding flow

Run: rg -n "make setup|bun install|cp backend/.env.example|bun run migrate" README.md
Expected: PASS with matches for bun install, env-file copy, and bun run migrate, but no match for make setup in the primary onboarding steps.

  • Step 2: Rewrite the getting-started section so make setup is the primary worktree bootstrap path
# HMP IoT Platform

Multi-tenant aquaculture (fish/shrimp farming) monitoring and management system for the Vietnamese market. Integrates IoT devices with business operations to track water quality, growth data, and manage farming cycles.

**Monorepo:** Bun workspaces | **Backend:** NestJS + PostgreSQL + Redis + MQTT | **Frontend:** React 19 + Vite + Ant Design

## Getting Started

### Bootstrap a New Worktree

```bash
make setup
make dev
```

`make setup` installs workspace dependencies, creates missing local env files from tracked examples, starts the local Docker infra stack, waits for PostgreSQL to become healthy, runs backend migrations, and seeds demo data only when the local database has no organizations yet.

If you rerun `make setup` from another worktree that points at the same local PostgreSQL instance, the command preserves existing env files and skips demo seeding once the shared database has already been initialized.

### Manual Fallback

```bash
bun install
cp backend/.env.example backend/.env
cp frontend/.env.example frontend/.env
docker compose -f docker-compose.infra.yml up -d
bun run migrate
bun run seed:demo
make dev
```

### Access

| Service | URL |
|---------|-----|
| Frontend | http://localhost:5173 |
| Backend API | http://localhost:3000/api/v1 |
| Swagger Docs | http://localhost:3000/api |
| EMQX Dashboard | http://localhost:18083 (admin/public) |
| MinIO Console | http://localhost:9001 (minioadmin/minioadmin) |

| Role | Email | Password |
|------|-------|----------|
| Super Admin | superadmin@hmpiot.com | Demo@123456 |
| Admin | admin@bentre-shrimp.vn | Demo@123456 |
| Owner | owner1@bentre-shrimp.vn | Demo@123456 |

### Useful Commands

```bash
docker compose -f docker-compose.infra.yml down      # Stop infrastructure
docker compose -f docker-compose.infra.yml logs -f   # View logs
```

---

## Make Commands

| Command | Description |
|---------|-------------|
| `make setup` | Install workspace dependencies, create missing local env files, start Docker infra, wait for PostgreSQL, run migrations, and seed demo data only when the local database has no organizations yet. |
| `make dev` | Start infra in Docker (detached) + run backend and frontend natively with `bun`. Ctrl+C stops the app processes. |
| `make dev:infra` | Start only the infrastructure containers (postgres, redis, emqx, minio) in detached mode. |
| `make dev:stop` | Stop and remove the infrastructure containers. |
| `make test` | Run backend and frontend test suites. |
| `make backend:test` | Run backend unit tests (`bun test`). |
| `make frontend:test` | Run frontend tests (`bun test`). |
| `make lint` | Run Biome lint check on backend and frontend. |
| `make build` | Build backend and frontend for production. |
| `make build-containers` | Build Docker images via `build.sh`. |
| `make check` | Run lint, build, and root Biome checks. |
| `make release` | Run full check then deploy via `release.sh`. |
  • Step 3: Verify the command is now discoverable in both docs and the command surface

Run: rg -n "make setup|setup:" README.md Makefile
Expected: PASS with hits in README.md and the root Makefile.

  • Step 4: Run repo-level verification required by this repo

Run: bun run check
Expected: PASS.

  • Step 5: Commit
git add README.md
git commit -m "docs: document make setup onboarding flow"