/** * @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"); /** * 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; } /** * 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) { return { layout: "monorepo", services: { backend: { dir: "backend", script: pickScript(backendPkg, ["start", "dev"]) }, frontend: { dir: "frontend", script: pickScript(frontendPkg, ["preview", "dev"]) }, }, database: null, env: null, }; } const rootPkg = readPackageJson(repoPath); if (rootPkg) { return { layout: "single-service", services: { app: { dir: ".", script: pickScript(rootPkg, ["start", "dev"]) } }, database: null, env: null, }; } return null; } module.exports = { IDENTIFIER_RE, readPackageJson, pickScript, detectNode };