Files
AgentHub/server/lib/lane-env.js
T
nntrivi2001 17d56d0e01 refactor!: rebrand CCAM to AgentHub
Clean break, no compatibility alias. Renames the product name, the CLI
binary (`ccam` -> `agenthub`), the env var prefix (`CCAM_` ->
`AGENTHUB_`), the Prometheus metric namespace (`ccam_*` ->
`agenthub_*`), the tmux session prefix (`ccam-lane-<id>` ->
`agenthub-lane-<id>`), the per-repo runtime profile directory
(`.ccam/profile` -> `.agenthub/profile`), the lanes state root
(`~/.claude/ccam-lanes` -> `~/.claude/agenthub-lanes`), the bundled
plugins (`plugins/ccam*` -> `plugins/agenthub*`) and their slash
commands (`/ccam-doctor` -> `/agenthub-doctor`, `/ccam:*` ->
`/agenthub:*`).

Because there is no alias, an existing install has to be updated by
hand: rename its `.ccam/profile` directory and any `CCAM_*` variables,
and reinstall the plugin under its new name.

Two other name families are deliberately left alone here, since they are
not just strings: the `Claude-Code-Monitor` repository/marketplace name,
and the `agent-dashboard` npm package plus its `~/.claude/agent-dashboard`
runtime directory, which holds the live database and needs a migration
rather than a rename.

Verified: server 1406/1406, client 459/459, `grep -i ccam` clean across
tracked files, `openapi.yaml` regenerated (95 paths, unchanged count).
2026-09-07 12:09:15 +07:00

151 lines
5.7 KiB
JavaScript

/**
* @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ĩ <vinnt@smartgift.vn>
*/
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<string,string>} 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 AgentHub may overwrite.
*
* @param {object} lane - Lane row; `slot` must already be allocated.
* @param {object} profile - Resolved profile.
* @param {Record<string,string>} 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 };