feat(lanes): validate a scaffolded profile with checkProfile (A3)
This commit is contained in:
@@ -262,3 +262,67 @@ describe("scaffoldProfile", () => {
|
|||||||
assert.doesNotThrow(() => detect.scaffoldProfile(repo, facts, { force: true }));
|
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)));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,6 +12,9 @@
|
|||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
const yaml = require("js-yaml");
|
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
|
* 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 };
|
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 = {
|
module.exports = {
|
||||||
IDENTIFIER_RE,
|
IDENTIFIER_RE,
|
||||||
readPackageJson,
|
readPackageJson,
|
||||||
@@ -318,4 +389,5 @@ module.exports = {
|
|||||||
findEnvSource,
|
findEnvSource,
|
||||||
detectNode,
|
detectNode,
|
||||||
scaffoldProfile,
|
scaffoldProfile,
|
||||||
|
checkProfile,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user