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
+131
View File
@@ -179,6 +179,136 @@ function detectNode(repoPath) {
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 = {
IDENTIFIER_RE,
readPackageJson,
@@ -187,4 +317,5 @@ module.exports = {
findComposeService,
findEnvSource,
detectNode,
scaffoldProfile,
};