Dev worktrees
A worktree here is a complete parallel dev environment, not just a second checkout. Each one
gets its own branch, node_modules, public domains, port block, SSL certs, Cloudflare DNS records,
Metro instance and cloned iOS simulator, so several can run at once without colliding.
Everything is keyed off one git-ignored file at the worktree root: localdomain.js. Read it
first whenever you need to know where anything in a checkout lives.
module.exports = {
main: "local2", api: "local2-api", streamingapi: "local2-streaming-api",
port: 8090, apiPort: 3010, streamingApiPort: 3011, metroPort: 8091,
};
The index → port scheme
scripts/worktree-create.sh:64 — OFFSET = (INDEX - 1) * 10:
| index | domains | web | api | streaming | metro | simulator |
|---|---|---|---|---|---|---|
| 1 (base repo, reserved) | local, local-api, local-streaming-api |
8080 | 3000 | 3001 | 8081 | iPhone 17e RNW |
| N (2..20) | localN, localN-api, localN-streaming-api |
8080+10(N-1) | 3000+10(N-1) | 3001+10(N-1) | 8081+10(N-1) | iPhone 17e RNW <dirname> |
Indices are capped at 2..20 and picked at random from the free ones (scripts/worktree-create.sh:50)
— deliberately a small reusable pool, because certs are never deleted, so the same handful of domains
get renewed rather than newly issued (Let's Encrypt rate-limits new certs, not renewals). Taken
indices are discovered by reading every worktrees/*/localdomain.js.
Create
scripts/worktree-create.sh <name> [index] # run from the base repo root
Does, in order: git worktree add worktrees/<name> -b <name> → writes localdomain.js → npm ci
→ generators (build:theme, build:markdown, build:programs, build:exercises) → issues/renews
certs via lambda/scripts/update_liftosaur_dev_certs.sh (certbot + Cloudflare DNS-01, into
~/.secrets) → points localN*.liftosaur.com A records at this machine's LAN IP via
lambda/scripts/change_liftosaur_dev_api.sh.
It does not touch native — pods and the simulator are provisioned later, on demand.
Daily use (all from inside worktrees/<name>)
npm start # webpack-dev-server on localdomain.port, https from ~/.secrets certs
npm run start:server # api + streaming api on apiPort / streamingApiPort
npm run worktree:ios # pods (once) + clone sim (once) + boot + run-ios on the right Metro port
npm run worktree:metro # Metro alone (= rn:start), on this checkout's metroPort
npm test # fine in a fresh worktree — build:programs already wrote programdata/
npm start is not optional for native work: the RN app's __HOST__ is the web dev server
(src/App.native.tsx:30), so images and other assets come from it.
How the port/host actually reaches each layer
Nothing may assume 8080/3000/8081. The plumbing:
- Web + API + tooling —
src/localdomain.tsre-exportslocaldomain.js; consumed bywebpack.config.js(devServer port, cert paths,__API_HOST__defines, proxies) anddevserver.ts:250(listens onapiPort, reads certs from~/.secrets/live/<api domain>/). - RN JS —
src/App.native.tsx:30-34sets__HOST__/__API_HOST__/__STREAMING_API_HOST__fromlocaldomain.jsunder__DEV__, so a native build in a worktree talks to that worktree's servers. - Metro port, CLI side —
scripts/metro-port.sh($RCT_METRO_PORT→localdomain.js→ 8081); used by theios/android/rn:startnpm scripts. - Metro port, iOS side —
RCT_METRO_PORTcannot work: React-Core is a prebuilt xcframework, so its default 8081 is compiled upstream. Insteadios/scripts/write-metro-port.shruns as an always-out-of-date Debug build phase (project.pbxproj:933) and writesmetro-port.txtinto the app bundle;MetroLocationinios/Liftosaur/AppDelegate.swift:104reads it and pinsjsLocation+packagerHost. Plainnpm run iosand ⌘R in Xcode are both correct. - Metro port, Android side —
android/app/build.gradle:95parsesmetroPortout oflocaldomain.jsand emitsresValue "integer", "react_native_dev_server_port". - Metro isolation —
metro.config.jsblock-listsworktrees/and.claude/worktrees/(anchored to the project root, so Metro still works when run from inside a worktree) to avoid haste-map collisions between duplicatepackage.jsons.
Remove
scripts/worktree-remove.sh <name> # run from anywhere
Kills whatever is bound to its four ports, deletes the DNS records (guarded: refuses when
main === "local"), deletes the cloned simulator, then git worktree remove --force and
git branch -D. Certs are kept on purpose — same reason the index pool is small.
Gotchas
- Cloning the simulator fails while the golden sim is booted —
xcrun simctl shutdown "iPhone 17e RNW", then re-run. - DNS points at the LAN IP captured at create time. Change networks and every worktree's
domain resolves to the wrong address. Re-run
sh lambda/scripts/change_liftosaur_dev_api.sh "$PWD/localdomain.js". - Certs live in
~/.secrets/live/<domain>/, shared across checkouts. If they're missing,webpack.config.jssilently falls back to plain http whiledevserver.tsthrows onreadFileSync. - Don't dismiss the RN redbox —
-[RCTLogBoxView dealloc]→doesNotRecognizeSelector→ SIGABRT. It usually means the worktree's servers aren't running; start them (or temporarily pointlocaldomain.jsat the base repo'slocal/8080/3000) instead of dismissing it. npm run pod-installdirtiesios/Podfile.lockandios/Liftosaur.xcodeproj/project.pbxproj— revert before committing feature work.- Run
npm run worktree:iosfrom the worktree root — it resolves./localdomainand names the sim afterbasename $PWD, which must match the<name>worktree-remove.shwill be given. git worktree add -b <name>fails if the branch already exists. Create it from a fresh name, orgit worktree addmanually and writelocaldomain.jsby hand.
Plans and archdocs for a worktree branch
lambda/scripts is a git submodule and worktree-create.sh does not initialise it, so inside a
worktree that directory is empty. Plans, codex transcripts and archdocs for the branch go in the
base repo's lambda/scripts/plans/ and lambda/scripts/archdocs/, as usual.
An archdoc's code links then have to reach into the worktree, because the base checkout does not
have the feature and the lint hook (grasp archdoc lint + scripts/lint-docs.ts, on every write
under lambda/scripts/archdocs/) reports a dead path for ../../../ios/Foo.swift. Write every
link as ../../../worktrees/<name>/ios/Foo.swift#L12 (relative to the doc's own directory), stamp
the header base <worktree HEAD sha> - head working, and lint resolves against the files on disk.
grasp finds the repo root through git rev-parse --git-common-dir, so lint and relink work
from either checkout. worktrees/ is git-ignored in the base repo, so relink routes every link to
the file view (no diff route) until the branch is committed.
After the branch is committed and merged, remove worktrees/<name>/ from every link, replace
working with the commit sha, and run grasp archdoc relink <doc> --write again.
Not per-worktree (known sharp edges)
liftosaur-localMCP is pinned tohttps://local.liftosaur.com:8080/mcp(in~/.claude.json). From a worktree it still talks to the base repo's server. Either run the base repo'snpm start/start:servertoo, or repoint the MCP URL at the worktree's port for the session.- Playwright needs
npm run build:devonce per worktree. Its start page is/app/, and the dev server serves that directory index only from a physicaldist/app/index.html(static:inwebpack.config.js); the in-memory bundle answers/app/index.htmlbut not/app/. A fresh worktree has nodist/app/, so every spec fails on the firstpage.waitForFunctionwith "Refused to evaluate a string as JavaScript" — the 404 page's CSP, not the app.build:devwrites the file; after thatnpx playwright test --reporter=lineruns against the worktree's own port. - API CORS allow-list hardcodes 8080 (
lambda/utils/response.ts:7), as do the OAuth/MCP redirect fallbacks (lambda/mcp/handler.ts,lambda/mcp/oauth.ts) and the dev CDK stack (liftosaur-cdk/liftosaur-cdk.ts). Cross-origin/OAuth flows may need the base ports. - Android has no per-worktree provisioning — same package id (
com.liftosaur.www.twa), one emulator, one install. Only the Metro port is worktree-aware. Two Android worktrees can't run side by side. .claude/worktrees/*are a different mechanism — created by the Claude Code harness (EnterWorktree), with nolocaldomain.js, ports, DNS or simulator. These scripts don't apply to them; they're only block-listed in Metro and excluded fromcopy:lambda.