# Lane Profile Scaffolding (A3, Node preset) Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** `ccam lanes profile init ` detects a Node.js project (single-service or backend+frontend monorepo) and scaffolds a working `.ccam/profile/`; `ccam lanes profile check []` validates one before it's trusted. **Architecture:** One new library module (`server/lib/lane-detect.js`) with three pure functions — `detectNode` (read-only filesystem inspection), `scaffoldProfile` (writes `.ccam/profile/**`), `checkProfile` (read-only validator) — plus two new CLI subcommands in `bin/ccam.js`. No new HTTP route, no database, no lane row required. **Tech Stack:** Node.js, `js-yaml` (already a project dependency, used here for the first time to parse `docker-compose.yml`), `node:test` + `node:assert/strict` (matches every other `server/__tests__/*.test.js` in this repo). ## Global Constraints - Spec: `docs/superpowers/specs/2026-08-03-lane-profile-scaffolding-design.md` — read it before Task 1, it has the full rationale for every rule below. - **Every applicable source file MUST start with the project's authorship header** (file overview + `@author Nguyễn Ngọc Trí Vĩ `) — see `.claude/skills/file-headers/`, verify with `bash .claude/skills/file-headers/scripts/check-headers.sh`. - **Layout precedence:** `backend/package.json` AND `frontend/package.json` both present → `monorepo`, checked *before* falling back to a root `package.json` → `single-service`. - **Shell-identifier validator:** any detected name (npm script name, docker-compose service name) written into generated **shell-script text** (not a `profile.env` `KEY=VALUE` line — those are already safe, parsed-never-sourced) must match `/^[\w.:-]+$/` first. A name that fails is treated as "not found" for that line. - **TODO hook bodies always `exit 0`.** A scaffold with an unresolved TODO must still be bootable; `checkProfile` is the hard gate, not the hook itself. - Node.js single preset only. No Python/Go/Ruby, no ORM/migration-tool detection, no pnpm/yarn/turborepo workspace layouts, no HTTP route, no `--fix` mode. If you find yourself building any of those, stop — it's out of scope, see the spec's Non-goals. - Run `npm run test:server` before considering any task done — this repo's whole `server/__tests__/*.test.js` suite must stay green (there are 3 pre-existing, unrelated `ccam-cli.test.js` color-detection failures in this sandbox — confirmed via `git stash` — don't chase those). --- ### Task 1: `detectNode` — layout and script detection **Files:** - Create: `server/lib/lane-detect.js` - Test: `server/__tests__/lane-detect.test.js` **Interfaces:** - Produces: `IDENTIFIER_RE` (RegExp, exported for Task 3's reuse), `readPackageJson(dir)` → `object|null`, `pickScript(pkg, candidates)` → `string|null`, `detectNode(repoPath)` → the facts object below, or `null` when nothing is detected. Facts object shape (grow it further in Task 2 — this task only fills `layout`/`services`): ```js { layout: "monorepo" | "single-service", services: { // monorepo: backend: { dir: "backend", script: "start" | "dev" | null }, frontend: { dir: "frontend", script: "preview" | "dev" | null }, // OR single-service: app: { dir: ".", script: "start" | "dev" | null }, }, database: null, // Task 2 fills this env: null, // Task 2 fills this } ``` - [ ] **Step 1: Write the failing test** Create `server/__tests__/lane-detect.test.js`: ```js /** * @file Tests for Node.js project detection and profile scaffolding * (server/lib/lane-detect.js): layout precedence, script selection, the * shell-identifier validator, scaffold output, and the profile checker. * @author Nguyễn Ngọc Trí Vĩ */ const os = require("node:os"); const path = require("node:path"); const fs = require("node:fs"); const { describe, it, after } = require("node:test"); const assert = require("node:assert/strict"); const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-detect-")); // Must be set before the first require of anything that transitively loads // server/lib/secrets.js (lane-detect.js does, for checkProfile) — SECRETS_PATH // is bound once at that module's first load, and without this a test run on a // machine with a real ~/.ccam/secrets.env would read that file instead of a // deterministic, isolated one. process.env.CCAM_SECRETS_PATH = path.join(SUITE_ROOT, "secrets.env"); process.env.DASHBOARD_DB_PATH = path.join(SUITE_ROOT, "dashboard.db"); process.env.LANES_ROOT = path.join(SUITE_ROOT, "lanes"); after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true })); const detect = require("../lib/lane-detect"); let repoSeq = 0; function makeRepo() { repoSeq += 1; const dir = path.join(SUITE_ROOT, `repo-${repoSeq}`); fs.mkdirSync(dir, { recursive: true }); return dir; } function writePkg(dir, scripts) { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ scripts }, null, 2)); } describe("detectNode: layout", () => { it("returns null when nothing is detected", () => { const repo = makeRepo(); assert.equal(detect.detectNode(repo), null); }); it("detects single-service from a root package.json", () => { const repo = makeRepo(); writePkg(repo, { start: "node index.js" }); const facts = detect.detectNode(repo); assert.equal(facts.layout, "single-service"); assert.equal(facts.services.app.script, "start"); }); it("prefers monorepo when both backend/ and frontend/ package.json exist, even with a root one too", () => { const repo = makeRepo(); writePkg(repo, { start: "node root.js" }); writePkg(path.join(repo, "backend"), { start: "node server.js" }); writePkg(path.join(repo, "frontend"), { dev: "vite" }); const facts = detect.detectNode(repo); assert.equal(facts.layout, "monorepo"); assert.equal(facts.services.backend.script, "start"); assert.equal(facts.services.frontend.script, "dev"); }); it("does not detect a backend/ with no matching frontend/", () => { const repo = makeRepo(); writePkg(path.join(repo, "backend"), { start: "node server.js" }); assert.equal(detect.detectNode(repo), null); }); }); describe("detectNode: script selection", () => { it("backend prefers start over dev", () => { const repo = makeRepo(); writePkg(path.join(repo, "backend"), { start: "node a.js", dev: "node b.js" }); writePkg(path.join(repo, "frontend"), { dev: "vite" }); assert.equal(detect.detectNode(repo).services.backend.script, "start"); }); it("frontend prefers preview over dev", () => { const repo = makeRepo(); writePkg(path.join(repo, "backend"), { start: "node a.js" }); writePkg(path.join(repo, "frontend"), { dev: "vite", preview: "vite preview" }); assert.equal(detect.detectNode(repo).services.frontend.script, "preview"); }); it("falls back to null (not a guess) when no candidate script exists", () => { const repo = makeRepo(); writePkg(path.join(repo, "backend"), { test: "jest" }); writePkg(path.join(repo, "frontend"), { build: "vite build" }); const facts = detect.detectNode(repo); assert.equal(facts.services.backend.script, null); assert.equal(facts.services.frontend.script, null); }); }); describe("IDENTIFIER_RE", () => { it("accepts plain identifiers", () => { for (const ok of ["start", "dev:watch", "build.prod", "api-server"]) { assert.ok(detect.IDENTIFIER_RE.test(ok), ok); } }); it("rejects shell metacharacters", () => { for (const bad of ["start; rm -rf /", "$(id)", "a b", "a|b", "a`b`"]) { assert.ok(!detect.IDENTIFIER_RE.test(bad), bad); } }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `node --test server/__tests__/lane-detect.test.js` Expected: FAIL — `Cannot find module '../lib/lane-detect'` - [ ] **Step 3: Write minimal implementation** Create `server/lib/lane-detect.js`: ```js /** * @file Node.js project detection and `.ccam/profile/` scaffolding (A3). * `detectNode` reads a repository's filesystem (package.json layout, * docker-compose.yml) without executing anything; `scaffoldProfile` turns * those facts into a working profile; `checkProfile` validates one. Only a * single preset (Node.js, single-service or a flat backend/+frontend/ * monorepo) is detected — anything else is refused rather than guessed, per * `docs/superpowers/specs/2026-08-03-lane-profile-scaffolding-design.md`. * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("node:fs"); const path = require("node:path"); /** * A name safe to embed as literal text inside a generated shell script (an * npm script name, a docker-compose service name). Deliberately NOT applied * to profile.env `KEY=VALUE` values — those are parsed, never sourced, and * already safe by construction (see `lane-profile.js:parseEnvFile`). This * guard is only for names that get spliced into `.sh` file TEXT. */ const IDENTIFIER_RE = /^[\w.:-]+$/; /** Read and parse a package.json, or null if it doesn't exist or is invalid JSON. */ function readPackageJson(dir) { try { return JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8")); } catch { return null; } } /** * First candidate script name present in `pkg.scripts` AND safe to embed in * shell text. Returns null (never a guess) when no candidate qualifies. * * @param {object|null} pkg - Parsed package.json. * @param {string[]} candidates - Script names in preference order. * @returns {string|null} */ function pickScript(pkg, candidates) { if (!pkg || typeof pkg.scripts !== "object" || !pkg.scripts) return null; for (const name of candidates) { if (typeof pkg.scripts[name] === "string" && IDENTIFIER_RE.test(name)) return name; } return null; } /** * Detect a Node.js project's layout and, for each service, which npm script * to run. Returns null when neither supported layout matches — the caller * must refuse rather than guess. * * @param {string} repoPath - Absolute path to the repository root. * @returns {object|null} */ function detectNode(repoPath) { const backendPkg = readPackageJson(path.join(repoPath, "backend")); const frontendPkg = readPackageJson(path.join(repoPath, "frontend")); if (backendPkg && frontendPkg) { return { layout: "monorepo", services: { backend: { dir: "backend", script: pickScript(backendPkg, ["start", "dev"]) }, frontend: { dir: "frontend", script: pickScript(frontendPkg, ["preview", "dev"]) }, }, database: null, env: null, }; } const rootPkg = readPackageJson(repoPath); if (rootPkg) { return { layout: "single-service", services: { app: { dir: ".", script: pickScript(rootPkg, ["start", "dev"]) } }, database: null, env: null, }; } return null; } module.exports = { IDENTIFIER_RE, readPackageJson, pickScript, detectNode }; ``` - [ ] **Step 4: Run test to verify it passes** Run: `node --test server/__tests__/lane-detect.test.js` Expected: PASS (11 tests) - [ ] **Step 5: Verify the header audit passes** Run: `bash .claude/skills/file-headers/scripts/check-headers.sh` Expected: `✔ All applicable files carry the authorship header.` - [ ] **Step 6: Commit** ```bash git add server/lib/lane-detect.js server/__tests__/lane-detect.test.js git commit -m "feat(lanes): detect a Node.js project's layout and boot scripts (A3)" ``` --- ### Task 2: `detectNode` — database, Redis, and `.env` wiring **Files:** - Modify: `server/lib/lane-detect.js` - Test: `server/__tests__/lane-detect.test.js` **Interfaces:** - Consumes: `IDENTIFIER_RE`, `detectNode` from Task 1. - Produces: `detectNode`'s `database` and `env` fields, now filled in: ```js database: { dbPrefix: string, dbService: string, dbKind: "postgres" } | null, redis: boolean, env: { dir: string, file: string, source: string, fromExample: boolean, rewrite: string[] } | null, ``` `env.dir`/`env.file`/`env.source` are paths **relative to the repo root** (e.g. `env.file = "backend/.env"`). `env.rewrite` is `["DATABASE_URL"]`, `["DATABASE_URL", "REDIS_URL"]`, or `["REDIS_URL"]` depending on what was detected. - [ ] **Step 1: Write the failing test** Append to `server/__tests__/lane-detect.test.js`: ```js function writeCompose(repo, servicesYaml) { fs.writeFileSync( path.join(repo, "docker-compose.yml"), `services:\n${servicesYaml}\n` ); } describe("detectNode: database + Redis + env wiring", () => { it("detects a postgres service in docker-compose.yml", () => { const repo = makeRepo(); writePkg(path.join(repo, "backend"), { start: "node server.js" }); writePkg(path.join(repo, "frontend"), { dev: "vite" }); writeCompose(repo, " postgres:\n image: postgres:16\n"); const facts = detect.detectNode(repo); assert.equal(facts.database.dbKind, "postgres"); assert.equal(facts.database.dbService, "postgres"); assert.match(facts.database.dbPrefix, /_l$/); assert.equal(facts.redis, false); }); it("detects redis independently of postgres", () => { const repo = makeRepo(); writePkg(path.join(repo, "backend"), { start: "node server.js" }); writePkg(path.join(repo, "frontend"), { dev: "vite" }); writeCompose(repo, " cache_redis:\n image: redis:7\n"); const facts = detect.detectNode(repo); assert.equal(facts.database, null); assert.equal(facts.redis, true); }); it("finds no database when there is no docker-compose.yml", () => { const repo = makeRepo(); writePkg(repo, { start: "node index.js" }); const facts = detect.detectNode(repo); assert.equal(facts.database, null); assert.equal(facts.redis, false); assert.equal(facts.env, null); }); it("wires ENV_REWRITE when a database is found and backend/.env.example exists", () => { const repo = makeRepo(); writePkg(path.join(repo, "backend"), { start: "node server.js" }); writePkg(path.join(repo, "frontend"), { dev: "vite" }); writeCompose(repo, " postgres:\n image: postgres:16\n redis:\n image: redis:7\n"); fs.writeFileSync(path.join(repo, "backend", ".env.example"), "FOO=bar\n"); const facts = detect.detectNode(repo); assert.equal(facts.env.file, "backend/.env"); assert.equal(facts.env.source, "backend/.env.example"); assert.equal(facts.env.fromExample, true); assert.deepEqual(facts.env.rewrite, ["DATABASE_URL", "REDIS_URL"]); }); it("leaves env null when a database is found but there is no .env or .env.example", () => { const repo = makeRepo(); writePkg(repo, { start: "node index.js" }); writeCompose(repo, " postgres:\n image: postgres:16\n"); assert.equal(detect.detectNode(repo).env, null); }); it("rejects a docker-compose service name that fails the shell-identifier check", () => { const repo = makeRepo(); writePkg(repo, { start: "node index.js" }); writeCompose(repo, " \"postgres; rm -rf /\":\n image: postgres:16\n"); assert.equal(detect.detectNode(repo).database, null); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `node --test server/__tests__/lane-detect.test.js` Expected: FAIL — `facts.database` is `null` in the postgres/redis tests (not yet implemented), and `writeCompose` tests throw or mismatch. - [ ] **Step 3: Write minimal implementation** `js-yaml` is already a project dependency (`package.json`) — do not add a new one. Add to `server/lib/lane-detect.js`: ```js const yaml = require("js-yaml"); /** A repo-name-derived DB_PREFIX: lowercase alnum + underscore, "_l" suffix * (lane N's actual name becomes "N"), matching the convention in * the postgres-compose profile template's example. */ function dbPrefixFor(repoPath) { const base = path .basename(repoPath) .toLowerCase() .replace(/[^a-z0-9]+/g, "_") .replace(/^_+|_+$/g, ""); return `${base || "app"}_l`; } /** The first docker-compose service name matching `pattern`, or null. Reads * `docker-compose.yml`/`.yaml` at the repo root; never executes it. */ function findComposeService(repoPath, pattern) { for (const name of ["docker-compose.yml", "docker-compose.yaml"]) { let doc; try { doc = yaml.load(fs.readFileSync(path.join(repoPath, name), "utf8")); } catch { continue; } const services = doc && typeof doc.services === "object" ? doc.services : {}; for (const serviceName of Object.keys(services)) { if (pattern.test(serviceName) && IDENTIFIER_RE.test(serviceName)) return serviceName; } } return null; } /** Where a lane's .env lives for this layout: backend/.env in a monorepo, * .env at the root for single-service. Returns the source to seed FROM * (real .env, else .env.example) or null when neither exists. */ function findEnvSource(repoPath, envDir) { const dir = path.join(repoPath, envDir); const real = path.join(dir, ".env"); const example = path.join(dir, ".env.example"); const relFile = path.join(envDir, ".env").replace(/\\/g, "/").replace(/^\.\//, ""); if (fs.existsSync(real)) return { file: relFile, source: relFile, fromExample: false }; if (fs.existsSync(example)) { return { file: relFile, source: `${relFile}.example`, fromExample: true }; } return null; } function detectDatabaseAndEnv(repoPath, envDir) { const dbService = findComposeService(repoPath, /postgres/i); const redis = findComposeService(repoPath, /redis/i) !== null; const database = dbService ? { dbPrefix: dbPrefixFor(repoPath), dbService, dbKind: "postgres" } : null; let env = null; if (database || redis) { const found = findEnvSource(repoPath, envDir); if (found) { const rewrite = []; if (database) rewrite.push("DATABASE_URL"); if (redis) rewrite.push("REDIS_URL"); env = { dir: envDir, ...found, rewrite }; } } return { database, redis, env }; } ``` Then wire it into `detectNode` — replace the two `return { ... database: null, env: null }` blocks: ```js if (backendPkg && frontendPkg) { const { database, redis, env } = detectDatabaseAndEnv(repoPath, "backend"); return { layout: "monorepo", services: { backend: { dir: "backend", script: pickScript(backendPkg, ["start", "dev"]) }, frontend: { dir: "frontend", script: pickScript(frontendPkg, ["preview", "dev"]) }, }, database, redis, env, }; } const rootPkg = readPackageJson(repoPath); if (rootPkg) { const { database, redis, env } = detectDatabaseAndEnv(repoPath, "."); return { layout: "single-service", services: { app: { dir: ".", script: pickScript(rootPkg, ["start", "dev"]) } }, database, redis, env, }; } ``` Update the module exports: ```js module.exports = { IDENTIFIER_RE, readPackageJson, pickScript, dbPrefixFor, findComposeService, findEnvSource, detectNode, }; ``` - [ ] **Step 4: Run test to verify it passes** Run: `node --test server/__tests__/lane-detect.test.js` Expected: PASS (17 tests) - [ ] **Step 5: Commit** ```bash git add server/lib/lane-detect.js server/__tests__/lane-detect.test.js git commit -m "feat(lanes): detect docker-compose database/Redis and wire ENV_REWRITE (A3)" ``` --- ### Task 3: `scaffoldProfile` — write `.ccam/profile/` **Files:** - Modify: `server/lib/lane-detect.js` - Test: `server/__tests__/lane-detect.test.js` - Reuse (read, don't modify): `server/data/profile-templates/postgres-compose/hooks/db-create.sh`, `db-drop.sh` **Interfaces:** - Consumes: `detectNode`'s facts object (Task 1+2 shape) and `IDENTIFIER_RE`. - Produces: `scaffoldProfile(repoPath, facts, options = {})` → `{ written: string[], todos: string[] }`. `options.force` (boolean) allows overwriting an existing `.ccam/profile/profile.env`. Throws `Object.assign(new Error(...), { code: "EPROFILEEXISTS" })` when one exists and `force` is not `true`. - [ ] **Step 1: Write the failing test** Append to `server/__tests__/lane-detect.test.js`: ```js describe("scaffoldProfile", () => { it("writes a single-service profile with no database: bootstrap/boot/health only, zero TODOs", () => { const repo = makeRepo(); writePkg(repo, { start: "node index.js" }); const facts = detect.detectNode(repo); const result = detect.scaffoldProfile(repo, facts); const profileDir = path.join(repo, ".ccam", "profile"); const env = fs.readFileSync(path.join(profileDir, "profile.env"), "utf8"); assert.match(env, /PORTS="app"/); assert.match(env, /PORT_BASE_app=3000/); assert.doesNotMatch(env, /DB_PREFIX/); assert.doesNotMatch(env, /TODO/); for (const hook of ["bootstrap.sh", "boot.sh", "health.sh"]) { const hookPath = path.join(profileDir, "hooks", hook); assert.ok(fs.existsSync(hookPath), hook); assert.ok(fs.statSync(hookPath).mode & 0o111, `${hook} must be executable`); } assert.match( fs.readFileSync(path.join(profileDir, "hooks", "boot.sh"), "utf8"), /npm run start/ ); assert.equal(result.todos.length, 0); }); it("writes a monorepo profile with a database: DB_PREFIX, ENV_REWRITE, copied db hooks, TODO migrate/seed", () => { const repo = makeRepo(); writePkg(path.join(repo, "backend"), { start: "node server.js" }); writePkg(path.join(repo, "frontend"), { dev: "vite" }); // no preview -> exercises dev fallback writeCompose(repo, " postgres:\n image: postgres:16\n redis:\n image: redis:7\n"); fs.writeFileSync(path.join(repo, "backend", ".env.example"), "FOO=bar\n"); const facts = detect.detectNode(repo); const result = detect.scaffoldProfile(repo, facts); const profileDir = path.join(repo, ".ccam", "profile"); const env = fs.readFileSync(path.join(profileDir, "profile.env"), "utf8"); assert.match(env, /DB_PREFIX=/); assert.match(env, /REDIS=1/); assert.match(env, /ENV_FILES="backend\/\.env"/); assert.match(env, /ENV_REWRITE="DATABASE_URL REDIS_URL"/); for (const hook of ["db-create.sh", "db-drop.sh", "migrate.sh", "seed.sh"]) { assert.ok(fs.existsSync(path.join(profileDir, "hooks", hook)), hook); } assert.match( fs.readFileSync(path.join(profileDir, "hooks", "migrate.sh"), "utf8"), /exit 0/ ); assert.match( fs.readFileSync(path.join(profileDir, "hooks", "boot.sh"), "utf8"), /npm run dev/ // frontend fallback, no preview script ); assert.ok(result.todos.length >= 2, "migrate and seed are both TODO"); }); it("writes a TODO boot line (never a guess) when no start/dev script was found", () => { const repo = makeRepo(); writePkg(repo, { test: "jest" }); // no start, no dev const facts = { layout: "single-service", services: { app: { dir: ".", script: null } }, database: null, redis: false, env: null }; const result = detect.scaffoldProfile(repo, facts); const boot = fs.readFileSync(path.join(repo, ".ccam", "profile", "hooks", "boot.sh"), "utf8"); assert.match(boot, /# TODO: no start\/dev script found/); assert.equal(result.todos.length, 1); }); it("refuses to overwrite an existing profile without force", () => { const repo = makeRepo(); writePkg(repo, { start: "node index.js" }); const facts = detect.detectNode(repo); detect.scaffoldProfile(repo, facts); assert.throws(() => detect.scaffoldProfile(repo, facts), (err) => err.code === "EPROFILEEXISTS"); }); it("overwrites when force is true", () => { const repo = makeRepo(); writePkg(repo, { start: "node index.js" }); const facts = detect.detectNode(repo); detect.scaffoldProfile(repo, facts); assert.doesNotThrow(() => detect.scaffoldProfile(repo, facts, { force: true })); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `node --test server/__tests__/lane-detect.test.js` Expected: FAIL — `detect.scaffoldProfile is not a function` - [ ] **Step 3: Write minimal implementation** Add to `server/lib/lane-detect.js`: ```js const TEMPLATE_DB_HOOKS_DIR = path.join( __dirname, "..", "data", "profile-templates", "postgres-compose", "hooks" ); function writeHook(profileDir, name, body) { const file = path.join(profileDir, "hooks", `${name}.sh`); fs.writeFileSync(file, body); fs.chmodSync(file, 0o755); } /** * A single service's boot.sh line: a real harness_spawn call, or a TODO * comment (never a guessed command) when no script was found. * * Passes the port via a `PORT` environment variable, not a `--port` argv * flag: framework CLI flags for the port genuinely differ (`-p` for Next.js, * `--port` for Vite, nothing at all for a plain Express app), but reading * `process.env.PORT` is the one convention broad enough to default to. It * will not work for every framework — same disclaimer as the hardcoded * `PORT_BASE_*` values themselves — but it's the safest single default. */ function bootLine(name, service) { if (!service.script) { return `# TODO: no start/dev script found in ${service.dir}/package.json — edit this line`; } const portVar = `${name.toUpperCase()}_PORT`; const workdir = service.dir === "." ? '"$LANE_DIR"' : `"$LANE_DIR/${service.dir}"`; return `harness_spawn ${name} ${workdir} env PORT="$${portVar}" npm run ${service.script}`; } /** * Scaffold `.ccam/profile/` under `repoPath` from `detectNode`'s facts. * Refuses to overwrite an existing `profile.env` unless `options.force`. * * @param {string} repoPath - Absolute repository path. * @param {object} facts - From `detectNode()`. * @param {{force?: boolean}} [options] * @returns {{written: string[], todos: string[]}} */ function scaffoldProfile(repoPath, facts, options = {}) { const profileDir = path.join(repoPath, ".ccam", "profile"); const envPath = path.join(profileDir, "profile.env"); if (fs.existsSync(envPath) && !options.force) { throw Object.assign(new Error(`profile already exists at ${envPath}`), { code: "EPROFILEEXISTS", }); } fs.mkdirSync(path.join(profileDir, "hooks"), { recursive: true }); const written = []; const todos = []; const lines = []; const bootLines = []; if (facts.layout === "monorepo") { lines.push('PORTS="api fe"', "PORT_BASE_api=8000", "PORT_BASE_fe=3000"); bootLines.push(bootLine("api", facts.services.backend)); bootLines.push(bootLine("fe", facts.services.frontend)); } else { lines.push('PORTS="app"', "PORT_BASE_app=3000"); bootLines.push(bootLine("app", facts.services.app)); } for (const line of bootLines) if (line.startsWith("# TODO")) todos.push(line); if (facts.database) { lines.push( `DB_PREFIX=${facts.database.dbPrefix}`, "DB_KIND=postgres", "DB_URL_SCHEME=postgresql", "COMPOSE_FILE=docker-compose.yml", `DB_SERVICE=${facts.database.dbService}` ); } if (facts.redis) lines.push("REDIS=1"); if (facts.env) { lines.push( `ENV_FILES="${facts.env.file}"`, `ENV_SOURCE="${facts.env.source}"`, `ENV_REWRITE="${facts.env.rewrite.join(" ")}"` ); } fs.writeFileSync(envPath, `${lines.join("\n")}\n`); written.push("profile.env"); writeHook( profileDir, "bootstrap", facts.layout === "monorepo" ? '#!/usr/bin/env bash\nset -euo pipefail\nnpm install --prefix "$LANE_DIR/backend"\nnpm install --prefix "$LANE_DIR/frontend"\n' : '#!/usr/bin/env bash\nset -euo pipefail\nnpm install --prefix "$LANE_DIR"\n' ); writeHook(profileDir, "boot", `#!/usr/bin/env bash\nset -euo pipefail\n${bootLines.join("\n")}\n`); const healthPortVar = facts.layout === "monorepo" ? "FE_PORT" : "APP_PORT"; writeHook( profileDir, "health", `#!/usr/bin/env bash\nset -euo pipefail\ncurl -sf --retry 30 --retry-delay 1 --retry-all-errors "http://127.0.0.1:$${healthPortVar}/" >/dev/null\n` ); written.push("hooks/bootstrap.sh", "hooks/boot.sh", "hooks/health.sh"); if (facts.database) { for (const name of ["db-create", "db-drop"]) { fs.copyFileSync( path.join(TEMPLATE_DB_HOOKS_DIR, `${name}.sh`), path.join(profileDir, "hooks", `${name}.sh`) ); fs.chmodSync(path.join(profileDir, "hooks", `${name}.sh`), 0o755); written.push(`hooks/${name}.sh`); } const todoBody = (label) => `#!/usr/bin/env bash\necho "TODO: no ${label} tool detected — add your ${label} command here"\nexit 0\n`; writeHook(profileDir, "migrate", todoBody("migration")); writeHook(profileDir, "seed", todoBody("seed")); written.push("hooks/migrate.sh", "hooks/seed.sh"); todos.push("hooks/migrate.sh", "hooks/seed.sh"); } return { written, todos }; } module.exports = { IDENTIFIER_RE, readPackageJson, pickScript, dbPrefixFor, findComposeService, findEnvSource, detectNode, scaffoldProfile, }; ``` - [ ] **Step 4: Run test to verify it passes** Run: `node --test server/__tests__/lane-detect.test.js` Expected: PASS (22 tests) - [ ] **Step 5: Run the file-header audit** Run: `bash .claude/skills/file-headers/scripts/check-headers.sh` Expected: pass (no new applicable files created this task — `db-create.sh`/`db-drop.sh` are *copied*, and the template source already carries its header) - [ ] **Step 6: Commit** ```bash git add server/lib/lane-detect.js server/__tests__/lane-detect.test.js git commit -m "feat(lanes): scaffold .ccam/profile/ from detected Node facts (A3)" ``` --- ### Task 4: `checkProfile` — read-only validator **Files:** - Modify: `server/lib/lane-detect.js` - Test: `server/__tests__/lane-detect.test.js` **Interfaces:** - Consumes: `server/lib/lane-profile.js:parseEnvFile` (already exported), `server/lib/ports.js:isListening` (already exported, async), `server/lib/secrets.js:SECRETS_PATH` (already exported). - Produces: `async checkProfile(dir)` → `{ ok: boolean, errors: string[], warnings: string[] }`. `dir` is a directory that may contain `.ccam/profile/` directly, OR may already point at a `.ccam/profile/` directory itself — check `.ccam/profile/profile.env` first, then `profile.env` directly, so `checkProfile(repoPath)` and `checkProfile(profileDir)` both work. - [ ] **Step 1: Write the failing test** Append to `server/__tests__/lane-detect.test.js`: ```js describe("checkProfile", () => { it("passes a freshly-scaffolded no-database profile with a real script", async () => { const repo = makeRepo(); writePkg(repo, { start: "node index.js" }); detect.scaffoldProfile(repo, detect.detectNode(repo)); const result = await detect.checkProfile(repo); assert.deepEqual(result.errors, []); assert.equal(result.ok, true); }); it("fails on a database profile with unresolved migrate/seed TODOs", async () => { const repo = makeRepo(); writePkg(path.join(repo, "backend"), { start: "node server.js" }); writePkg(path.join(repo, "frontend"), { dev: "vite" }); writeCompose(repo, " postgres:\n image: postgres:16\n"); detect.scaffoldProfile(repo, detect.detectNode(repo)); const result = await detect.checkProfile(repo); assert.equal(result.ok, false); assert.ok(result.errors.some((e) => /TODO/.test(e))); }); it("fails when a declared port is already in use", async () => { const net = require("node:net"); const repo = makeRepo(); writePkg(repo, { start: "node index.js" }); detect.scaffoldProfile(repo, detect.detectNode(repo)); const server = net.createServer(() => {}); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(3000, "127.0.0.1", resolve); }); try { const result = await detect.checkProfile(repo); assert.equal(result.ok, false); assert.ok(result.errors.some((e) => /3000/.test(e))); } finally { server.close(); } }); it("fails when a referenced hook is missing", async () => { const repo = makeRepo(); writePkg(repo, { start: "node index.js" }); detect.scaffoldProfile(repo, detect.detectNode(repo)); fs.rmSync(path.join(repo, ".ccam", "profile", "hooks", "health.sh")); const result = await detect.checkProfile(repo); assert.equal(result.ok, false); assert.ok(result.errors.some((e) => /health/.test(e))); }); it("warns (does not fail) when secrets.env is missing for a database profile", async () => { const repo = makeRepo(); writePkg(repo, { start: "node index.js" }); writeCompose(repo, " postgres:\n image: postgres:16\n"); detect.scaffoldProfile(repo, detect.detectNode(repo)); // Remove the migrate/seed TODO lines so only the secrets warning is left to check. const hooksDir = path.join(repo, ".ccam", "profile", "hooks"); fs.writeFileSync(path.join(hooksDir, "migrate.sh"), "#!/usr/bin/env bash\nexit 0\n"); fs.writeFileSync(path.join(hooksDir, "seed.sh"), "#!/usr/bin/env bash\nexit 0\n"); const result = await detect.checkProfile(repo); assert.ok(result.warnings.some((w) => /secrets\.env/.test(w))); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `node --test server/__tests__/lane-detect.test.js` Expected: FAIL — `detect.checkProfile is not a function` - [ ] **Step 3: Write minimal implementation** Add to `server/lib/lane-detect.js`: ```js const { parseEnvFile, HOOKS } = require("./lane-profile"); const { isListening } = require("./ports"); const { SECRETS_PATH } = require("./secrets"); /** Every hook name a scaffolded Node profile might reference, given what * profile.env declares — mirrors the same "only what's declared" gating * scaffoldProfile itself uses. */ function referencedHooks(env) { const hooks = ["bootstrap", "boot", "health"]; if (env.DB_PREFIX) hooks.push("db-create", "db-drop", "migrate", "seed"); return hooks; } function containsTodo(text) { return text.includes("TODO:"); } /** * Validate a scaffolded (or hand-written) profile. Read-only: never mutates * anything, never spawns a hook. `dir` may be a repo root (profile at * `.ccam/profile/`) or a `.ccam/profile/` directory itself. * * @param {string} dir * @returns {Promise<{ok: boolean, errors: string[], warnings: string[]}>} */ async function checkProfile(dir) { const errors = []; const warnings = []; const candidates = [path.join(dir, ".ccam", "profile"), dir]; const profileDir = candidates.find((p) => fs.existsSync(path.join(p, "profile.env"))); if (!profileDir) { return { ok: false, errors: [`no profile.env found under ${dir}`], warnings: [] }; } const envPath = path.join(profileDir, "profile.env"); const envText = fs.readFileSync(envPath, "utf8"); let env; try { env = parseEnvFile(envText); } catch (err) { return { ok: false, errors: [`profile.env does not parse: ${err.message}`], warnings: [] }; } if (containsTodo(envText)) errors.push("profile.env still has an unresolved TODO"); for (const name of referencedHooks(env)) { const hookPath = path.join(profileDir, "hooks", `${name}.sh`); if (!fs.existsSync(hookPath)) { errors.push(`missing hook: ${name}.sh`); continue; } if (!(fs.statSync(hookPath).mode & 0o111)) errors.push(`hook not executable: ${name}.sh`); const body = fs.readFileSync(hookPath, "utf8"); if (containsTodo(body)) errors.push(`${name}.sh still has an unresolved TODO`); } if (env.DB_PREFIX && !fs.existsSync(SECRETS_PATH)) { warnings.push(`no secrets.env at ${SECRETS_PATH} — database will use built-in defaults`); } for (const key of Object.keys(env)) { const match = /^PORT_BASE_(.+)$/.exec(key); if (!match) continue; const port = Number(env[key]); if (Number.isInteger(port) && (await isListening(port))) { errors.push(`port ${port} (PORT_BASE_${match[1]}) is already in use`); } } return { ok: errors.length === 0, errors, warnings }; } module.exports = { IDENTIFIER_RE, readPackageJson, pickScript, dbPrefixFor, findComposeService, findEnvSource, detectNode, scaffoldProfile, checkProfile, }; ``` - [ ] **Step 4: Run test to verify it passes** Run: `node --test server/__tests__/lane-detect.test.js` Expected: PASS (27 tests) - [ ] **Step 5: Commit** ```bash git add server/lib/lane-detect.js server/__tests__/lane-detect.test.js git commit -m "feat(lanes): validate a scaffolded profile with checkProfile (A3)" ``` --- ### Task 5: CLI — `ccam lanes profile init` / `ccam lanes profile check` **Files:** - Modify: `bin/ccam.js` - Test: manual (this repo's CLI tests spawn the real binary against a live/offline server; profile init/check are pure-filesystem and don't need the server — verified by hand per this task's steps, folded into Task 6's fixtures for automated coverage) **Interfaces:** - Consumes: `server/lib/lane-detect.js`'s `detectNode`, `scaffoldProfile`, `checkProfile` (all synchronous except `checkProfile`). - [ ] **Step 1: Add the command implementations** In `bin/ccam.js`, near `cmdLanesAdd` (search for that function), add: ```js /** * `ccam lanes profile init ` — detect a Node.js project and scaffold * `.ccam/profile/`. Pure filesystem action against the SOURCE repo; does not * talk to the dashboard server at all. */ function cmdLanesProfileInit(args) { const repo = args.find((arg) => !arg.startsWith("--")); const force = args.includes("--force"); if (!repo) { console.error("usage: ccam lanes profile init [--force]"); process.exitCode = 1; return; } const resolved = path.resolve(repo); if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) { console.error(`✖ not a directory: ${resolved}`); process.exitCode = 1; return; } const laneDetect = require(path.join(REPO_ROOT, "server", "lib", "lane-detect.js")); const facts = laneDetect.detectNode(resolved); if (!facts) { console.error( `✖ No detectable Node.js project at ${resolved} (looked for backend/package.json +\n` + " frontend/package.json, or a root package.json).\n" + " Auto-scaffolding currently supports Node.js repos in that layout only.\n" + " Write .ccam/profile/ by hand — see docs/LANES.md." ); process.exitCode = 1; return; } let result; try { result = laneDetect.scaffoldProfile(resolved, facts, { force }); } catch (err) { if (err.code === "EPROFILEEXISTS") { console.error(`✖ ${err.message} — pass --force to overwrite it.`); process.exitCode = 1; return; } throw err; } console.log(`${c.green("✔")} Scaffolded .ccam/profile/ at ${resolved} (${facts.layout})`); for (const file of result.written) console.log(` wrote ${file}`); if (result.todos.length) { console.log(`\n${c.yellow(`⚠ ${result.todos.length} item(s) need manual attention:`)}`); for (const todo of result.todos) console.log(` ${todo}`); } console.log(`\nNext: ccam lanes profile check ${repo}`); } /** * `ccam lanes profile check []` — validate a profile without needing a * lane to exist for it yet. Defaults to the current directory, NOT lane-id * resolution (unlike every other `lanes` subcommand) — this is meant to run * against a bare repo right after `profile init`. */ async function cmdLanesProfileCheck(args) { const target = args.find((arg) => !arg.startsWith("--")) || process.cwd(); const resolved = path.resolve(target); const laneDetect = require(path.join(REPO_ROOT, "server", "lib", "lane-detect.js")); const result = await laneDetect.checkProfile(resolved); if (result.errors.length === 0) { console.log(`${c.green("✔")} profile at ${resolved} looks good`); } else { console.log(`${c.red(`✖ ${result.errors.length} problem(s) at ${resolved}:`)}`); for (const error of result.errors) console.log(` ${error}`); } for (const warning of result.warnings) console.log(`${c.yellow("⚠")} ${warning}`); process.exitCode = result.ok ? 0 : 1; } ``` - [ ] **Step 2: Wire the subcommand dispatch** In `bin/ccam.js`, the dispatcher (`const [cmd, ...rest] = argv;` around line 2695) has this exact `case "lanes":` block around line 2755 — find it with `grep -n 'case "lanes"' bin/ccam.js`: ```js case "lanes": if (rest[0] === "add") { return cmdLanesAdd(rest.slice(1)); } if (["reset", "remove", "purge"].includes(rest[0])) { return cmdLanesLifecycle(rest[0], rest.slice(1)); } if (["up", "down", "runtime", "logs", "hook"].includes(rest[0])) { return cmdLanesRuntime(rest[0], rest.slice(1)); } return cmdLanes(); ``` Replace it with (adds one `if` block for `profile`, everything else byte-identical): ```js case "lanes": if (rest[0] === "add") { return cmdLanesAdd(rest.slice(1)); } if (rest[0] === "profile") { if (rest[1] === "init") return cmdLanesProfileInit(rest.slice(2)); if (rest[1] === "check") return cmdLanesProfileCheck(rest.slice(2)); console.error( "usage: ccam lanes profile init [--force] | ccam lanes profile check []" ); process.exitCode = 1; return; } if (["reset", "remove", "purge"].includes(rest[0])) { return cmdLanesLifecycle(rest[0], rest.slice(1)); } if (["up", "down", "runtime", "logs", "hook"].includes(rest[0])) { return cmdLanesRuntime(rest[0], rest.slice(1)); } return cmdLanes(); ``` - [ ] **Step 3: Add the help-table entries** In `bin/ccam.js`'s `COMMAND_GROUPS`, in the `"Lanes"` group (search for `["lanes runtime"`), add two rows right after the `lanes add --repo` row: ```js [ "lanes profile init", " [--force]", "Detect a Node.js project and scaffold .ccam/profile/", ], [ "lanes profile check", "[]", "Validate a profile (path defaults to cwd, not a lane id)", ], ``` - [ ] **Step 4: Manual smoke test** ```bash mkdir -p /tmp/ccam-smoke/backend /tmp/ccam-smoke/frontend echo '{"scripts":{"start":"node -e \"require(\\\"http\\\").createServer((_,r)=>r.end(\\\"ok\\\")).listen(process.env.PORT||8000)\"}}' > /tmp/ccam-smoke/backend/package.json echo '{"scripts":{"dev":"true"}}' > /tmp/ccam-smoke/frontend/package.json node bin/ccam.js lanes profile init /tmp/ccam-smoke node bin/ccam.js lanes profile check /tmp/ccam-smoke ``` Expected: `init` prints `✔ Scaffolded ...`, no TODOs listed (both packages have runnable scripts, no database detected); `check` prints `✔ profile at ... looks good`, exit 0. ```bash node bin/ccam.js lanes profile init /tmp/ccam-smoke ``` Expected (run again without `--force`): `✖ profile already exists ... — pass --force to overwrite it.`, exit 1. ```bash rm -rf /tmp/ccam-smoke ``` - [ ] **Step 5: Commit** ```bash git add bin/ccam.js git commit -m "feat(lanes): add ccam lanes profile init/check CLI (A3)" ``` --- ### Task 6: End-to-end fixtures — real boot, and init+check cooperation **Files:** - Modify: `server/__tests__/lane-detect.test.js` **Interfaces:** - Consumes: `server/lib/lane-detect.js` (Tasks 1-4), `server/lib/lanes.js:createLane`, `server/lib/lane-runtime.js:upLane`/`downLane` (same pattern `server/__tests__/lane-runtime.test.js` already uses). - [ ] **Step 1: Write the failing tests** `DASHBOARD_DB_PATH`/`LANES_ROOT` are already set at the top of `server/__tests__/lane-detect.test.js` from Task 1 (needed there for `checkProfile`'s isolation, reused here for `createLane`/`upLane`). Append this new describe block at the end of the file: ```js describe("end to end: init -> check -> real boot", () => { it("a single-service, no-database repo boots for real after init", async () => { const lanesLib = require("../lib/lanes"); const runtime = require("../lib/lane-runtime"); const repo = makeRepo(); // A plain Node script reading process.env.PORT — the exact convention // bootLine's `env PORT="$..." npm run