import { auditModule } from '../../module/packaging/audit'; import type { IntegrityViolation } from '../../module/packaging/extract'; import { hasFlag } from '../parser'; import type { CommandResult } from '../types'; /** * Verify module integrity across the three planes a module lives in. * * Usage: celilo module verify [--deep] [--json] * * installed tree vs baseline did anything change the files since install? * generated project vs installed is what we would deploy built from it? * host vs generated project is what is running what we generated? (--deep) * * The first two are local and take milliseconds. `--deep` is one SSH per * system, so it is opt-in (openspec/changes/module-integrity-rigor, D3). * * Renamed from `module audit` per CELILO_UPDATE D11 — `audit` is now * reserved for system-level drift detection (`celilo system audit`). * The legacy `module audit` continues to work via a deprecation alias * (see `module-audit.ts`). * * Returns a CommandResult so the dispatcher controls process exit behavior. */ export async function moduleVerify( args: string[], flags: Record = {}, ): Promise { if (args.length === 0) { return { success: false, error: 'Module ID is required\n\nUsage: celilo module verify [--deep] [--json]', }; } const moduleId = args[0]; const deep = hasFlag(flags, 'deep'); const json = hasFlag(flags, 'json'); const result = await auditModule(moduleId, undefined, { deep }); if (json) { // The whole point of D9: one call answers "is the installed tree the // version celilo thinks it is", with the digests on both sides, so nobody // needs a shell on celilo-mgr to settle a celilo#925-shaped question. const payload = { moduleId, deep, ok: result.success && !result.error, error: result.error ?? null, moduleVersion: result.moduleVersion ?? null, baselineVersion: result.baselineVersion ?? null, violations: result.violations.map((v) => ({ type: v.type, path: v.path, message: v.message, expectedDigest: v.expectedDigest ?? null, actualDigest: v.actualDigest ?? null, })), hosts: result.hostPlane?.findings ?? [], deepOptOut: result.hostPlane?.optedOut ?? null, }; const text = JSON.stringify(payload, null, 2); return payload.ok ? { success: true, message: text } : { success: false, error: text }; } if (result.error) { return { success: false, error: result.error }; } const lines: string[] = []; // An opt-out nobody sees is a check that quietly disappeared, so it prints // whether the module is clean or not (task 6.3). if (result.hostPlane?.optedOut) { lines.push( ` ⚠ [DEEP SKIPPED] This module opts out of the host check: ${result.hostPlane.optedOut.reason}`, ); } for (const finding of result.hostPlane?.findings ?? []) { if (finding.state === 'converged') { lines.push(` ✓ [HOST] ${finding.hostname}: running what celilo generated`); } else { const tag = finding.state === 'drift' ? 'HOST-DRIFT' : 'HOST-UNMEASURED'; lines.push( ` ${finding.state === 'drift' ? '✗' : '⚠'} [${tag}] ${finding.hostname}: ${finding.detail}`, ); } } if (result.success) { return { success: true, message: [`Module '${moduleId}' passed integrity check`, ...lines, ' No violations found.'] .join('\n') .trimEnd(), }; } const ICONS: Record = { missing: '⚠', modified: '✗', extra: '!', 'stale-baseline': '⚠', }; return { success: false, error: [ `Module '${moduleId}' failed integrity check`, ` Found ${result.violations.length} violation(s):`, ...result.violations.map((v) => ` ${ICONS[v.type]} [${v.type.toUpperCase()}] ${v.message}`), ...lines, ].join('\n'), }; }