/** * Module update command * * Updates module code (manifest, scripts, templates) while preserving state * (configs, secrets, infrastructure, capabilities). * * Usage: celilo module update * * The module ID is read from the manifest at the given path. */ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, } from 'node:fs'; import { unlink } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, relative, resolve } from 'node:path'; import { eq } from 'drizzle-orm'; import { parse as parseYaml } from 'yaml'; import { registerModuleCapabilities } from '../../capabilities/registration'; import { getDb } from '../../db/client'; import { capabilities, moduleIntegrity, modules } from '../../db/schema'; import { ModuleManifestSchema } from '../../manifest/schema'; import type { ModuleManifest } from '../../manifest/schema'; import { computeChecksums } from '../../module/packaging/build'; import { cleanupTempDir, extractPackage } from '../../module/packaging/extract'; import { classifyModulePath } from '../../module/packaging/package-rules'; import { RegistryClient } from '../../registry/client'; import { askConfirm, withInterviewSession } from '../../services/bus-interview'; import { InterviewAbandonedError, InterviewUnansweredError } from '../../services/interview-errors'; import { getFlag } from '../parser'; import { log } from '../prompts'; import type { CommandResult } from '../types'; type UpdateOutcome = | { status: 'success'; moduleId: string; /** Version that was on disk before the upgrade (manifest.yml semver). */ previousVersion: string; /** Version that's now installed. Includes +N revision when known * (registry-driven upgrades pass the canonical "1.0.0+5" form; * path-driven upgrades fall back to the manifest semver). */ newVersion: string; } | { status: 'failed'; moduleId: string; error: string } // `skipped` means the path expanded from a glob but isn't an // upgradable target — either no manifest at all (probably a non- // module sibling like `modules/__archive__/`) or a real module that // isn't installed in this celilo. Treated as a soft pass so // `celilo module update modules/*` does what users expect. | { status: 'skipped'; moduleId: string; reason: string }; /** * Tunables for `updateOne`. Quiet mode silences the per-call log * lines so callers driving a batch (the registry sweep) can render * their own structured output without duplicates. `displayVersion` * lets the registry caller carry the canonical `+N` revision through * to both the DB column and the success log. */ interface UpdateOpts { quiet?: boolean; displayVersion?: string; } /** * Parse a celilo version string into [major, minor, patch, revision]. * Celilo's published versions look like `1.0.0+3` — semver core plus a * publish revision suffix (the +N resets on every semver bump). Missing * segments default to 0; non-numeric segments are clamped to 0 so we * never throw on weird upstream input. */ function parseModuleVersion(v: string): [number, number, number, number] { const cleaned = v.replace(/^[v=]+/, ''); const [core, rev] = cleaned.split('+'); const parts = (core ?? '').split('.'); const num = (s: string | undefined) => { const n = Number(s ?? '0'); return Number.isNaN(n) ? 0 : n; }; return [num(parts[0]), num(parts[1]), num(parts[2]), num(rev)]; } export type VersionChangeKind = 'up-to-date' | 'ahead' | 'patch' | 'minor' | 'major'; /** * Classify a registry-side update relative to the installed version. * `major` = breaking (semver-major bump). Operator must approve. * `minor` = additive feature. Auto-applied. * `patch` = bugfix or revision-only (+N) bump. Auto-applied. * `up-to-date` = identical version. * `ahead` = installed is newer than registry. Skip silently — usually * means the operator pushed locally without publishing. * * Exported for unit tests. */ export function classifyVersionChange(installed: string, latest: string): VersionChangeKind { const [aMaj, aMin, aPat, aRev] = parseModuleVersion(installed); const [bMaj, bMin, bPat, bRev] = parseModuleVersion(latest); if (bMaj > aMaj) return 'major'; if (bMaj < aMaj) return 'ahead'; if (bMin > aMin) return 'minor'; if (bMin < aMin) return 'ahead'; if (bPat > aPat) return 'patch'; if (bPat < aPat) return 'ahead'; if (bRev > aRev) return 'patch'; if (bRev < aRev) return 'ahead'; return 'up-to-date'; } /** * Download a registry package into a temp file. Shared by `fetchAndUpdate` * (the full update path) and `fetchTargetManifest` (the upgrade gate that * must read the TARGET manifest before it mutates anything). The index entry * carries the published sha256; absence is tolerated, matching * `fetchAndUpdate`'s original tolerance for entries predating cksum. */ export async function downloadRegistryPackage( client: RegistryClient, moduleId: string, version: string, ): Promise<{ ok: true; tmpPath: string } | { ok: false; error: string }> { const tmpPath = join(tmpdir(), `${moduleId}-${version}-${Date.now()}.netapp`); try { const entries = await client.getIndex(moduleId).catch(() => []); const cksum = entries.find((entry) => entry.vers === version)?.cksum; const pkgData = await client.download(moduleId, version, cksum); await Bun.write(tmpPath, pkgData); return { ok: true, tmpPath }; } catch (err) { return { ok: false, error: `Download failed: ${err instanceof Error ? err.message : String(err)}`, }; } } /** * Read the manifest of a registry version WITHOUT installing it: download, * extract to a temp dir, parse manifest.yml, clean up. Used by the upgrade * path to gate on the target's capability requirements before any state * changes (celilo#1361). Failure to fetch or parse is not itself fatal — the * caller proceeds to `fetchAndUpdate`, which reports the same failure through * its own channel. */ export async function fetchTargetManifest( client: RegistryClient, moduleId: string, version: string, ): Promise<{ ok: true; manifest: ModuleManifest } | { ok: false }> { const downloaded = await downloadRegistryPackage(client, moduleId, version); if (!downloaded.ok) return { ok: false }; try { const extractResult = await extractPackage(downloaded.tmpPath); if (!extractResult.success || !extractResult.tempDir) return { ok: false }; try { const manifestPath = join(extractResult.tempDir, 'manifest.yml'); if (!existsSync(manifestPath)) return { ok: false }; const parsed = parseYaml(readFileSync(manifestPath, 'utf-8')); return { ok: true, manifest: ModuleManifestSchema.parse(parsed) }; } finally { await cleanupTempDir(extractResult.tempDir); } } catch { return { ok: false }; } finally { try { await unlink(downloaded.tmpPath); } catch {} } } /** * Download a module package from the registry into a temp file and run * the standard updateOne path against it. Cleans the temp file in a * finally block so a mid-flight failure doesn't leak a tar.zst on disk. */ export async function fetchAndUpdate( client: RegistryClient, moduleId: string, version: string, db: ReturnType, flags: Record, ): Promise { // The download (index lookup, cksum verification, temp write) is shared // with `fetchTargetManifest`, which reads the same package before this // update path ever runs. const downloaded = await downloadRegistryPackage(client, moduleId, version); if (!downloaded.ok) { return { status: 'failed', moduleId, error: downloaded.error }; } try { // Registry packages are pre-verified at publish time; skip the // signature check here to match `module import`'s registry path. // `quiet: true` suppresses updateOne's per-call log lines so the // sweep can render its own structured per-module output without // duplicates. `displayVersion: version` carries the registry's // canonical "X.Y.Z+N" through to both the DB column and the success // log line — without it, output would say "v1.0.0 → v1.0.0" because // the manifest semver doesn't include the +N revision. return await updateOne( downloaded.tmpPath, db, { ...flags, 'skip-verify': true }, { quiet: true, displayVersion: version }, ); } finally { try { await unlink(downloaded.tmpPath); } catch {} } } /** * Upgrade a single module from a source path */ /** * Every file under an installed module that an update is entitled to remove: * `package` (the new version decides whether it survives) and `unknown` (no * version ever shipped it, so it self-heals a tree an older update polluted). * * `derived` is neither walked nor removed. It is celilo's or the operator's — * `generated/`, the hook runtime closure, `screenshots/`, `cookies.json` — and * `generated/` alone carries terraform state and provider binaries. */ /** * What an update leaves alone: celilo's own output and the operator's state, * never the package's. The set the in-place update skipped, kept verbatim so * the staging swap changes only WHEN files move, not WHICH ones. */ const PRESERVED_ENTRIES = new Set(['generated', 'screenshots', 'cookies.json']); function listPrunableFiles(root: string, dir = root): string[] { const found: string[] = []; for (const entry of readdirSync(dir, { withFileTypes: true })) { const full = join(dir, entry.name); const rel = relative(root, full); if (classifyModulePath(rel) === 'derived') continue; if (entry.isDirectory()) { found.push(...listPrunableFiles(root, full)); } else if (entry.isFile()) { found.push(rel); } } return found; } /** * Replace an installed module tree atomically (celilo#1008). * * The install used to be built in place: copy the new files over the old, * then prune what the new version dropped. A failure anywhere in that * sequence left a module half old and half new, with nothing on disk or in * the DB recording which files were which. The comment on the in-place copy * already named the hazard — "the copy dies partway, after the prune has * already run". * * So the new tree is assembled in a sibling directory and swapped in with two * renames. Everything that can fail — the copy, the prune, a bad source — * fails while the live install is still untouched, because nothing has moved * yet. If the second rename throws, the first is undone and the original is * back. A sibling is deliberate: it is on the same filesystem, so the renames * are atomic rather than a copy in disguise. * * The staging tree is removed on both paths, so a failure leaves no debris * beside the module for the next reader to wonder about. * * `prune` receives the STAGED root, so the surviving-path rule is the one * `updateOne` has always applied — only where it runs has changed. * * Adapted from `replaceInstalledModule` in Jeremy Banka's `f9a57f1b`. The rest * of that commit is superseded by celilo#925; this half is not. It uses * celilo's `classifyModulePath` rather than that commit's hand-rolled * preserve/skip sets, which is what makes the `celilo/types.d.ts` special case * it carried unnecessary — the classifier already calls that file `derived`. */ function replaceInstalledModule( actualPath: string, installedPath: string, prune: (stagedRoot: string) => void, ): void { const suffix = `${process.pid}-${Date.now()}`; const stagedPath = `${installedPath}.update-${suffix}`; const previousPath = `${installedPath}.previous-${suffix}`; rmSync(stagedPath, { recursive: true, force: true }); mkdirSync(stagedPath, { recursive: true }); try { // The incoming version, filtered by the same classifier the in-place copy // used, so an update from a directory still cannot plant `e2e/`, // `*.test.ts` or `scripts/tsconfig.json` in the install. for (const entry of readdirSync(actualPath)) { if (PRESERVED_ENTRIES.has(entry)) continue; cpSync(join(actualPath, entry), join(stagedPath, entry), { recursive: true, force: true, filter: (from) => classifyModulePath(relative(actualPath, from)) !== 'unknown', }); } // Carry the live tree's own copies across. Same three entries the in-place // update left alone, for the same reason: they are celilo's or the // operator's, not the package's. Every other `derived` path — the hook // runtime closure under `scripts/node_modules`, `celilo/types.d.ts` — // still comes from the incoming version exactly as it did before, so a new // module version can still deliver new hook dependencies. for (const entry of PRESERVED_ENTRIES) { const from = join(installedPath, entry); if (!existsSync(from)) continue; cpSync(from, join(stagedPath, entry), { recursive: true, force: true }); } prune(stagedPath); renameSync(installedPath, previousPath); try { renameSync(stagedPath, installedPath); } catch (error) { renameSync(previousPath, installedPath); throw error; } rmSync(previousPath, { recursive: true, force: true }); } catch (error) { rmSync(stagedPath, { recursive: true, force: true }); throw error; } } export async function updateOne( sourcePath: string, db: ReturnType, flags: Record = {}, opts: UpdateOpts = {}, ): Promise { const originalCwd = process.env.CELILO_ORIGINAL_CWD || process.cwd(); const importPath = resolve(originalCwd, sourcePath); if (!existsSync(importPath)) { return { status: 'failed', moduleId: sourcePath, error: `Source path not found: ${importPath}`, }; } // Handle .netapp packages: extract to temp dir let actualPath = importPath; let tempDir: string | null = null; if (importPath.endsWith('.netapp')) { const extractResult = await extractPackage(importPath); if (!extractResult.success || !extractResult.tempDir) { return { status: 'failed', moduleId: sourcePath, error: extractResult.error || 'Failed to extract package', }; } tempDir = extractResult.tempDir; actualPath = tempDir; // Skip signature verification if --skip-verify if (flags['skip-verify'] !== true) { const { verifyPackageIntegrity } = await import('../../module/packaging/extract'); const verifyResult = await verifyPackageIntegrity(tempDir); if (!verifyResult.success) { await cleanupTempDir(tempDir); return { status: 'failed', moduleId: sourcePath, error: verifyResult.error || 'Package verification failed', }; } } else if (!opts.quiet) { log.warn('Skipping package signature verification (--skip-verify)'); } } const manifestPath = join(actualPath, 'manifest.yml'); if (!existsSync(manifestPath)) { if (tempDir) await cleanupTempDir(tempDir); // No manifest means the path isn't a module directory at all — // a likely outcome of `module update modules/*` matching a // non-module sibling. Skip silently rather than fail the batch. return { status: 'skipped', moduleId: sourcePath, reason: 'not a module directory (no manifest.yml)', }; } let newManifest: ModuleManifest; try { const raw = readFileSync(manifestPath, 'utf-8'); const parsed = parseYaml(raw); newManifest = ModuleManifestSchema.parse(parsed); } catch (err) { if (tempDir) await cleanupTempDir(tempDir); const msg = err instanceof Error ? err.message : String(err); return { status: 'failed', moduleId: sourcePath, error: `Invalid manifest: ${msg}` }; } const moduleId = newManifest.id; const module = db.select().from(modules).where(eq(modules.id, moduleId)).get(); if (!module) { if (tempDir) await cleanupTempDir(tempDir); // Module isn't installed in this celilo. Don't fail the batch — // `module update modules/*` should keep going for everything // that IS installed. Caller surfaces the skip count so the user // sees what was passed over. return { status: 'skipped', moduleId, reason: `not installed (run 'celilo module import ${sourcePath}' to add)`, }; } // Pointed at the module's OWN install, `updateOne` used to copy every file // onto itself and die inside `cpSync` with `EINVAL: copy_file_range` — an // error that names a syscall and not the mistake. Worse since D1: the copy // dies partway, after the prune has already run. Refuse by name instead // (D11). if (resolve(actualPath) === resolve(module.sourcePath)) { if (tempDir) await cleanupTempDir(tempDir); return { status: 'failed', moduleId, error: `'${sourcePath}' IS the installed copy of ${moduleId}. There is nothing to update it from. Point 'module update' at the module's source tree or a .netapp, or run 'celilo module upgrade ${moduleId}' to take the registry's version.`, }; } // Old version comes from the DB so we capture whatever was last // recorded (which IS the registry-versioned form, e.g. "1.0.0+5", // for registry-driven installs/upgrades). const previousVersion = module.version; // New version: prefer the caller-supplied display version (registry's // canonical "X.Y.Z+N"), fall back to the manifest semver core when // upgrading from a local path. const newVersion = opts.displayVersion ?? newManifest.version; if (!opts.quiet) { log.info(`Upgrading ${moduleId}: ${previousVersion} → ${newVersion}`); } const installedPath = module.sourcePath; // The integrity baseline for the version just installed. Prefer the package's // own signed `checksums.json`; a directory update has none, so compute over // the source we just copied. Read before the temp dir goes away. const packagedChecksumsPath = join(actualPath, 'checksums.json'); const packagedSignaturePath = join(actualPath, 'signature.sig'); let baselineChecksums: Record; if (existsSync(packagedChecksumsPath)) { const parsed = JSON.parse(readFileSync(packagedChecksumsPath, 'utf-8')) as { files?: Record; }; baselineChecksums = parsed.files ?? {}; } else { baselineChecksums = (await computeChecksums(actualPath)).files; } const packagedSignature = existsSync(packagedSignaturePath) ? readFileSync(packagedSignaturePath, 'utf-8').trim() : null; // Remove what the new version dropped. `updateOne` only ever overlaid files, // so a hook script deleted in 1.1.0 stayed on the box and stayed runnable — // code celilo no longer believes it has installed, which is the same class of // lie as celilo#925 pointing the other way. Only `package`-class paths are // pruned: `generated/`, the hook runtime closure, `screenshots/` and // `cookies.json` are celilo's or the operator's, and survive an update by // design. // // Unchanged except for WHERE it runs. It now prunes the staged tree, before // anything is swapped in, so a prune that throws cannot leave the live // install short of files (celilo#1008). const survivingPaths = new Set( Object.keys(baselineChecksums).filter((p) => classifyModulePath(p) === 'package'), ); const pruneDropped = (root: string) => { for (const relPath of listPrunableFiles(root)) { if (!survivingPaths.has(relPath)) { rmSync(join(root, relPath)); } } }; // Build the new tree beside the install and swap it in with two renames. // Everything above this line is reversible by doing nothing; everything // below it runs only once the files are really in place. replaceInstalledModule(actualPath, installedPath, pruneDropped); // Clean up the extracted package. This waits until after the swap because // `replaceInstalledModule` reads `actualPath`, which IS `tempDir` for a // .netapp source — deleting first is what made every registry-driven // upgrade die with `ENOENT ... scandir .tmp-module-extract/` // (ce-h4of, a regression from the celilo#1008 staging swap). if (tempDir) await cleanupTempDir(tempDir); // Record it. `updateOne` never touched this table, so the baseline stayed // frozen at the module's FIRST import no matter how many times it was // updated. That is why `module verify` reported the same violations whether // the files were old or the checksums were old, and could not answer the one // question that matters (celilo#925). db.insert(moduleIntegrity) .values({ moduleId, checksums: baselineChecksums, version: newVersion, signature: packagedSignature, }) .onConflictDoUpdate({ target: moduleIntegrity.moduleId, set: { checksums: baselineChecksums, version: newVersion, signature: packagedSignature, updatedAt: new Date(), }, }) .run(); // Update manifest in database. We persist the display version (with // +N when known) so subsequent `module list` / `module update` calls // see the same version string the registry reported. updatedAt moves with // the version: a frozen timestamp beside a new version read as "nothing // happened here" to the audit (celilo#1363). db.update(modules) .set({ manifestData: newManifest as unknown as Record, version: newVersion, name: newManifest.name, updatedAt: new Date(), }) .where(eq(modules.id, moduleId)) .run(); // Re-register capabilities db.delete(capabilities).where(eq(capabilities.moduleId, moduleId)).run(); if (newManifest.provides?.capabilities && newManifest.provides.capabilities.length > 0) { const regResult = await registerModuleCapabilities(moduleId, newManifest, db.$client); if (!regResult.success && !opts.quiet) { // Capability re-registration warnings are useful when upgrading // from a path (operator iterating on dev module); for the // registry sweep, the caller will surface them itself if needed. log.warn(` ${moduleId}: capability re-registration warning: ${regResult.error}`); } } // (Re-)register event-bus subscriptions from the new manifest — // mirrors import.ts (ISS-0091: update used to skip this, so a // refreshed module silently lost its reconcile subscriptions). // registerModuleSubscriptions is idempotent; best-effort like import: // a bus problem shouldn't wedge the upgrade, but must be loud. try { const { registerModuleSubscriptions } = await import('../../services/module-subscriptions'); registerModuleSubscriptions(newManifest, installedPath); } catch (error) { const msg = error instanceof Error ? error.message : String(error); log.warn(` ${moduleId}: failed to register event-bus subscriptions: ${msg}`); log.warn( ' Module upgraded, but reactive flows on the event bus will not fire until this is fixed.', ); } if (!opts.quiet) { log.success(`Upgraded ${moduleId} (${previousVersion} → ${newVersion})`); } return { status: 'success', moduleId, previousVersion, newVersion }; } /** * Handle module upgrade command * * @param args - Command arguments: [path, path, ...] * @returns Command result */ export async function handleModuleUpdate( args: string[], flags: Record = {}, ): Promise { const db = getDb(); // Zero args = registry sweep: walk every installed module, pick up // any non-breaking update from the registry automatically, and // prompt per-module for breaking (semver-major) updates. if (args.length === 0) { return runRegistrySweep(db, flags); } const results: UpdateOutcome[] = []; for (const path of args) { const result = await updateOne(path, db, flags); results.push(result); } const succeeded = results.filter( (r): r is Extract => r.status === 'success', ); const failed = results.filter( (r): r is Extract => r.status === 'failed', ); const skipped = results.filter( (r): r is Extract => r.status === 'skipped', ); // Skips that fall under a wildcard expansion (e.g. modules/* picking // up `modules/__archive__/`) shouldn't even be mentioned — they're not // signal. Skips for "module not installed" ARE signal because the // user explicitly named the path; surface those. const meaningfulSkips = skipped.filter((r) => !r.reason.startsWith('not a module directory')); if (failed.length > 0) { const errors = failed.map((r) => ` ${r.moduleId}: ${r.error}`).join('\n'); const parts: string[] = []; if (succeeded.length > 0) { parts.push( `Upgraded ${succeeded.length} module(s): ${succeeded.map((r) => r.moduleId).join(', ')}`, ); } if (meaningfulSkips.length > 0) { const skipLines = meaningfulSkips.map((r) => ` ${r.moduleId}: ${r.reason}`).join('\n'); parts.push(`Skipped ${meaningfulSkips.length}:\n${skipLines}`); } parts.push(`Failed ${failed.length}:\n${errors}`); return { success: false, error: parts.join('\n\n') }; } const lines: string[] = []; if (succeeded.length > 0) { lines.push( `Updated ${succeeded.length} module(s): ${succeeded.map((r) => r.moduleId).join(', ')}`, ); } if (meaningfulSkips.length > 0) { const skipLines = meaningfulSkips.map((r) => ` ${r.moduleId}: ${r.reason}`).join('\n'); lines.push(`Skipped ${meaningfulSkips.length}:\n${skipLines}`); } if (lines.length === 0) { // Every arg was a non-module sibling — odd but not a failure. lines.push( `No modules to update (${results.length} path(s) skipped — none had a manifest.yml)`, ); } return { success: true, message: lines.join('\n\n') }; } interface UpdatePlan { moduleId: string; installedVersion: string; targetVersion: string; classification: Exclude; } /** * Walk every installed module, query the registry, and produce a plan. * Auto-apply non-breaking updates (patch/minor); prompt per-module for * breaking (major) updates. Modules absent from the registry are * surfaced as a skip — typically these are local-only modules the * operator imported from a path, never published. */ async function runRegistrySweep( db: ReturnType, flags: Record, ): Promise { const installed = db.select().from(modules).all(); if (installed.length === 0) { return { success: true, message: 'No modules installed.' }; } const registryUrl = getFlag(flags, 'registry', ''); const client = new RegistryClient(registryUrl || undefined); log.info(`Checking ${installed.length} installed module(s) against the registry…`); const plans: UpdatePlan[] = []; const upToDate: string[] = []; const notInRegistry: string[] = []; const errored: Array<{ moduleId: string; error: string }> = []; for (const mod of installed) { try { const entries = await client.getIndex(mod.id); if (entries.length === 0) { notInRegistry.push(mod.id); continue; } const latest = client.latestVersion(entries); if (!latest) { // All versions yanked. notInRegistry.push(mod.id); continue; } const cmp = classifyVersionChange(mod.version, latest.vers); if (cmp === 'up-to-date' || cmp === 'ahead') { upToDate.push(mod.id); continue; } plans.push({ moduleId: mod.id, installedVersion: mod.version, targetVersion: latest.vers, classification: cmp, }); } catch (err) { errored.push({ moduleId: mod.id, error: err instanceof Error ? err.message : String(err), }); } } if (plans.length === 0) { const lines: string[] = ['All installed modules are up to date.']; if (notInRegistry.length > 0) { lines.push(`Not in registry: ${notInRegistry.join(', ')}`); } if (errored.length > 0) { lines.push(`Registry errors: ${errored.map((e) => `${e.moduleId} (${e.error})`).join('; ')}`); } return { success: true, message: lines.join('\n') }; } const nonBreaking = plans.filter((p) => p.classification !== 'major'); const breaking = plans.filter((p) => p.classification === 'major'); let appliedNonBreaking = 0; const failed: Array<{ moduleId: string; error: string }> = []; if (nonBreaking.length > 0) { log.info(`Auto-applying ${nonBreaking.length} non-breaking update(s):`); for (const plan of nonBreaking) { const result = await fetchAndUpdate(client, plan.moduleId, plan.targetVersion, db, flags); if (result.status === 'failed') { failed.push({ moduleId: plan.moduleId, error: result.error }); console.log( ` ✗ ${plan.moduleId.padEnd(30)} ${plan.installedVersion} → ${plan.targetVersion} (${plan.classification}, FAILED)`, ); } else if (result.status === 'success') { appliedNonBreaking++; console.log( ` ✓ ${plan.moduleId.padEnd(30)} ${result.previousVersion} → ${result.newVersion} (${plan.classification})`, ); } // status === 'skipped' shouldn't happen for registry-fetched packages // (we know the module is installed; the package definitely has a // manifest), but treat it as a no-op if it does. } } let appliedBreaking = 0; let declinedBreaking = 0; const unanswered: Array<{ moduleId: string; error: string }> = []; const abandoned: Array<{ moduleId: string; error: string }> = []; if (breaking.length > 0) { log.info('\nBreaking updates available — review required (semver-major bump):'); for (const plan of breaking) { console.log( ` ⚠ ${plan.moduleId.padEnd(30)} ${plan.installedVersion} → ${plan.targetVersion}`, ); } log.message('Each breaking update will be applied only on explicit confirmation.\n'); for (const plan of breaking) { let proceed: boolean; try { proceed = await withInterviewSession(() => askConfirm({ scope: `module-upgrade:${plan.moduleId}`, key: 'apply_breaking', message: `Apply breaking update for ${plan.moduleId} (${plan.installedVersion} → ${plan.targetVersion})?`, defaultValue: false, }), ); } catch (err) { // Neither of these is a decline, and they are not each other either: // UNANSWERED = no responder was listening at all (fail-fast probe); // ABANDONED = the question stood and its parked session expired. // Record and keep going so updates already applied aren't thrown away. if (err instanceof InterviewUnansweredError) { unanswered.push({ moduleId: plan.moduleId, error: err.message }); console.log( ` ? ${plan.moduleId.padEnd(30)} ${plan.installedVersion} → ${plan.targetVersion} (major, UNANSWERED)`, ); continue; } if (err instanceof InterviewAbandonedError) { abandoned.push({ moduleId: plan.moduleId, error: err.message }); console.log( ` ? ${plan.moduleId.padEnd(30)} ${plan.installedVersion} → ${plan.targetVersion} (major, ABANDONED)`, ); continue; } throw err; } if (!proceed) { declinedBreaking++; continue; } const result = await fetchAndUpdate(client, plan.moduleId, plan.targetVersion, db, flags); if (result.status === 'failed') { failed.push({ moduleId: plan.moduleId, error: result.error }); console.log( ` ✗ ${plan.moduleId.padEnd(30)} ${plan.installedVersion} → ${plan.targetVersion} (major, FAILED)`, ); } else if (result.status === 'success') { appliedBreaking++; console.log( ` ✓ ${plan.moduleId.padEnd(30)} ${result.previousVersion} → ${result.newVersion} (major)`, ); } } } // Summary const summary: string[] = []; const totalApplied = appliedNonBreaking + appliedBreaking; if (totalApplied > 0) { const parts = [`${appliedNonBreaking} non-breaking`]; if (appliedBreaking > 0) parts.push(`${appliedBreaking} breaking`); summary.push(`Applied ${totalApplied} update(s) (${parts.join(', ')}).`); } else { summary.push('No updates applied.'); } if (declinedBreaking > 0) { summary.push(`Skipped ${declinedBreaking} breaking update(s) (operator declined).`); } if (unanswered.length > 0) { summary.push( `Skipped ${unanswered.length} breaking update(s) — NOT declined: the confirmation could not be answered (${unanswered .map((u) => u.moduleId) .join( ', ', )}). Re-run with a responder attached, or pre-stage the answer under "module-upgrade:.apply_breaking".`, ); } if (abandoned.length > 0) { summary.push( `Skipped ${abandoned.length} breaking update(s) — NOT declined: the confirmation was never decided and its session expired (${abandoned .map((a) => a.moduleId) .join( ', ', )}). Answer it next time with "celilo events list-unanswered" + "celilo events reply ".`, ); } if (notInRegistry.length > 0) { summary.push(`Not in registry (${notInRegistry.length}): ${notInRegistry.join(', ')}`); } if (errored.length > 0) { summary.push( `Registry errors (${errored.length}): ${errored.map((e) => `${e.moduleId} — ${e.error}`).join('; ')}`, ); } if (failed.length > 0 || unanswered.length > 0 || abandoned.length > 0) { const detail: string[] = []; if (failed.length > 0) { detail.push('', 'Failures:', ...failed.map((f) => ` ${f.moduleId}: ${f.error}`)); } if (unanswered.length > 0) { detail.push('', 'Unanswered:', ...unanswered.map((u) => ` ${u.moduleId}: ${u.error}`)); } if (abandoned.length > 0) { detail.push('', 'Abandoned:', ...abandoned.map((a) => ` ${a.moduleId}: ${a.error}`)); } return { success: false, error: [...summary, ...detail].join('\n') }; } return { success: true, message: summary.join('\n') }; }