From 0ecc2eec247e4a415fc8a10d77a96bcdb3da52ad Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Tue, 4 Aug 2026 09:06:45 +0700 Subject: [PATCH] feat(lanes): detect a Node.js project's layout and boot scripts (A3) --- server/__tests__/lane-detect.test.js | 109 +++++++++++++++++++++++++++ server/lib/lane-detect.js | 86 +++++++++++++++++++++ 2 files changed, 195 insertions(+) create mode 100644 server/__tests__/lane-detect.test.js create mode 100644 server/lib/lane-detect.js diff --git a/server/__tests__/lane-detect.test.js b/server/__tests__/lane-detect.test.js new file mode 100644 index 0000000..9e6eb56 --- /dev/null +++ b/server/__tests__/lane-detect.test.js @@ -0,0 +1,109 @@ +/** + * @file Tests for Node.js project detection and profile scaffolding + * (server/lib/lane-detect.js): layout precedence, script selection, the + * shell-identifier validator, scaffold output, and the profile checker. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const os = require("node:os"); +const path = require("node:path"); +const fs = require("node:fs"); +const { describe, it, after } = require("node:test"); +const assert = require("node:assert/strict"); + +const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-detect-")); +// Must be set before the first require of anything that transitively loads +// server/lib/secrets.js (lane-detect.js does, for checkProfile) — SECRETS_PATH +// is bound once at that module's first load, and without this a test run on a +// machine with a real ~/.ccam/secrets.env would read that file instead of a +// deterministic, isolated one. +process.env.CCAM_SECRETS_PATH = path.join(SUITE_ROOT, "secrets.env"); +process.env.DASHBOARD_DB_PATH = path.join(SUITE_ROOT, "dashboard.db"); +process.env.LANES_ROOT = path.join(SUITE_ROOT, "lanes"); +after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true })); + +const detect = require("../lib/lane-detect"); + +let repoSeq = 0; +function makeRepo() { + repoSeq += 1; + const dir = path.join(SUITE_ROOT, `repo-${repoSeq}`); + fs.mkdirSync(dir, { recursive: true }); + return dir; +} + +function writePkg(dir, scripts) { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ scripts }, null, 2)); +} + +describe("detectNode: layout", () => { + it("returns null when nothing is detected", () => { + const repo = makeRepo(); + assert.equal(detect.detectNode(repo), null); + }); + + it("detects single-service from a root package.json", () => { + const repo = makeRepo(); + writePkg(repo, { start: "node index.js" }); + const facts = detect.detectNode(repo); + assert.equal(facts.layout, "single-service"); + assert.equal(facts.services.app.script, "start"); + }); + + it("prefers monorepo when both backend/ and frontend/ package.json exist, even with a root one too", () => { + const repo = makeRepo(); + writePkg(repo, { start: "node root.js" }); + writePkg(path.join(repo, "backend"), { start: "node server.js" }); + writePkg(path.join(repo, "frontend"), { dev: "vite" }); + const facts = detect.detectNode(repo); + assert.equal(facts.layout, "monorepo"); + assert.equal(facts.services.backend.script, "start"); + assert.equal(facts.services.frontend.script, "dev"); + }); + + it("does not detect a backend/ with no matching frontend/", () => { + const repo = makeRepo(); + writePkg(path.join(repo, "backend"), { start: "node server.js" }); + assert.equal(detect.detectNode(repo), null); + }); +}); + +describe("detectNode: script selection", () => { + it("backend prefers start over dev", () => { + const repo = makeRepo(); + writePkg(path.join(repo, "backend"), { start: "node a.js", dev: "node b.js" }); + writePkg(path.join(repo, "frontend"), { dev: "vite" }); + assert.equal(detect.detectNode(repo).services.backend.script, "start"); + }); + + it("frontend prefers preview over dev", () => { + const repo = makeRepo(); + writePkg(path.join(repo, "backend"), { start: "node a.js" }); + writePkg(path.join(repo, "frontend"), { dev: "vite", preview: "vite preview" }); + assert.equal(detect.detectNode(repo).services.frontend.script, "preview"); + }); + + it("falls back to null (not a guess) when no candidate script exists", () => { + const repo = makeRepo(); + writePkg(path.join(repo, "backend"), { test: "jest" }); + writePkg(path.join(repo, "frontend"), { build: "vite build" }); + const facts = detect.detectNode(repo); + assert.equal(facts.services.backend.script, null); + assert.equal(facts.services.frontend.script, null); + }); +}); + +describe("IDENTIFIER_RE", () => { + it("accepts plain identifiers", () => { + for (const ok of ["start", "dev:watch", "build.prod", "api-server"]) { + assert.ok(detect.IDENTIFIER_RE.test(ok), ok); + } + }); + + it("rejects shell metacharacters", () => { + for (const bad of ["start; rm -rf /", "$(id)", "a b", "a|b", "a`b`"]) { + assert.ok(!detect.IDENTIFIER_RE.test(bad), bad); + } + }); +}); diff --git a/server/lib/lane-detect.js b/server/lib/lane-detect.js new file mode 100644 index 0000000..54d89b6 --- /dev/null +++ b/server/lib/lane-detect.js @@ -0,0 +1,86 @@ +/** + * @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 };