prefix the env with OFFICERDEV_, and keep reading the bare names

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.

Four parts.

officer-setup now writes OFFICERDEV_-prefixed names, with the reasoning in the
generated file so whoever opens it later knows why.

Every consumer reads `OFFICERDEV_X ?? X` — eleven sites across the databases,
the DAV router, officer-url.mjs, os-user-postgres and two scripts. The bare
name is the FALLBACK, never the source, which is what makes this safe to ship
before every machine has been converted.

`bun run fix:env` converts a file in place. Idempotent, preserves comments and
`export`, leaves the value untouched including an `=` inside it, and refuses to
touch names belonging to other software — prefixing PATH or NODE_ENV does not
rename a variable, it deletes it.

And it runs on every restart from the server settings screen. There rather than
at boot because a restart is the one moment the file can be rewritten and take
effect in the same breath: the process about to start is the one that reads it.
Non-fatal, since the fallback means the worst case is that nothing changed.

gen-index reads either spelling out of the file directly, because it is run by
hand as often as by the setup and a bare shell has no .env loaded.

Verified by converting this machine's own .env: PORT, PUBLIC_URL and the
database all still resolve, gen:index still finds its URL, 1152 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 11:46:00 +00:00
co-authored by Claude Opus 5
parent b27410a6fc
commit b9524ee4d5
15 changed files with 203 additions and 19 deletions
+79
View File
@@ -0,0 +1,79 @@
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 };
}