# 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**: `<repo>/backend/package.json` AND `<repo>/frontend/package.json` both present.
2. **Single-service**: a `package.json` at `<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`/`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 <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.

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 <path> — 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 `<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), 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 `<backend-dir>/.env` or `<backend-dir>/.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 <name> "$LANE_DIR/<dir>" npm run <script>` per service (or the TODO comment from step 2 if no script was found).
9. **`health`**: a curl-retry loop against **the frontend's port in monorepo layout, `PORT_BASE_app` in single-service layout** — path `/`, matching the exact pattern already documented in `docs/LANES.md`'s worked example:
   ```bash
   #!/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.env` parses without throwing (`lane-profile.js:parseEnvFile`).
- Every hook referenced (`bootstrap`, `boot`, `health`, plus `db-create`/`db-drop` when `DB_PREFIX` is set) exists on disk and is executable (`fs.statSync(...).mode & 0o111`).
- No literal string `TODO:` remains anywhere in `profile.env` or any hook file's contents. **This is expected to fail immediately after `profile init` scaffolds 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_PREFIX` is set: `db-create.sh`/`db-drop.sh` both 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_PREFIX` is 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 validation `POST /api/lanes/worktree` already applies to `sourceRepo`).
- 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: <list>" 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 to `process.cwd()`. Deliberately **not** lane-id/cwd-resolved the way `lanes up`/`lanes logs`/etc. are — `profile check` is meant to run against a bare repo right after `profile 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`):

1. **Single-service, no database** — root `package.json` with a `start` script that runs a tiny real HTTP server (same pattern as `lane-runtime.test.js`'s `python3 -m http.server`, or an equivalent one-line Node script). No `docker-compose.yml`.
   - `profile init` → exactly `profile.env`, `hooks/bootstrap.sh`, `hooks/boot.sh`, `hooks/health.sh` written; **zero** TODOs.
   - `profile check` → passes (exit 0).
   - `runtime.upLane` on 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).
2. **Monorepo with a Postgres+Redis docker-compose** — `backend/package.json` (`start` script) + `frontend/package.json` (`dev` script only, no `preview` — deliberately, to prove the fallback-to-`dev` path) + `docker-compose.yml` with `postgres`/`redis` services + `backend/.env.example`.
   - `profile init` → `DB_PREFIX`/`DB_KIND`/`REDIS=1`/`ENV_FILES`/`ENV_REWRITE` all set correctly; `db-create.sh`/`db-drop.sh` copied from the template; `migrate.sh`/`seed.sh` scaffolded as TODO stubs; the frontend line of `boot.sh` runs `npm run dev` (the fallback, since no `preview` exists).
   - `profile check` → fails with exactly the expected errors (`migrate`/`seed` still TODO) — this fixture is **not** booted for real; its job is to prove `init` and `check` cooperate 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).
3. **Unit-level tests on `lane-detect.js` directly** (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 neither `start`/`dev` nor `preview`/`dev`), the shell-identifier validator rejecting a malicious compose service name / script name, no-package.json → `null`, `backend/` without `frontend/` → `null` (single-service check on repo root, which also has none → `null`), `--force` allowing 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 `.sh` file *text* (not a `profile.env` value, 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_REWRITE` alongside `DB_PREFIX` when a `.env`/`.env.example` exists — a database with no `.env` rewiring 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 is `profile check`, run explicitly by the human, not a silent failure the first time someone tries `lanes up`.
