Files
multica/Makefile
Lambda e583c149ef fix(dev): make the local dev flow survive its own tooling
Three defects in the documented local flow, plus the bootstrap collapse
that keeps them from coming back.

1. `make daemon` produced a daemon that could not run any task. The chain
   ended in `go run`, and the daemon records its own executable path at
   startup to re-exec as the execution-environment helper for every task.
   `go run` deletes that temp binary when the launcher exits, so the
   daemon started, registered, heartbeat — then failed every task with
   "fork/exec .../go-build.../exe/multica: no such file or directory".
   `make daemon` now builds `server/bin/multica` and runs that (`ARGS`
   forwards any daemon subcommand), and `daemon start` refuses an
   ephemeral binary up front instead of deferring the failure.

2. `make stop` killed the daemon. `lsof -ti:PORT` matches every process
   holding a socket on the port, including CLIENTS — and the daemon holds
   a long-lived connection to the backend. Stop now goes through
   scripts/port-guard.sh, which filters to `-sTCP:LISTEN` and sends TERM
   before KILL. scripts/port-guard.test.sh pins both halves against a real
   listener and a real connected client.

3. Restarting over a running instance failed invisibly: the new process
   died on the bind, the old one kept serving, and /health answered 200
   throughout. `make dev` now preflights both ports and names the process
   that owns them, and /health reports `pid`, `started_at`, and `commit`
   so a caller can prove which process answered.

4. `make dev-bootstrap` collapses the ~12-step manual sequence into one
   command: env file, fixed verification code before first launch, DB via
   DATABASE_URL (not docker exec), port preflight, detached services,
   health verified against process identity, single-shot login, PAT,
   workspace, CLI profile, built daemon binary. It pins a durable TMPDIR
   so an agent-created environment outlives the run that created it.
   `make dev-bootstrap-stop` tears it down.

Measured on a clean checkout: 11.7s to a logged-in, daemon-attached
environment.

Co-authored-by: multica-agent <github@multica.ai>
2026-08-07 16:37:26 +08:00

331 lines
15 KiB
Makefile

.PHONY: help makehelp dev dev-bootstrap dev-bootstrap-stop server daemon cli multica multica-bin build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree db-up db-down db-reset selfhost selfhost-build selfhost-stop
MAIN_ENV_FILE ?= .env
WORKTREE_ENV_FILE ?= .env.worktree
ENV_FILE ?= $(if $(wildcard $(MAIN_ENV_FILE)),$(MAIN_ENV_FILE),$(if $(wildcard $(WORKTREE_ENV_FILE)),$(WORKTREE_ENV_FILE),$(MAIN_ENV_FILE)))
ifneq ($(wildcard $(ENV_FILE)),)
include $(ENV_FILE)
endif
POSTGRES_DB ?= multica
POSTGRES_USER ?= multica
POSTGRES_PASSWORD ?= multica
POSTGRES_PORT ?= 5432
PORT := $(or $(BACKEND_PORT),$(API_PORT),$(SERVER_PORT),$(PORT),8080)
ifeq ($(origin MULTICA_PUBLIC_URL), undefined)
MULTICA_PUBLIC_URL := http://localhost:$(PORT)
endif
FRONTEND_PORT ?= 3000
FRONTEND_ORIGIN ?= http://localhost:$(FRONTEND_PORT)
MULTICA_APP_URL ?= $(FRONTEND_ORIGIN)
DATABASE_URL ?= postgres://$(POSTGRES_USER):$(POSTGRES_PASSWORD)@localhost:$(POSTGRES_PORT)/$(POSTGRES_DB)?sslmode=disable
NEXT_PUBLIC_API_URL ?= http://localhost:$(PORT)
NEXT_PUBLIC_WS_URL ?= ws://localhost:$(PORT)/ws
GOOGLE_REDIRECT_URI ?= $(FRONTEND_ORIGIN)/auth/callback
MULTICA_SERVER_URL ?= ws://localhost:$(PORT)/ws
LOCAL_UPLOAD_BASE_URL ?= http://localhost:$(PORT)
export
MULTICA_ARGS ?= $(ARGS)
COMPOSE := docker compose
define REQUIRE_ENV
@if [ ! -f "$(ENV_FILE)" ]; then \
echo "Missing env file: $(ENV_FILE)"; \
echo "Create .env from .env.example, or run 'make worktree-env' and use .env.worktree."; \
exit 1; \
fi
endef
# Self-hosting requires the Docker Compose CLI plugin (`docker compose`).
# The self-host compose files use compose-spec syntax (top-level `name:`, no
# `version:`) that the legacy v1 `docker-compose` standalone cannot parse, so we
# fail early with an actionable message instead of a cryptic CLI parse error
# (e.g. "unknown shorthand flag: 'f' in -f") when the plugin is missing or v1.
# Keep the message short and OS-agnostic: per-OS install steps belong in docs.
define REQUIRE_COMPOSE
@if ! compose_version=$$($(COMPOSE) version --short 2>/dev/null); then \
echo "Docker Compose ('docker compose') was not found."; \
echo "Self-hosting requires the Compose CLI plugin; legacy 'docker-compose' v1 is not supported."; \
echo "Install Docker Compose from https://docs.docker.com/compose/install/ and verify with: docker compose version"; \
exit 1; \
fi; \
case "$$compose_version" in \
1.*|v1.*) \
echo "'$(COMPOSE)' is legacy Docker Compose v1 ($$compose_version)."; \
echo "Self-hosting requires the Compose CLI plugin; legacy 'docker-compose' v1 is not supported."; \
echo "Install Docker Compose from https://docs.docker.com/compose/install/ and verify with: docker compose version"; \
exit 1; \
;; \
esac
endef
# Default target changed from selfhost to help: bare `make` now prints this help
# instead of launching a full Docker Compose build, which is safer for onboarding.
.DEFAULT_GOAL := help
##@ Help
help: ## Show available make targets and common local workflows
@awk 'BEGIN {FS = ":.*## "; printf "\nUsage:\n make \033[36m<target>\033[0m\n\nQuick start:\n \033[36mmake dev-bootstrap\033[0m Clean checkout to a logged-in, daemon-attached environment\n \033[36mmake dev\033[0m Start backend + frontend only, in the foreground\n \033[36mmake check\033[0m Run the full local verification pipeline\n\nCheckout modes:\n Main checkout uses \033[36m.env\033[0m\n Worktrees use \033[36m.env.worktree\033[0m (generate with \033[36mmake worktree-env\033[0m)\n\n"} \
/^##@/ {printf "\n\033[1m%s\033[0m\n", substr($$0, 5); next} \
/^[a-zA-Z0-9_.-]+:.*## / {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
makehelp: help ## Alias for `make help`
# ---------- Self-hosting (Docker Compose) ----------
##@ Self-hosting
selfhost: ## Create .env if needed, then pull and start the official self-hosted images
$(REQUIRE_COMPOSE)
@if [ ! -f .env ]; then \
echo "==> Creating .env from .env.example..."; \
cp .env.example .env; \
JWT=$$(openssl rand -hex 32); \
PGPASS=$$(openssl rand -hex 24); \
VCSKEY=$$(openssl rand -base64 32); \
if [ "$$(uname)" = "Darwin" ]; then \
sed -i '' "s/^JWT_SECRET=.*/JWT_SECRET=$$JWT/" .env; \
sed -i '' "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$$PGPASS/" .env; \
sed -i '' -E "s#^(DATABASE_URL=postgres://[^:]+:)[^@]*(@.*)#\1$$PGPASS\2#" .env; \
sed -i '' "s#^MULTICA_VCS_SECRET_KEY=.*#MULTICA_VCS_SECRET_KEY=$$VCSKEY#" .env; \
else \
sed -i "s/^JWT_SECRET=.*/JWT_SECRET=$$JWT/" .env; \
sed -i "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$$PGPASS/" .env; \
sed -i -E "s#^(DATABASE_URL=postgres://[^:]+:)[^@]*(@.*)#\1$$PGPASS\2#" .env; \
sed -i "s#^MULTICA_VCS_SECRET_KEY=.*#MULTICA_VCS_SECRET_KEY=$$VCSKEY#" .env; \
fi; \
echo "==> Generated random JWT_SECRET, POSTGRES_PASSWORD, and MULTICA_VCS_SECRET_KEY"; \
fi
@echo "==> Pulling official Multica images..."
@if ! $(COMPOSE) -f docker-compose.selfhost.yml pull; then \
echo ""; \
echo "Official images for tag '$${MULTICA_IMAGE_TAG:-latest}' are not published yet."; \
echo "If this is before the first GHCR release, build from the current checkout:"; \
echo " make selfhost-build"; \
exit 1; \
fi
@echo "==> Starting Multica via Docker Compose..."
$(COMPOSE) -f docker-compose.selfhost.yml up -d
@bash scripts/selfhost-wait.sh official
selfhost-build: ## Build backend/web from the current checkout and start the self-hosted stack
$(REQUIRE_COMPOSE)
@if [ ! -f .env ]; then \
echo "==> Creating .env from .env.example..."; \
cp .env.example .env; \
JWT=$$(openssl rand -hex 32); \
PGPASS=$$(openssl rand -hex 24); \
VCSKEY=$$(openssl rand -base64 32); \
if [ "$$(uname)" = "Darwin" ]; then \
sed -i '' "s/^JWT_SECRET=.*/JWT_SECRET=$$JWT/" .env; \
sed -i '' "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$$PGPASS/" .env; \
sed -i '' -E "s#^(DATABASE_URL=postgres://[^:]+:)[^@]*(@.*)#\1$$PGPASS\2#" .env; \
sed -i '' "s#^MULTICA_VCS_SECRET_KEY=.*#MULTICA_VCS_SECRET_KEY=$$VCSKEY#" .env; \
else \
sed -i "s/^JWT_SECRET=.*/JWT_SECRET=$$JWT/" .env; \
sed -i "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$$PGPASS/" .env; \
sed -i -E "s#^(DATABASE_URL=postgres://[^:]+:)[^@]*(@.*)#\1$$PGPASS\2#" .env; \
sed -i "s#^MULTICA_VCS_SECRET_KEY=.*#MULTICA_VCS_SECRET_KEY=$$VCSKEY#" .env; \
fi; \
echo "==> Generated random JWT_SECRET, POSTGRES_PASSWORD, and MULTICA_VCS_SECRET_KEY"; \
fi
@echo "==> Building Multica from the current checkout..."
$(COMPOSE) -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build
@bash scripts/selfhost-wait.sh build
selfhost-stop: ## Stop the self-hosted Docker Compose stack
$(REQUIRE_COMPOSE)
@echo "==> Stopping Multica services..."
$(COMPOSE) -f docker-compose.selfhost.yml down
@echo "✓ All services stopped."
# ---------- One-click commands ----------
##@ One-click
setup: ## Prepare the current checkout from its env file: install deps, ensure DB, run migrations
$(REQUIRE_ENV)
@echo "==> Using env file: $(ENV_FILE)"
@echo "==> Installing dependencies..."
pnpm install
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
@echo "==> Running migrations..."
cd server && go run ./cmd/migrate up
@echo ""
@echo "✓ Setup complete! Run 'make start' to launch the app."
start: ## Start backend and frontend for the current checkout and run migrations first
$(REQUIRE_ENV)
@echo "Using env file: $(ENV_FILE)"
@echo "Backend: http://localhost:$(PORT)"
@echo "Frontend: http://localhost:$(FRONTEND_PORT)"
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
@echo "Running migrations..."
cd server && go run ./cmd/migrate up
@echo "Starting backend and frontend..."
@trap 'kill 0' EXIT; \
(cd server && go run -ldflags "-X main.commit=$(COMMIT)" ./cmd/server) & \
pnpm dev:web & \
wait
# Stops LISTENERS only, and asks politely first. A bare `lsof -ti:PORT` also
# matches every client connected to the port — the local daemon holds a
# long-lived connection to the backend — so `kill -9` on that list took the
# daemon down with the backend, silently. See scripts/port-guard.sh.
stop: ## Stop backend and frontend processes for the current checkout
$(REQUIRE_ENV)
@echo "Stopping services..."
@bash scripts/port-guard.sh stop $(PORT) backend
@bash scripts/port-guard.sh stop $(FRONTEND_PORT) frontend
@case "$(DATABASE_URL)" in \
""|*@localhost:*|*@localhost/*|*@127.0.0.1:*|*@127.0.0.1/*|*@\[::1\]:*|*@\[::1\]/*) \
echo "✓ App processes stopped. Shared PostgreSQL is still running on localhost:$(POSTGRES_PORT)." ;; \
*) \
echo "✓ App processes stopped. Remote PostgreSQL was not affected." ;; \
esac
check: ## Run typecheck, TS tests, Go tests, and Playwright E2E for the current checkout
$(REQUIRE_ENV)
@ENV_FILE="$(ENV_FILE)" bash scripts/check.sh
db-up: ## Start the shared PostgreSQL container used by main and worktrees
@$(COMPOSE) up -d postgres
db-down: ## Stop the shared PostgreSQL container without removing its Docker volume
@$(COMPOSE) down
# Drop + recreate the current env's database, then run all migrations.
# Use for a clean slate in local dev. Only affects the DB named in
# ENV_FILE (POSTGRES_DB); the shared postgres container and other
# worktree DBs are untouched. Refuses to run against a remote host.
db-reset: ## Drop and recreate the current env's database, then re-run all migrations
$(REQUIRE_ENV)
@case "$(DATABASE_URL)" in \
""|*@localhost:*|*@localhost/*|*@127.0.0.1:*|*@127.0.0.1/*|*@\[::1\]:*|*@\[::1\]/*) ;; \
*) echo "Refusing to reset: DATABASE_URL points at a remote host."; exit 1 ;; \
esac
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
@echo "==> Dropping and recreating database '$(POSTGRES_DB)'..."
@$(COMPOSE) exec -T postgres psql -U $(POSTGRES_USER) -d postgres -v ON_ERROR_STOP=1 \
-c "DROP DATABASE IF EXISTS \"$(POSTGRES_DB)\" WITH (FORCE);" \
-c "CREATE DATABASE \"$(POSTGRES_DB)\";"
@echo "==> Running migrations..."
cd server && go run ./cmd/migrate up
@echo ""
@echo "✓ Database '$(POSTGRES_DB)' reset. Run 'make start' to launch the app."
worktree-env: ## Generate .env.worktree with a unique DB name and app ports for this worktree
@bash scripts/init-worktree-env.sh .env.worktree
setup-main: ## Prepare the main checkout using .env
@$(MAKE) setup ENV_FILE=$(MAIN_ENV_FILE)
start-main: ## Start the main checkout using .env
@$(MAKE) start ENV_FILE=$(MAIN_ENV_FILE)
stop-main: ## Stop the main checkout processes defined by .env
@$(MAKE) stop ENV_FILE=$(MAIN_ENV_FILE)
check-main: ## Run the full verification pipeline for the main checkout
@ENV_FILE=$(MAIN_ENV_FILE) bash scripts/check.sh
setup-worktree: ## Ensure .env.worktree exists, then prepare this worktree
@if [ ! -f "$(WORKTREE_ENV_FILE)" ]; then \
echo "==> Generating $(WORKTREE_ENV_FILE) with unique ports..."; \
bash scripts/init-worktree-env.sh $(WORKTREE_ENV_FILE); \
else \
echo "==> Using existing $(WORKTREE_ENV_FILE)"; \
fi
@$(MAKE) setup ENV_FILE=$(WORKTREE_ENV_FILE)
start-worktree: ## Start this worktree using .env.worktree
@$(MAKE) start ENV_FILE=$(WORKTREE_ENV_FILE)
stop-worktree: ## Stop this worktree's backend and frontend processes
@$(MAKE) stop ENV_FILE=$(WORKTREE_ENV_FILE)
check-worktree: ## Run the full verification pipeline for this worktree
@ENV_FILE=$(WORKTREE_ENV_FILE) bash scripts/check.sh
# ---------- Individual commands ----------
##@ Individual commands
dev: ## Bootstrap this checkout end-to-end: create env if needed, ensure DB, migrate, start services
@bash scripts/dev.sh
dev-bootstrap: ## Take this checkout to a logged-in, daemon-attached environment in one command
@bash scripts/dev-bootstrap.sh
dev-bootstrap-stop: ## Stop everything `make dev-bootstrap` started (backend, frontend, daemon)
@bash scripts/dev-bootstrap.sh --stop
server: ## Run only the Go server for the current checkout
$(REQUIRE_ENV)
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
cd server && go run ./cmd/server
# The daemon must NOT run through `go run`. It records its own executable path
# at startup and re-execs it for every task, and `go run` deletes that temp
# binary as soon as the launcher exits — so a go-run daemon starts fine and then
# fails every task with "no such file or directory". `daemon start` now refuses
# such a binary outright; this target builds a real one first.
DAEMON_ARGS ?= $(if $(strip $(MULTICA_ARGS)),$(MULTICA_ARGS),restart --profile local)
daemon: multica-bin ## Run a daemon command from a built binary (ARGS, default: restart --profile local)
./server/bin/multica daemon $(DAEMON_ARGS)
multica-bin: ## Build only the multica CLI into server/bin/multica
cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE)" -o bin/multica ./cmd/multica
# One-shot CLI commands are fine through `go run` (~2.4s warm). Long-lived
# daemon processes are not — use `make daemon ARGS="..."` for those.
cli: ## Run the multica CLI with ARGS or MULTICA_ARGS from source
@$(MAKE) multica MULTICA_ARGS="$(MULTICA_ARGS)"
multica: ## Run the multica CLI entrypoint directly from the Go source tree
cd server && go run -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE)" ./cmd/multica $(MULTICA_ARGS)
VERSION ?= $(shell git describe --tags --match 'v[0-9]*' --always --dirty 2>/dev/null || echo dev)
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
DATE ?= $(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
build: multica-bin ## Build the server, CLI, and migrate binaries into server/bin
cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT)" -o bin/server ./cmd/server
cd server && go build -o bin/migrate ./cmd/migrate
test: ## Run Go tests after ensuring the target DB exists and migrations are applied
$(REQUIRE_ENV)
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
cd server && go run ./cmd/migrate up
bash scripts/test-go.sh --race
# Database
##@ Database
migrate-up: ## Create the target DB if needed, then apply database migrations
$(REQUIRE_ENV)
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
cd server && go run ./cmd/migrate up
migrate-down: ## Create the target DB if needed, then roll back database migrations
$(REQUIRE_ENV)
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
cd server && go run ./cmd/migrate down
sqlc: ## Regenerate sqlc code
cd server && sqlc generate
# Cleanup
##@ Cleanup
clean: ## Remove build caches, generated binaries, and temp files
rm -rf server/bin server/tmp
rm -rf apps/*/.next apps/*/.source apps/*/.expo
rm -rf apps/*/out apps/*/dist apps/*/dist-electron packages/*/dist
rm -rf .turbo apps/*/.turbo packages/*/.turbo
rm -rf apps/*/*.tsbuildinfo packages/*/*.tsbuildinfo
@echo "✓ Clean complete."