/** * @file Node.js project detection and `.ccam/profile/` scaffolding (A3). * `detectNode` reads a repository's filesystem (package.json layout, * docker-compose.yml) without executing anything; `scaffoldProfile` turns * those facts into a working profile; `checkProfile` validates one. Only a * single preset (Node.js, single-service or a flat backend/+frontend/ * monorepo) is detected — anything else is refused rather than guessed, per * `docs/superpowers/specs/2026-08-03-lane-profile-scaffolding-design.md`. * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("node:fs"); const path = require("node:path"); const yaml = require("js-yaml"); /** * A name safe to embed as literal text inside a generated shell script (an * npm script name, a docker-compose service name). Deliberately NOT applied * to profile.env `KEY=VALUE` values — those are parsed, never sourced, and * already safe by construction (see `lane-profile.js:parseEnvFile`). This * guard is only for names that get spliced into `.sh` file TEXT. */ const IDENTIFIER_RE = /^[\w.:-]+$/; /** Read and parse a package.json, or null if it doesn't exist or is invalid JSON. */ function readPackageJson(dir) { try { return JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8")); } catch { return null; } } /** * First candidate script name present in `pkg.scripts` AND safe to embed in * shell text. Returns null (never a guess) when no candidate qualifies. * * @param {object|null} pkg - Parsed package.json. * @param {string[]} candidates - Script names in preference order. * @returns {string|null} */ function pickScript(pkg, candidates) { if (!pkg || typeof pkg.scripts !== "object" || !pkg.scripts) return null; for (const name of candidates) { if (typeof pkg.scripts[name] === "string" && IDENTIFIER_RE.test(name)) return name; } return null; } /** * A repo-name-derived DB_PREFIX: lowercase alnum + underscore, "_l" suffix * (lane N's actual name becomes "N"), matching the convention in * the postgres-compose profile template's example. * * @param {string} repoPath - Absolute path to the repository root. * @returns {string} */ function dbPrefixFor(repoPath) { const base = path .basename(repoPath) .toLowerCase() .replace(/[^a-z0-9]+/g, "_") .replace(/^_+|_+$/g, ""); return `${base || "app"}_l`; } /** * The first docker-compose service name matching `pattern`, or null. Reads * `docker-compose.yml`/`.yaml` at the repo root; never executes it. * * @param {string} repoPath - Absolute path to the repository root. * @param {RegExp} pattern - Pattern to match service name. * @returns {string|null} */ function findComposeService(repoPath, pattern) { for (const name of ["docker-compose.yml", "docker-compose.yaml"]) { let doc; try { doc = yaml.load(fs.readFileSync(path.join(repoPath, name), "utf8")); } catch { continue; } const services = doc && typeof doc.services === "object" ? doc.services : {}; for (const serviceName of Object.keys(services)) { if (pattern.test(serviceName) && IDENTIFIER_RE.test(serviceName)) return serviceName; } } return null; } /** * Where a lane's .env lives for this layout: backend/.env in a monorepo, * .env at the root for single-service. Returns the source to seed FROM * (real .env, else .env.example) or null when neither exists. * * @param {string} repoPath - Absolute path to the repository root. * @param {string} envDir - Directory where .env lives (e.g. "backend" or "."). * @returns {object|null} */ function findEnvSource(repoPath, envDir) { const dir = path.join(repoPath, envDir); const real = path.join(dir, ".env"); const example = path.join(dir, ".env.example"); const relFile = path.join(envDir, ".env").replace(/\\/g, "/").replace(/^\.\//, ""); if (fs.existsSync(real)) return { file: relFile, source: relFile, fromExample: false }; if (fs.existsSync(example)) { return { file: relFile, source: `${relFile}.example`, fromExample: true }; } return null; } /** * Detect database and Redis services from docker-compose.yml and wire up * .env rewriting if services are found. * * @param {string} repoPath - Absolute path to the repository root. * @param {string} envDir - Directory where .env lives (e.g. "backend" or "."). * @returns {object} */ function detectDatabaseAndEnv(repoPath, envDir) { const dbService = findComposeService(repoPath, /postgres/i); const redis = findComposeService(repoPath, /redis/i) !== null; const database = dbService ? { dbPrefix: dbPrefixFor(repoPath), dbService, dbKind: "postgres" } : null; let env = null; if (database || redis) { const found = findEnvSource(repoPath, envDir); if (found) { const rewrite = []; if (database) rewrite.push("DATABASE_URL"); if (redis) rewrite.push("REDIS_URL"); env = { dir: envDir, ...found, rewrite }; } } return { database, redis, env }; } /** * Detect a Node.js project's layout and, for each service, which npm script * to run. Returns null when neither supported layout matches — the caller * must refuse rather than guess. * * @param {string} repoPath - Absolute path to the repository root. * @returns {object|null} */ function detectNode(repoPath) { const backendPkg = readPackageJson(path.join(repoPath, "backend")); const frontendPkg = readPackageJson(path.join(repoPath, "frontend")); if (backendPkg && frontendPkg) { const { database, redis, env } = detectDatabaseAndEnv(repoPath, "backend"); return { layout: "monorepo", services: { backend: { dir: "backend", script: pickScript(backendPkg, ["start", "dev"]) }, frontend: { dir: "frontend", script: pickScript(frontendPkg, ["preview", "dev"]) }, }, database, redis, env, }; } const rootPkg = readPackageJson(repoPath); if (rootPkg) { const { database, redis, env } = detectDatabaseAndEnv(repoPath, "."); return { layout: "single-service", services: { app: { dir: ".", script: pickScript(rootPkg, ["start", "dev"]) } }, database, redis, env, }; } 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, pickScript, dbPrefixFor, findComposeService, findEnvSource, detectNode, scaffoldProfile, };