feat(lanes): scaffold .ccam/profile/ from detected Node facts (A3)

This commit is contained in:
2026-08-04 09:20:22 +07:00
parent c4b11799dc
commit 9f9879cc3d
2 changed files with 222 additions and 0 deletions
+91
View File
@@ -171,3 +171,94 @@ describe("detectNode: database + Redis + env wiring", () => {
assert.equal(detect.detectNode(repo).database, null); assert.equal(detect.detectNode(repo).database, null);
}); });
}); });
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 }));
});
});
+131
View File
@@ -179,6 +179,136 @@ function detectNode(repoPath) {
return null; return null;
} }
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 = { module.exports = {
IDENTIFIER_RE, IDENTIFIER_RE,
readPackageJson, readPackageJson,
@@ -187,4 +317,5 @@ module.exports = {
findComposeService, findComposeService,
findEnvSource, findEnvSource,
detectNode, detectNode,
scaffoldProfile,
}; };