/** * @file Seed and repair a lane's `.env` file(s): copy the source repository's * real `.env` into the lane on first boot (or on a `--force` refresh) and then * rewrite the per-lane keys (`DATABASE_URL`, `REDIS_URL`, `UPLOAD_DIR`, …) so * the file is correct ON ITS OWN — not merely masked by a hook's runtime * exports. Ports Shipyard's `lane-env-seed.sh`. * * Two hard-won behaviours are preserved verbatim. A `--force` refresh keeps * `ENV_PRESERVE` keys (e.g. `JWT_SECRET`) from the lane's OWN existing file: * swapping in the source's secret would 401 a running lane's tokens until * reboot. And a missing source `.env` falls back to `.env.example` with a * loud warning rather than a silent, broken seed. * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("node:fs"); const path = require("node:path"); const { assertManaged } = require("./worktree"); const { splitList, parseEnvFile } = require("./lane-profile"); const { dataFacts } = require("./lane-slots"); /** Resolve `relative` against `root`, refusing anything that climbs out of * it — same confinement rule `lane-runtime.js:makeLaneDirs` applies to * `LANE_DIRS`, so a `.` file declared by a repository can only ever touch * its own tree. */ function confine(root, relative, code) { const target = path.resolve(root, relative); const rel = path.relative(root, target); if (rel.startsWith("..") || path.isAbsolute(rel)) { throw Object.assign(new Error(`path escapes ${root}: ${relative}`), { code }); } return target; } /** * Rewrite declared keys inside an `.env` file's text, preserving every other * line byte-for-byte. A key not already present is appended, mirroring * Shipyard's python rewriter. * * @param {string} text - The file's current contents. * @param {Record} want - Keys to set, already resolved to their final values. * @returns {string} */ function rewriteEnvText(text, want) { const seen = new Set(); const lines = text.split("\n").map((line) => { const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=/.exec(line); if (match && Object.hasOwn(want, match[1])) { seen.add(match[1]); return `${match[1]}=${want[match[1]]}`; } return line; }); for (const [key, value] of Object.entries(want)) { if (!seen.has(key)) lines.push(`${key}=${value}`); } return lines.join("\n"); } /** * Seed or repair a lane's declared `.env` file(s). * * A no-op when the profile declares no `ENV_FILES` — the feature is off by * default, and an adopted repo that never opts in gets no `.env` writes at * all. Refuses on an adopted lane (`assertManaged`): that file is the user's * real working config, not a template CCAM may overwrite. * * @param {object} lane - Lane row; `slot` must already be allocated. * @param {object} profile - Resolved profile. * @param {Record} secrets - From `secrets.js:readSecrets()`. * @param {{force?: boolean}} [options] - `force` re-copies from source even when the file exists. * @returns {{skipped: boolean, seeded?: Array<{file: string, fromExample: boolean}>}} */ function seedEnv(lane, profile, secrets, { force = false } = {}) { assertManaged(lane); const files = splitList(profile.env.ENV_FILES); if (!files.length) return { skipped: true }; const sources = splitList(profile.env.ENV_SOURCE || profile.env.ENV_FILES); const rewriteKeys = new Set(splitList(profile.env.ENV_REWRITE)); const preserveKeys = splitList(profile.env.ENV_PRESERVE); const sourceRoot = lane.source_repo || lane.cwd; const facts = dataFacts(lane, profile, secrets); const computed = { DATABASE_URL: facts.databaseUrl, REDIS_URL: facts.redisUrl, UPLOAD_DIR: facts.uploadDir, }; const seeded = []; for (let i = 0; i < files.length; i += 1) { const relFile = files[i]; const relSource = sources[i] || relFile; const targetPath = confine(lane.cwd, relFile, "EBADENVFILE"); const sourcePath = confine(sourceRoot, relSource, "EBADENVFILE"); const existed = fs.existsSync(targetPath); const preserved = {}; if (existed && force && preserveKeys.length) { try { const current = parseEnvFile(fs.readFileSync(targetPath, "utf8")); for (const key of preserveKeys) { if (current[key] !== undefined) preserved[key] = current[key]; } } catch { /* an unreadable existing file has nothing worth preserving */ } } if (!existed || force) { let content; let fromExample = false; if (fs.existsSync(sourcePath)) { content = fs.readFileSync(sourcePath, "utf8"); } else if (fs.existsSync(`${sourcePath}.example`)) { content = fs.readFileSync(`${sourcePath}.example`, "utf8"); fromExample = true; console.warn( `[lane-env] lane ${lane.id}: ${relSource} is missing — seeded ${relFile} from ` + `${relSource}.example instead (no real secrets/keys)` ); } else { throw Object.assign( new Error(`no ${relSource} (or ${relSource}.example) to seed ${relFile} from`), { code: "ENOENVSOURCE", relSource } ); } fs.mkdirSync(path.dirname(targetPath), { recursive: true }); fs.writeFileSync(targetPath, content); seeded.push({ file: relFile, fromExample }); } const want = {}; for (const [key, value] of Object.entries(computed)) { if (value != null && rewriteKeys.has(key)) want[key] = value; } Object.assign(want, preserved); if (Object.keys(want).length) { fs.writeFileSync(targetPath, rewriteEnvText(fs.readFileSync(targetPath, "utf8"), want)); } } if (facts.uploadDir) fs.mkdirSync(facts.uploadDir, { recursive: true }); return { skipped: false, seeded }; } module.exports = { seedEnv, rewriteEnvText };