feat(lanes): detect a Node.js project's layout and boot scripts (A3)
This commit is contained in:
@@ -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ĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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ĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 };
|
||||||
Reference in New Issue
Block a user