/** * Status Command * Show overall system and module status */ import { existsSync } from 'node:fs'; import { stat } from 'node:fs/promises'; import { join } from 'node:path'; import { eq } from 'drizzle-orm'; import { getDb } from '../../db/client'; import { capabilities, moduleConfigs, modules, systemConfig, systemSecrets } from '../../db/schema'; import { type ModuleManifest, getSingularSystemSpec } from '../../manifest/schema'; import { formatPausedDuration } from '../../services/module-pause'; import type { CommandResult } from '../types'; /** * Determine module status for display. * * Uses the DB state as the primary source of truth. * Falls back to filesystem / config inspection for IMPORTED/CONFIGURED modules * that haven't yet been deployed. */ async function determineModuleStatus( _moduleId: string, manifest: ModuleManifest, configs: (typeof moduleConfigs.$inferSelect)[], generatedPath: string, dbState: string, ): Promise<{ status: | 'IMPORTED' | 'CONFIGURED' | 'GENERATED' | 'DEPLOYED' | 'VERIFIED' | 'NEEDS_UPDATE' | 'PAUSED'; missingCount?: number; }> { // Deployed states come directly from the DB — don't infer from filesystem. // PAUSED is checked FIRST: a paused module still has a generated/ directory // and a full config, so every derivation below it would report it as an // ordinary deployed module and the pause would be invisible here. if (dbState === 'PAUSED') return { status: 'PAUSED' }; if (dbState === 'VERIFIED') return { status: 'VERIFIED' }; if (dbState === 'INSTALLED') return { status: 'DEPLOYED' }; // For pre-deploy states, derive from filesystem / config if (existsSync(generatedPath)) { return { status: 'GENERATED' }; } const requiredVars = manifest.variables?.owns?.filter((v) => v.required) || []; const configMap = new Map(configs.map((c) => [c.key, true])); const missingVars = requiredVars.filter((v) => !configMap.has(v.name)); if (missingVars.length > 0) { return { status: 'IMPORTED', missingCount: missingVars.length }; } if (requiredVars.length > 0 || configs.length > 0) { return { status: 'CONFIGURED' }; } return { status: 'IMPORTED' }; } /** * Handle status command * * Usage: celilo status * * @returns Command result with system and module status */ export async function handleStatus(): Promise { const db = getDb(); try { const lines: string[] = ['Celilo Status', '']; // System Configuration lines.push('System Configuration:'); const configs = await db.select().from(systemConfig).all(); const secrets = await db.select().from(systemSecrets).all(); // Count how many secrets have values const setSecrets = secrets.filter((s) => s.encryptedValue).length; const totalSecrets = secrets.length; lines.push(' ✓ Database initialized'); lines.push(` ${configs.length > 0 ? '✓' : '⚠'} System config: ${configs.length} key(s) set`); if (totalSecrets === 0) { lines.push(' ℹ No system secrets configured'); } else if (setSecrets === totalSecrets) { lines.push(` ✓ System secrets: ${setSecrets}/${totalSecrets} set`); } else { const missing = totalSecrets - setSecrets; lines.push(` ⚠ System secrets: ${setSecrets}/${totalSecrets} set (${missing} missing)`); } lines.push(''); // Module Status const allModules = await db.select().from(modules).all(); lines.push(`Modules: ${allModules.length} total`); lines.push(''); if (allModules.length === 0) { lines.push(' (none)'); } else { for (const module of allModules) { const manifest = module.manifestData as ModuleManifest; // Get module configurations const moduleConfigsList = await db .select() .from(moduleConfigs) .where(eq(moduleConfigs.moduleId, module.id)) .all(); // Determine status const generatedPath = join(module.sourcePath, 'generated'); const statusInfo = await determineModuleStatus( module.id, manifest, moduleConfigsList, generatedPath, module.state, ); // Status icon // A paused module is deliberately out of service, so it must never // carry the same ✓ as a healthy one — that is exactly how a pause turns // into an outage nobody notices. const icon = statusInfo.status === 'VERIFIED' || statusInfo.status === 'DEPLOYED' || statusInfo.status === 'GENERATED' ? '✓' : '⚠'; lines.push(` ${icon} ${module.id} (v${module.version})`); if (statusInfo.status === 'PAUSED') { // State plus AGE — a pause with alerting suppressed is only safe if // how long it has run is impossible to miss (design D7). lines.push(` Status: PAUSED (${formatPausedDuration(module.pausedAt)})`); if (module.pauseReason) lines.push(` Paused: ${module.pauseReason}`); } else { lines.push(` Status: ${statusInfo.status}`); } // Type: VPS or Container const zone = getSingularSystemSpec(manifest)?.zone; if (zone) { lines.push(` Type: Container (${zone.toUpperCase()})`); // Show VMID/IP if allocated const { ipAllocations } = await import('../../db/schema'); const allocation = await db .select() .from(ipAllocations) .where(eq(ipAllocations.moduleId, module.id)) .get(); if (allocation) { lines.push(` VMID: ${allocation.vmid}, IP: ${allocation.containerIp}`); } } else if (manifest.variables?.owns?.some((v) => v.name === 'vps_ip')) { lines.push(' Type: VPS-based'); } // Show capabilities const moduleCapabilities = await db .select() .from(capabilities) .where(eq(capabilities.moduleId, module.id)) .all(); if (manifest.requires?.capabilities && manifest.requires.capabilities.length > 0) { const requires = manifest.requires.capabilities.map((c) => c.name).join(', '); lines.push(` Requires: ${requires}`); } if (moduleCapabilities.length > 0) { const provides = moduleCapabilities.map((c) => c.capabilityName).join(', '); lines.push(` Provides: ${provides}`); } // Show missing config count if (statusInfo.missingCount && statusInfo.missingCount > 0) { lines.push( ` Missing: Configuration incomplete (${statusInfo.missingCount} required variable${statusInfo.missingCount > 1 ? 's' : ''})`, ); } // Show when last generated (if applicable) if ( statusInfo.status === 'GENERATED' || statusInfo.status === 'DEPLOYED' || statusInfo.status === 'VERIFIED' ) { try { const stats = await stat(generatedPath); const age = Date.now() - stats.mtimeMs; const ageHours = Math.floor(age / (1000 * 60 * 60)); const ageDays = Math.floor(ageHours / 24); let ageStr: string; if (ageDays > 0) { ageStr = `${ageDays} day${ageDays > 1 ? 's' : ''} ago`; } else if (ageHours > 0) { ageStr = `${ageHours} hour${ageHours > 1 ? 's' : ''} ago`; } else { ageStr = 'recently'; } lines.push(` Last generated: ${ageStr}`); } catch { // Ignore stat errors } } lines.push(''); } } // Legend lines.push('Legend:'); lines.push(' IMPORTED - Module imported, not configured'); lines.push(' CONFIGURED - Configuration complete, not generated'); lines.push(' GENERATED - Infrastructure code generated, not deployed'); lines.push(' DEPLOYED - Deployed (Ansible complete, health check pending)'); lines.push(' VERIFIED - Deployed and health checks passed'); return { success: true, message: lines.join('\n'), }; } catch (err) { return { success: false, error: err instanceof Error ? err.message : String(err), }; } }