import { existsSync, readFileSync, writeFileSync } from 'node:fs'; // Giving every variable in `.env` the `OFFICERDEV_` prefix. // // ── Why prefix at all ── // // `PORT`, `PUBLIC_URL` and `POSTGRES_URL` are names half the software in the world uses. Bun auto-loads // `.env` into EVERY process started in the platform directory, so a bare `PORT` reaches the sidecars, // the tools they spawn and anything an agent runs from here — and a bare `POSTGRES_URL` reaches things // with no business holding a database credential. Prefixed, they cannot be picked up by accident. // // ── Why every consumer still reads both ── // // `process.env.OFFICERDEV_X ?? process.env.X`, everywhere. An install that has not been converted keeps // working, which is what makes this safe to ship before every machine has run it. The unprefixed name is // the fallback, never the source. // // ── Idempotent, because it runs unattended ── // // This runs on every restart from the server settings screen, not only when somebody types // `bun run fix:env`. An already-prefixed line is left exactly as it is, and a file that needs no change // is not written at all — writing an identical file still moves its mtime. const PREFIX = 'OFFICERDEV_'; /** * Names that must NOT be prefixed. * * These are read by other software rather than by Officer: `NODE_ENV` is the runtime's, `PATH` and * `HOME` are the operating system's. Prefixing one does not rename a variable, it deletes it. */ const LEAVE_ALONE = new Set(['NODE_ENV', 'PATH', 'HOME', 'SHELL', 'USER', 'LANG', 'TZ', 'TMPDIR', 'XDG_DATA_HOME']); export type FixEnvResult = { changed: string[]; alreadyPrefixed: number; path: string }; /** Rewrite one env file's text. Pure, so the rules are testable without a filesystem. */ export function prefixEnvText(text: string): { text: string; changed: string[]; alreadyPrefixed: number } { const changed: string[] = []; let alreadyPrefixed = 0; const out = text.split('\n').map((line) => { // Comments, blanks and anything that is not `KEY=…` pass through untouched. A `.env` is usually // commented, and losing that would be a poor trade for a rename. // // `export` is captured rather than skipped so it can be put back — dropping it would change what the // line does in a shell that sources this file. const match = line.match(/^(\s*)(export\s+)?([A-Za-z_][A-Za-z0-9_]*)(\s*=)(.*)$/); if (!match) return line; const [, indent, exported, key, eq, value] = match as unknown as [ string, string, string | undefined, string, string, string, ]; if (key.startsWith(PREFIX)) { alreadyPrefixed += 1; return line; } if (LEAVE_ALONE.has(key)) return line; changed.push(key); return `${indent}${exported ?? ''}${PREFIX}${key}${eq}${value}`; }); return { text: out.join('\n'), changed, alreadyPrefixed }; } /** Apply it to a file. Returns what changed; writes nothing when nothing did. */ export function fixEnvFile(path: string): FixEnvResult { if (!existsSync(path)) return { changed: [], alreadyPrefixed: 0, path }; const before = readFileSync(path, 'utf8'); const { text, changed, alreadyPrefixed } = prefixEnvText(before); if (text !== before) writeFileSync(path, text); return { changed, alreadyPrefixed, path }; }