feat(lanes): validate a scaffolded profile with checkProfile (A3)

This commit is contained in:
2026-08-04 09:28:59 +07:00
parent 9f9879cc3d
commit fa489016b4
2 changed files with 136 additions and 0 deletions
+72
View File
@@ -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,
};