From 9f9879cc3d30cd626c437e778736c499271e7238 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Tue, 4 Aug 2026 09:20:22 +0700 Subject: [PATCH] feat(lanes): scaffold .ccam/profile/ from detected Node facts (A3) --- server/__tests__/lane-detect.test.js | 91 +++++++++++++++++++ server/lib/lane-detect.js | 131 +++++++++++++++++++++++++++ 2 files changed, 222 insertions(+) diff --git a/server/__tests__/lane-detect.test.js b/server/__tests__/lane-detect.test.js index af52065..931468c 100644 --- a/server/__tests__/lane-detect.test.js +++ b/server/__tests__/lane-detect.test.js @@ -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 })); + }); +}); diff --git a/server/lib/lane-detect.js b/server/lib/lane-detect.js index f02fb25..948f954 100644 --- a/server/lib/lane-detect.js +++ b/server/lib/lane-detect.js @@ -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, };