From c024534475f747cae06a1d2073212da9aee06cae Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Tue, 4 Aug 2026 09:42:25 +0700 Subject: [PATCH] feat(lanes): add ccam lanes profile init/check CLI (A3) --- bin/ccam.js | 319 +++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 301 insertions(+), 18 deletions(-) diff --git a/bin/ccam.js b/bin/ccam.js index 7b3a2b3..be47130 100755 --- a/bin/ccam.js +++ b/bin/ccam.js @@ -1520,6 +1520,82 @@ async function cmdLanesAdd(args) { console.log(`${c.green("✔")} Created lane #${lane.id}: ${lane.title}`); } +/** + * `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; +} + // Keep these confirmation facts in lockstep with expectedFields() in // server/routes/lanes.js. The server remains authoritative and rejects an // 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 fields = LANE_PREFLIGHT_FIELDS[action]; if (!id) { - console.error(`usage: ccam lanes ${action} [--force] --yes`); + const extra = action === "reset" ? " [--keep-db]" : ""; + console.error(`usage: ccam lanes ${action} [--force]${extra} --yes`); process.exitCode = 1; return; } const preflight = await get(`/api/lanes/${id}/preflight?action=${action}`); 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) { console.log("Warnings:"); 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 body = { confirm: true, expect }; if (args.includes("--force")) body.force = true; + if (keepDb) body.keepDb = true; const result = await post(`/api/lanes/${id}/${action}`, body, { allowError: true }); if (result.status) { 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 [] [--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 [] [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 [flags]` — the lane equivalent of Shipyard's * `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; }; - let laneId = flag("lane"); - if (!laneId) { - const cwd = require("path").resolve(flag("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; - } - laneId = match.id; - } + // `--lane` wins, otherwise the lane owning this directory. The stage name is + // args[0], so only what follows it can carry a lane reference. + const resolved = await resolveLaneArg(args.slice(1)); + if (!resolved) return; + const laneId = resolved.laneId; const { lane } = await post(`/api/lanes/${laneId}/stage`, { stage, @@ -1781,10 +2034,28 @@ const COMMAND_GROUPS = [ "Provision a managed worktree lane", ], [ - "lanes reset|remove|purge", - " [--force] --yes", - "Show preflight facts, then perform a destructive lane action", + "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)", + ], + [ + "lanes reset|remove|purge", + " [--force] [--keep-db] --yes", + "Show preflight facts, then perform a destructive lane action (--keep-db: reset only)", + ], + [ + "lanes up|down", + "[] [--no-build]", + "Boot or stop the lane's own app stack (id defaults to the lane owning this directory)", + ], + ["lanes runtime", "[]", "Slot, ports, service health and the last boot error"], + ["lanes logs", "[] [--tail N]", "Tail one of the lane's hook or service logs"], + ["lanes hook", "[] [args…]", "Run a profile hook (ci-gate, e2e, migrate, …)"], ["stage [flags]", "", "Report the current pipeline stage for a lane"], ], ], @@ -2571,9 +2842,21 @@ async function runCommand(argv) { 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(); case "stage": return cmdStage(rest);