/** * The settings resolution ladder: env → NRN parameters → derived. * * Every real plugin resolves its configuration the same three ways, in the * same order, for the same reasons: * * 1. Environment — the local-dev override. `mise run dev` inherits the shell * and should never be overridden by whatever the platform holds. (Under * some worker backends env never arrives at all, which is why it cannot * be the only source.) * 2. NRN parameters — the platform's own key/value store, resolved down the * hierarchy for the acting scope's NRN. Secrets live here with RBAC and * an audit trail instead of in a shell profile or an image. * 3. Derived — computed defaults the platform already knows, e.g. a dispatch * repository read off the application's repository_url. The value nobody * should have to configure. * * Failures collect into `missing` instead of throwing on the first gap: a * publish-and-deploy cycle costs minutes and should answer every open * question at once. */ import { parameters as fetchParameters } from "./api"; export interface SettingSpec { /** Environment variable consulted first — the local-dev override. */ env?: string; /** NRN parameter name consulted second (platform-managed). */ parameter?: string; /** * Computed fallback, tried last. Async welcome; a thrown error becomes a * `missing` entry naming the setting, never an exception. */ derive?: () => Promise | string | undefined; /** When true, an unresolved value lands in `missing` with its sources. */ required?: boolean; } export interface ResolveOptions { /** Enables the parameter rung; without it only env + derive are consulted. */ nrn?: string; /** Include secret parameter values (default true — that's what they're for). */ secrets?: boolean; /** Test seam: replaces the platform parameter fetch. */ fetchParams?: ( nrn: string, secrets: boolean ) => Promise>; } export interface Resolved { values: Record; /** Human-readable gaps: what is absent and every place it could have come from. */ missing: string[]; /** Non-fatal notes (a failed parameter lookup when env already sufficed, …). */ notes: string[]; } const defaultFetch = ( nrn: string, secrets: boolean ): Promise> => fetchParameters(nrn, { includeSecrets: secrets }); export async function resolveSettings( specs: Record, opts: ResolveOptions = {} ): Promise> { const entries = Object.entries(specs) as Array<[K, SettingSpec]>; const values = {} as Record; const missing: string[] = []; const notes: string[] = []; // Rung 1: environment. for (const [key, spec] of entries) { values[key] = (spec.env && process.env[spec.env]?.trim()) || ""; } // Rung 2: NRN parameters — one fetch, only when something still needs it. const wantsParams = entries.some(([k, s]) => s.parameter && !values[k]); if (wantsParams && opts.nrn) { try { const params = await (opts.fetchParams ?? defaultFetch)( opts.nrn, opts.secrets ?? true ); for (const [key, spec] of entries) { if (!values[key] && spec.parameter) values[key] = params[spec.parameter] ?? ""; } } catch (err) { notes.push( `could not read parameters for ${opts.nrn}: ${ err instanceof Error ? err.message : String(err) }` ); } } // Rung 3: derived defaults. for (const [key, spec] of entries) { if (values[key] || !spec.derive) continue; try { values[key] = (await spec.derive())?.trim() || ""; } catch (err) { notes.push( `deriving ${String(key)} failed: ${ err instanceof Error ? err.message : String(err) }` ); } } for (const [key, spec] of entries) { if (values[key] || !spec.required) continue; const sources = [ spec.env && `env ${spec.env}`, spec.parameter && `a "${spec.parameter}" parameter on the scope's NRN`, spec.derive && "a platform-derived default", ].filter(Boolean); missing.push(`missing ${String(key)} (${sources.join(", or ")})`); } return { values, missing, notes }; }