# 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: 1. **Monorepo**: `/backend/package.json` AND `/frontend/package.json` both present. 2. **Single-service**: a `package.json` at `` 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`/`seed` are 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 flat `backend/` + `frontend/` layout already documented as CCAM's convention (`docs/LANES.md`'s `BACKEND_DIR`/`FRONTEND_DIR` example) is detected. - Any HTTP API route. `profile init`/`profile check` are CLI-only, local filesystem actions against the *source* repo — no dashboard surface needs them. - A `--fix` mode for `profile 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 [--force] ccam lanes profile check [] # 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. 1. **Layout** (monorepo checked first, single-service is the fallback): - `backend/package.json` **and** `frontend/package.json` both exist → `monorepo`. - Else a root `package.json` exists → `single-service`. - Else → `null` (nothing detected). 2. **Script per service** (reads each package.json's `scripts` object, 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): prefer `scripts.start`, else `scripts.dev`. - Frontend-ish package.json (monorepo's `frontend/package.json` only — single-service has no separate frontend): prefer `scripts.preview`, else `scripts.dev`. - Neither candidate present for a role → that role's `boot.sh` line becomes a literal `# TODO: no start/dev script found in — edit this line` comment instead of a runnable command. - **Every script/service name interpolated into generated shell-script TEXT (not a `profile.env` value) 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 hostile `package.json`/`docker-compose.yml` from injecting shell syntax into a generated `.sh` file. (A `profile.env` `KEY=VALUE` line is safe regardless — that file is parsed, never sourced, per the existing contract; this rule is only about literal script text.) 3. **Ports**: never discoverable from `package.json`, so always the existing system defaults — `PORT_BASE_api=8000`/`PORT_BASE_fe=3000` (monorepo) or `PORT_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 makes `health` fail loudly later; it never corrupts data, so a placeholder is acceptable (unlike a guessed database URL). 4. **Database**: `docker-compose.yml` (or `.yaml`) at the repo root, parsed as YAML, with a `services` entry whose key matches `/postgres/i` → `DB_PREFIX` set (to `_l`), `DB_KIND=postgres`, `DB_URL_SCHEME=postgresql`, `COMPOSE_FILE=docker-compose.yml`, `DB_SERVICE=` (passed through the same `^[\w.:-]+$` validator; a compose file with a service name that fails it is treated as "no database detected" for safety), and `hooks/db-create.sh`/`hooks/db-drop.sh` copied verbatim from the existing `server/data/profile-templates/postgres-compose/hooks/` template. No compose file, or no service matching → `DB_PREFIX` is **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). 5. **`.env` seeding (A2 wiring)** — only attempted when step 4 found *something* (a database and/or Redis): look for `/.env` or `/.env.example` (monorepo: `backend/`; single-service: repo root). If found, set `ENV_FILES`/`ENV_SOURCE` to that path (relative to the lane), and `ENV_REWRITE` to whichever of `DATABASE_URL`/`REDIS_URL` apply. No `.env`/`.env.example` found → `ENV_FILES` stays unset (nothing to seed — not a TODO, since "no `.env` file in this repo" is a normal, valid state, not a detection failure). 6. **`migrate`/`seed`**: scaffolded only when step 4 found a database. Body is always: ```bash #!/usr/bin/env bash echo "TODO: no migration tool detected — add your migration command here" exit 0 ``` 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 `profile check`'s job — see below). 7. **`bootstrap`**: always scaffolded — `npm install` at the repo root (single-service) or in both `backend/` and `frontend/` (monorepo), via `harness_spawn`-free plain synchronous commands (bootstrap doesn't need detachment; it's expected to finish and exit before the hook returns, unlike `boot`). 8. **`boot`**: `harness_spawn "$LANE_DIR/" npm run