e783a2a946
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.
1301 lines
50 KiB
Markdown
1301 lines
50 KiB
Markdown
# 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 <repo>` detects a Node.js project (single-service or backend+frontend monorepo) and scaffolds a working `.ccam/profile/`; `ccam lanes profile check [<path>]` 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ĩ <vinnt@smartgift.vn>`) — 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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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 "<prefix>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 <repo>` — 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 <repo> [--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 [<path>]` — 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 <repo> [--force] | ccam lanes profile check [<path>]"
|
|
);
|
|
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",
|
|
"<repo> [--force]",
|
|
"Detect a Node.js project and scaffold .ccam/profile/",
|
|
],
|
|
[
|
|
"lanes profile check",
|
|
"[<path>]",
|
|
"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 <script>` targets.
|
|
fs.writeFileSync(
|
|
path.join(repo, "package.json"),
|
|
JSON.stringify({
|
|
scripts: {
|
|
start:
|
|
"node -e \"require('http').createServer((_,res)=>res.end('ok')).listen(process.env.PORT)\"",
|
|
},
|
|
})
|
|
);
|
|
const facts = detect.detectNode(repo);
|
|
detect.scaffoldProfile(repo, facts);
|
|
const checked = await detect.checkProfile(repo);
|
|
assert.equal(checked.ok, true, JSON.stringify(checked.errors));
|
|
|
|
const lane = lanesLib.createLane({ title: "e2e", cwd: repo, kind: "managed" });
|
|
try {
|
|
const runtimeFacts = await runtime.upLane(lanesLib.getLane(lane.id));
|
|
assert.equal(runtimeFacts.up, true);
|
|
assert.equal(runtimeFacts.healthy, true);
|
|
} finally {
|
|
await runtime.downLane(lanesLib.getLane(lane.id));
|
|
}
|
|
});
|
|
|
|
it("a monorepo with a database scaffolds correctly but check fails until migrate/seed are finished", 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 redis:\n image: redis:7\n");
|
|
fs.writeFileSync(path.join(repo, "backend", ".env.example"), "SOMETHING=1\n");
|
|
|
|
const facts = detect.detectNode(repo);
|
|
const scaffolded = detect.scaffoldProfile(repo, facts);
|
|
assert.ok(scaffolded.todos.includes("hooks/migrate.sh"));
|
|
assert.ok(scaffolded.todos.includes("hooks/seed.sh"));
|
|
|
|
const checked = await detect.checkProfile(repo);
|
|
assert.equal(checked.ok, false);
|
|
assert.ok(checked.errors.some((e) => /migrate\.sh/.test(e)));
|
|
assert.ok(checked.errors.some((e) => /seed\.sh/.test(e)));
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `node --test server/__tests__/lane-detect.test.js`
|
|
Expected: FAIL — the two new `it()` blocks don't exist yet before this step, or (if run after Step 1's edit) fail on a missing/incorrect behavior, not on an environment error. If it fails with a `better-sqlite3`/database-path error instead, `DASHBOARD_DB_PATH`/`LANES_ROOT` from Task 1's setup aren't actually before every `require` in the file — fix that first.
|
|
|
|
- [ ] **Step 3: Run test to verify it passes**
|
|
|
|
Run: `node --test server/__tests__/lane-detect.test.js`
|
|
Expected: PASS (29 tests total)
|
|
|
|
- [ ] **Step 4: Run the full server suite**
|
|
|
|
Run: `npm run test:server`
|
|
Expected: green, aside from the 3 pre-existing unrelated `ccam-cli.test.js` failures noted in Global Constraints.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add server/__tests__/lane-detect.test.js
|
|
git commit -m "test(lanes): prove profile scaffolding end to end with a real boot (A3)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Documentation
|
|
|
|
**Files:**
|
|
- Modify: `docs/LANES.md`
|
|
- Modify: `docs/CLI.md`
|
|
- Modify: `ARCHITECTURE.md`
|
|
- Modify: `docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md` (mark A3 done)
|
|
|
|
**Interfaces:** none — documentation only.
|
|
|
|
- [ ] **Step 1: `docs/LANES.md`**
|
|
|
|
Find the `### Declaring a profile` section (search for that heading). Immediately after it (before `### Slots and ports`), add:
|
|
|
|
```markdown
|
|
### Scaffolding a profile automatically
|
|
|
|
```bash
|
|
ccam lanes profile init <repo> [--force] # detect + write .ccam/profile/
|
|
ccam lanes profile check [<path>] # validate one (path defaults to cwd)
|
|
```
|
|
|
|
`profile init` currently detects **Node.js only**, in exactly two layouts: a root `package.json` (single-service), or `backend/package.json` **and** `frontend/package.json` both present (monorepo, checked first). Anything else — Python/Go/Ruby, a pnpm/yarn/turborepo workspace, non-standard directory names — is refused with an actionable message rather than guessed at; write `.ccam/profile/` by hand for those, following the reference above.
|
|
|
|
Detection reads `package.json` (which of `start`/`dev`/`preview` exists) and `docker-compose.yml` (a service matching `/postgres/i` or `/redis/i`), never executes anything, and never guesses a migration tool: a detected database gets `migrate.sh`/`seed.sh` scaffolded as an explicit, always-`exit 0` TODO stub, not a guessed Prisma/Knex/TypeORM command. `profile check` is the hard gate — it fails (non-zero exit, every problem listed) on any leftover `TODO:`, any missing or non-executable hook, or a declared port already in use; it only *warns* when `~/.ccam/secrets.env` doesn't exist yet for a database-declaring profile.
|
|
|
|
`profile check` takes a **path**, not a lane id or `--lane`/`--cwd` flag like every other `lanes` subcommand — it is meant to run against a bare repository right after `init`, before any lane or worktree exists for it.
|
|
```
|
|
|
|
- [ ] **Step 2: `docs/CLI.md`**
|
|
|
|
Find the `### Lanes` table (search for `ccam lanes reset`). Add two rows right after the `ccam lanes add --repo ...` row:
|
|
|
|
```markdown
|
|
| `ccam lanes profile init <repo> [--force]` | Detect a Node.js project (single-service or backend+frontend monorepo) and scaffold `.ccam/profile/`. Refuses to overwrite an existing one without `--force` |
|
|
| `ccam lanes profile check [<path>]` | Validate a profile — parses, every referenced hook exists and is executable, no leftover `TODO:`, declared ports free. `<path>` defaults to the current directory (not a lane id) |
|
|
```
|
|
|
|
- [ ] **Step 3: `ARCHITECTURE.md`**
|
|
|
|
Find the `lib/lane-slots.js` row in the module responsibility table (search for that exact string) and add a new row directly after the `lib/lane-runtime.js` row:
|
|
|
|
```markdown
|
|
| `lib/lane-detect.js` | (A3) Node.js project detection and `.ccam/profile/` scaffolding. `detectNode` reads `package.json` layout (root, or `backend/`+`frontend/`) and `docker-compose.yml` (a service matching `/postgres/i` or `/redis/i`) — read-only, never executes anything. Any detected name written into GENERATED SHELL TEXT (an npm script name, a compose service name) must pass a strict identifier check first; a `profile.env` value is already safe regardless, since that file is parsed, never sourced. `scaffoldProfile` writes the profile, always leaving `migrate`/`seed` as an `exit 0` TODO stub when a database is found rather than guessing a migration tool. `checkProfile` is the read-only hard gate: unresolved `TODO:`, a missing/non-executable hook, or a port already in use all fail it; a missing `~/.ccam/secrets.env` only warns |
|
|
```
|
|
|
|
- [ ] **Step 4: Mark A3 done in the parent plan**
|
|
|
|
In `docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md`, update the status table row and the `## Order` section the same way A1/A2 were marked done (search for `| **A3** |` and for `A3 next`).
|
|
|
|
- [ ] **Step 5: Verify docs and tests together**
|
|
|
|
```bash
|
|
bash .claude/skills/file-headers/scripts/check-headers.sh
|
|
npm run test:server
|
|
```
|
|
|
|
Expected: header check passes; full suite green apart from the 3 known-unrelated `ccam-cli.test.js` failures.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add docs/LANES.md docs/CLI.md ARCHITECTURE.md docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md
|
|
git commit -m "docs(lanes): document ccam lanes profile init/check (A3)"
|
|
```
|