diff --git a/docs/src/hooks.md b/docs/src/hooks.md index e7aeb4a..8981e6b 100644 --- a/docs/src/hooks.md +++ b/docs/src/hooks.md @@ -4,9 +4,10 @@ Hooks run shell commands at lifecycle points. Define them in `.ecluse.toml`: ```toml [hooks] -pre_up = "echo starting" -post_up = "npx prisma migrate deploy" -pre_down = "npx prisma migrate reset --force" +pre_up = "echo starting" +pre_spawn = "envsubst < .env.template > .env.local" +post_up = "npx prisma migrate deploy" +pre_down = "npx prisma migrate reset --force" post_down = "echo done" ``` @@ -14,30 +15,52 @@ post_down = "echo done" ``` ecluse up - └─ pre_up → runs from repo root, no env vars yet - └─ [services start, worktree created, .env.ecluse written] - └─ post_up → runs from worktree root, full env available + └─ pre_up → repo root, NO env vars yet + └─ [ports allocated, docker services started, worktree created, + .env.ecluse written to worktree] + └─ pre_spawn → worktree root, FULL env — native services not yet started + └─ [native services spawn via tmux/nohup] + └─ post_up → worktree root, full env, all services running ecluse down - └─ pre_down → runs from worktree root, full env available (services still running) + └─ pre_down → worktree root, full env (services still running) └─ [services stopped, worktree removed] - └─ post_down → runs from repo root, env vars still available + └─ post_down → repo root, env vars still available ``` ## pre_up -Runs before any infrastructure is created. Working directory is the repo root. No `ECLUSE_*` variables are available yet. +Runs before any infrastructure is created. Working directory is the repo root. **No `ECLUSE_*` variables are available yet** — ports haven't been allocated, the worktree doesn't exist, no docker containers are up. + +Use it for: pre-flight checks that don't need slot info (`command -v pnpm`, disk-space checks, image pulls that should happen before slot reservation). + +## pre_spawn + +Runs after ports are allocated and `.env.ecluse` is written, but **before native services are spawned**. Working directory is the worktree root. All `ECLUSE_*` variables are available, docker data services are up. -Use it for: pre-flight checks, pulling images, anything that must happen before services start. +This is the hook for **slot-aware setup that must complete before your app boots**. Because it runs before native services start, anything you write to disk here will be present when the service reads it during startup — `post_up` is too late for that, since the service has already read its config. + +Use it for: +- Writing per-worktree `.env.local` or `.dev.vars` with slot-specific URLs (e.g. `API_URL=http://localhost:$ECLUSE_API_PORT`) that a framework reads once at boot +- Rewriting an env file to substitute slot-derived values in place +- Waiting for postgres to accept queries (the docker container may be started but not yet ready) +- Applying database migrations that must exist before app code runs +- Generating client code (`prisma generate`) that services import at boot +- Installing dependencies (`pnpm install`) before services try to resolve them +- Setting up symlinks / overlay files that services read at startup + +**Why not just use `post_up`?** Because a service that reads its config once at startup (Cloudflare vite plugin, most `dotenv` loaders, any framework using `sh -c 'export ... && ...'`) will see whatever the file contained before the hook ran — and then never re-read it. `post_up` fires after that point. ## post_up -Runs after all services are up and `.env.ecluse` is written. Working directory is the worktree root. All `ECLUSE_*` variables are available. +Runs after all services are up and running. Working directory is the worktree root. All `ECLUSE_*` variables are available. Use it for: -- Database migrations -- Seeding -- Any setup your app needs before it can run +- Post-boot actions that need running services (curl a health endpoint, warm a cache) +- Sync-only setup that doesn't affect service startup env (some migration workflows against an already-running DB) +- Notifications, dashboard updates + +**Prefer `pre_spawn`** when you're writing files the services will read at boot — `post_up` runs too late for that. ## pre_down @@ -46,6 +69,7 @@ Runs before services are killed or containers are stopped. Working directory is Use it for: - Draining connections - Resetting database state while the database is still running +- Recording final metrics before teardown ## post_down @@ -55,25 +79,89 @@ Use it for: cleanup that should happen after everything is gone (notifications, ## Environment -All hooks receive the session's env vars except `pre_up` (which runs before anything exists): - -| Hook | Working dir | Env vars | -|---|---|---| -| `pre_up` | repo root | none | -| `post_up` | worktree root | all `ECLUSE_*` + `PORT` | -| `pre_down` | worktree root | all `ECLUSE_*` + `PORT` | -| `post_down` | repo root | all `ECLUSE_*` + `PORT` | +| Hook | Working dir | Env vars | Services state | +|---|---|---|---| +| `pre_up` | repo root | none | nothing exists yet | +| `pre_spawn` | worktree root | all `ECLUSE_*` + `PORT` | docker up, native not started | +| `post_up` | worktree root | all `ECLUSE_*` + `PORT` | everything running | +| `pre_down` | worktree root | all `ECLUSE_*` + `PORT` | everything still running | +| `post_down` | repo root | all `ECLUSE_*` + `PORT` | everything torn down | ## Examples ### Prisma migrations +Migrations don't affect service startup env, so `post_up` is fine: + ```toml [hooks] post_up = "npx prisma migrate deploy" pre_down = "npx prisma migrate reset --force" ``` +### Injecting slot-specific URLs before service boot + +A frontend that reads `VITE_API_URL` at boot (Vite, Next.js, Cloudflare workers): the URL depends on the api service's allocated port, which only exists once ports are reserved. Write the file in `pre_spawn` so the frontend picks it up: + +```toml +[[services]] +name = "api" +base_port = 4444 +port_env = "ECLUSE_API_PORT" +command = "pnpm --filter api dev --port $ECLUSE_API_PORT" + +[[services]] +name = "web" +base_port = 3000 +port_env = "ECLUSE_WEB_PORT" +command = "pnpm --filter web dev --port $ECLUSE_WEB_PORT" + +[hooks] +pre_spawn = """ +cat > apps/web/.env.development.local </dev/null 2>&1; then break; fi + sleep 1 +done +npx prisma migrate deploy +npx prisma generate +""" +``` + +### Rewriting `.env.local` per worktree + +If `.env.local` holds slot-specific URLs (DB, Redis, API endpoints), the default `inherit_env` symlink would leak every `ecluse up`'s rewrite into every other worktree. Use `mode = "copy"` so each worktree has its own file, then rewrite it in `pre_spawn`: + +```toml +inherit_env = [ + ".env", + { file = ".env.local", mode = "copy" }, +] + +[hooks] +pre_spawn = """ +awk -v pgport="$ECLUSE_POSTGRES_PORT" ' + /^DATABASE_URL=/ { print "DATABASE_URL=postgres://app@localhost:" pgport "/app"; next } + { print } +' .env.local > .env.local.tmp && mv .env.local.tmp .env.local +""" +``` + ### Rails ```toml @@ -84,11 +172,35 @@ pre_down = "bundle exec rails db:drop" ### Multiple commands +Chain with `&&` (fail fast) or `;` (continue on error). For long blocks, use a TOML multi-line string: + ```toml [hooks] -post_up = "npx prisma migrate deploy && npx prisma db seed" +pre_spawn = """ +set -e +pnpm install +pnpm run --filter=@app/prisma prisma generate +pnpm run --filter=@app/prisma prisma migrate deploy +""" ``` +## Which hook when + +| Situation | Hook | +|---|---| +| Pre-flight check that doesn't need slot info | `pre_up` | +| Write per-slot config file a service reads at boot | `pre_spawn` | +| Wait for a docker service to accept queries | `pre_spawn` | +| Generate client code services import at boot | `pre_spawn` | +| Install dependencies before services try to use them | `pre_spawn` | +| Run migration against an already-running DB (service doesn't care) | `post_up` | +| Curl a health endpoint after boot | `post_up` | +| Drain connections before teardown | `pre_down` | +| Wipe DB state before docker stops it | `pre_down` | +| Send notification after tear-down | `post_down` | + +The rule of thumb: **if a service reads the thing you're setting up at boot, use `pre_spawn`. Otherwise `post_up`.** + ## Deprecated field names -`on_up` and `on_down` still work as aliases for `pre_up` and `pre_down` respectively, but are deprecated. Migrate to the four-field form above. +`on_up` and `on_down` still work as aliases for `pre_up` and `pre_down` respectively, but are deprecated. Migrate to the five-field form above. diff --git a/examples/nextjs-hybrid/.ecluse.toml b/examples/nextjs-hybrid/.ecluse.toml index 9d47fa3..08ea901 100644 --- a/examples/nextjs-hybrid/.ecluse.toml +++ b/examples/nextjs-hybrid/.ecluse.toml @@ -2,6 +2,14 @@ mode = "hybrid" max_slots = 6 prefix = "ecluse" +# .env is symlinked (shared secrets stay in sync); .env.local is a per-worktree +# copy so pre_spawn can rewrite it with slot-specific DATABASE_URL without +# leaking into sibling worktrees. +inherit_env = [ + ".env", + { file = ".env.local", mode = "copy" }, +] + # Next.js dev server: slot 1 → ECLUSE_APP_PORT=3001 + PORT alias, slot 2 → 3002 [[services]] name = "app" @@ -13,5 +21,20 @@ name = "postgres" run = "docker" base_port = 5432 +# pre_spawn: write DATABASE_URL and apply migrations BEFORE Next.js boots. +# Prisma reads DATABASE_URL at boot from .env.local; if we wrote it in +# post_up, Prisma would already have connected to the wrong port (or the +# default hardcoded in .env). Migrations must also complete before the app +# queries a table that doesn't exist yet. [hooks] -post_up = "npx prisma migrate deploy" +pre_spawn = """ +set -e +cat > .env.local </dev/null 2>&1; then break; fi + sleep 1 +done +npx prisma migrate deploy +""" diff --git a/examples/nextjs-hybrid/README.md b/examples/nextjs-hybrid/README.md index ceb89a3..c78cc37 100644 --- a/examples/nextjs-hybrid/README.md +++ b/examples/nextjs-hybrid/README.md @@ -25,7 +25,11 @@ Postgres runs in a Docker container managed by ecluse. Next.js runs natively. Ea ## Hooks -- `post_up`: runs `npx prisma migrate deploy` against the slot's database. +- `pre_spawn`: writes `.env.local` with the slot's `DATABASE_URL`, waits for postgres to accept queries, then applies migrations. All of this must complete **before** Next.js boots — Prisma reads `DATABASE_URL` once at startup, and the app queries tables that must already exist. Using `post_up` here would mean the app boots against stale env / a missing schema and crashes. + +## Why `.env.local` is copied, not symlinked + +`inherit_env` defaults to symlinking `.env` and `.env.local` from the repo root into each worktree. That works for shared secrets (`.env`), but breaks for `.env.local` here: `pre_spawn` rewrites `.env.local` with the current slot's `DATABASE_URL`, and if the file were a symlink, every `ecluse up` in a different worktree would overwrite the single shared file — last writer wins, all other worktrees end up pointing at the wrong slot's postgres. Setting `mode = "copy"` gives each worktree its own real `.env.local`. ## Usage diff --git a/examples/t3-monorepo/.ecluse.toml b/examples/t3-monorepo/.ecluse.toml index 410f8cd..91f77cb 100644 --- a/examples/t3-monorepo/.ecluse.toml +++ b/examples/t3-monorepo/.ecluse.toml @@ -2,6 +2,14 @@ mode = "hybrid" max_slots = 4 prefix = "ecluse" +# .env is symlinked from root (shared secrets stay in sync). +# .env.local is a per-worktree copy so pre_spawn can rewrite it with +# slot-specific URLs without leaking into sibling worktrees. +inherit_env = [ + ".env", + { file = ".env.local", mode = "copy" }, +] + # tRPC / REST API: slot 1 → ECLUSE_API_PORT=3001 + PORT alias, slot 2 → 3002 [[services]] name = "api" @@ -33,6 +41,30 @@ name = "redis" run = "docker" base_port = 6379 +# pre_spawn: wire slot-specific URLs into .env.local BEFORE any Next.js +# service boots. Every app in the monorepo reads NEXT_PUBLIC_API_URL and +# NEXT_PUBLIC_WEB_URL at build/boot time — post_up is too late for that. +# DATABASE_URL and REDIS_URL are read by the api / worker at connection +# time, but writing them here (before any process starts) keeps the file +# consistent with what the operator sees in .env.local. +# +# post_up handles the migration, since the api can wait for the schema +# to appear (Prisma will retry on first query if the migration is still +# running). [hooks] +pre_spawn = """ +set -e +cat > .env.local </dev/null 2>&1; then break; fi + sleep 1 +done +""" post_up = "npx prisma migrate deploy" pre_down = "npx prisma migrate reset --force" diff --git a/examples/t3-monorepo/README.md b/examples/t3-monorepo/README.md index 63611d7..8ed5906 100644 --- a/examples/t3-monorepo/README.md +++ b/examples/t3-monorepo/README.md @@ -56,9 +56,10 @@ Each app reads its port from the matching env var. Example for `apps/web`: const port = process.env.ECLUSE_WEB_PORT ?? 3000; ``` -And for the API to know where the frontend lives (CORS, redirects): +Cross-service URLs (`NEXT_PUBLIC_API_URL` for the web, `INTERNAL_API_URL` for the worker) are written into `.env.local` by the `pre_spawn` hook, **before** any app boots. Every Next.js service reads these at startup — using `post_up` would fire too late (the apps have already read the file). This is why `inherit_env` uses `mode = "copy"` for `.env.local`: without it, the hook would overwrite the single shared file and every other worktree would end up pointing at this slot's ports. -```env -NEXT_PUBLIC_API_URL=http://localhost:${ECLUSE_API_PORT} -NEXT_PUBLIC_WEB_URL=http://localhost:${ECLUSE_WEB_PORT} -``` +## Hooks + +- `pre_spawn`: writes `.env.local` with slot-derived `DATABASE_URL`, `REDIS_URL`, `NEXT_PUBLIC_API_URL`, `NEXT_PUBLIC_WEB_URL`, and `INTERNAL_API_URL`; waits for postgres to accept queries. Runs before any app service boots so every process reads the correct per-slot config at startup. +- `post_up`: applies Prisma migrations. This is fine here (post-boot) because the api uses Prisma's default reconnect-on-first-query behavior and tolerates a brief window where the schema is still being applied. If your app fail-fasts on schema errors, move `prisma migrate deploy` into `pre_spawn` instead. +- `pre_down`: wipes the slot's database on teardown (drop this if you want to keep data across down/up cycles). diff --git a/skills/ecluse/SKILL.md b/skills/ecluse/SKILL.md index 4a3c246..a6eed1d 100644 --- a/skills/ecluse/SKILL.md +++ b/skills/ecluse/SKILL.md @@ -879,7 +879,19 @@ process_manager = "tmux" # "tmux" | "nohup" | "none" `ecluse init` auto-detects: tmux if present, otherwise nohup. `ecluse validate` checks the binary is installed. This is per-machine, not per-repo. -Hooks run as shell commands inside the worktree directory. `pre_up` runs before any infrastructure exists (env vars not yet available). `pre_spawn` runs after `.env.ecluse` is written but before native services are started — use it to derive env values from allocated ports (e.g. `CORE_API_URL=http://localhost:$ECLUSE_API_PORT`). `post_up`, `pre_down`, and `post_down` all have the full `.env.ecluse` set (`PORT`, `ECLUSE_SLUG`, `ECLUSE__PORT`, etc.). ecluse does not manage databases directly — use `post_up` for migrations and `pre_down` for teardown. +Hooks run as shell commands inside the worktree directory. Five lifecycle points are available: + +| Hook | When | Env | Typical use | +|---|---|---|---| +| `pre_up` | before any infrastructure exists | none | pre-flight checks that don't need slot info (`command -v pnpm`, disk space) | +| `pre_spawn` | after `.env.ecluse` written, **before native services boot** | full `ECLUSE_*` + `PORT` | write per-slot `.env.local` / `.dev.vars` with derived URLs, wait for postgres, run `prisma generate` / `pnpm install`, apply migrations that services need at boot | +| `post_up` | after all services are running | full `ECLUSE_*` + `PORT` | migrations the app tolerates racing against, curl a health endpoint, warm a cache, send a notification | +| `pre_down` | before services are stopped | full `ECLUSE_*` + `PORT` | drain connections, wipe DB state while it's still running | +| `post_down` | after worktree removed | full `ECLUSE_*` + `PORT` | cleanup, CI status updates | + +**Rule of thumb:** if a service reads the thing you're setting up at boot, use `pre_spawn`. Otherwise `post_up`. Injecting `NEXT_PUBLIC_API_URL=http://localhost:$ECLUSE_API_PORT` into `.env.local`, waiting for postgres, or generating a Prisma client all belong in `pre_spawn` — using `post_up` for these fires too late, and the service boots against stale env / missing artifacts. + +ecluse does not manage databases directly — use `pre_spawn` or `post_up` for migrations and `pre_down` for teardown. ## Examples