SUB/WAVE worktree dev setup
Get the SUB/WAVE dev stack running from inside a git worktree so branch changes can be tested without touching the main checkout.
Why a worktree needs prep
A worktree checks out every tracked file, so all source is already there. But the dev stack also depends on gitignored files that do not travel with a worktree checkout:
| File | Why the stack needs it |
|---|---|
.env | Root .env — the controller's only env_file in dev compose (./.env), and compose's substitution source. ADMIN_USER / ADMIN_PASS / SITE_URL, plus NAVIDROME_* and LLM/TTS keys. Compose refuses to start without it. |
controller/.env | Legacy — nothing reads it. Older checkouts still keep NAVIDROME_* and API keys here; the script folds any key it has into the worktree's root .env (root wins, ICECAST_* skipped). Without that, the station boots with needsSetup: true and "could not reach http://navidrome:4533". |
web/.env.local | Dev API/stream URL overrides (NEXT_PUBLIC_API_URL etc.). Without it the web UI defaults to same-origin /api and cannot reach the controller. Copied from main if present, otherwise written against $SUBWAVE_DEV_HOST (default localhost). |
docker/.env | Compose variable substitution (legacy — harmless to copy). |
state/setup-config.json | Navidrome creds the wizard saved on main. Without this the controller reports needsSetup: true and the player redirects to /onboarding on every load. |
state/secrets.env | Cloud LLM / TTS API keys (if main has any). Sourced into the controller's process.env on boot. |
state/settings.json | Operator's full config — LLM provider/model, personas, shows, TTS. Without it the controller boots with default settings (llm.model = ''), every LLM call returns "fetch failed", and the new skills cannot run. |
state/moods.json | Library mood tagging produced by npm run tag (LLM walk over the whole library — expensive to redo per branch). |
state/sfx.json + sfx/ | Sound-effects catalogue metadata + rendered WAVs the admin UI lists and liquidsoap mixes in. |
state/jingles.json + jingles.m3u + jingles/ | Pre-rendered station idents. Liquidsoap reads the M3U with reload_mode="watch". |
state/recent-plays.json | 24h play log — the picker and the library-deep-cut skill key off it for dedup. |
state/sessions/ | Archived DJ sessions (the live one is regenerated on boot). |
state/voice/ | Persona TTS reference voices (Chatterbox cloning refs, if any). |
web/node_modules | Needed by npm run dev. |
state/ | Bind-mounted into the containers. A worktree's state/ is empty. |
state/ is mirrored from main so the worktree boots into the same configured
station — same LLM provider, same personas, same library moods, same jingles —
ready to test branch changes against real data. Runtime state (queue.json,
session.json, archive/, logs/, listeners.jsonl, now-playing.json) is
not copied, since those would clash with what the worktree's own containers
produce fresh on first boot. The broadcast container generates its own
state/icecast-secrets.env on first boot, so worktrees never need to copy
icecast config either.
Two load-bearing facts
- One stack at a time. Both compose files use fixed container names
(
sub-wave-broadcast, …) and host ports (Web7700, Controller7701, Icecast7702). A worktree stack and the main-checkout stack collide — stop whatever is running before starting another. - Controller changes need
--build, web changes do not. ThecontrollerimageCOPYs its source at build time, so testing worktree controller code requiresdocker compose up -d --build. The web dev server (npm run dev) hot-reloads from the worktree filesystem, so UI changes appear with no build.
Workflow
Step 0 — Identify the worktree
The target worktree is usually the session's current directory. Confirm it is a linked worktree and not the main checkout:
bashgit rev-parse --absolute-git-dir # a worktree's is <main>/.git/worktrees/<name> git worktree list # shows the main tree + every linked worktree
If the user named a specific worktree path, use that.
Step 1 — Stop any running stack
Only one stack can run. Stop whatever is up (it may be the main checkout's):
bash# Kill the web dev server if it holds :7700 — but only if it is `node`, # never macOS ControlCenter/AirPlay. WEB_PID=$(lsof -nP -iTCP:7700 -sTCP:LISTEN -t 2>/dev/null | head -1) [ -n "$WEB_PID" ] && ps -p "$WEB_PID" -o comm= | grep -qi node && kill "$WEB_PID" # Bring down whichever dev stack is up. The compose file lives at the # checkout root (not under docker/); container names are global, so any # checkout's docker-compose.dev.yml targets the running containers. docker compose -f <some-checkout>/docker-compose.dev.yml down
Step 2 — Prep the worktree
Run the bundled script. It copies the env files, mirrors the operator's
declarative state/ from main (settings, moods, sfx, jingles, sessions, voice),
and runs npm install for the web app. <skill base directory> is the
absolute path shown as "Base directory for this skill" when the skill loads.
bashbash "<skill base directory>/scripts/prep-worktree.sh" <worktree-path>
Flags: --reset-state wipes and re-scaffolds state/ (use when the user wants
a clean slate); --skip-npm skips the dependency install (use when
node_modules is already good). The script is idempotent — re-running it only
fills in what is missing, never overwrites a file the worktree already has.
If the user opens the player from another device (e.g. over Tailscale), pass
that host as SUBWAVE_DEV_HOST=<ip> so a scaffolded web/.env.local points
there, and start the web server with SUBWAVE_DEV_ORIGINS=<ip> npm run dev —
Next dev blocks /_next/* for non-localhost origins otherwise.
If /state still reports needsSetup: true after boot, the root .env has no
NAVIDROME_* and main had no state/setup-config.json — that is a fresh
station headed for /onboarding, not a fault. Add the creds to the worktree's
root .env and docker compose -f docker-compose.dev.yml up -d controller
(env_file changes need a recreate, not a restart).
Step 3 — Start the stack from the worktree
Compose files now live at the worktree root (not under docker/):
bashcd <worktree-path> && docker compose -f docker-compose.dev.yml up -d --build
--build is required so the worktree's controller source is baked into the
image. The first build is slow (all services); later builds only rebuild
changed layers. Then start the web dev server in the background (it is a
long-running foreground process):
bashcd <worktree-path>/web && npm run dev
Step 4 — Verify on-air
Give Liquidsoap ~5s to connect to Icecast, then probe the controller:
bashsleep 5 curl -sf http://localhost:7701/health # expect {"status":"on-air"} curl -sf -o /dev/null -w '%{http_code}\n' http://localhost:7700 # expect 200
If /health fails, peek at docker compose logs --tail=30 controller broadcast
and surface the error. A fresh state/ is normal — an empty queue and default
settings on first boot are expected, not faults.
Iterating
- Web/UI change — nothing to do;
npm run devhot-reloads it. - Controller source change — rebuild just that service:
cd <worktree-path> && docker compose -f docker-compose.dev.yml up -d --build controller. liquidsoap/radio.liqchange — it is bind-mounted in dev; justdocker compose restart broadcast.
Allowed without confirmation
- Running
scripts/prep-worktree.sh(copies env files, scaffoldsstate/,npm install) docker compose up -d --build/downagainst a worktree's dev compose filenpm run dev/ killing anodeprocess on:7700- Reading logs,
curlhealth probes
Confirm before running
--reset-statewhenstate/already has real data the user may wantdocker compose down -v(wipes volumes)- Killing a non-
nodeprocess on:7700 - Touching the main checkout's
controller/.env,state/, or config
When NOT to use this skill / hand off
- Main checkout, not a worktree → use
subwave-controlto start/stop. - Production (
docker-compose.ymlordocker-compose.byo.yml) → usesubwave-control. - First-time repo setup,
git pull+ rebuild, jingle generation, config changes → usesubwave-deploy.


