Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 136 additions & 24 deletions docs/src/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,40 +4,63 @@ 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"
```

## Lifecycle order

```
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

Expand All @@ -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

Expand All @@ -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 <<EOF
VITE_API_URL=http://localhost:$ECLUSE_API_PORT
EOF
"""
```

The web service reads `.env.development.local` at boot and dials the correct per-slot api URL. Using `post_up` here would produce the wrong URL — the web service would have already booted with whatever was in the file before the hook ran.

### Waiting for postgres, then migrating, before services boot

`docker compose up` returns when the container has *started*, not when postgres is ready to accept queries. Services that fail-fast on the first connection attempt (Go binaries, most ORMs' initial pool ping) need postgres actually up before they boot:

```toml
[hooks]
pre_spawn = """
set -e
for i in 1 2 3 4 5 6 7 8 9 10; do
if pg_isready -h localhost -p "$ECLUSE_POSTGRES_PORT" -U app >/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
Expand All @@ -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.
25 changes: 24 additions & 1 deletion examples/nextjs-hybrid/.ecluse.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 <<EOF
DATABASE_URL=postgres://postgres@localhost:$ECLUSE_POSTGRES_PORT/app
EOF
for i in 1 2 3 4 5 6 7 8 9 10; do
if pg_isready -h localhost -p "$ECLUSE_POSTGRES_PORT" -U postgres >/dev/null 2>&1; then break; fi
sleep 1
done
npx prisma migrate deploy
"""
6 changes: 5 additions & 1 deletion examples/nextjs-hybrid/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
32 changes: 32 additions & 0 deletions examples/t3-monorepo/.ecluse.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 <<EOF
DATABASE_URL=postgres://postgres@localhost:$ECLUSE_POSTGRES_PORT/app
REDIS_URL=redis://localhost:$ECLUSE_REDIS_PORT
NEXT_PUBLIC_API_URL=http://localhost:$ECLUSE_API_PORT
NEXT_PUBLIC_WEB_URL=http://localhost:$ECLUSE_WEB_PORT
INTERNAL_API_URL=http://localhost:$ECLUSE_API_PORT
EOF
for i in 1 2 3 4 5 6 7 8 9 10; do
if pg_isready -h localhost -p "$ECLUSE_POSTGRES_PORT" -U postgres >/dev/null 2>&1; then break; fi
sleep 1
done
"""
post_up = "npx prisma migrate deploy"
pre_down = "npx prisma migrate reset --force"
11 changes: 6 additions & 5 deletions examples/t3-monorepo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
14 changes: 13 additions & 1 deletion skills/ecluse/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<NAME>_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

Expand Down
Loading