feat(lanes): add ccam lanes profile init/check CLI (A3)

This commit is contained in:
2026-08-04 09:42:25 +07:00
parent c3546f568a
commit c024534475
+301 -18
View File
@@ -1520,6 +1520,82 @@ async function cmdLanesAdd(args) {
console.log(`${c.green("✔")} Created lane #${lane.id}: ${lane.title}`); console.log(`${c.green("✔")} Created lane #${lane.id}: ${lane.title}`);
} }
/**
* `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;
}
// Keep these confirmation facts in lockstep with expectedFields() in // Keep these confirmation facts in lockstep with expectedFields() in
// server/routes/lanes.js. The server remains authoritative and rejects an // server/routes/lanes.js. The server remains authoritative and rejects an
// incomplete or stale echo, while the CLI shows exactly what it will send. // incomplete or stale echo, while the CLI shows exactly what it will send.
@@ -1543,13 +1619,19 @@ async function cmdLanesLifecycle(action, args) {
const id = args.find((arg) => !arg.startsWith("--")); const id = args.find((arg) => !arg.startsWith("--"));
const fields = LANE_PREFLIGHT_FIELDS[action]; const fields = LANE_PREFLIGHT_FIELDS[action];
if (!id) { if (!id) {
console.error(`usage: ccam lanes ${action} <id> [--force] --yes`); const extra = action === "reset" ? " [--keep-db]" : "";
console.error(`usage: ccam lanes ${action} <id> [--force]${extra} --yes`);
process.exitCode = 1; process.exitCode = 1;
return; return;
} }
const preflight = await get(`/api/lanes/${id}/preflight?action=${action}`); const preflight = await get(`/api/lanes/${id}/preflight?action=${action}`);
printLaneFacts(`Preflight for ${action} lane #${id}:`, preflight, fields); printLaneFacts(`Preflight for ${action} lane #${id}:`, preflight, fields);
const keepDb = action === "reset" && args.includes("--keep-db");
if (preflight.database) {
const fate = action === "remove" ? "dropped" : keepDb ? "kept as-is" : "dropped and recreated";
console.log(` database: ${preflight.database} (${fate})`);
}
if (Array.isArray(preflight.warnings) && preflight.warnings.length) { if (Array.isArray(preflight.warnings) && preflight.warnings.length) {
console.log("Warnings:"); console.log("Warnings:");
for (const warning of preflight.warnings) console.log(` ${warning}`); for (const warning of preflight.warnings) console.log(` ${warning}`);
@@ -1579,6 +1661,7 @@ async function cmdLanesLifecycle(action, args) {
const expect = Object.fromEntries(fields.map((field) => [field, preflight[field]])); const expect = Object.fromEntries(fields.map((field) => [field, preflight[field]]));
const body = { confirm: true, expect }; const body = { confirm: true, expect };
if (args.includes("--force")) body.force = true; if (args.includes("--force")) body.force = true;
if (keepDb) body.keepDb = true;
const result = await post(`/api/lanes/${id}/${action}`, body, { allowError: true }); const result = await post(`/api/lanes/${id}/${action}`, body, { allowError: true });
if (result.status) { if (result.status) {
const error = result.data?.error || {}; const error = result.data?.error || {};
@@ -1638,6 +1721,185 @@ async function cmdLanes() {
); );
} }
/**
* Which lane a command is about: an explicit id, or the lane owning the working
* directory (longest path-boundary match, the same rule the server uses). A
* session running inside a lane never has to know its own id.
*
* Only the FIRST positional counts as an id, and only when it is all digits.
* Scanning the whole argv for a number would swallow flag values —
* `ccam lanes logs web --tail 4096` would have addressed lane 4096.
*
* @param {string[]} args - Arguments after the subcommand.
* @returns {Promise<{laneId: string|number, rest: string[]}|null>} null once an error is printed.
*/
async function resolveLaneArg(args) {
const flagValue = (name) => {
const i = args.indexOf(`--${name}`);
return i > -1 ? args[i + 1] : undefined;
};
const explicit = flagValue("lane");
if (explicit) return { laneId: explicit, rest: args };
if (args.length && /^\d+$/.test(args[0])) return { laneId: args[0], rest: args.slice(1) };
const cwd = require("path").resolve(flagValue("cwd") || process.cwd());
const { lanes } = await get("/api/lanes");
const match = lanes
.filter((l) => cwd === l.cwd || cwd.startsWith(`${l.cwd}/`))
.sort((a, b) => b.cwd.length - a.cwd.length)[0];
if (!match) {
console.error(`no lane owns ${cwd} — create one with: ccam lanes add --cwd ${cwd}`);
process.exitCode = 1;
return null;
}
return { laneId: match.id, rest: args };
}
/** One line per declared port: name, number, listening, and any base drift. */
function printRuntime(runtime) {
if (!runtime.available) {
console.log("no .ccam/profile for this lane — nothing to run.");
if (runtime.searched) for (const p of runtime.searched) console.log(` looked in ${p}`);
return;
}
if (!runtime.provisioned) {
console.log("profile found, runtime not provisioned yet — run: ccam lanes up");
return;
}
console.log(`slot ${runtime.slot} · profile ${runtime.profileDir}`);
if (runtime.database) {
console.log(` database ${runtime.database.name} (test: ${runtime.database.testName})`);
}
if (runtime.redisIndex != null) {
console.log(` redis logical db ${runtime.redisIndex}`);
}
for (const [name, info] of Object.entries(runtime.ports)) {
const drift =
info.port && info.port !== info.expected ? ` ⚠ base expects ${info.expected}` : "";
console.log(
` ${name.padEnd(10)} :${String(info.port ?? "-").padEnd(6)} ` +
`${info.listening ? "listening" : "down"}${drift}`
);
}
for (const service of runtime.services) {
console.log(
` ${service.name.padEnd(10)} pid ${service.pid} ${service.alive ? "alive" : "gone"}`
);
}
if (runtime.lastError) {
console.log(` last error: ${runtime.lastError.code || ""} ${runtime.lastError.message}`);
}
if (runtime.logs.length) console.log(` logs: ${runtime.logs.join(", ")} (${runtime.logDir})`);
}
/**
* `ccam lanes up|down|runtime|logs|hook` — a lane's own application stack, as
* opposed to `start`/`stop`, which drive its Claude run. Two lifecycles, one
* lane id.
*
* Every subcommand resolves the lane from the working directory when no id is
* given, so a session inside a lane can call them without knowing its id — this
* is the surface the driving skill uses.
*/
async function cmdLanesRuntime(sub, args) {
const resolved = await resolveLaneArg(args);
if (!resolved) return;
const { laneId, rest: laneArgs } = resolved;
if (sub === "runtime") {
printRuntime(await get(`/api/lanes/${laneId}/runtime`));
return;
}
if (sub === "up") {
const body = laneArgs.includes("--no-build") ? { build: false } : {};
const result = await post(`/api/lanes/${laneId}/up`, body, { allowError: true });
if (result.status) {
console.error(`✖ up lane #${laneId}${result.data?.error?.message || result.status}`);
process.exitCode = 1;
return;
}
// The server answers 202 and boots in the background; poll until the stack
// reports healthy or a boot error lands, so the command exits on a real
// outcome rather than on "accepted".
console.log(`lane #${laneId} booting…`);
const deadline = Date.now() + 15 * 60 * 1000;
for (;;) {
await new Promise((r) => setTimeout(r, 2000));
const runtime = await get(`/api/lanes/${laneId}/runtime`);
if (runtime.healthy) {
printRuntime(runtime);
return;
}
if (runtime.lastError) {
console.error(`${runtime.lastError.code || ""} ${runtime.lastError.message}`);
printRuntime(runtime);
process.exitCode = 1;
return;
}
if (Date.now() > deadline) {
console.error("✖ timed out waiting for the stack to become healthy");
printRuntime(runtime);
process.exitCode = 1;
return;
}
}
}
if (sub === "down") {
const result = await post(`/api/lanes/${laneId}/down`, {}, { allowError: true });
if (result.status) {
console.error(`✖ down lane #${laneId}${result.data?.error?.message || result.status}`);
process.exitCode = 1;
return;
}
console.log(`lane #${laneId} down (${result.killed?.length || 0} processes stopped)`);
return;
}
if (sub === "logs") {
const svc = laneArgs.find((arg) => !arg.startsWith("--"));
if (!svc) {
console.error("usage: ccam lanes logs [<id>] <service> [--tail bytes]");
process.exitCode = 1;
return;
}
const i = laneArgs.indexOf("--tail");
const query = i > -1 && laneArgs[i + 1] ? `?tail=${encodeURIComponent(laneArgs[i + 1])}` : "";
const log = await get(`/api/lanes/${laneId}/logs/${encodeURIComponent(svc)}${query}`);
if (!log.available) {
console.log("no logs for this lane yet.");
return;
}
if (log.truncated) console.log(`… (showing the tail of ${log.size} bytes)`);
process.stdout.write(log.text);
return;
}
if (sub === "hook") {
const name = laneArgs[0];
if (!name || name.startsWith("--")) {
console.error("usage: ccam lanes hook [<id>] <name> [args…]");
process.exitCode = 1;
return;
}
const result = await post(
`/api/lanes/${laneId}/hook/${encodeURIComponent(name)}`,
{ args: laneArgs.slice(1) },
{ allowError: true }
);
if (result.status) {
console.error(`✖ hook ${name}${result.data?.error?.message || result.status}`);
process.exitCode = 1;
return;
}
console.log(
`lane #${laneId} running hook ${name} — follow it with: ccam lanes logs ${laneId} ${name}`
);
}
}
/** /**
* `ccam stage <stage> [flags]` — the lane equivalent of Shipyard's * `ccam stage <stage> [flags]` — the lane equivalent of Shipyard's
* `state.sh N set stage=…`. A skill calls this at each phase boundary so the * `state.sh N set stage=…`. A skill calls this at each phase boundary so the
@@ -1657,20 +1919,11 @@ async function cmdStage(args) {
return i > -1 ? args[i + 1] : undefined; return i > -1 ? args[i + 1] : undefined;
}; };
let laneId = flag("lane"); // `--lane` wins, otherwise the lane owning this directory. The stage name is
if (!laneId) { // args[0], so only what follows it can carry a lane reference.
const cwd = require("path").resolve(flag("cwd") || process.cwd()); const resolved = await resolveLaneArg(args.slice(1));
const { lanes } = await get("/api/lanes"); if (!resolved) return;
const match = lanes const laneId = resolved.laneId;
.filter((l) => cwd === l.cwd || cwd.startsWith(`${l.cwd}/`))
.sort((a, b) => b.cwd.length - a.cwd.length)[0];
if (!match) {
console.error(`no lane owns ${cwd} — create one with: ccam lanes add --cwd ${cwd}`);
process.exitCode = 1;
return;
}
laneId = match.id;
}
const { lane } = await post(`/api/lanes/${laneId}/stage`, { const { lane } = await post(`/api/lanes/${laneId}/stage`, {
stage, stage,
@@ -1781,10 +2034,28 @@ const COMMAND_GROUPS = [
"Provision a managed worktree lane", "Provision a managed worktree lane",
], ],
[ [
"lanes reset|remove|purge", "lanes profile init",
"<id> [--force] --yes", "<repo> [--force]",
"Show preflight facts, then perform a destructive lane action", "Detect a Node.js project and scaffold .ccam/profile/",
], ],
[
"lanes profile check",
"[<path>]",
"Validate a profile (path defaults to cwd, not a lane id)",
],
[
"lanes reset|remove|purge",
"<id> [--force] [--keep-db] --yes",
"Show preflight facts, then perform a destructive lane action (--keep-db: reset only)",
],
[
"lanes up|down",
"[<id>] [--no-build]",
"Boot or stop the lane's own app stack (id defaults to the lane owning this directory)",
],
["lanes runtime", "[<id>]", "Slot, ports, service health and the last boot error"],
["lanes logs", "[<id>] <svc> [--tail N]", "Tail one of the lane's hook or service logs"],
["lanes hook", "[<id>] <name> [args…]", "Run a profile hook (ci-gate, e2e, migrate, …)"],
["stage <stage> [flags]", "", "Report the current pipeline stage for a lane"], ["stage <stage> [flags]", "", "Report the current pipeline stage for a lane"],
], ],
], ],
@@ -2571,9 +2842,21 @@ async function runCommand(argv) {
if (rest[0] === "add") { if (rest[0] === "add") {
return cmdLanesAdd(rest.slice(1)); 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])) { if (["reset", "remove", "purge"].includes(rest[0])) {
return cmdLanesLifecycle(rest[0], rest.slice(1)); 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(); return cmdLanes();
case "stage": case "stage":
return cmdStage(rest); return cmdStage(rest);