From 202d93c17065d44dd93964d4585bfdf6c6c34459 Mon Sep 17 00:00:00 2001 From: Kenny Candra Date: Thu, 16 Apr 2026 20:47:05 +0200 Subject: [PATCH 1/2] feat(env): write COMPOSE_PROJECT_NAME into each worktree's .env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit External tooling that runs inside a worktree (Makefiles, raw `docker compose ...` invocations, editor tasks, etc.) previously had no way to discover the per-worktree Compose project name — it was only passed to the `docker compose up` child process via the `-p` flag and the spawn env. Write it into the managed `# --- wtc port overrides ---` block alongside the port allocations so any shell that sources .env (including Docker Compose itself) targets the correct project. The value is also exposed as a `${COMPOSE_PROJECT_NAME}` token for `envOverrides` interpolation. Fixes #2 Made-with: Cursor --- README.md | 7 +++++-- src/commands/start.ts | 1 + src/sync/env.ts | 18 +++++++++++++----- tests/sync/env.test.ts | 36 ++++++++++++++++++++++++++++++++++++ 4 files changed, 55 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 0783d43..6483ca3 100644 --- a/README.md +++ b/README.md @@ -132,12 +132,13 @@ Before starting, `wtc` copies infrastructure files from main into the worktree: ### Env Injection -After copying `.env`, `wtc` appends an idempotent block with allocated port overrides: +After copying `.env`, `wtc` appends an idempotent block with the worktree's `COMPOSE_PROJECT_NAME` and allocated port overrides: ```bash # existing .env content stays untouched... # --- wtc port overrides --- +COMPOSE_PROJECT_NAME=myapp-wt-1-feature-auth POSTGRES_PORT=25435 REDIS_PORT=26381 BACKEND_PORT=28001 @@ -145,6 +146,8 @@ FRONTEND_PORT=25174 # --- end wtc --- ``` +Because `COMPOSE_PROJECT_NAME` lives in `.env`, external tooling (Makefiles, raw `docker compose ...` invocations, editor tasks, etc.) running inside a worktree automatically targets the correct project. + ## Commands ### `wtc start [indices...]` @@ -219,7 +222,7 @@ Extra files/directories to copy from main into each worktree on start. Use for g ### `envOverrides` -Additional env vars injected into `.env`. Supports `${VAR}` interpolation with allocated port values. Use when env vars depend on allocated ports (e.g. `VITE_API_URL`). +Additional env vars injected into `.env`. Supports `${VAR}` interpolation with allocated port values and `${COMPOSE_PROJECT_NAME}`. Use when env vars depend on allocated ports (e.g. `VITE_API_URL`) or on the per-worktree project name. ## MCP Server diff --git a/src/commands/start.ts b/src/commands/start.ts index 98b9525..745e675 100644 --- a/src/commands/start.ts +++ b/src/commands/start.ts @@ -44,6 +44,7 @@ export function startCommand(indices: number[]): void { `${wt.path}/.env`, allocations, ctx.config.envOverrides, + project, ); log.success("Injected port overrides into .env"); diff --git a/src/sync/env.ts b/src/sync/env.ts index 3fcc076..6b0f574 100644 --- a/src/sync/env.ts +++ b/src/sync/env.ts @@ -21,20 +21,27 @@ export function stripOverrideBlock(content: string): string { export function buildOverrideBlock( allocations: PortAllocation[], envOverrides?: Record, + projectName?: string, ): string { const lines: string[] = [BLOCK_START]; - const portValues = new Map(); + const interpolations = new Map(); + + if (projectName) { + lines.push(`COMPOSE_PROJECT_NAME=${projectName}`); + interpolations.set("COMPOSE_PROJECT_NAME", projectName); + } + for (const a of allocations) { lines.push(`${a.envVar}=${a.port}`); - portValues.set(a.envVar, a.port); + interpolations.set(a.envVar, String(a.port)); } if (envOverrides) { for (const [key, template] of Object.entries(envOverrides)) { let value = template; - for (const [envVar, port] of portValues) { - value = value.replace(`\${${envVar}}`, String(port)); + for (const [varName, varValue] of interpolations) { + value = value.replaceAll(`\${${varName}}`, varValue); } lines.push(`${key}=${value}`); } @@ -48,6 +55,7 @@ export function injectPortOverrides( envPath: string, allocations: PortAllocation[], envOverrides?: Record, + projectName?: string, ): void { let content = ""; if (fs.existsSync(envPath)) { @@ -56,7 +64,7 @@ export function injectPortOverrides( content = stripOverrideBlock(content); - const block = buildOverrideBlock(allocations, envOverrides); + const block = buildOverrideBlock(allocations, envOverrides, projectName); const result = content.trimEnd() + "\n\n" + block + "\n"; fs.writeFileSync(envPath, result, "utf-8"); diff --git a/tests/sync/env.test.ts b/tests/sync/env.test.ts index 83a5cf8..18ee790 100644 --- a/tests/sync/env.test.ts +++ b/tests/sync/env.test.ts @@ -43,6 +43,42 @@ describe("buildOverrideBlock", () => { expect(block).toContain("VITE_API_URL=http://localhost:28001"); expect(block).toContain("VITE_APP_URL=http://localhost:25174"); }); + + it("writes COMPOSE_PROJECT_NAME when a project name is provided", () => { + const block = buildOverrideBlock( + allocations, + undefined, + "myapp-wt-1-feature-auth", + ); + expect(block).toContain("COMPOSE_PROJECT_NAME=myapp-wt-1-feature-auth"); + }); + + it("omits COMPOSE_PROJECT_NAME when no project name is provided", () => { + const block = buildOverrideBlock(allocations); + expect(block).not.toContain("COMPOSE_PROJECT_NAME"); + }); + + it("interpolates ${COMPOSE_PROJECT_NAME} inside envOverrides", () => { + const block = buildOverrideBlock( + allocations, + { + STACK_LABEL: "stack:${COMPOSE_PROJECT_NAME}", + MIXED: "${COMPOSE_PROJECT_NAME}-${BACKEND_PORT}", + }, + "myapp-wt-1-feature-auth", + ); + expect(block).toContain("STACK_LABEL=stack:myapp-wt-1-feature-auth"); + expect(block).toContain("MIXED=myapp-wt-1-feature-auth-28001"); + }); + + it("replaces every occurrence of an interpolation token", () => { + const block = buildOverrideBlock( + allocations, + { DUP: "${BACKEND_PORT}/${BACKEND_PORT}" }, + "myapp-wt-1-feature-auth", + ); + expect(block).toContain("DUP=28001/28001"); + }); }); describe("stripOverrideBlock", () => { From 646970ecb03b2ee281d16d6914d02b4b2bd87f8a Mon Sep 17 00:00:00 2001 From: Kenny Candra Date: Thu, 16 Apr 2026 23:07:16 +0200 Subject: [PATCH 2/2] fix(env): make projectName required so MCP path also injects COMPOSE_PROJECT_NAME MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback on #3. Making `projectName` optional on `buildOverrideBlock` / `injectPortOverrides` meant callers could silently skip `COMPOSE_PROJECT_NAME` injection — which is exactly what `startWorktrees` in `src/mcp/server.ts` was doing. Worktrees started via MCP therefore did not get the per-worktree project name in `.env`, so bare `docker compose ...` inside the worktree would fall back to the directory basename and collide with the CLI-managed stack. That defeats the whole feature for anyone driving `wtc` from an AI agent. Tighten the signature so TypeScript refuses to compile if any caller omits the project name, reorder the arg so `projectName` comes before the optional `envOverrides`, and update the MCP path accordingly. Also clarify the `envOverrides` section of the README: `${COMPOSE_PROJECT_NAME}` is a placeholder that `wtc` substitutes at inject time, not a value the user supplies. Made-with: Cursor --- README.md | 19 ++++++++++++++- src/commands/start.ts | 2 +- src/mcp/server.ts | 1 + src/sync/env.ts | 17 ++++++------- tests/sync/env.test.ts | 54 ++++++++++++++++-------------------------- 5 files changed, 48 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 6483ca3..ff8841d 100644 --- a/README.md +++ b/README.md @@ -222,7 +222,24 @@ Extra files/directories to copy from main into each worktree on start. Use for g ### `envOverrides` -Additional env vars injected into `.env`. Supports `${VAR}` interpolation with allocated port values and `${COMPOSE_PROJECT_NAME}`. Use when env vars depend on allocated ports (e.g. `VITE_API_URL`) or on the per-worktree project name. +Additional env vars to inject into each worktree's `.env`. Values may reference the per-worktree allocated ports and project name as `${...}` placeholders — `wtc` substitutes them at inject time. You don't supply the values of these placeholders; they're computed by `wtc`. + +Available placeholders: + +- `${COMPOSE_PROJECT_NAME}` — the per-worktree Compose project (e.g. `myapp-wt-1-feature-auth`). Always injected automatically as a top-level line in the managed block; this placeholder is for referencing that value inside your own overrides. +- `${_PORT}` — each allocated host port (e.g. `${BACKEND_PORT}`, `${POSTGRES_PORT}`). + +Example: + +```json +{ + "envOverrides": { + "VITE_API_URL": "http://localhost:${BACKEND_PORT}", + "SENTRY_ENVIRONMENT": "dev-${COMPOSE_PROJECT_NAME}", + "LOG_PREFIX": "[${COMPOSE_PROJECT_NAME}] " + } +} +``` ## MCP Server diff --git a/src/commands/start.ts b/src/commands/start.ts index 745e675..798d3d7 100644 --- a/src/commands/start.ts +++ b/src/commands/start.ts @@ -43,8 +43,8 @@ export function startCommand(indices: number[]): void { injectPortOverrides( `${wt.path}/.env`, allocations, - ctx.config.envOverrides, project, + ctx.config.envOverrides, ); log.success("Injected port overrides into .env"); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 60b6c93..42c7925 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -33,6 +33,7 @@ function startWorktrees(indices: number[]): string { injectPortOverrides( `${wt.path}/.env`, allocations, + project, ctx.config.envOverrides, ); diff --git a/src/sync/env.ts b/src/sync/env.ts index 6b0f574..2cc4bc4 100644 --- a/src/sync/env.ts +++ b/src/sync/env.ts @@ -20,17 +20,14 @@ export function stripOverrideBlock(content: string): string { export function buildOverrideBlock( allocations: PortAllocation[], + projectName: string, envOverrides?: Record, - projectName?: string, ): string { - const lines: string[] = [BLOCK_START]; + const lines: string[] = [BLOCK_START, `COMPOSE_PROJECT_NAME=${projectName}`]; - const interpolations = new Map(); - - if (projectName) { - lines.push(`COMPOSE_PROJECT_NAME=${projectName}`); - interpolations.set("COMPOSE_PROJECT_NAME", projectName); - } + const interpolations = new Map([ + ["COMPOSE_PROJECT_NAME", projectName], + ]); for (const a of allocations) { lines.push(`${a.envVar}=${a.port}`); @@ -54,8 +51,8 @@ export function buildOverrideBlock( export function injectPortOverrides( envPath: string, allocations: PortAllocation[], + projectName: string, envOverrides?: Record, - projectName?: string, ): void { let content = ""; if (fs.existsSync(envPath)) { @@ -64,7 +61,7 @@ export function injectPortOverrides( content = stripOverrideBlock(content); - const block = buildOverrideBlock(allocations, envOverrides, projectName); + const block = buildOverrideBlock(allocations, projectName, envOverrides); const result = content.trimEnd() + "\n\n" + block + "\n"; fs.writeFileSync(envPath, result, "utf-8"); diff --git a/tests/sync/env.test.ts b/tests/sync/env.test.ts index 18ee790..e712584 100644 --- a/tests/sync/env.test.ts +++ b/tests/sync/env.test.ts @@ -19,24 +19,32 @@ const allocations: PortAllocation[] = [ }, ]; +const project = "myapp-wt-1-feature-auth"; + describe("buildOverrideBlock", () => { - it("creates a delimited block with port assignments", () => { - const block = buildOverrideBlock(allocations); + it("creates a delimited block with the project name and port assignments", () => { + const block = buildOverrideBlock(allocations, project); expect(block).toContain("# --- wtc port overrides ---"); + expect(block).toContain(`COMPOSE_PROJECT_NAME=${project}`); expect(block).toContain("BACKEND_PORT=28001"); expect(block).toContain("FRONTEND_PORT=25174"); expect(block).toContain("# --- end wtc ---"); }); + it("always writes COMPOSE_PROJECT_NAME (required argument)", () => { + const block = buildOverrideBlock(allocations, project); + expect(block).toContain(`COMPOSE_PROJECT_NAME=${project}`); + }); + it("interpolates envOverrides with port values", () => { - const block = buildOverrideBlock(allocations, { + const block = buildOverrideBlock(allocations, project, { VITE_API_URL: "http://localhost:${BACKEND_PORT}", }); expect(block).toContain("VITE_API_URL=http://localhost:28001"); }); it("handles multiple envOverrides", () => { - const block = buildOverrideBlock(allocations, { + const block = buildOverrideBlock(allocations, project, { VITE_API_URL: "http://localhost:${BACKEND_PORT}", VITE_APP_URL: "http://localhost:${FRONTEND_PORT}", }); @@ -44,39 +52,19 @@ describe("buildOverrideBlock", () => { expect(block).toContain("VITE_APP_URL=http://localhost:25174"); }); - it("writes COMPOSE_PROJECT_NAME when a project name is provided", () => { - const block = buildOverrideBlock( - allocations, - undefined, - "myapp-wt-1-feature-auth", - ); - expect(block).toContain("COMPOSE_PROJECT_NAME=myapp-wt-1-feature-auth"); - }); - - it("omits COMPOSE_PROJECT_NAME when no project name is provided", () => { - const block = buildOverrideBlock(allocations); - expect(block).not.toContain("COMPOSE_PROJECT_NAME"); - }); - it("interpolates ${COMPOSE_PROJECT_NAME} inside envOverrides", () => { - const block = buildOverrideBlock( - allocations, - { - STACK_LABEL: "stack:${COMPOSE_PROJECT_NAME}", - MIXED: "${COMPOSE_PROJECT_NAME}-${BACKEND_PORT}", - }, - "myapp-wt-1-feature-auth", - ); - expect(block).toContain("STACK_LABEL=stack:myapp-wt-1-feature-auth"); - expect(block).toContain("MIXED=myapp-wt-1-feature-auth-28001"); + const block = buildOverrideBlock(allocations, project, { + STACK_LABEL: "stack:${COMPOSE_PROJECT_NAME}", + MIXED: "${COMPOSE_PROJECT_NAME}-${BACKEND_PORT}", + }); + expect(block).toContain(`STACK_LABEL=stack:${project}`); + expect(block).toContain(`MIXED=${project}-28001`); }); it("replaces every occurrence of an interpolation token", () => { - const block = buildOverrideBlock( - allocations, - { DUP: "${BACKEND_PORT}/${BACKEND_PORT}" }, - "myapp-wt-1-feature-auth", - ); + const block = buildOverrideBlock(allocations, project, { + DUP: "${BACKEND_PORT}/${BACKEND_PORT}", + }); expect(block).toContain("DUP=28001/28001"); }); });