lilith-platform.live/infrastructure/scripts/vault-to-env.ts
2026-05-19 23:51:04 -07:00

221 lines
8 KiB
TypeScript

#!/usr/bin/env -S bun run
/**
* vault-to-env — derive `.env`-format mail credentials from the local vault.
*
* Vault is the source-of-truth for mailbox passwords; every other place
* (prod /etc/quinn-api/secrets.env, dev .env.development) used to copy them
* by hand, which drifted (this whole session's IMAP-auth bug came from a
* stale `quinn-mail-bookings-sansonnet.txt` paste). This script reads vault
* and emits the exact env fragment those consumers need.
*
* Vault layout (existing convention, see `~/Code/@applications/@lilith/lilith-platform/vault/`):
* one file per IMAP account, named `quinn-mail-<inbox-key>-<brand>.txt`,
* plain YAML-ish key: value:
*
* account: salut@sansonnet.maison
* password: <plaintext>
* purpose: ...
* used-by: ...
* brand: ...
* aliases:
* bookings@sansonnet.maison
* salut@maisonsansonnet.com
*
* Tombstone files (no `account:`/`password:` lines or password starting with
* `TBD`) are skipped silently.
*
* Output: an `.env`-format fragment. Default to stdout — caller decides where
* it goes (commit to `infrastructure/generated/mail.env`, source into systemd,
* pipe into `kubectl create secret`, …). Pass `--out <file>` to write to disk.
*
* Generated fields (per the @lilith/mailer-multi + admin/mail-threads contract):
*
* MAIL_ACCOUNTS — JSON map {address: password, …}
* MAIL_ALIASES — JSON map {alias: underlying-account, …}
* <INBOX>_IMAP_USER — per-inbox username (mirrors `account:`)
* <INBOX>_IMAP_PASS — per-inbox password
*
* The `<INBOX>` token is derived from the vault filename:
* quinn-mail-<inbox-key>-<brand>.txt → <INBOX> = uppercase(inbox-key)_uppercase(brand)
* e.g. quinn-mail-concierge-sansonnet.txt → CONCIERGE_SANSONNET
*
* Usage:
* bun run infrastructure/scripts/vault-to-env.ts
* bun run infrastructure/scripts/vault-to-env.ts --out infrastructure/generated/mail.env
* bun run infrastructure/scripts/vault-to-env.ts --vault /custom/path
*/
import { readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { resolve, dirname, basename } from 'node:path';
import { homedir } from 'node:os';
interface VaultEntry {
filename: string;
account: string;
password: string;
/** Logical inbox identifiers (from `inbox-keys:` field) that use this credential. */
inboxKeys: readonly string[];
/** DMS-side aliases that share this account's password for auth. */
aliases: readonly string[];
}
const ADDRESS_RE = /^[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}$/;
const DEFAULT_VAULT_DIR = resolve(
homedir(),
'Code/@applications/@lilith/lilith-platform/vault',
);
const MAIL_FILE_RE = /^quinn-mail-([a-z0-9]+)-([a-z0-9]+)\.txt$/;
const HELP_TEXT = `Usage: vault-to-env [--vault <dir>] [--out <file>]
Default vault dir: ${DEFAULT_VAULT_DIR}
Default output: stdout
`;
function parseArgs(argv: readonly string[]): { vaultDir: string; out: string | null } {
let vaultDir = DEFAULT_VAULT_DIR;
let out: string | null = null;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--vault') {
const next = argv[i + 1];
if (!next) throw new Error('--vault requires a path argument');
vaultDir = resolve(next);
i++;
} else if (arg === '--out') {
const next = argv[i + 1];
if (!next) throw new Error('--out requires a path argument');
out = resolve(next);
i++;
} else if (arg === '--help' || arg === '-h') {
process.stdout.write(HELP_TEXT);
process.exit(0);
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
return { vaultDir, out };
}
function parseVaultFile(path: string): VaultEntry | null {
const text = readFileSync(path, 'utf8');
const lines = text.split('\n');
let account: string | null = null;
let password: string | null = null;
const inboxKeys: string[] = [];
const aliases: string[] = [];
let inAliases = false;
for (const raw of lines) {
if (raw.startsWith('#')) continue;
if (/^TOMBSTONE\b/.test(raw)) return null;
if (inAliases) {
// Indented address line under `aliases:` — must look like a bare email,
// not prose containing an @.
const trimmed = raw.trim();
if (raw.startsWith(' ') && ADDRESS_RE.test(trimmed)) {
aliases.push(trimmed);
continue;
}
if (/^[a-z][a-z0-9-]*:/i.test(raw)) {
inAliases = false;
}
}
const kv = raw.match(/^([a-z][a-z0-9-]*):\s*(.*)$/i);
if (!kv) continue;
const key = kv[1]!.toLowerCase();
const value = (kv[2] ?? '').trim();
if (key === 'account') {
if (ADDRESS_RE.test(value)) account = value;
} else if (key === 'password') {
password = value;
} else if (key === 'inbox-keys') {
for (const k of value.split(/[,\s]+/)) {
if (k && /^[a-z0-9-]+$/.test(k)) inboxKeys.push(k);
}
} else if (key === 'aliases') {
inAliases = true;
if (value && value !== '') {
for (const a of value.split(/[,\s]+/)) {
if (ADDRESS_RE.test(a)) aliases.push(a);
}
}
}
}
if (!account || !password) return null;
if (password === 'TBD' || password.startsWith('TBD-')) return null;
return { filename: basename(path), account, password, inboxKeys, aliases };
}
function inboxKeyToEnvToken(inboxKey: string): string {
// "concierge-sansonnet" → "CONCIERGE_SANSONNET" (matches inboxes.json's
// *_IMAP_USER / *_IMAP_PASS naming convention).
return inboxKey.replace(/-/g, '_').toUpperCase();
}
function escapeJsonForEnv(json: string): string {
return `'${json}'`;
}
function buildEnvFragment(vaultDir: string, entries: readonly VaultEntry[]): string {
if (entries.length === 0) return '# vault-to-env: no usable mail entries found\n';
const accounts: Record<string, string> = {};
const aliasMap: Record<string, string> = {};
for (const e of entries) {
accounts[e.account] = e.password;
for (const alias of e.aliases) {
aliasMap[alias] = e.account;
}
}
const lines: string[] = [];
lines.push('# ─────────────────────────────────────────────────────────────');
lines.push('# Generated by infrastructure/scripts/vault-to-env.ts');
lines.push(`# Source: ${vaultDir}`);
lines.push(`# Entries: ${entries.length}`);
lines.push('# Do NOT hand-edit — re-run the script to refresh.');
lines.push('# ─────────────────────────────────────────────────────────────');
lines.push('');
lines.push(`MAIL_ACCOUNTS=${escapeJsonForEnv(JSON.stringify(accounts))}`);
if (Object.keys(aliasMap).length > 0) {
lines.push(`MAIL_ALIASES=${escapeJsonForEnv(JSON.stringify(aliasMap))}`);
}
lines.push('');
for (const e of entries) {
if (e.inboxKeys.length === 0) {
lines.push(`# (no inbox-keys declared in ${e.filename} — no per-inbox env emitted for ${e.account})`);
continue;
}
for (const inboxKey of e.inboxKeys) {
const token = inboxKeyToEnvToken(inboxKey);
lines.push(`${token}_IMAP_USER=${e.account}`);
lines.push(`${token}_IMAP_PASS=${e.password}`);
}
}
lines.push('');
return lines.join('\n');
}
function main(): void {
const { vaultDir, out } = parseArgs(process.argv.slice(2));
const filenames = readdirSync(vaultDir).filter((f) => MAIL_FILE_RE.test(f));
const entries: VaultEntry[] = [];
for (const filename of filenames) {
const entry = parseVaultFile(resolve(vaultDir, filename));
if (entry) entries.push(entry);
}
entries.sort((a, b) => a.account.localeCompare(b.account));
const fragment = buildEnvFragment(vaultDir, entries);
if (out) {
mkdirSync(dirname(out), { recursive: true });
writeFileSync(out, fragment, { mode: 0o600 });
process.stderr.write(`vault-to-env: wrote ${entries.length} entries → ${out}\n`);
} else {
process.stdout.write(fragment);
}
}
main();