From f7157ff04c4d539e628cd847ef406a25a94fb06e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois?= Date: Tue, 7 Jul 2026 16:30:52 +0100 Subject: [PATCH 1/3] docs(hooks): document pre_spawn with lifecycle diagram and env table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pre_spawn has been supported by the binary since 0.2.14 but was omitted from docs/src/hooks.md — the canonical hooks reference on ecluse.ai. A user reading the docs would see only pre_up, pre_down, post_up, and post_down and reach for post_up to inject slot-specific URLs into config files that services read at boot — which fires too late, so the service boots against stale env. This commit brings the reference up to five hooks and adds the "which hook when" guidance that would have unblocked the users who figured pre_spawn out from the binary strings: - Add pre_spawn to the header sample. - Update the lifecycle diagram to show pre_spawn between the worktree/.env.ecluse setup and the native service spawn, so readers see WHY it's the hook for pre-boot config writes. - Add a dedicated `## pre_spawn` section explaining that this is where slot-aware setup goes: writing .env.local / .dev.vars with URLs containing $ECLUSE_*_PORT, waiting for postgres, running migrations and code generation that services import at startup. Explicitly contrast against post_up (too late for boot-time reads). - Expand the environment table to include pre_spawn (worktree root, full env, native services not yet started). - Add three canonical examples: slot-specific URL injection into a frontend's .env.development.local, wait-for-postgres + migrate, and awk-rewrite of .env.local with slot-derived values (matching the pattern real users are using in their configs). - Add a "which hook when" decision table so readers don't have to extrapolate from prose. --- docs/src/hooks.md | 160 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 136 insertions(+), 24 deletions(-) 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. From cd9d8448d60a02a6f7ed051a6c3a573a04ba0c44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois?= Date: Tue, 7 Jul 2026 16:32:30 +0100 Subject: [PATCH 2/3] examples: showcase pre_spawn URL injection in nextjs-hybrid and t3-monorepo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zero examples in the repo demonstrated pre_spawn. The single most common real-world pattern — inject slot-specific URLs into a .env file that a Next.js / Vite / Cloudflare-workers frontend reads at boot — was completely absent. Users who needed this either: (a) discovered pre_spawn from the binary strings or PR history, or (b) tried post_up first, saw services boot against stale env, and had to debug from silence. nextjs-hybrid (single-app case): - pre_spawn writes .env.local with DATABASE_URL, waits for postgres, applies migrations — all before Next.js boots. Moves the prisma migrate call from post_up (where the app would query a missing schema during the migration window) to pre_spawn. - inherit_env switches to mode = "copy" for .env.local so the rewrite doesn't leak across worktrees. - README explains WHY pre_spawn is the right hook here (Prisma reads DATABASE_URL once at boot) and WHY copy mode is required for .env.local (the rewrite would clobber siblings under symlink). t3-monorepo (multi-app case with cross-service URLs): - pre_spawn writes NEXT_PUBLIC_API_URL and NEXT_PUBLIC_WEB_URL into .env.local so all three Next.js apps (api, web, worker) read the correct per-slot endpoints at boot. INTERNAL_API_URL is set for the worker's server-side calls back to the api. - post_up keeps the prisma migrate (Prisma's default retry-on-first- query handles the brief boot-time race here). - Same .env.local copy-mode note as nextjs-hybrid. - README's "App config" section is rewritten: previously it showed the URLs as if the user had to hand-write them in .env; now it correctly identifies pre_spawn as the source and explains the interaction with inherit_env. --- examples/nextjs-hybrid/.ecluse.toml | 25 +++++++++++++++++++++- examples/nextjs-hybrid/README.md | 6 +++++- examples/t3-monorepo/.ecluse.toml | 32 +++++++++++++++++++++++++++++ examples/t3-monorepo/README.md | 11 +++++----- 4 files changed, 67 insertions(+), 7 deletions(-) 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). From f6c2d4309275a53437eb36f79ba2d236c1571ef2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois?= Date: Tue, 7 Jul 2026 16:33:09 +0100 Subject: [PATCH 3/3] docs(skill): add "which hook when" guidance including pre_spawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents loading the ecluse skill previously saw pre_spawn mentioned only in passing (config-block sample, error-code reference). No guidance on when to reach for it vs. post_up. The result: agents defaulted to post_up for URL injection and slot-specific .env writes, which fires after the services have already booted with stale env. Replace the short paragraph on hooks with a five-row table (one row per lifecycle point) covering when each fires, what env is available, and what it's for. Add an explicit rule-of-thumb: "if a service reads the thing you're setting up at boot, use pre_spawn; otherwise post_up." Concrete examples in-line — NEXT_PUBLIC_API_URL, wait-for-postgres, prisma generate — so agents recognize the shapes they're likely to hit. --- skills/ecluse/SKILL.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) 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