/** * Module generate command */ import { existsSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { eq } from 'drizzle-orm'; import { promptForMissingCapabilitySecrets, validateCapabilitySecrets, } from '../../capabilities/secret-validation'; import { getDb } from '../../db/client'; import { moduleConfigs, modules } from '../../db/schema'; import type { ModuleManifest } from '../../manifest/schema'; import { interviewForMissingSecrets, validateModuleSecrets } from '../../services/config-interview'; import { generateTemplates } from '../../templates/generator'; import { promptForMissingConfig } from '../interactive-config'; import { getArg, getFlag, validateRequiredArgs } from '../parser'; import type { CommandResult } from '../types'; /** * Declared `build.artifacts` absent from the module's delivered payload. * * D1 (control-plane-stops-building-modules): generation consumes the payload. * A declared output the payload does not carry is a publish defect, so it is * reported to the caller and named, never repaired by running a build here. * A fallback build is the shape that hid celilo#1307 for four months: the * failure must fail loudly at the boundary, not be repaired downstream. */ export function missingBuildOutputs(modulePath: string, manifest: ModuleManifest): string[] { const artifacts = manifest.build?.artifacts ?? []; return artifacts.filter((artifact) => !existsSync(join(modulePath, artifact))); } /** * Handle module generate command * * Usage: celilo module generate [--output ] * * @param args - Command arguments * @param flags - Command flags * @returns Command result */ export async function handleModuleGenerate( args: string[], flags: Record, ): Promise { // Validate arguments const error = validateRequiredArgs(args, 1); if (error) { return { success: false, error: `${error}\n\nUsage: celilo module generate [--output ]`, }; } const moduleId = getArg(args, 0); if (!moduleId) { return { success: false, error: 'Module ID is required', }; } const db = getDb(); // Check if module exists const module = db.select().from(modules).where(eq(modules.id, moduleId)).get(); if (!module) { return { success: false, error: `Module not found: ${moduleId}`, }; } // Get module path and output path const modulePath = module.sourcePath; // Check if module files exist (may have been deleted from /tmp) if (!existsSync(modulePath)) { return { success: false, error: `Module files not found at: ${modulePath}\n\nThe module files may have been deleted (e.g., /tmp cleanup).\n\nTo fix this:\n 1. Remove the module: celilo module remove ${moduleId}\n 2. Re-import it: celilo module import \n 3. Reconfigure if needed: celilo module config set ${moduleId} \n 4. Regenerate: celilo module generate ${moduleId}`, }; } // D1 (control-plane-stops-building-modules): generation consumes the // delivered payload. Whether this machine ever ran a build is a fact about // its own history, not about the module in front of it — gating on it is // what made celilo#1307 refuse generation on a production control plane // whose payload carried the correct binaries one directory away. const manifest = module.manifestData as ModuleManifest; const missingOutputs = missingBuildOutputs(modulePath, manifest); if (missingOutputs.length > 0) { return { success: false, error: [ `Module payload is missing ${missingOutputs.length} declared build output(s):`, ...missingOutputs.map((artifact) => ` ${artifact}`), '', "The payload must carry the module's build outputs; a control plane never builds. This is a publish defect: re-package the module and update it. Generation will not fall back to running a build.", ].join('\n'), }; } const outputPathFlag = getFlag(flags, 'output', ''); const outputPath = outputPathFlag || `${modulePath}/generated`; const resolvedOutputPath = resolve(outputPath); // Check for missing capability secrets const secretValidation = await validateCapabilitySecrets(moduleId, db.$client); if (!secretValidation.success) { // If missing secrets and in interactive mode, prompt user if (process.stdin.isTTY && secretValidation.missingSecrets) { const promptResult = await promptForMissingCapabilitySecrets( moduleId, secretValidation.missingSecrets, db.$client, ); if (!promptResult.success) { return { success: false, error: promptResult.error || 'Failed to collect capability secrets', }; } } else { // Non-interactive: error with helpful message return { success: false, error: secretValidation.error || 'Missing capability secrets', }; } } // Check for missing module secrets const moduleSecretsMissing = await validateModuleSecrets(moduleId, db); if (moduleSecretsMissing.length > 0) { // interviewForMissingSecrets fires `secret.required.*` bus events for // user_provided secrets and waits for a responder. busInterviewGuarded // (ISS-0025) is the shared backstop that fails fast when none is listening, // but we probe here FIRST so `module generate` can emit a command-tailored // error: the full list of missing secrets plus a `module secret set` line // for each. The shared guard would only name one prompt at a time. // // Auto-generated secrets (manifest `generate:` field or schema // source: 'generated') don't go through the bus, so missing-but- // auto-generatable doesn't need a responder. Filter those out // before deciding whether to probe. if (!process.stdin.isTTY) { const { getSecretMetadata } = await import('../../services/secret-schema-loader'); const promptable: typeof moduleSecretsMissing = []; for (const s of moduleSecretsMissing) { if (s.generate) continue; // manifest-declared auto-generate const meta = await getSecretMetadata(moduleId, s.name, db); if (meta?.source === 'generated') continue; // schema-declared auto-generate promptable.push(s); } if (promptable.length > 0) { const { probeForResponder } = await import('../../services/responder-probe'); const { getEventBusPath } = await import('../../config/paths'); const responderAvailable = await probeForResponder(getEventBusPath()); if (!responderAvailable) { const names = promptable.map((s) => s.name).join(', '); const setCommands = promptable .map((s) => ` celilo module secret set ${moduleId} ${s.name} `) .join('\n'); return { success: false, error: `Missing required secret(s): ${names}\n\nNo responder is running and stdin isn't a TTY, so module generate can't prompt for them. Either:\n 1. Run interactively (in a terminal)\n 2. Pre-set the secrets:\n${setCommands}\n 3. Run a responder in another shell:\n celilo events respond --values values.json`, }; } } } // Auto-generated secrets work in non-interactive mode const result = await interviewForMissingSecrets(moduleId, moduleSecretsMissing, db); if (!result.success) { return { success: false, error: result.error || 'Failed to collect module secrets', }; } } // Try to generate templates let result = await generateTemplates({ moduleId, modulePath, outputPath: resolvedOutputPath, db, }); // If missing config, prompt for it and retry if (!result.success && result.error?.includes('Missing required configuration')) { // Get missing required variables const requiredVars = manifest.variables?.owns?.filter((v) => v.required) || []; if (requiredVars.length > 0) { // Get current module configuration const configs = db .select() .from(moduleConfigs) .where(eq(moduleConfigs.moduleId, moduleId)) .all(); const configMap = new Map(configs.map((c) => [c.key, c.value || c.valueJson])); // Find missing variables const missingVars = requiredVars.filter((v) => !configMap.has(v.name)); if (missingVars.length > 0) { // Only prompt if in interactive mode if (!process.stdin.isTTY) { // Non-interactive: return error return { success: false, error: result.error || 'Missing required configuration', }; } // Prompt for missing config const collected = await promptForMissingConfig(moduleId, missingVars, db); if (!collected) { return { success: false, error: 'Failed to collect required configuration', }; } // Retry generation with new config result = await generateTemplates({ moduleId, modulePath, outputPath: resolvedOutputPath, db, }); } } } if (!result.success) { return { success: false, error: result.error, details: result.details, }; } // Build infrastructure info message let infrastructureMsg = ''; if (result.infrastructure) { const infra = result.infrastructure; if (infra.type === 'machine') { infrastructureMsg = `\nšŸ“¦ Infrastructure: Existing machine "${infra.machineName || infra.machineId}" (zone: ${infra.zone})`; } else if (infra.type === 'container_service') { infrastructureMsg = `\nšŸ“¦ Infrastructure: Container service "${infra.serviceName || infra.serviceId}" (zone: ${infra.zone})`; } } const filesList = result.files.map((f) => ` - ${f.path}`).join('\n'); return { success: true, message: `Successfully generated ${result.files.length} files:\n${filesList}\n\nOutput: ${result.outputPath}${infrastructureMsg}`, data: { fileCount: result.files.length, outputPath: result.outputPath, files: result.files, infrastructure: result.infrastructure, }, }; }