221 lines
8 KiB
TypeScript
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();
|