/** * Interactive fill for Vault boot gaps during `oke dev`. * * Probes the same gap set app boot would throw (`allowDevFallbacks` on for * `dev`), then prompts one-by-one and persists into `.env.local`. */ import { resolve } from "node:path"; import { resolveDriverId } from "../config/index.ts"; import { VAULT_DEFAULTS } from "../config/driver-defaults.ts"; import { resolveAppEntryForPluginTables } from "../elements/store/load-plugin-tables.ts"; import type { VaultSecretDecl } from "../elements/vault/declare.ts"; import { buildVaultBootChain, normalizeVaultDriverId } from "../elements/vault/boot-chain.ts"; import { createVaultRuntime, VaultBootError, type VaultGap } from "../elements/vault/runtime.ts"; import type { OkeOptions } from "../kernel/app.ts"; import { requiredEnvRegistry, secretRegistry } from "../kernel/element-registries.ts"; import { formatCliChrome, formatStatusLine } from "../term.ts"; import { loadOkeConfig } from "./load-config.ts"; import { openEnvStore } from "./vault-cmd.ts"; import { promptHidden } from "./vault-secure-input.ts"; /** Options for {@link maybeAskVaultGaps}. */ export interface AskVaultGapsOptions { readonly cwd: string; /** App entry override (relative or absolute). */ readonly entry?: string; readonly write?: (text: string) => void; readonly stdinIsTTY?: boolean; /** Skip prompt (tests / non-interactive). */ readonly skip?: boolean; /** * Injectable gap probe (tests). Default: import entry + vault boot. */ readonly gapsFn?: () => Promise; /** * Injectable secret reader (tests). Default: {@link promptHidden}. */ readonly readSecret?: (prompt: string) => Promise; /** * Env file relative to cwd (default `.env.local`). */ readonly envFile?: string; } /** * Probe Vault contracts declared by the app entry and return boot gaps. * * `oke()` drains `secretRegistry` at construction (`registry: "consume"`), * so the probe reads `app.$options.secrets` — the same list boot uses — * and unions any leftovers still on the module-evaluation registries. * * @param cwd - Project root * @param entry - Optional entry override */ export async function probeVaultGaps(cwd: string, entry?: string): Promise { const prevSecrets = secretRegistry.slice(); const prevRequired = requiredEnvRegistry.slice(); secretRegistry.length = 0; requiredEnvRegistry.length = 0; try { const entryAbs = await resolveAppEntryForPluginTables(cwd, entry); if (!entryAbs) return []; const mod = (await import(entryAbs)) as Record; const fromApp = vaultProbeContracts(mod); const secrets = unionSecrets(fromApp?.secrets ?? [], secretRegistry.slice()); const requiredEnv = [ ...new Set([...(fromApp?.requiredEnv ?? []), ...requiredEnvRegistry.slice()]), ]; if (secrets.length === 0 && requiredEnv.length === 0) return []; const loaded = await loadOkeConfig(cwd).catch(() => null); const configEnv = "dev" as const; const driverId = normalizeVaultDriverId( resolveDriverId(loaded?.config?.drivers?.vault, configEnv, VAULT_DEFAULTS) ?? "env", ); const chain = buildVaultBootChain({ driverId, env: configEnv, cwd, seed: {}, }); const vault = createVaultRuntime({ secrets, requiredEnv, chain, allowDevFallbacks: true, }); try { await vault.boot(); return []; } catch (err) { if (err instanceof VaultBootError) return err.gaps; throw err; } } finally { secretRegistry.length = 0; secretRegistry.push(...prevSecrets); requiredEnvRegistry.length = 0; requiredEnvRegistry.push(...prevRequired); } } /** * Contracts the imported `oke()` app will boot with (`$options.secrets` / * `vault.requiredEnv`). Undefined when the module has no bootable app. * * @param mod - Imported entry namespace */ function vaultProbeContracts(mod: Record): | { readonly secrets: readonly VaultSecretDecl[]; readonly requiredEnv: readonly string[]; } | undefined { for (const key of ["app", "default", ...Object.keys(mod)]) { const value = mod[key]; if (!value || typeof value !== "object") continue; const boot = (value as { boot?: unknown }).boot; const options = (value as { $options?: OkeOptions }).$options; if (typeof boot !== "function" || options === undefined) continue; return { secrets: options.secrets ?? [], requiredEnv: options.vault?.requiredEnv ?? [], }; } return undefined; } /** * Concat secret lists, skipping later entries that share a contract name. * * @param primary - App `$options.secrets` (boot source of truth) * @param extra - Registry leftovers (`registry: "ignore"` / no `oke()` export) */ function unionSecrets( primary: readonly VaultSecretDecl[], extra: readonly VaultSecretDecl[], ): VaultSecretDecl[] { const out: VaultSecretDecl[] = [...primary]; const names = new Set(primary.map((secret) => secret.name)); for (const secret of extra) { if (!names.has(secret.name)) { names.add(secret.name); out.push(secret); } } return out; } /** * Writer for {@link promptHidden} during `oke dev`. * * The question is one chrome row with no trailing newline, so mask * characters stay on that row. Echo (`*`, backspace, Enter) goes straight * to stdout: `formatCliChrome` would turn each `*` into its own `│` line, * and the anchored board would count each of those writes as a new row. * * @param sink - Line-oriented stdout (the dev board wrapper) * @param echo - Raw echo. Defaults to `process.stdout.write` */ export function secretPromptWrite( sink: (text: string) => void, echo: (text: string) => void = (text) => { process.stdout.write(text); }, ): (text: string) => void { let opened = false; return (text: string) => { if (!opened) { opened = true; const line = formatStatusLine(text); sink(line.endsWith("\n") ? line.slice(0, -1) : line); return; } echo(text); }; } /** * After compose hydrate (and usually schema push), fill Vault boot gaps * one-by-one into `.env.local`. Skips when non-TTY or no gaps. * * @param options - Project / injectables * @returns `0` when ready to continue, `1` when aborted (still missing) */ export async function maybeAskVaultGaps(options: AskVaultGapsOptions): Promise { if (options.skip) return 0; const tty = options.stdinIsTTY ?? Boolean(process.stdin.isTTY); if (!tty) return 0; const cwd = options.cwd; const write = options.write ?? ((t) => process.stdout.write(t)); const chromeWrite = (t: string) => write(formatCliChrome(t)); const gaps = options.gapsFn ? await options.gapsFn() : await probeVaultGaps(cwd, options.entry); if (gaps.length === 0) return 0; chromeWrite( gaps.length === 1 ? "oke vault: 1 missing secret — enter a value (stored in .env.local)\n" : `oke vault: ${gaps.length} missing secrets — enter each value (stored in .env.local)\n`, ); const envPath = resolve(cwd, options.envFile ?? ".env.local"); const store = await openEnvStore(envPath); const read = options.readSecret ?? ((prompt: string) => promptHidden(prompt, { write: secretPromptWrite(write), })); const remaining: VaultGap[] = []; for (const gap of gaps) { const label = gap.description ? `${gap.name} (${gap.description})` : gap.name; const value = (await read(`Enter value for ${label}: `)).trim(); if (value.length === 0) { remaining.push(gap); chromeWrite(`oke vault: skipped ${gap.name} (empty)\n`); continue; } store.set(gap.name, value); process.env[gap.name] = value; chromeWrite(`oke vault: set ${gap.name}\n`); } await store.save?.(); if (remaining.length > 0) { const err = new VaultBootError(remaining); chromeWrite(`${err.message}\n`); chromeWrite("oke vault: set the missing names, then re-run `oke dev`\n"); return 1; } return 0; }