Skip to content

Commit 4584be3

Browse files
tyler-daneclaude
andauthored
feat(dev): assign per-worktree dev ports via preflight script (#1963)
* feat(dev): assign per-worktree dev ports via preflight script Every git worktree gets its own gitignored compass.yaml defaulting to the same ports (web 9080, backend 3000), so the second worktree's dev server crashes with EADDRINUSE. Fix it at config level instead of runtime: dev:web/dev:backend now run a small preflight (dev:ports) that copies compass.yaml from the main checkout when missing, and — only when this worktree's ports are claimed by a sibling worktree's compass.yaml — rewrites web.port, web.url, backend.port, backend.apiUrl, and the localhost originsAllowed entries to the next free pair. Comments and secrets are preserved via the yaml Document API, reruns are no-ops, and customized URLs (tunnels, real domains) are left alone. Because each worktree's config stays internally consistent, no app code changes at all: CORS, SuperTokens domains, and the web bundle's baked API_BASEURL all keep working through their existing paths. Playwright is unaffected — it launches dev.ts directly, bypassing the preflight. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(dev): sync .claude/launch.json ports with assigned worktree ports .claude/launch.json hardcodes the same default ports as compass.yaml, so preview tooling was still pointed at 9080/3000 in a worktree that dev-ports.ts had reassigned elsewhere. The preflight now keeps the Backend/Web entries in sync via a surgical regex replace of just the port digits (not JSON.parse + stringify, which would reformat every line and produce a noisy diff for a two-number change) — mirroring the minimal-diff approach already used for compass.yaml. Debug Web (a static http-server on 8080, unrelated to compass config) is untouched. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * refactor(scripts): simplify dev-ports port search and dedupe sibling lookup Extract siblingConfigPaths() to remove a duplicated filter/map/filter chain from readSiblingPorts() and ensureConfigExists(), and split the ~50-line main() into findNextPorts() (pure port-search loop) and a shared isPortsClaimed() predicate, so main() reads as three named phases instead of one long function with an inline loop. No behavior change; verified with dev-ports.test.ts and a live run. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 282b2d8 commit 4584be3

6 files changed

Lines changed: 427 additions & 3 deletions

File tree

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/development/local-development.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,27 @@ Bun build/dev behavior:
124124
- Mongo persistence
125125
- SSE stream behavior
126126

127+
## Multiple Worktrees
128+
129+
`bun run dev:web` and `bun run dev:backend` run a small preflight
130+
(`bun run dev:ports`) that gives each git worktree its own dev ports. If this
131+
worktree has no `compass.yaml` yet, the preflight copies it from the main
132+
checkout; if its ports are already claimed by another worktree's
133+
`compass.yaml`, it rewrites `web.port`, `web.url`, `backend.port`,
134+
`backend.apiUrl`, and the localhost `originsAllowed` entries to the next free
135+
pair (9081/3001, 9082/3002, ...). Comments and secrets in the file are
136+
preserved, and reruns are no-ops. `.claude/launch.json`'s `Backend`/`Web`
137+
preview-tooling ports are kept in sync the same way.
138+
139+
Notes:
140+
141+
- The main checkout keeps the defaults (9080/3000).
142+
- If `web.url` or `backend.apiUrl` is customized (e.g. a tunnel or real
143+
domain), the preflight leaves the file alone — manage ports manually.
144+
- Real Google sign-in only works on ports whose redirect URIs are registered
145+
in Google Cloud Console; pin a worktree back to the default ports if you
146+
need it.
147+
127148
## Backend Health Probe
128149

129150
When debugging backend startup or connectivity issues, use the health endpoint first:

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@
1919
"debug:supertokens": "export DEBUG=com.supertokens &&bun run dev:backend",
2020
"debug:web": "http-server build/web/",
2121
"dev:backend -verbose": "export DEBUG=* &&bun run dev:backend",
22-
"dev:backend": "cd packages/backend && bun --watch src/app.ts",
22+
"dev:backend": "bun run dev:ports && cd packages/backend && bun --watch src/app.ts",
23+
"dev:ports": "bun packages/scripts/src/commands/dev-ports.ts",
2324
"dev:update": "git checkout main && git pull &&bun install",
24-
"dev:web": "cd packages/web &&bun run dev.ts",
25+
"dev:web": "bun run dev:ports && cd packages/web &&bun run dev.ts",
2526
"lint": "biome check .",
2627
"lint:fix": "biome check --write .",
2728
"format": "biome format --write .",

packages/scripts/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
"commander": "^10.0.0",
1212
"inquirer": "^8.0.0",
1313
"open": "^10.1.0",
14-
"umzug": "^3.8.2"
14+
"umzug": "^3.8.2",
15+
"yaml": "^2.9.0"
1516
},
1617
"devDependencies": {
1718
"@types/inquirer": "^9.0.1",
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import {
2+
readPorts,
3+
reassignPorts,
4+
syncLaunchConfig,
5+
} from "@scripts/commands/dev-ports";
6+
7+
const SAMPLE_YAML = `# Compass Config
8+
# hand-written setup notes live here
9+
10+
runtime:
11+
nodeEnv: development
12+
timezone: Etc/UTC
13+
14+
web:
15+
port: 9080
16+
url: http://localhost:9080
17+
18+
backend:
19+
port: 3000
20+
apiUrl: http://localhost:3000/api
21+
originsAllowed:
22+
- http://localhost:3000
23+
- http://localhost:9080
24+
- https://staging.example.com
25+
compassToken: super-secret-token
26+
27+
mongo:
28+
# keep this uri pointed at the dev cluster
29+
uri: mongodb+srv://admin:s3cret@cluster0.example.mongodb.net/dev_calendar
30+
`;
31+
32+
describe("readPorts", () => {
33+
it("reads configured ports", () => {
34+
expect(readPorts(SAMPLE_YAML)).toEqual({ web: 9080, backend: 3000 });
35+
});
36+
37+
it("falls back to defaults when ports are missing", () => {
38+
expect(readPorts("web:\n url: http://localhost:9080\n")).toEqual({
39+
web: 9080,
40+
backend: 3000,
41+
});
42+
});
43+
44+
it("returns null for malformed yaml", () => {
45+
expect(readPorts("{{ not yaml")).toBeNull();
46+
});
47+
});
48+
49+
describe("reassignPorts", () => {
50+
const next = { web: 9081, backend: 3001 };
51+
52+
it("rewrites ports, urls, and localhost origins consistently", () => {
53+
const result = reassignPorts(SAMPLE_YAML, next);
54+
55+
expect(result).toContain("port: 9081");
56+
expect(result).toContain("url: http://localhost:9081");
57+
expect(result).toContain("port: 3001");
58+
expect(result).toContain("apiUrl: http://localhost:3001/api");
59+
expect(result).toContain("- http://localhost:3001");
60+
expect(result).toContain("- http://localhost:9081");
61+
expect(result).not.toContain("9080");
62+
expect(result).not.toContain(": 3000");
63+
});
64+
65+
it("preserves comments, secrets, and non-localhost origins", () => {
66+
const result = reassignPorts(SAMPLE_YAML, next);
67+
68+
expect(result).toContain("# hand-written setup notes live here");
69+
expect(result).toContain("# keep this uri pointed at the dev cluster");
70+
expect(result).toContain("compassToken: super-secret-token");
71+
expect(result).toContain(
72+
"mongodb+srv://admin:s3cret@cluster0.example.mongodb.net/dev_calendar",
73+
);
74+
expect(result).toContain("- https://staging.example.com");
75+
});
76+
77+
it("bails when apiUrl is customized (e.g. a tunnel)", () => {
78+
const customized = SAMPLE_YAML.replace(
79+
"apiUrl: http://localhost:3000/api",
80+
"apiUrl: https://example.trycloudflare.com/api",
81+
);
82+
expect(reassignPorts(customized, next)).toBeNull();
83+
});
84+
85+
it("bails when web.url is customized", () => {
86+
const customized = SAMPLE_YAML.replace(
87+
"url: http://localhost:9080",
88+
"url: https://compass.example.com",
89+
);
90+
expect(reassignPorts(customized, next)).toBeNull();
91+
});
92+
});
93+
94+
describe("syncLaunchConfig", () => {
95+
const SAMPLE_LAUNCH = `{
96+
"version": "0.0.1",
97+
"configurations": [
98+
{
99+
"name": "Backend",
100+
"runtimeExecutable": "/opt/homebrew/bin/bun",
101+
"runtimeArgs": ["dev:backend"],
102+
"port": 3000
103+
},
104+
{
105+
"name": "Web",
106+
"runtimeExecutable": "/opt/homebrew/bin/bun",
107+
"runtimeArgs": ["run", "dev:web"],
108+
"port": 9080
109+
},
110+
{
111+
"name": "Debug Web",
112+
"runtimeExecutable": "/opt/homebrew/bin/bun",
113+
"runtimeArgs": ["run", "debug:web"],
114+
"port": 8080
115+
}
116+
]
117+
}
118+
`;
119+
120+
it("replaces only the port digits, leaving every other line untouched", () => {
121+
const result = syncLaunchConfig(SAMPLE_LAUNCH, {
122+
web: 9081,
123+
backend: 3001,
124+
});
125+
126+
expect(result).toContain(
127+
'"runtimeArgs": ["dev:backend"],\n "port": 3001',
128+
);
129+
expect(result).toContain(
130+
'"runtimeArgs": ["run", "dev:web"],\n "port": 9081',
131+
);
132+
expect(result).toContain('"port": 8080'); // Debug Web untouched
133+
expect(JSON.parse(result as string)).toEqual(
134+
JSON.parse(
135+
SAMPLE_LAUNCH.replace('"port": 3000', '"port": 3001').replace(
136+
'"port": 9080',
137+
'"port": 9081',
138+
),
139+
),
140+
);
141+
});
142+
143+
it("returns null when ports already match (no-op)", () => {
144+
expect(
145+
syncLaunchConfig(SAMPLE_LAUNCH, { web: 9080, backend: 3000 }),
146+
).toBeNull();
147+
});
148+
});

0 commit comments

Comments
 (0)