feat(lanes): scaffold .ccam/profile/ from detected Node facts (A3)
This commit is contained in:
@@ -171,3 +171,94 @@ describe("detectNode: database + Redis + env wiring", () => {
|
||||
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 }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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