/** * Minimal dotenv parser — the secrets-file boundary. * * `secrets: '.env.'` in an adapter's params points a gitignored file whose * entire contents are secrets. `loadDotenvFile` reads it into a flat * `Record` the CLI hands the adapter (injected into the local * runtime in dev, pushed to a secret store in prod). No `process.env` mutation, * no interpolation magic beyond `${VAR}` against earlier keys in the same file. */ import { readFileSync } from 'node:fs' export function parseDotenv(contents: string): Record { const out: Record = {} for (const raw of contents.split('\n')) { const line = raw.trim() if (!line || line.startsWith('#')) continue const match = /^(?:export\s+)?([A-Za-z_]\w*)\s*=\s*(.*)$/.exec(line) if (!match) continue const [, key, rawValue] = match let value = rawValue.trim() // Single quotes mean a LITERAL value (standard dotenv): no `${VAR}` // interpolation, so a secret that legitimately contains a literal `${...}` // (e.g. a password) survives intact instead of being silently blanked. const singleQuoted = value.length >= 2 && value.startsWith("'") && value.endsWith("'") if ((value.length >= 2 && value.startsWith('"') && value.endsWith('"')) || singleQuoted) { value = value.slice(1, -1) } out[key] = singleQuoted ? value : value.replace(/\$\{(\w+)\}/g, (_, name: string) => out[name] ?? '') } return out } /** Read + parse a dotenv file. Returns `{}` if the file is absent (CI-safe). */ export function loadDotenvFile(path: string): Record { let contents: string try { contents = readFileSync(path, 'utf-8') } catch { return {} } return parseDotenv(contents) } /** * Read + parse a dotenv file the preset explicitly DECLARED (`secrets:`). * Declared-but-absent is a wiring error (the gitignored file was never created * on this machine), so it throws instead of loading `{}` — silently forwarding * nothing surfaces much later as an empty credential at the provider. A file * that exists but holds no keys is legitimate (a domain with no secrets yet). */ export function loadDeclaredSecrets(path: string, declaration: string): Record { let contents: string try { contents = readFileSync(path, 'utf-8') } catch { throw new Error( `Secrets file '${declaration}' is declared in the preset but missing at ${path}. ` + `Create it (it may be empty) or remove 'secrets:' from the preset.`, ) } return parseDotenv(contents) }