diff --git a/server/__tests__/lane-detect.test.js b/server/__tests__/lane-detect.test.js index 931468c..abed8c0 100644 --- a/server/__tests__/lane-detect.test.js +++ b/server/__tests__/lane-detect.test.js @@ -262,3 +262,67 @@ describe("scaffoldProfile", () => { assert.doesNotThrow(() => detect.scaffoldProfile(repo, facts, { force: true })); }); }); + +describe("checkProfile", () => { + it("passes a freshly-scaffolded no-database profile with a real script", async () => { + const repo = makeRepo(); + writePkg(repo, { start: "node index.js" }); + detect.scaffoldProfile(repo, detect.detectNode(repo)); + const result = await detect.checkProfile(repo); + assert.deepEqual(result.errors, []); + assert.equal(result.ok, true); + }); + + it("fails on a database profile with unresolved migrate/seed TODOs", async () => { + const repo = makeRepo(); + writePkg(path.join(repo, "backend"), { start: "node server.js" }); + writePkg(path.join(repo, "frontend"), { dev: "vite" }); + writeCompose(repo, " postgres:\n image: postgres:16\n"); + detect.scaffoldProfile(repo, detect.detectNode(repo)); + const result = await detect.checkProfile(repo); + assert.equal(result.ok, false); + assert.ok(result.errors.some((e) => /TODO/.test(e))); + }); + + it("fails when a declared port is already in use", async () => { + const net = require("node:net"); + const repo = makeRepo(); + writePkg(repo, { start: "node index.js" }); + detect.scaffoldProfile(repo, detect.detectNode(repo)); + const server = net.createServer(() => {}); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(3000, "127.0.0.1", resolve); + }); + try { + const result = await detect.checkProfile(repo); + assert.equal(result.ok, false); + assert.ok(result.errors.some((e) => /3000/.test(e))); + } finally { + server.close(); + } + }); + + it("fails when a referenced hook is missing", async () => { + const repo = makeRepo(); + writePkg(repo, { start: "node index.js" }); + detect.scaffoldProfile(repo, detect.detectNode(repo)); + fs.rmSync(path.join(repo, ".ccam", "profile", "hooks", "health.sh")); + const result = await detect.checkProfile(repo); + assert.equal(result.ok, false); + assert.ok(result.errors.some((e) => /health/.test(e))); + }); + + it("warns (does not fail) when secrets.env is missing for a database profile", async () => { + const repo = makeRepo(); + writePkg(repo, { start: "node index.js" }); + writeCompose(repo, " postgres:\n image: postgres:16\n"); + detect.scaffoldProfile(repo, detect.detectNode(repo)); + // Remove the migrate/seed TODO lines so only the secrets warning is left to check. + const hooksDir = path.join(repo, ".ccam", "profile", "hooks"); + fs.writeFileSync(path.join(hooksDir, "migrate.sh"), "#!/usr/bin/env bash\nexit 0\n"); + fs.writeFileSync(path.join(hooksDir, "seed.sh"), "#!/usr/bin/env bash\nexit 0\n"); + const result = await detect.checkProfile(repo); + assert.ok(result.warnings.some((w) => /secrets\.env/.test(w))); + }); +}); diff --git a/server/lib/lane-detect.js b/server/lib/lane-detect.js index 948f954..07fc35b 100644 --- a/server/lib/lane-detect.js +++ b/server/lib/lane-detect.js @@ -12,6 +12,9 @@ const fs = require("node:fs"); const path = require("node:path"); const yaml = require("js-yaml"); +const { parseEnvFile } = require("./lane-profile"); +const { isListening } = require("./ports"); +const { SECRETS_PATH } = require("./secrets"); /** * A name safe to embed as literal text inside a generated shell script (an @@ -309,6 +312,74 @@ function scaffoldProfile(repoPath, facts, options = {}) { return { written, todos }; } +/** Every hook name a scaffolded Node profile might reference, given what + * profile.env declares — mirrors the same "only what's declared" gating + * scaffoldProfile itself uses. */ +function referencedHooks(env) { + const hooks = ["bootstrap", "boot", "health"]; + if (env.DB_PREFIX) hooks.push("db-create", "db-drop", "migrate", "seed"); + return hooks; +} + +function containsTodo(text) { + return text.includes("TODO:"); +} + +/** + * Validate a scaffolded (or hand-written) profile. Read-only: never mutates + * anything, never spawns a hook. `dir` may be a repo root (profile at + * `.ccam/profile/`) or a `.ccam/profile/` directory itself. + * + * @param {string} dir + * @returns {Promise<{ok: boolean, errors: string[], warnings: string[]}>} + */ +async function checkProfile(dir) { + const errors = []; + const warnings = []; + + const candidates = [path.join(dir, ".ccam", "profile"), dir]; + const profileDir = candidates.find((p) => fs.existsSync(path.join(p, "profile.env"))); + if (!profileDir) { + return { ok: false, errors: [`no profile.env found under ${dir}`], warnings: [] }; + } + + const envPath = path.join(profileDir, "profile.env"); + const envText = fs.readFileSync(envPath, "utf8"); + let env; + try { + env = parseEnvFile(envText); + } catch (err) { + return { ok: false, errors: [`profile.env does not parse: ${err.message}`], warnings: [] }; + } + if (containsTodo(envText)) errors.push("profile.env still has an unresolved TODO"); + + for (const name of referencedHooks(env)) { + const hookPath = path.join(profileDir, "hooks", `${name}.sh`); + if (!fs.existsSync(hookPath)) { + errors.push(`missing hook: ${name}.sh`); + continue; + } + if (!(fs.statSync(hookPath).mode & 0o111)) errors.push(`hook not executable: ${name}.sh`); + const body = fs.readFileSync(hookPath, "utf8"); + if (containsTodo(body)) errors.push(`${name}.sh still has an unresolved TODO`); + } + + if (env.DB_PREFIX && !fs.existsSync(SECRETS_PATH)) { + warnings.push(`no secrets.env at ${SECRETS_PATH} — database will use built-in defaults`); + } + + for (const key of Object.keys(env)) { + const match = /^PORT_BASE_(.+)$/.exec(key); + if (!match) continue; + const port = Number(env[key]); + if (Number.isInteger(port) && (await isListening(port))) { + errors.push(`port ${port} (PORT_BASE_${match[1]}) is already in use`); + } + } + + return { ok: errors.length === 0, errors, warnings }; +} + module.exports = { IDENTIFIER_RE, readPackageJson, @@ -318,4 +389,5 @@ module.exports = { findEnvSource, detectNode, scaffoldProfile, + checkProfile, };