import { and, eq } from 'drizzle-orm'; import { log, promptPassword } from '../cli/prompts'; import type { DbClient } from '../db/client'; import { moduleConfigs, modules, secrets } from '../db/schema'; import type { Ensure } from '../manifest/schema'; import { decryptSecret, encryptSecret } from '../secrets/encryption'; import { deriveSecret, generateGpgPrivateKey, generateSecret } from '../secrets/generators'; import { getOrCreateMasterKey } from '../secrets/master-key'; import type { Machine } from '../types/infrastructure'; import { type ConfigReply, type ConfigRequiredPayload, EVENT_TYPES, type EnsureReply, type EnsureRequiredPayload, type SecretAck, type SecretRequiredPayload, busInterviewGuarded, } from './bus-interview'; import { parseStoredConfigValue, upsertModuleConfig } from './module-config'; import { getSecretMetadata } from './secret-schema-loader'; /** * Read a module's manifest from disk. Returns null on any read/parse * failure (so callers can degrade gracefully instead of crashing the * deploy/generate flow on a malformed install). * * Used by `validateModuleSecrets` to discover the canonical secret * declarations. Deploy-side callers already have the manifest loaded * and call `findMissingSecrets` directly without going through here. */ async function loadInstalledManifest( moduleId: string, db: DbClient, ): Promise<{ secrets?: { declares?: unknown[] } } | null> { const moduleRow = db.select().from(modules).where(eq(modules.id, moduleId)).get(); if (!moduleRow?.sourcePath) return null; try { const { readFile } = await import('node:fs/promises'); const { join } = await import('node:path'); const { parse: parseYaml } = await import('yaml'); const yamlContent = await readFile(join(moduleRow.sourcePath, 'manifest.yml'), 'utf-8'); return parseYaml(yamlContent) as { secrets?: { declares?: unknown[] } }; } catch { return null; } } /** * Canonical "find missing secrets" entry point. Walks * `manifest.secrets.declares[]`, filters to required + uninstalled, * and projects each one into a MissingVariable with all the fields * downstream consumers (config-interview, terminal-responder) need: * type (incl. 'string-map'), key_label/value_label (for the add-loop * prompt), and the auto-generation block. * * Single source of truth for both the deploy path * (`findMissingRequiredVariables` in deploy-validation.ts) and the * generate path (`validateModuleSecrets` below). Previously these * had two separate implementations that diverged on what fields they * carried — a regression slipped through where deploy was dropping * type/key_label/value_label, leaving namecheap on the old JSON-blob * prompt UX even after the manifest declared `string-map`. One impl, * one set of tests. */ interface SecretDeclareLike { name: string; type?: string; required?: boolean; description?: string; key_label?: string; value_label?: string; key_pattern?: string; key_pattern_message?: string; value_pattern?: string; value_pattern_message?: string; generate?: { method: string; length: number; encoding: string; identity?: string }; } /** * Coerce one entry from `manifest.secrets.declares[]` into the strict * shape `findMissingSecrets` consumes. Returns null on any item that's * unrecognizable (not an object, missing name, etc.) — best-effort * parsing matches the deploy/generate paths' historical leniency. */ function coerceSecretDeclare(entry: unknown): SecretDeclareLike | null { if (typeof entry !== 'object' || entry === null) return null; const e = entry as Record; if (typeof e.name !== 'string') return null; const out: SecretDeclareLike = { name: e.name }; if (typeof e.type === 'string') out.type = e.type; if (typeof e.required === 'boolean') out.required = e.required; if (typeof e.description === 'string') out.description = e.description; if (typeof e.key_label === 'string') out.key_label = e.key_label; if (typeof e.value_label === 'string') out.value_label = e.value_label; if (typeof e.key_pattern === 'string') out.key_pattern = e.key_pattern; if (typeof e.key_pattern_message === 'string') out.key_pattern_message = e.key_pattern_message; if (typeof e.value_pattern === 'string') out.value_pattern = e.value_pattern; if (typeof e.value_pattern_message === 'string') out.value_pattern_message = e.value_pattern_message; if (typeof e.generate === 'object' && e.generate !== null) { const g = e.generate as Record; if ( typeof g.method === 'string' && typeof g.length === 'number' && typeof g.encoding === 'string' ) { const gen: { method: string; length: number; encoding: string; identity?: string } = { method: g.method as string, length: g.length as number, encoding: g.encoding as string, }; if (typeof g.identity === 'string') gen.identity = g.identity; out.generate = gen; } } return out; } export async function findMissingSecrets( moduleId: string, manifest: { secrets?: { declares?: unknown[] } }, db: DbClient, ): Promise { const missing: MissingVariable[] = []; if (!manifest.secrets?.declares) return missing; for (const raw of manifest.secrets.declares) { const secret = coerceSecretDeclare(raw); if (!secret) continue; // Process a secret if it's required OR has a `generate` directive — an // auto-generated secret (e.g. a GPG signing key) is needed for the module // to function and must never be silently skipped just because it isn't // marked required. Optional, non-generated secrets are still skipped. if (!secret.required && !secret.generate) continue; const existing = db .select() .from(secrets) .where(and(eq(secrets.moduleId, moduleId), eq(secrets.name, secret.name))) .get(); if (existing) continue; missing.push({ name: secret.name, source: 'secret', description: secret.description, type: secret.type, key_label: secret.key_label, value_label: secret.value_label, key_pattern: secret.key_pattern, key_pattern_message: secret.key_pattern_message, value_pattern: secret.value_pattern, value_pattern_message: secret.value_pattern_message, generate: secret.generate, }); } return missing; } export interface MissingVariable { name: string; source: 'user' | 'secret' | 'capability' | 'system'; description?: string; /** Variable type from manifest (string, array, string-map, etc.) */ type?: string; /** Derivation source (e.g., "$machine:ipAddress") */ derive_from?: string; /** For multi-select: available options */ options?: Array<{ value: string; label: string; hint?: string }>; /** Follow-up prompt for each selected option */ per_selection?: { key_pattern: string; prompt: string; type?: string; derive_from?: string; }; /** Secret auto-generation config from manifest */ generate?: { method: string; length: number; encoding: string; identity?: string; }; /** For `type: string-map` only — labels shown in the add-loop prompt. */ key_label?: string; value_label?: string; /** For `type: string-map` only — optional regex validation per entry. */ key_pattern?: string; key_pattern_message?: string; value_pattern?: string; value_pattern_message?: string; } export interface InterviewResult { success: boolean; configured: string[]; error?: string; } /** * Resolve a $machine: derivation from an earmarked machine * * @returns Resolved value, or null if not resolvable */ function resolveMachineDerivation(deriveFrom: string, machine: Machine): string | null { const key = deriveFrom.replace('$machine:', ''); switch (key) { case 'ipAddress': return machine.ipAddress; case 'hostname': return machine.hostname; case 'zone': return machine.zone; case 'zones': // Return comma-separated list of zones from interfaces if (machine.interfaces.length === 0) return null; return [ ...new Set(machine.interfaces.map((i) => i.zone).filter((z) => z !== 'unknown')), ].join(','); default: return null; } } /** * Resolve a $machine:zone_ip derivation for a per_selection follow-up * Looks up the interface IP for a given zone */ function resolveMachineZoneIp(machine: Machine, zone: string): string | null { const iface = machine.interfaces.find((i) => i.zone === zone); return iface?.ipAddress ?? null; } /** * Auto-derive $machine: variables from a machine without prompting. * Called in both interactive and non-interactive deployments so machine- * catalogued data (zones, zone IPs, etc.) is always applied automatically. * * @returns InterviewResult with the keys that were successfully derived */ export async function autoDeriveMachineConfig( moduleId: string, missingVariables: MissingVariable[], db: DbClient, machine: Machine, ): Promise { const configured: string[] = []; for (const variable of missingVariables) { if (!variable.derive_from?.startsWith('$machine:')) continue; const derived = resolveMachineDerivation(variable.derive_from, machine); if (derived === null) continue; log.success(`${variable.name} = ${derived} (auto-derived from ${machine.hostname})`); upsertModuleConfig(db, moduleId, variable.name, derived); configured.push(variable.name); // Handle per_selection follow-ups (e.g., `zone..ip` from the zone list) if (variable.options && variable.per_selection) { for (const selectedVal of derived.split(',')) { const followUpKey = variable.per_selection.key_pattern.replace('{value}', selectedVal); let followUpValue: string | null = null; if (variable.per_selection.derive_from === '$machine:zone_ip') { followUpValue = resolveMachineZoneIp(machine, selectedVal); } if (followUpValue !== null) { log.success(`${followUpKey} = ${followUpValue} (auto-derived from ${machine.hostname})`); upsertModuleConfig(db, moduleId, followUpKey, followUpValue); configured.push(followUpKey); } } } } return { success: true, configured }; } /** * Interview user for missing required configuration * * @param moduleId - Module identifier * @param missingVariables - Variables that need to be configured * @param db - Database connection * @param earmarkedMachine - Optional earmarked machine for $machine: derivation * @returns Interview result */ export async function interviewForMissingConfig( moduleId: string, missingVariables: MissingVariable[], db: DbClient, earmarkedMachine?: Machine | null, ): Promise { const configured: string[] = []; log.info(`Module '${moduleId}' requires configuration. Please provide the following:`); for (const variable of missingVariables) { try { // Try to derive from earmarked machine before prompting if (variable.derive_from?.startsWith('$machine:') && earmarkedMachine) { const derived = resolveMachineDerivation(variable.derive_from, earmarkedMachine); if (derived !== null) { log.info(`✓ ${variable.name} = ${derived} (from machine ${earmarkedMachine.hostname})`); // For multi-select variables with options, also handle per_selection follow-ups if (variable.options && variable.per_selection) { const selectedValues = derived.split(','); // Store the main variable upsertModuleConfig(db, moduleId, variable.name, derived); configured.push(variable.name); // Handle per_selection follow-ups for (const selectedVal of selectedValues) { const followUpKey = variable.per_selection.key_pattern.replace( '{value}', selectedVal, ); // Try to derive the follow-up value from machine let followUpValue: string | null = null; if (variable.per_selection.derive_from === '$machine:zone_ip' && earmarkedMachine) { followUpValue = resolveMachineZoneIp(earmarkedMachine, selectedVal); } if (followUpValue !== null) { log.info( `✓ ${followUpKey} = ${followUpValue} (from machine ${earmarkedMachine.hostname})`, ); upsertModuleConfig(db, moduleId, followUpKey, followUpValue); configured.push(followUpKey); } else { // Can't derive — bus-mediated prompt (responder // races terminal vs `events respond` etc.) const option = variable.options?.find((o) => o.value === selectedVal); const followUpPrompt = variable.per_selection.prompt .replace('{value}', selectedVal) .replace('{label}', option?.label || selectedVal) .replace('{hint}', option?.hint || ''); const followUpPayload: ConfigRequiredPayload = { module: moduleId, key: followUpKey, type: (variable.per_selection.type as ConfigRequiredPayload['type']) ?? 'string', required: true, description: followUpPrompt, }; const followUpReply = await busInterviewGuarded( EVENT_TYPES.configRequired(moduleId, followUpKey), followUpPayload, ); await writeModuleConfigKey(moduleId, followUpKey, followUpReply.value, db); configured.push(followUpKey); } } continue; // Already handled this variable fully } // Simple (non-multi-select) derived variable upsertModuleConfig(db, moduleId, variable.name, derived); configured.push(variable.name); continue; } } let value: string; if (variable.source === 'secret') { // Prompt for secret (masked input) const message = variable.description ? `${variable.name} - ${variable.description}:` : `${variable.name}:`; value = await promptPassword({ message, validate: (val) => { if (!val || val.trim() === '') { return 'This field is required'; } }, }); // Encrypt and store secret const masterKey = await getOrCreateMasterKey(); const encrypted = encryptSecret(value, masterKey); await db .insert(secrets) .values({ moduleId, name: variable.name, encryptedValue: encrypted.encryptedValue, iv: encrypted.iv, authTag: encrypted.authTag, }) .run(); configured.push(`${variable.name} (secret)`); } else if (variable.options && variable.options.length > 0) { // Multi-select via bus. The terminal-responder (or any other // responder) sees the options[] in the payload and presents a // multiselect prompt; the reply value is the selected // string[]. Same race + first-reply-wins semantics as the // simple-text path. const payload: ConfigRequiredPayload = { module: moduleId, key: variable.name, type: 'array', required: true, description: variable.description, options: variable.options, }; const reply = await busInterviewGuarded( EVENT_TYPES.configRequired(moduleId, variable.name), payload, ); const selectedValues = reply.value as string[]; await writeModuleConfigKey(moduleId, variable.name, selectedValues, db); configured.push(variable.name); // Handle per_selection follow-up prompts. One bus event per // selected option; each races independently. if (variable.per_selection) { for (const selectedVal of selectedValues) { const option = variable.options?.find((o) => o.value === selectedVal); const followUpKey = variable.per_selection.key_pattern.replace('{value}', selectedVal); const followUpPrompt = variable.per_selection.prompt .replace('{value}', selectedVal) .replace('{label}', option?.label || selectedVal) .replace('{hint}', option?.hint || ''); // Skip if this follow-up was already answered (e.g., a // previous deploy attempt). const existingFollowUp = db .select() .from(moduleConfigs) .where(and(eq(moduleConfigs.moduleId, moduleId), eq(moduleConfigs.key, followUpKey))) .get(); if (!existingFollowUp || !existingFollowUp.value) { const followUpPayload: ConfigRequiredPayload = { module: moduleId, key: followUpKey, type: (variable.per_selection.type as ConfigRequiredPayload['type']) ?? 'string', required: true, description: followUpPrompt, }; const followUpReply = await busInterviewGuarded( EVENT_TYPES.configRequired(moduleId, followUpKey), followUpPayload, ); await writeModuleConfigKey(moduleId, followUpKey, followUpReply.value, db); configured.push(followUpKey); } } } } else { // Bus-mediated prompt: emit a query event, await the reply // from any responder. The terminal-responder (started by // module-deploy when stdin.isTTY) is one racer; the Claude // subagent and `celilo events respond` from another shell are // others. First reply wins. No timeout — the deploy hangs // until someone answers; misconfigured environments are // observable via `celilo events list-pending`. const payload: ConfigRequiredPayload = { module: moduleId, key: variable.name, type: (variable.type as ConfigRequiredPayload['type']) ?? 'string', required: true, description: variable.description, }; const reply = await busInterviewGuarded( EVENT_TYPES.configRequired(moduleId, variable.name), payload, ); // Persist via writeModuleConfigKey so the (string value, // typed valueJson) pair is set correctly. Downstream readers // (validate_config hooks, capability resolvers) deserialize // typed values from valueJson — earlier I was only writing // value with valueJson:null, which broke `array`/`object` // typed configs that consumers expected to be JSON-parsed. await writeModuleConfigKey(moduleId, variable.name, reply.value, db); configured.push(variable.name); } } catch (error) { return { success: false, configured, error: `Failed to configure ${variable.name}: ${error instanceof Error ? error.message : 'Unknown error'}`, }; } } return { success: true, configured, }; } /** * Validate module secrets against manifest declarations * * Policy function (Rule 10.1) - reads database and manifest, no side effects * * @param moduleId - Module identifier * @param db - Database connection * @returns Array of missing secrets with metadata */ export async function validateModuleSecrets( moduleId: string, db: DbClient, ): Promise { // The manifest is the canonical declaration of which secrets a // module needs (every module in the codebase has one; the // schema/secrets.json file is supplementary, used for getSecretMetadata // lookups elsewhere). Read it directly and delegate to findMissingSecrets. const manifest = await loadInstalledManifest(moduleId, db); if (!manifest) return []; const missingSecrets = await findMissingSecrets(moduleId, manifest, db); return missingSecrets; } /** * Interview user for missing module secrets with schema-aware logic * * Execution function (Rule 10.1) - performs I/O (prompts, database writes) * * Handles three secret source types: * - "generated": Auto-generate, never prompt * - "user_provided": Always prompt, required * - "generated_optional": Prompt with "Press Enter to auto-generate" * * Also handles derived secrets (derive_from field) * * @param moduleId - Module identifier * @param missingSecrets - Secrets that need to be configured * @param db - Database connection * @returns Interview result */ export async function interviewForMissingSecrets( moduleId: string, missingSecrets: MissingVariable[], db: DbClient, ): Promise { const configured: string[] = []; log.message(`Module '${moduleId}' requires secrets. Configuring:`); const masterKey = await getOrCreateMasterKey(); // Sort secrets to ensure derived secrets come after their source secrets // Build dependency map const metadataMap = new Map>>(); for (const variable of missingSecrets) { const metadata = await getSecretMetadata(moduleId, variable.name, db); metadataMap.set(variable.name, metadata); } // Topological sort: ensure derived secrets come immediately after their source const sorted: typeof missingSecrets = []; const processed = new Set(); function addWithDependents(secret: (typeof missingSecrets)[0]) { if (processed.has(secret.name)) return; processed.add(secret.name); sorted.push(secret); // Immediately add any secrets that derive from this one for (const s of missingSecrets) { const meta = metadataMap.get(s.name); if (meta?.deriveFrom === secret.name && !processed.has(s.name)) { addWithDependents(s); } } } // Process all non-derived secrets first (in original order), each followed by its dependents for (const secret of missingSecrets) { const meta = metadataMap.get(secret.name); if (!meta?.deriveFrom) { addWithDependents(secret); } } // Add any remaining secrets (shouldn't happen if derivation graph is valid) for (const secret of missingSecrets) { if (!processed.has(secret.name)) { addWithDependents(secret); } } for (const variable of sorted) { try { // Get metadata from schema (already loaded during sorting) const metadata = metadataMap.get(variable.name); // Manifest generate field takes priority over schema metadata const hasManifestGenerate = !!variable.generate; // If no metadata, default to user_provided (safe default) const source = hasManifestGenerate ? 'generated' : metadata?.source || 'user_provided'; // Whether we need to encrypt + insert here, or whether the // bus responder already wrote the value out-of-band. let value: string | null = null; // Handle derived secrets first if (metadata?.deriveFrom) { // Look up source secret const sourceSecret = db .select() .from(secrets) .where(and(eq(secrets.moduleId, moduleId), eq(secrets.name, metadata.deriveFrom))) .get(); if (!sourceSecret) { return { success: false, configured, error: `Cannot derive ${variable.name}: source secret ${metadata.deriveFrom} not found`, }; } // Decrypt source secret const decryptedSource = await import('../secrets/encryption').then((m) => m.decryptSecret( { encryptedValue: sourceSecret.encryptedValue, iv: sourceSecret.iv, authTag: sourceSecret.authTag, }, masterKey, ), ); // Derive value if (!metadata.deriveMethod) { return { success: false, configured, error: `Cannot derive ${variable.name}: derive_method not specified in schema`, }; } value = deriveSecret({ sourceSecret: decryptedSource, deriveMethod: metadata.deriveMethod, }); log.message(`Derived ${variable.name} from ${metadata.deriveFrom}`); } else if (source === 'generated') { // Auto-generate without prompting. method: gpg mints a real signing // key (celilo owns it as infrastructure); otherwise random bytes. if (variable.generate?.method === 'gpg') { const identity = variable.generate.identity || moduleId; value = generateGpgPrivateKey(identity); log.message(`Auto-generated GPG signing key for ${variable.name} (${identity})`); } else { // Manifest generate field takes priority over schema metadata const format = variable.generate?.encoding || metadata?.format || 'base64'; const length = variable.generate?.length || metadata?.length || 32; value = generateSecret({ format, length }); log.message(`Auto-generated ${format} secret: ${variable.name}`); } } else if ( source === 'user_provided' || source === 'user_password' || source === 'generated_optional' ) { // Bus-mediated interview. The responder (terminal-responder // when running on a TTY, Claude subagent, or `events respond` // from another shell) prompts the user, writes the secret // value into the encrypted store out-of-band, then replies // with `{ acknowledged: true }`. The value never crosses the // bus. See INTERACTIVE_DEPLOYS_VIA_BUS.md. // The bus payload accepts scalar types and `string-map` // (Record, gathered via add-loop, stored as JSON). // Cross-module ensure flows write composite shapes via a separate // path, but the interview-driven `string-map` lands here. const declaredType = (variable.type as SecretRequiredPayload['type']) ?? 'string'; const payload: SecretRequiredPayload = { module: moduleId, key: variable.name, type: declaredType === 'string-map' ? 'string-map' : 'string', required: true, description: variable.description, style: source, generate: source === 'generated_optional' ? { format: metadata?.format || 'base64', length: metadata?.length || 32, } : undefined, key_label: variable.key_label, value_label: variable.value_label, key_pattern: variable.key_pattern, key_pattern_message: variable.key_pattern_message, value_pattern: variable.value_pattern, value_pattern_message: variable.value_pattern_message, }; await busInterviewGuarded( EVENT_TYPES.secretRequired(moduleId, variable.name), payload, ); log.success(`Saved ${variable.name}`); configured.push(`${variable.name} (secret)`); // Responder already wrote to the encrypted store; skip the // encrypt+insert below. continue; } else { return { success: false, configured, error: `Unknown secret source type: ${source}`, }; } // Encrypt and store secret (deriveFrom + generated paths only; // bus-mediated paths above already wrote and `continue`d). if (value === null) { return { success: false, configured, error: `Internal error: ${variable.name} branch left value unset`, }; } const encrypted = encryptSecret(value, masterKey); await db .insert(secrets) .values({ moduleId, name: variable.name, encryptedValue: encrypted.encryptedValue, iv: encrypted.iv, authTag: encrypted.authTag, }) .run(); configured.push(`${variable.name} (secret)`); } catch (error) { return { success: false, configured, error: `Failed to configure ${variable.name}: ${error instanceof Error ? error.message : 'Unknown error'}`, }; } } return { success: true, configured, }; } // ───────────────────────────────────────────────────────────────────────── // Cross-module "ensure" interview // // When a hook's capability call detects that a value isn't covered by a // provider module's config, the framework runs this interview against // the provider's `ensures` block to extend its config in place. See // `apps/celilo/designs/CROSS_MODULE_CONFIG_INTERVIEW.md`. // ───────────────────────────────────────────────────────────────────────── interface EnsureTargetParts { /** Always one of "config" | "secret" — derived from `target:` prefix. */ scope: 'config' | 'secret'; /** Variable / secret name on the provider module. */ name: string; } function parseEnsureTarget(target: string): EnsureTargetParts { const [scope, name] = target.split('.', 2); if ((scope !== 'config' && scope !== 'secret') || !name) { throw new Error( `Invalid ensure target: "${target}" (expected "config." or "secret.")`, ); } return { scope, name }; } function renderEnsureTemplate(template: string, value: string): string { return template.replace(/\{\{\s*value\s*\}\}/g, value); } export async function readModuleConfigKey( moduleId: string, key: string, db: DbClient, ): Promise { const row = db .select() .from(moduleConfigs) .where(and(eq(moduleConfigs.moduleId, moduleId), eq(moduleConfigs.key, key))) .get(); if (!row) return undefined; return parseStoredConfigValue(row); } export async function writeModuleConfigKey( moduleId: string, key: string, value: unknown, db: DbClient, ): Promise { upsertModuleConfig( db, moduleId, key, value as string | number | boolean | unknown[] | Record, ); } export async function readModuleSecretKey( moduleId: string, name: string, db: DbClient, masterKey: Buffer, ): Promise { const row = db .select() .from(secrets) .where(and(eq(secrets.moduleId, moduleId), eq(secrets.name, name))) .get(); if (!row) return undefined; return decryptSecret( { encryptedValue: row.encryptedValue, iv: row.iv, authTag: row.authTag }, masterKey, ); } export async function writeModuleSecretKey( moduleId: string, name: string, plaintext: string, db: DbClient, masterKey: Buffer, ): Promise { const encrypted = encryptSecret(plaintext, masterKey); // No unique index on (module_id, name) for secrets; manual upsert. const existing = db .select() .from(secrets) .where(and(eq(secrets.moduleId, moduleId), eq(secrets.name, name))) .get(); if (existing) { await db .update(secrets) .set({ encryptedValue: encrypted.encryptedValue, iv: encrypted.iv, authTag: encrypted.authTag, updatedAt: new Date(), }) .where(and(eq(secrets.moduleId, moduleId), eq(secrets.name, name))) .run(); } else { await db .insert(secrets) .values({ moduleId, name, encryptedValue: encrypted.encryptedValue, iv: encrypted.iv, authTag: encrypted.authTag, }) .run(); } } export interface EnsureInterviewOptions { /** Override prompts for testing — return string answers in order. */ promptOverride?: (prompt: string, hint?: string) => Promise; } export interface EnsureInterviewResult { success: boolean; /** True when every input was already satisfied (idempotent retry). */ alreadyApplied?: boolean; error?: string; /** Human-readable lines describing what changed (for the deploy log). */ applied: string[]; } /** * Render the CLI recipe operators see in `events list-pending` when * an ensure interview is waiting for a responder. Generated from the * manifest's `ensures` block so it stays correct as modules evolve. * * Pre-stage 4 this was the abort-message body for `--no-interactive` * deploys; now it's a diagnostic for stuck queries (the bus path * waits indefinitely, but the recipe tells the operator exactly * which CLI commands they could run by hand to satisfy the ensure). */ export function renderEnsureRecipe( providerModuleId: string, ensure: Ensure, value: string, ): string { const lines: string[] = [ `Provider module "${providerModuleId}" doesn't yet ensure "${ensure.id}" for "${value}".`, 'Resolve before re-running:', '', ]; for (const input of ensure.inputs) { const { scope, name } = parseEnsureTarget(input.target); if (input.kind === 'append_to_array') { lines.push( ` # append "${value}" to ${scope}.${name}:`, ` celilo module config get ${providerModuleId} ${name} # read existing array`, ` celilo module config set ${providerModuleId} ${name} ''`, ); } else { const renderedKey = renderEnsureTemplate(input.key, value); const renderedPrompt = renderEnsureTemplate(input.prompt, value); const cmd = scope === 'secret' ? 'secret' : 'config'; lines.push( ` # ${renderedPrompt}:`, ` celilo module ${cmd} set ${providerModuleId} ${name} '">'`, ); } lines.push(''); } if (ensure.post === 'redeploy_self') { lines.push(` celilo module deploy ${providerModuleId}`); } return lines.join('\n'); } /** * Apply one `ensures` block to the provider module's config + secrets. * * - `append_to_array`: idempotent — re-running with an already-present * value is a no-op. * - `set_in_object`: prompts only when the key isn't already set on the * target object. Re-running with the same value is a no-op. * * Returns success even when every input was already satisfied — the * caller (module-deploy) decides whether to retry the hook regardless. */ export async function interviewForEnsureInputs( providerModuleId: string, ensure: Ensure, value: string, db: DbClient, options: EnsureInterviewOptions = {}, ): Promise { const applied: string[] = []; let allNoop = true; // No confirm prompt: running `module deploy ` is itself the // user's consent for whatever cross-module config its hooks imply. A // confirm here would be redundant — the user just typed the deploy // command. Prompts that *gather information* (e.g. a DDNS password) // still appear, because the framework genuinely doesn't know the // value. See apps/celilo/designs/CADDY_HOSTNAME_LIST.md, Decision 6. const masterKey = await getOrCreateMasterKey(); // 1. Apply append_to_array inputs deterministically. The trigger // value is known up-front, so no responder is needed. for (const input of ensure.inputs) { if (input.kind !== 'append_to_array') continue; const { scope, name } = parseEnsureTarget(input.target); if (scope !== 'config') { return { success: false, applied, error: `append_to_array only supports config targets (got ${input.target})`, }; } const current = await readModuleConfigKey(providerModuleId, name, db); const arr = Array.isArray(current) ? [...current] : []; if (arr.includes(value)) { applied.push(`${input.target} already contains "${value}" — skipped`); continue; } arr.push(value); await writeModuleConfigKey(providerModuleId, name, arr, db); applied.push(`${input.target} ← appended "${value}"`); allNoop = false; } // 2. Collect set_in_object inputs that aren't already populated. // These are the ones that genuinely need user input. interface PendingInput { target: string; name: string; scope: 'config' | 'secret'; objectKey: string; prompt: string; hint?: string; type: string; } const pending: PendingInput[] = []; for (const input of ensure.inputs) { if (input.kind !== 'set_in_object') continue; const { scope, name } = parseEnsureTarget(input.target); const objectKey = renderEnsureTemplate(input.key, value); const promptMsg = renderEnsureTemplate(input.prompt, value); if (scope === 'config') { const current = await readModuleConfigKey(providerModuleId, name, db); const obj = current && typeof current === 'object' && !Array.isArray(current) ? (current as Record) : {}; if (objectKey in obj) { applied.push(`${input.target}["${objectKey}"] already set — skipped`); continue; } } else { const currentRaw = await readModuleSecretKey(providerModuleId, name, db, masterKey); let obj: Record = {}; if (currentRaw) { try { const parsed = JSON.parse(currentRaw); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { obj = parsed as Record; } } catch { // Existing secret isn't a JSON object — overwrite later. } } if (objectKey in obj) { applied.push(`${input.target}["${objectKey}"] already set — skipped`); continue; } } pending.push({ target: input.target, name, scope, objectKey, prompt: promptMsg, hint: input.hint, // EnsureInputSchema doesn't carry a type — set_in_object is // always a string today. Carry 'string' through to the bus // payload's type field for future-proofing. type: 'string', }); } if (pending.length === 0) { return { success: true, alreadyApplied: allNoop, applied }; } // 3. Test escape hatch: if a promptOverride is provided, run the // legacy direct-prompt path. Lets existing unit tests keep their // inline prompt stubs without setting up a bus responder. if (options.promptOverride) { for (const input of pending) { const userValue = await options.promptOverride(input.prompt, input.hint); await applyEnsureInput(input, userValue, providerModuleId, db, masterKey); applied.push(`${input.target}["${input.objectKey}"] set`); allNoop = false; } return { success: true, alreadyApplied: allNoop, applied }; } // 4. Bus-mediated interview. One event for the whole ensure; the // responder prompts the user, sets secret-target values // out-of-band, replies with config-target values plus an // `acknowledged: true` flag when secrets were touched. const payload: EnsureRequiredPayload = { consumer: providerModuleId, provider: providerModuleId, ensureId: ensure.id, triggerValue: value, description: ensure.description, inputs: pending.map((i) => ({ target: i.target, kind: 'set_in_object', prompt: i.prompt, hint: i.hint, type: i.type, objectKey: i.objectKey, })), }; const reply = await busInterviewGuarded( EVENT_TYPES.ensureRequired(providerModuleId, ensure.id), payload, ); // 5. Apply reply values for config targets. Secret targets aren't // in `values` — the responder wrote them out-of-band; we record // them as applied since the encrypted store is already updated. const replyValues = reply.values ?? {}; for (const input of pending) { if (input.scope === 'secret') { applied.push(`${input.target}["${input.objectKey}"] set`); allNoop = false; continue; } const userValue = replyValues[input.target]; if (userValue === undefined) { return { success: false, applied, error: `Responder reply missing value for ${input.target}`, }; } await applyEnsureInput(input, userValue, providerModuleId, db, masterKey); applied.push(`${input.target}["${input.objectKey}"] set`); allNoop = false; } return { success: true, alreadyApplied: allNoop, applied }; } /** * Read-merge-write one set_in_object input. Used by both the * promptOverride test path and the bus-mediated config-target path * after the responder reply is in hand. */ async function applyEnsureInput( input: { target: string; name: string; scope: 'config' | 'secret'; objectKey: string }, userValue: unknown, providerModuleId: string, db: DbClient, masterKey: Buffer, ): Promise { if (input.scope === 'config') { const current = await readModuleConfigKey(providerModuleId, input.name, db); const obj = current && typeof current === 'object' && !Array.isArray(current) ? { ...(current as Record) } : {}; obj[input.objectKey] = userValue; await writeModuleConfigKey(providerModuleId, input.name, obj, db); return; } const currentRaw = await readModuleSecretKey(providerModuleId, input.name, db, masterKey); let obj: Record = {}; if (currentRaw) { try { const parsed = JSON.parse(currentRaw); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { obj = parsed as Record; } } catch { // Existing secret isn't a JSON object — overwrite. } } obj[input.objectKey] = userValue; await writeModuleSecretKey(providerModuleId, input.name, JSON.stringify(obj), db, masterKey); } /** * Look up an `ensures` block on a provider module's manifest by id. * Returns null when the module doesn't exist or doesn't declare a * matching ensure — callers should treat that as a hard failure since * the consumer's capability call asked for something the provider * doesn't know how to satisfy. */ export function findEnsureOnProvider( providerModuleId: string, ensureId: string, db: DbClient, ): Ensure | null { const row = db.select().from(modules).where(eq(modules.id, providerModuleId)).get(); if (!row?.manifestData) return null; const manifest = row.manifestData as { provides?: { capabilities?: Array<{ ensures?: Ensure[] }> }; }; for (const cap of manifest.provides?.capabilities ?? []) { for (const ensure of cap.ensures ?? []) { if (ensure.id === ensureId) return ensure; } } return null; }