Help text for "lanes up|down" implied --no-build applied to both; it's only read by up. Also commits the A3 profile-scaffolding design spec and implementation plan that weren't yet in git.
14 KiB
Lane profile scaffolding (A3, v1: Node preset) — design
Source of truth for "Shipyard": ~/MyDrive/Projects/ResearchAndDevelopment/AgentWorkflow/. This is the third phase of the Shipyard-parity roadmap (docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md, phase A3), scoped down to exactly the piece that phase's own text asked for: detect a Node.js project and scaffold a working .ccam/profile/, so adopting a repo becomes one command instead of hand-writing nine hook scripts. Python/Go/Ruby detection, named in the long-range plan, is explicitly out of scope for this design — see Non-goals.
Problem
Today, using CCAM's runtime/data-isolation features (A1: slots+ports, A2: database/Redis/.env) on a new repo means a human reads docs/LANES.md and hand-writes .ccam/profile/profile.env plus every hook script from scratch. That's the adoption barrier this removes.
Scope (v1)
Detect and scaffold for one preset: Node.js, in exactly two supported layouts:
- Monorepo:
<repo>/backend/package.jsonAND<repo>/frontend/package.jsonboth present. - Single-service: a
package.jsonat<repo>root, and layout 1 didn't match.
Anything else (no package.json anywhere, or a backend/ without a matching frontend/, or a pnpm/yarn/turborepo workspace layout, or frontend/backend under different names like client//apps/web/) is not detected — profile init refuses cleanly rather than guessing. This mirrors the project's own rule for A3 in the plan doc: "a wrong default that boots something is worse than a refusal."
Non-goals (explicit)
- Detecting Python (Django/FastAPI), Go, Ruby, or any non-Node stack. That is real, planned work (see the parent plan's A3 section) but a separate preset, added later, informed by whatever this Node preset's implementation actually looks like once it exists.
- Detecting ORMs/migration tools (Prisma, Knex, TypeORM, Sequelize, alembic, …).
migrate/seedare always scaffolded as an explicit TODO stub when a database is detected — never a guessed command. - pnpm/yarn/npm workspaces or turborepo-style (
apps/,packages/) monorepo layouts. Only the flatbackend/+frontend/layout already documented as CCAM's convention (docs/LANES.md'sBACKEND_DIR/FRONTEND_DIRexample) is detected. - Any HTTP API route.
profile init/profile checkare CLI-only, local filesystem actions against the source repo — no dashboard surface needs them. - A
--fixmode forprofile check. It is read-only/diagnostic only.
Architecture
One new library module, server/lib/lane-detect.js, plus two new CLI subcommands. It does not touch the database, does not require a lane row to exist, and does not execute anything in the target repo — it only reads files and writes files.
server/lib/lane-detect.js
detectNode(repoPath) -> { layout: "monorepo"|"single-service", ... facts } | null
scaffoldProfile(repoPath, facts, { force }) -> writes .ccam/profile/**, returns { written: [...], todos: [...] }
checkProfile(dir) -> { ok: boolean, errors: [...], warnings: [...] }
bin/ccam.js gains:
ccam lanes profile init <repo> [--force]
ccam lanes profile check [<path>] # defaults to cwd
Both are pure local actions on whatever machine runs the CLI — no dashboard server round trip needed for the write itself (unlike lanes add --repo, this never creates a worktree or a lane row; it just prepares a repo so that a later lanes add --repo on it gets a working profile for free, because resolveProfile already reads .ccam/profile/ from the lane's own working copy or its source repo — nothing downstream needs to change).
Detection rules (detectNode)
Reads only; never executes npm, never shells out.
- Layout (monorepo checked first, single-service is the fallback):
backend/package.jsonandfrontend/package.jsonboth exist →monorepo.- Else a root
package.jsonexists →single-service. - Else →
null(nothing detected).
- Script per service (reads each package.json's
scriptsobject, does not parse the script's shell value — only its name):- Backend-ish package.json (the monorepo's
backend/package.json, or the single-service root one): preferscripts.start, elsescripts.dev. - Frontend-ish package.json (monorepo's
frontend/package.jsononly — single-service has no separate frontend): preferscripts.preview, elsescripts.dev. - Neither candidate present for a role → that role's
boot.shline becomes a literal# TODO: no start/dev script found in <path> — edit this linecomment instead of a runnable command. - Every script/service name interpolated into generated shell-script TEXT (not a
profile.envvalue) must pass^[\w.:-]+$first. A name that fails this check is treated as "not found" for that line (falls to the TODO comment) — this is what keeps a hostilepackage.json/docker-compose.ymlfrom injecting shell syntax into a generated.shfile. (Aprofile.envKEY=VALUEline is safe regardless — that file is parsed, never sourced, per the existing contract; this rule is only about literal script text.)
- Backend-ish package.json (the monorepo's
- Ports: never discoverable from
package.json, so always the existing system defaults —PORT_BASE_api=8000/PORT_BASE_fe=3000(monorepo) orPORT_BASE_app=3000(single-service) — written with a comment telling the user to adjust if their dev server uses something else. A wrong value here only makeshealthfail loudly later; it never corrupts data, so a placeholder is acceptable (unlike a guessed database URL). - Database:
docker-compose.yml(or.yaml) at the repo root, parsed as YAML, with aservicesentry whose key matches/postgres/i→DB_PREFIXset (to<sanitized-repo-name>_l),DB_KIND=postgres,DB_URL_SCHEME=postgresql,COMPOSE_FILE=docker-compose.yml,DB_SERVICE=<the matched service name>(passed through the same^[\w.:-]+$validator; a compose file with a service name that fails it is treated as "no database detected" for safety), andhooks/db-create.sh/hooks/db-drop.shcopied verbatim from the existingserver/data/profile-templates/postgres-compose/hooks/template. No compose file, or no service matching →DB_PREFIXis not written at all (A2's "empty = feature off", not a half-configured placeholder).- Same compose file additionally checked for a service matching
/redis/i→REDIS=1. Independent of the Postgres check (a repo could have one, the other, both, or neither).
- Same compose file additionally checked for a service matching
.envseeding (A2 wiring) — only attempted when step 4 found something (a database and/or Redis): look for<backend-dir>/.envor<backend-dir>/.env.example(monorepo:backend/; single-service: repo root). If found, setENV_FILES/ENV_SOURCEto that path (relative to the lane), andENV_REWRITEto whichever ofDATABASE_URL/REDIS_URLapply. No.env/.env.examplefound →ENV_FILESstays unset (nothing to seed — not a TODO, since "no.envfile in this repo" is a normal, valid state, not a detection failure).migrate/seed: scaffolded only when step 4 found a database. Body is always:Deliberately never guessing an ORM, and deliberately exits 0 — an unresolved TODO must not make a fresh scaffold impossible to boot; it is a visible reminder, not a hard failure baked into the hook itself (the hard failure is#!/usr/bin/env bash echo "TODO: no migration tool detected — add your migration command here" exit 0profile check's job — see below).bootstrap: always scaffolded —npm installat the repo root (single-service) or in bothbackend/andfrontend/(monorepo), viaharness_spawn-free plain synchronous commands (bootstrap doesn't need detachment; it's expected to finish and exit before the hook returns, unlikeboot).boot:harness_spawn <name> "$LANE_DIR/<dir>" npm run <script>per service (or the TODO comment from step 2 if no script was found).health: a curl-retry loop against the frontend's port in monorepo layout,PORT_BASE_appin single-service layout — path/, matching the exact pattern already documented indocs/LANES.md's worked example:#!/usr/bin/env bash set -euo pipefail curl -sf --retry 30 --retry-delay 1 --retry-all-errors "http://127.0.0.1:$<PORT_VAR>/" >/dev/null
scaffoldProfile
Writes <repo>/.ccam/profile/profile.env and <repo>/.ccam/profile/hooks/*.sh (mode 0755) from the facts detectNode returned. Refuses if .ccam/profile/profile.env already exists, unless --force. Returns which files it wrote and which lines are TODO placeholders, so the CLI can print a clear "N things need your attention" summary instead of silently declaring success.
checkProfile (validator, read-only)
Given a directory (a repo root, or a lane's cwd — the same two places resolveProfile already searches):
profile.envparses without throwing (lane-profile.js:parseEnvFile).- Every hook referenced (
bootstrap,boot,health, plusdb-create/db-dropwhenDB_PREFIXis set) exists on disk and is executable (fs.statSync(...).mode & 0o111). - No literal string
TODO:remains anywhere inprofile.envor any hook file's contents. This is expected to fail immediately afterprofile initscaffolds a repo whose detection left any TODO (no start/dev script, or a database with unconfigured migrate/seed) — that is the intended signal to finish the manual step, not a bug. - If
DB_PREFIXis set:db-create.sh/db-drop.shboth exist (redundant with the hook check above, but reported as its own line so the message reads as "database configuration" rather than a generic missing-hook error). - If
DB_PREFIXis set and~/.ccam/secrets.env(server/lib/secrets.js:SECRETS_PATH) does not exist: a warning (not a failure) — "no secrets.env; database will use built-in defaults until you create one." - Every declared
PORT_BASE_<name>port is currently free (server/lib/ports.js:isListening, reused as-is).
Exits non-zero and prints every failure found (not just the first) when any of the hard checks fail; warnings never affect the exit code.
CLI
ccam lanes profile init <repo> [--force]
<repo>must be an absolute, existing, git-repository path (same validationPOST /api/lanes/worktreealready applies tosourceRepo).- On success: prints what was scaffolded, and — if anything was left as a TODO — a clear "N item(s) need manual attention before this profile is usable: " block, plus the suggested next command (
ccam lanes profile check <repo>). - On "nothing detected": a clear, actionable refusal — not a bare error:
✖ No detectable Node.js project at <repo> (looked for backend/package.json + frontend/package.json, or a root package.json). Auto-scaffolding currently supports Node.js repos in that layout only. Write .ccam/profile/ by hand — see docs/LANES.md.
ccam lanes profile check [<path>]
<path>defaults toprocess.cwd(). Deliberately not lane-id/cwd-resolved the waylanes up/lanes logs/etc. are —profile checkis meant to run against a bare repo right afterprofile init, before any lane/worktree exists for it.- Prints every failure and warning found; exits 0 only when there are zero failures (warnings are fine).
Testing (Verify)
Two fixture repos under server/__tests__/fixtures/ (or generated into a temp dir at test time, matching the existing lane-runtime.test.js/lane-data.test.js pattern of building fixtures in SUITE_ROOT):
- Single-service, no database — root
package.jsonwith astartscript that runs a tiny real HTTP server (same pattern aslane-runtime.test.js'spython3 -m http.server, or an equivalent one-line Node script). Nodocker-compose.yml.profile init→ exactlyprofile.env,hooks/bootstrap.sh,hooks/boot.sh,hooks/health.shwritten; zero TODOs.profile check→ passes (exit 0).runtime.upLaneon a real lane built from this fixture → actually boots, actually becomes healthy (the same end-to-end proof A1/A2 used — not just a file-content assertion).
- Monorepo with a Postgres+Redis docker-compose —
backend/package.json(startscript) +frontend/package.json(devscript only, nopreview— deliberately, to prove the fallback-to-devpath) +docker-compose.ymlwithpostgres/redisservices +backend/.env.example.profile init→DB_PREFIX/DB_KIND/REDIS=1/ENV_FILES/ENV_REWRITEall set correctly;db-create.sh/db-drop.shcopied from the template;migrate.sh/seed.shscaffolded as TODO stubs; the frontend line ofboot.shrunsnpm run dev(the fallback, since nopreviewexists).profile check→ fails with exactly the expected errors (migrate/seedstill TODO) — this fixture is not booted for real; its job is to proveinitandcheckcooperate correctly, not to prove the runtime boots (that's fixture 1's job, already proven, and this fixture has no real backend/frontend server to boot anyway).
- Unit-level tests on
lane-detect.jsdirectly (no full fixture needed): layout precedence (both root package.json and backend/+frontend/ present → monorepo wins), the true no-script-found TODO-fallback line (a package.json with neitherstart/devnorpreview/dev), the shell-identifier validator rejecting a malicious compose service name / script name, no-package.json →null,backend/withoutfrontend/→null(single-service check on repo root, which also has none →null),--forceallowing an overwrite, refusing without it.
Risks
- A detected name embedded in generated shell text. Mitigated by the
^[\w.:-]+$validator on any script/service name written into.shfile text (not aprofile.envvalue, which is provably inert already —lane-profile.js's existing test proves$(...)/backticks stay literal there). Review this validator's regex and its use sites twice. - A profile that "looks scaffolded" but silently doesn't wire A2. Mitigated by always scaffolding
ENV_REWRITEalongsideDB_PREFIXwhen a.env/.env.exampleexists — a database with no.envrewiring was flagged as a real gap in review and is why this step exists at all, not an afterthought. - A TODO stub that fails a boot instead of just failing a check. Mitigated by every TODO hook body being
exit 0— the hard gate isprofile check, run explicitly by the human, not a silent failure the first time someone trieslanes up.