feat(lanes): add ccam lanes profile init/check CLI (A3)
This commit is contained in:
+301
-18
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user