feat(lanes): scaffold .ccam/profile/ from detected Node facts (A3)
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user