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 — ADMIN_USER / ADMIN_PASS / SITE_URL. Dev compose references it as ./.env; compose refuses to start without it. |
controller/.env |
Dev compose declares env_file: ./controller/.env — controller container won't start without it. Navidrome + Ollama config. |
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. |
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:
git 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):
# 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.
bash "<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.
Step 3 — Start the stack from the worktree
Compose files now live at the worktree root (not under docker/):
cd <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):
cd <worktree-path>/web && npm run dev
Step 4 — Verify on-air
Give Liquidsoap ~5s to connect to Icecast, then probe the controller:
sleep 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.