Skip to content
Merged
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
47 changes: 18 additions & 29 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,14 @@ Create this file in your repository root and commit it. See [Configuration Refer
"envFiles": [
{
"source": ".env",
"seedFrom": ".env.example",
"patches": [
{ "var": "DATABASE_URL", "type": "database" }
]
},
{
"source": "backend/.env",
"seedFrom": "backend/.env.example",
"patches": [
{ "var": "DATABASE_URL", "type": "database" },
{ "var": "REDIS_URL", "type": "url", "service": "redis" },
Expand All @@ -99,17 +101,13 @@ Create this file in your repository root and commit it. See [Configuration Refer
},
{
"source": "frontend/.env",
"seedFrom": "frontend/.env.example",
"patches": [
{ "var": "PORT", "type": "port", "service": "web" },
{ "var": "API_URL", "type": "url", "service": "api" }
]
}
],
"seedEnvFiles": [
{ "source": ".env.example", "target": ".env" },
{ "source": "backend/.env.example", "target": "backend/.env" },
{ "source": "frontend/.env.example", "target": "frontend/.env" }
],
"postSetup": ["npm install"],
"autoInstall": true
}
Expand Down Expand Up @@ -358,11 +356,12 @@ This file lives in your repository root and is committed to version control.
}
],

// Env files to copy and patch for each worktree
// Env files to seed, copy, and patch for each worktree
"envFiles": [
{
"source": string, // Path relative to repo root
"patches": [
"seedFrom": string, // Optional .env.example path relative to the target checkout
"patches": [ // Optional; defaults to []
{
"var": string, // Env var name to patch
"type": string, // "database" | "port" | "url" | "branch"
Expand All @@ -372,16 +371,6 @@ This file lives in your repository root and is committed to version control.
}
],

// Safe example env files to merge into local env files (default: []).
// Missing targets are created from the example. Existing targets keep
// developer values; only missing vars are appended.
"seedEnvFiles": [
{
"source": string, // Example path relative to the target checkout
"target": string // Local env path relative to the target checkout
}
],

// Commands to run in the worktree after env setup (default: [])
"postSetup": string[],

Expand All @@ -407,24 +396,25 @@ Legacy `type: "redis"` patches are no longer supported. Declare Redis in `docker

### Env Seeding

`seedEnvFiles` is for committed safe defaults. Each entry maps a checked-in example file to the local env file that should receive those defaults:
`envFiles[].seedFrom` is for committed safe defaults. It maps a checked-in example file to the local env file in the same `envFiles` entry:

```json
{
"seedEnvFiles": [
{ "source": ".env.example", "target": ".env" },
{ "source": "server/.env.example", "target": "server/.env" }
"envFiles": [
{ "source": ".env", "seedFrom": ".env.example" },
{ "source": "server/.env", "seedFrom": "server/.env.example" }
]
}
```

Rules:

- If `target` does not exist, it is created from `source`.
- If `target` exists, existing values are preserved.
- Variables present in `source` but missing from `target` are appended under a generated marker.
- Variables removed from `source` are not removed from `target`.
- If `source` does not exist, it is created from `seedFrom`.
- If `source` exists, existing values are preserved.
- Variables present in `seedFrom` but missing from `source` are appended under a generated marker.
- Variables removed from `seedFrom` are not removed from `source`.
- Placeholder or blank values from examples are copied exactly; `wt` does not infer secrets.
- `patches` is optional. Use `patches: []` or omit it for seed-only env files.

### `.worktree-registry.json`

Expand Down Expand Up @@ -529,15 +519,14 @@ Using the discovered information, construct the config:
1. baseDatabaseName = the DB name from the main DATABASE_URL
2. services = each dev server as { name, defaultPort }
3. dockerServices = each per-worktree container, with ports referencing `services`
4. envFiles = each .env file with its patches
5. seedEnvFiles = each safe example mapped to its local env target
6. postSetup = the install command for the package manager (npm install, pnpm install, etc.)
4. envFiles = each .env file with optional seedFrom and optional patches
5. postSetup = the install command for the package manager (npm install, pnpm install, etc.)
```

Validate that:
- Every `port` and `url` patch has a `service` that exists in `services`
- Every `dockerServices[].ports[].service` exists in `services`
- Every `seedEnvFiles[].source` is a committed example file with safe local defaults
- Every `envFiles[].seedFrom` is a committed example file with safe local defaults
- If using `dockerServices`, Docker is available locally
- The `portStride` (default 100) doesn't cause port collisions with other local services
- `maxSlots * portStride` doesn't push ports into reserved ranges (e.g., above 65535)
Expand Down
26 changes: 26 additions & 0 deletions __tests__/config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,30 @@ describe('loadConfig', () => {
"Docker service 'electric' references unknown port service 'electric'.",
);
});

it('allows seedFrom with omitted patches and defaults patches to an empty array', () => {
fs.writeFileSync(
path.join(tmpDir, 'wt.config.json'),
JSON.stringify({
baseDatabaseName: 'myapp',
services: [{ name: 'web', defaultPort: 3000 }],
envFiles: [
{
source: '.env',
seedFrom: '.env.example',
},
],
}, null, 2),
);

const config = loadConfig(tmpDir);

expect(config.envFiles).toEqual([
{
source: '.env',
seedFrom: '.env.example',
patches: [],
},
]);
});
});
9 changes: 4 additions & 5 deletions skills/wt/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,23 +64,22 @@ Build the config file at the repository root:
"envFiles": [
{
"source": "<relative path to .env file>",
"seedFrom": "<optional relative path to .env.example>",
"patches": [
{ "var": "<VAR_NAME>", "type": "<database|port|url|branch>", "service": "<name>" }
]
}
],
"seedEnvFiles": [
{ "source": "<relative path to .env.example>", "target": "<relative path to .env>" }
],
"postSetup": ["<install command>"],
"autoInstall": true
}
```

Validation rules:
- Every `port` and `url` patch must have a `service` that exists in `services`
- `patches` may be omitted for seed-only env files
- Every `dockerServices[].ports[].service` must exist in `services`
- Every `seedEnvFiles[].source` should be a committed example file with safe local defaults
- Every `envFiles[].seedFrom` should be a committed example file with safe local defaults
- `portStride` * `maxSlots` + max default port must be < 65535
- `baseDatabaseName` must match the actual DB name in `DATABASE_URL`
- If using `dockerServices`, Docker must be available locally
Expand Down Expand Up @@ -260,7 +259,7 @@ Run:
wt env seed $1
```

Use this in the root worktree or any branch worktree when `.env.example` files have gained new safe local-development variables. It creates missing configured `.env` targets from examples and appends only variables that are missing from existing targets. It never overwrites developer values.
Use this in the root worktree or any branch worktree when `.env.example` files configured through `envFiles[].seedFrom` have gained new safe local-development variables. It creates missing configured `.env` targets from examples and appends only variables that are missing from existing targets. It never overwrites developer values.

Variants:

Expand Down
22 changes: 11 additions & 11 deletions src/commands/env.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,22 @@ jest.mock('../core/git', () => ({
}));

jest.mock('../core/env-patcher', () => ({
seedEnvFiles: jest.fn(),
seedEnvFileDefaults: jest.fn(),
}));

jest.mock('./setup', () => ({
loadConfig: jest.fn(),
}));

import { getMainWorktreePath } from '../core/git';
import { seedEnvFiles } from '../core/env-patcher';
import { seedEnvFileDefaults } from '../core/env-patcher';
import { loadConfig } from './setup';
import { envSeedCommand } from './env';
import type { WtConfig } from '../types';

const mockGetMainWorktreePath = getMainWorktreePath as jest.MockedFunction<typeof getMainWorktreePath>;
const mockSeedEnvFiles = seedEnvFiles as jest.MockedFunction<typeof seedEnvFiles>;
const mockSeedEnvFileDefaults =
seedEnvFileDefaults as jest.MockedFunction<typeof seedEnvFileDefaults>;
const mockLoadConfig = loadConfig as jest.MockedFunction<typeof loadConfig>;

describe('env seed command', () => {
Expand All @@ -38,8 +39,7 @@ describe('env seed command', () => {
maxSlots: 50,
services: [{ name: 'web', defaultPort: 3000 }],
dockerServices: [],
envFiles: [],
seedEnvFiles: [{ source: '.env.example', target: '.env' }],
envFiles: [{ source: '.env', seedFrom: '.env.example' }],
postSetup: [],
autoInstall: true,
};
Expand All @@ -52,7 +52,7 @@ describe('env seed command', () => {
stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
mockGetMainWorktreePath.mockReturnValue(tmpDir);
mockLoadConfig.mockReturnValue(config);
mockSeedEnvFiles.mockReturnValue({
mockSeedEnvFileDefaults.mockReturnValue({
dryRun: false,
changed: true,
files: [
Expand All @@ -78,8 +78,8 @@ describe('env seed command', () => {
envSeedCommand(tmpDir, { json: true, dryRun: false });

expect(mockLoadConfig).toHaveBeenCalledWith(tmpDir);
expect(mockSeedEnvFiles).toHaveBeenCalledWith(
config.seedEnvFiles,
expect(mockSeedEnvFileDefaults).toHaveBeenCalledWith(
config.envFiles,
tmpDir,
{ dryRun: false },
);
Expand All @@ -93,7 +93,7 @@ describe('env seed command', () => {
});

it('passes dry-run through to the seed helper for branch worktrees', () => {
mockSeedEnvFiles.mockReturnValue({
mockSeedEnvFileDefaults.mockReturnValue({
dryRun: true,
changed: true,
files: [
Expand All @@ -108,8 +108,8 @@ describe('env seed command', () => {

envSeedCommand(targetDir, { json: false, dryRun: true });

expect(mockSeedEnvFiles).toHaveBeenCalledWith(
config.seedEnvFiles,
expect(mockSeedEnvFileDefaults).toHaveBeenCalledWith(
config.envFiles,
targetDir,
{ dryRun: true },
);
Expand Down
4 changes: 2 additions & 2 deletions src/commands/env.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as path from 'node:path';
import { getMainWorktreePath } from '../core/git';
import { seedEnvFiles, type SeedEnvFilesResult } from '../core/env-patcher';
import { seedEnvFileDefaults, type SeedEnvFilesResult } from '../core/env-patcher';
import { extractErrorMessage, formatJson, success, error } from '../output';
import { loadConfig } from './setup';

Expand All @@ -17,7 +17,7 @@ export function envSeedCommand(
const mainRoot = getMainWorktreePath();
const config = loadConfig(mainRoot);
const root = path.resolve(targetPath ?? process.cwd());
const result = seedEnvFiles(config.seedEnvFiles, root, {
const result = seedEnvFileDefaults(config.envFiles, root, {
dryRun: options.dryRun,
});

Expand Down
2 changes: 0 additions & 2 deletions src/commands/new.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,6 @@ describe('new command branch selection', () => {
],
dockerServices: [],
envFiles: [],
seedEnvFiles: [],
postSetup: [],
autoInstall: true,
};
Expand Down Expand Up @@ -356,7 +355,6 @@ describe('new command rollback on failure', () => {
patches: [{ var: 'REDIS_URL', type: 'url', service: 'redis' }],
},
],
seedEnvFiles: [],
dockerServices: [
{
name: 'redis',
Expand Down
1 change: 0 additions & 1 deletion src/commands/prune.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,6 @@ describe('pruneCommand', () => {
},
],
envFiles: [],
seedEnvFiles: [],
postSetup: [],
autoInstall: true,
};
Expand Down
1 change: 0 additions & 1 deletion src/commands/setup.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,6 @@ const config: WtConfig = {
services: [{ name: 'web', defaultPort: 3000 }],
dockerServices: [],
envFiles: [],
seedEnvFiles: [],
postSetup: [],
autoInstall: true,
};
Expand Down
2 changes: 1 addition & 1 deletion src/commands/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ function validateConfig(config: WtConfig): WtConfig {
}

for (const envFile of config.envFiles) {
for (const patch of envFile.patches) {
for (const patch of envFile.patches ?? []) {
if ('service' in patch && !seenServiceNames.has(patch.service)) {
throw new Error(
`Patch '${patch.var}' references unknown service '${patch.service}'.`,
Expand Down
Loading
Loading