@celilo/cli 0.5.0-alpha.9 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,571 +1,276 @@
1
1
  /**
2
- * Module update command
2
+ * `celilo module upgrade [name]` — the build-bus CD verb (ISS-0138) + the
3
+ * registry-poll it drives (ISS-0139). v2/BUILD_BUS.md.
3
4
  *
4
- * Updates module code (manifest, scripts, templates) while preserving state
5
- * (configs, secrets, infrastructure, capabilities).
5
+ * module upgrade <name> one module: registry-latest update [backup,
6
+ * posture-gated] deploy verify.
7
+ * module upgrade NO name = the CD poll: every INSTALLED module that
8
+ * opted into `auto_upgrade` and has a newer registry
9
+ * version, upgraded the same way. This is what the
10
+ * timer-driven CD job runs.
6
11
  *
7
- * Usage: celilo module update <path>
8
- *
9
- * The module ID is read from the manifest at the given path.
12
+ * `module update` refreshes the stored definition; `module deploy` applies it;
13
+ * `module upgrade` is the safe compound. Fast/safe posture is derived
14
+ * (deploy-posture.ts): a low-risk revision/patch skips the backup, a minor/major
15
+ * backs up first.
10
16
  */
11
17
 
12
- import { cpSync, existsSync, readFileSync, readdirSync } from 'node:fs';
13
- import { unlink } from 'node:fs/promises';
14
- import { tmpdir } from 'node:os';
15
- import { join, resolve } from 'node:path';
16
18
  import { eq } from 'drizzle-orm';
17
- import { parse as parseYaml } from 'yaml';
18
- import { registerModuleCapabilities } from '../../capabilities/registration';
19
- import { getDb } from '../../db/client';
20
- import { capabilities, modules } from '../../db/schema';
21
- import { ModuleManifestSchema } from '../../manifest/schema';
19
+ import { type DbClient, getDb } from '../../db/client';
20
+ import { modules } from '../../db/schema';
22
21
  import type { ModuleManifest } from '../../manifest/schema';
23
- import { cleanupTempDir, extractPackage } from '../../module/packaging/extract';
24
22
  import { RegistryClient } from '../../registry/client';
25
- import { askConfirm, withInterviewSession } from '../../services/bus-interview';
26
- import { getFlag } from '../parser';
23
+ import { createModuleBackup } from '../../services/backup-create';
24
+ import { type UpgradePolicy, resolveDeployPosture } from '../../services/deploy-posture';
25
+ import { runModuleHealthCheck } from '../../services/health-runner';
26
+ import { getModuleConfigValue } from '../../services/module-config';
27
+ import { deployModule } from '../../services/module-deploy';
28
+ import { getArg, getFlag } from '../parser';
27
29
  import { log } from '../prompts';
28
30
  import type { CommandResult } from '../types';
31
+ import { classifyVersionChange, fetchAndUpdate } from './module-update';
29
32
 
30
- type UpgradeOutcome =
31
- | {
32
- status: 'success';
33
- moduleId: string;
34
- /** Version that was on disk before the upgrade (manifest.yml semver). */
35
- previousVersion: string;
36
- /** Version that's now installed. Includes +N revision when known
37
- * (registry-driven upgrades pass the canonical "1.0.0+5" form;
38
- * path-driven upgrades fall back to the manifest semver). */
39
- newVersion: string;
40
- }
41
- | { status: 'failed'; moduleId: string; error: string }
42
- // `skipped` means the path expanded from a glob but isn't an
43
- // upgradable target — either no manifest at all (probably a non-
44
- // module sibling like `modules/archive/`) or a real module that
45
- // isn't installed in this celilo. Treated as a soft pass so
46
- // `celilo module update modules/*` does what users expect.
47
- | { status: 'skipped'; moduleId: string; reason: string };
33
+ const VALID_POLICIES: readonly UpgradePolicy[] = ['by-semver', 'always-safe', 'always-fast'];
48
34
 
49
35
  /**
50
- * Tunables for `upgradeOne`. Quiet mode silences the per-call log
51
- * lines so callers driving a batch (the registry sweep) can render
52
- * their own structured output without duplicates. `displayVersion`
53
- * lets the registry caller carry the canonical `+N` revision through
54
- * to both the DB column and the success log.
36
+ * Pick the effective upgrade policy. Pure (Rule 10): operator config override
37
+ * wins over the manifest default; an unknown/absent value falls back to
38
+ * `by-semver`.
55
39
  */
56
- interface UpgradeOpts {
57
- quiet?: boolean;
58
- displayVersion?: string;
40
+ export function pickUpgradePolicy(
41
+ fromConfig: string | undefined,
42
+ fromManifest: string | undefined,
43
+ ): UpgradePolicy {
44
+ const candidate = fromConfig ?? fromManifest;
45
+ return VALID_POLICIES.includes(candidate as UpgradePolicy)
46
+ ? (candidate as UpgradePolicy)
47
+ : 'by-semver';
59
48
  }
60
49
 
61
50
  /**
62
- * Parse a celilo version string into [major, minor, patch, revision].
63
- * Celilo's published versions look like `1.0.0+3` semver core plus a
64
- * publish revision suffix (the +N resets on every semver bump). Missing
65
- * segments default to 0; non-numeric segments are clamped to 0 so we
66
- * never throw on weird upstream input.
51
+ * Resolve whether a module opts into auto-upgrade by the poll. Pure (Rule 10):
52
+ * operator config override wins over the manifest default; the default is OFF
53
+ * (opt-in a production module isn't auto-upgraded unless chosen).
67
54
  */
68
- function parseModuleVersion(v: string): [number, number, number, number] {
69
- const cleaned = v.replace(/^[v=]+/, '');
70
- const [core, rev] = cleaned.split('+');
71
- const parts = (core ?? '').split('.');
72
- const num = (s: string | undefined) => {
73
- const n = Number(s ?? '0');
74
- return Number.isNaN(n) ? 0 : n;
75
- };
76
- return [num(parts[0]), num(parts[1]), num(parts[2]), num(rev)];
55
+ export function pickAutoUpgrade(
56
+ fromConfig: string | boolean | undefined,
57
+ fromManifest: boolean | undefined,
58
+ ): boolean {
59
+ if (typeof fromConfig === 'boolean') return fromConfig;
60
+ if (fromConfig === 'true') return true;
61
+ if (fromConfig === 'false') return false;
62
+ return fromManifest ?? false;
77
63
  }
78
64
 
79
- export type VersionChangeKind = 'up-to-date' | 'ahead' | 'patch' | 'minor' | 'major';
65
+ export interface PollCandidate {
66
+ moduleId: string;
67
+ installed: string;
68
+ /** Latest registry version, or null when the module isn't on the registry. */
69
+ latest: string | null;
70
+ autoUpgrade: boolean;
71
+ }
80
72
 
81
- /**
82
- * Classify a registry-side update relative to the installed version.
83
- * `major` = breaking (semver-major bump). Operator must approve.
84
- * `minor` = additive feature. Auto-applied.
85
- * `patch` = bugfix or revision-only (+N) bump. Auto-applied.
86
- * `up-to-date` = identical version.
87
- * `ahead` = installed is newer than registry. Skip silently — usually
88
- * means the operator pushed locally without publishing.
89
- *
90
- * Exported for unit tests.
91
- */
92
- export function classifyVersionChange(installed: string, latest: string): VersionChangeKind {
93
- const [aMaj, aMin, aPat, aRev] = parseModuleVersion(installed);
94
- const [bMaj, bMin, bPat, bRev] = parseModuleVersion(latest);
95
- if (bMaj > aMaj) return 'major';
96
- if (bMaj < aMaj) return 'ahead';
97
- if (bMin > aMin) return 'minor';
98
- if (bMin < aMin) return 'ahead';
99
- if (bPat > aPat) return 'patch';
100
- if (bPat < aPat) return 'ahead';
101
- if (bRev > aRev) return 'patch';
102
- if (bRev < aRev) return 'ahead';
103
- return 'up-to-date';
73
+ export interface PollTarget {
74
+ moduleId: string;
75
+ from: string;
76
+ to: string;
104
77
  }
105
78
 
106
79
  /**
107
- * Download a module package from the registry into a temp file and run
108
- * the standard upgradeOne path against it. Cleans the temp file in a
109
- * finally block so a mid-flight failure doesn't leak a tar.zst on disk.
80
+ * Pure: which candidates the poll should upgrade opted into auto_upgrade AND a
81
+ * newer registry version exists. (Rule 10; the I/O registry queries — happens
82
+ * in the caller.)
110
83
  */
111
- export async function fetchAndUpgrade(
112
- client: RegistryClient,
113
- moduleId: string,
114
- version: string,
115
- db: ReturnType<typeof getDb>,
116
- flags: Record<string, string | boolean>,
117
- ): Promise<UpgradeOutcome> {
118
- const tmpPath = join(tmpdir(), `${moduleId}-${version}-${Date.now()}.netapp`);
119
- try {
120
- const pkgData = await client.download(moduleId, version);
121
- await Bun.write(tmpPath, pkgData);
122
- } catch (err) {
123
- return {
124
- status: 'failed',
125
- moduleId,
126
- error: `Download failed: ${err instanceof Error ? err.message : String(err)}`,
127
- };
128
- }
129
- try {
130
- // Registry packages are pre-verified at publish time; skip the
131
- // signature check here to match `module import`'s registry path.
132
- // `quiet: true` suppresses upgradeOne's per-call log lines so the
133
- // sweep can render its own structured per-module output without
134
- // duplicates. `displayVersion: version` carries the registry's
135
- // canonical "X.Y.Z+N" through to both the DB column and the success
136
- // log line — without it, output would say "v1.0.0 → v1.0.0" because
137
- // the manifest semver doesn't include the +N revision.
138
- return await upgradeOne(
139
- tmpPath,
140
- db,
141
- { ...flags, 'skip-verify': true },
142
- { quiet: true, displayVersion: version },
143
- );
144
- } finally {
145
- try {
146
- await unlink(tmpPath);
147
- } catch {}
84
+ export function selectPollTargets(candidates: PollCandidate[]): PollTarget[] {
85
+ const targets: PollTarget[] = [];
86
+ for (const c of candidates) {
87
+ if (!c.autoUpgrade || !c.latest) continue;
88
+ const change = classifyVersionChange(c.installed, c.latest);
89
+ if (change === 'up-to-date' || change === 'ahead') continue;
90
+ targets.push({ moduleId: c.moduleId, from: c.installed, to: c.latest });
148
91
  }
92
+ return targets;
93
+ }
94
+
95
+ /** Resolve a module's auto_upgrade flag from config (override) + manifest (default). */
96
+ function resolveAutoUpgrade(moduleId: string, manifest: ModuleManifest): boolean {
97
+ const cfg = getModuleConfigValue(moduleId, 'auto_upgrade');
98
+ const fromConfig =
99
+ typeof cfg?.value === 'string' || typeof cfg?.value === 'boolean' ? cfg.value : undefined;
100
+ return pickAutoUpgrade(
101
+ fromConfig,
102
+ (manifest as ModuleManifest & { auto_upgrade?: boolean }).auto_upgrade,
103
+ );
149
104
  }
150
105
 
151
106
  /**
152
- * Upgrade a single module from a source path
107
+ * Upgrade ONE module to a known target version: posture → update → backup →
108
+ * deploy → verify. The shared core for both the single-module command and the
109
+ * poll. `mod` is the current DB row; `targetVersion` is the registry latest.
153
110
  */
154
- export async function upgradeOne(
155
- sourcePath: string,
156
- db: ReturnType<typeof getDb>,
157
- flags: Record<string, string | boolean> = {},
158
- opts: UpgradeOpts = {},
159
- ): Promise<UpgradeOutcome> {
160
- const originalCwd = process.env.CELILO_ORIGINAL_CWD || process.cwd();
161
- const importPath = resolve(originalCwd, sourcePath);
162
- if (!existsSync(importPath)) {
163
- return {
164
- status: 'failed',
165
- moduleId: sourcePath,
166
- error: `Source path not found: ${importPath}`,
167
- };
111
+ async function upgradeOneModule(
112
+ mod: typeof modules.$inferSelect,
113
+ targetVersion: string,
114
+ client: RegistryClient,
115
+ db: DbClient,
116
+ flags: Record<string, string | boolean>,
117
+ ): Promise<CommandResult> {
118
+ const moduleId = mod.id;
119
+ const manifest = mod.manifestData as ModuleManifest;
120
+
121
+ // Posture.
122
+ const configPolicy = getModuleConfigValue(moduleId, 'upgrade_policy');
123
+ const modulePolicy = pickUpgradePolicy(
124
+ typeof configPolicy?.value === 'string' ? configPolicy.value : undefined,
125
+ (manifest as ModuleManifest & { upgrade_policy?: string }).upgrade_policy,
126
+ );
127
+ // Per-release deploy_posture override lives in the .netapp release metadata;
128
+ // reading it requires fetching the package first. Deferred — the classifier
129
+ // supports it (deploy-posture.ts) and no app stamps it yet.
130
+ const { posture, reason } = resolveDeployPosture({
131
+ installed: mod.version,
132
+ next: targetVersion,
133
+ releasePosture: null,
134
+ modulePolicy,
135
+ });
136
+ log.info(`Upgrading ${moduleId} ${mod.version} → ${targetVersion} (${posture} — ${reason})`);
137
+
138
+ // Update (refresh stored def).
139
+ const updated = await fetchAndUpdate(client, moduleId, targetVersion, db, flags);
140
+ if (updated.status !== 'success') {
141
+ const why = updated.status === 'failed' ? updated.error : updated.reason;
142
+ return { success: false, error: `Update failed for ${moduleId}: ${why}` };
168
143
  }
169
144
 
170
- // Handle .netapp packages: extract to temp dir
171
- let actualPath = importPath;
172
- let tempDir: string | null = null;
173
-
174
- if (importPath.endsWith('.netapp')) {
175
- const extractResult = await extractPackage(importPath);
176
- if (!extractResult.success || !extractResult.tempDir) {
177
- return {
178
- status: 'failed',
179
- moduleId: sourcePath,
180
- error: extractResult.error || 'Failed to extract package',
181
- };
182
- }
183
- tempDir = extractResult.tempDir;
184
- actualPath = tempDir;
185
-
186
- // Skip signature verification if --skip-verify
187
- if (flags['skip-verify'] !== true) {
188
- const { verifyPackageIntegrity } = await import('../../module/packaging/extract');
189
- const verifyResult = await verifyPackageIntegrity(tempDir);
190
- if (!verifyResult.success) {
191
- await cleanupTempDir(tempDir);
145
+ // Safe back up first (when there's a backup hook).
146
+ if (posture === 'safe') {
147
+ if (manifest.hooks?.on_backup) {
148
+ const backup = await createModuleBackup(moduleId);
149
+ if (!backup.success) {
192
150
  return {
193
- status: 'failed',
194
- moduleId: sourcePath,
195
- error: verifyResult.error || 'Package verification failed',
151
+ success: false,
152
+ error: `Pre-upgrade backup failed for ${moduleId}: ${backup.error}`,
196
153
  };
197
154
  }
198
- } else if (!opts.quiet) {
199
- log.warn('Skipping package signature verification (--skip-verify)');
155
+ log.success(`Backed up ${moduleId} before deploy`);
156
+ } else {
157
+ log.warn(`${moduleId} has no on_backup hook — proceeding without a pre-upgrade backup`);
200
158
  }
201
159
  }
202
160
 
203
- const manifestPath = join(actualPath, 'manifest.yml');
204
- if (!existsSync(manifestPath)) {
205
- if (tempDir) await cleanupTempDir(tempDir);
206
- // No manifest means the path isn't a module directory at all —
207
- // a likely outcome of `module update modules/*` matching a
208
- // non-module sibling. Skip silently rather than fail the batch.
161
+ // Deploy (idempotent).
162
+ const deployed = await deployModule(moduleId, db, {});
163
+ if (!deployed.success) {
209
164
  return {
210
- status: 'skipped',
211
- moduleId: sourcePath,
212
- reason: 'not a module directory (no manifest.yml)',
165
+ success: false,
166
+ error: `Deploy failed for ${moduleId} (now at ${targetVersion}): ${deployed.error}`,
213
167
  };
214
168
  }
215
169
 
216
- let newManifest: ModuleManifest;
217
- try {
218
- const raw = readFileSync(manifestPath, 'utf-8');
219
- const parsed = parseYaml(raw);
220
- newManifest = ModuleManifestSchema.parse(parsed);
221
- } catch (err) {
222
- if (tempDir) await cleanupTempDir(tempDir);
223
- const msg = err instanceof Error ? err.message : String(err);
224
- return { status: 'failed', moduleId: sourcePath, error: `Invalid manifest: ${msg}` };
225
- }
226
-
227
- const moduleId = newManifest.id;
228
-
229
- const module = db.select().from(modules).where(eq(modules.id, moduleId)).get();
230
- if (!module) {
231
- if (tempDir) await cleanupTempDir(tempDir);
232
- // Module isn't installed in this celilo. Don't fail the batch —
233
- // `module update modules/*` should keep going for everything
234
- // that IS installed. Caller surfaces the skip count so the user
235
- // sees what was passed over.
170
+ // Verify.
171
+ const health = await runModuleHealthCheck(moduleId, db, {});
172
+ if (health.status === 'unhealthy' || health.status === 'error') {
236
173
  return {
237
- status: 'skipped',
238
- moduleId,
239
- reason: `not installed (run 'celilo module import ${sourcePath}' to add)`,
174
+ success: false,
175
+ error: `Upgraded ${moduleId} to ${targetVersion} but post-deploy verify failed (health: ${health.status}${health.error ? ` — ${health.error}` : ''}).`,
240
176
  };
241
177
  }
178
+ const verifyNote = health.status === 'degraded' ? ' (health: degraded)' : '';
179
+ return {
180
+ success: true,
181
+ message: `Upgraded ${moduleId} ${mod.version} → ${targetVersion} (${posture}); verified${verifyNote}.`,
182
+ };
183
+ }
242
184
 
243
- // Old version comes from the DB so we capture whatever was last
244
- // recorded (which IS the registry-versioned form, e.g. "1.0.0+5",
245
- // for registry-driven installs/upgrades).
246
- const previousVersion = module.version;
247
- // New version: prefer the caller-supplied display version (registry's
248
- // canonical "X.Y.Z+N"), fall back to the manifest semver core when
249
- // upgrading from a local path.
250
- const newVersion = opts.displayVersion ?? newManifest.version;
251
- if (!opts.quiet) {
252
- log.info(`Upgrading ${moduleId}: ${previousVersion} → ${newVersion}`);
253
- }
254
-
255
- // Copy new module files, preserving generated output and state
256
- const installedPath = module.sourcePath;
257
- const preserveDirs = new Set(['generated', 'screenshots', 'cookies.json']);
258
-
259
- const skipDirs = new Set(['.git', 'node_modules', '.next', '.cache']);
260
- const entries = readdirSync(actualPath);
261
- for (const entry of entries) {
262
- if (preserveDirs.has(entry) || skipDirs.has(entry)) continue;
263
- const src = join(actualPath, entry);
264
- const dest = join(installedPath, entry);
265
- cpSync(src, dest, { recursive: true, force: true });
266
- }
267
-
268
- // Clean up temp dir if we extracted a .netapp
269
- if (tempDir) await cleanupTempDir(tempDir);
270
-
271
- // Update manifest in database. We persist the display version (with
272
- // +N when known) so subsequent `module list` / `module update` calls
273
- // see the same version string the registry reported.
274
- db.update(modules)
275
- .set({
276
- manifestData: newManifest as unknown as Record<string, unknown>,
277
- version: newVersion,
278
- name: newManifest.name,
279
- })
280
- .where(eq(modules.id, moduleId))
281
- .run();
282
-
283
- // Re-register capabilities
284
- db.delete(capabilities).where(eq(capabilities.moduleId, moduleId)).run();
285
-
286
- if (newManifest.provides?.capabilities && newManifest.provides.capabilities.length > 0) {
287
- const regResult = await registerModuleCapabilities(moduleId, newManifest, db.$client);
288
- if (!regResult.success && !opts.quiet) {
289
- // Capability re-registration warnings are useful when upgrading
290
- // from a path (operator iterating on dev module); for the
291
- // registry sweep, the caller will surface them itself if needed.
292
- log.warn(` ${moduleId}: capability re-registration warning: ${regResult.error}`);
293
- }
294
- }
295
-
296
- // (Re-)register event-bus subscriptions from the new manifest —
297
- // mirrors import.ts (ISS-0091: update used to skip this, so a
298
- // refreshed module silently lost its reconcile subscriptions).
299
- // registerModuleSubscriptions is idempotent; best-effort like import:
300
- // a bus problem shouldn't wedge the upgrade, but must be loud.
301
- try {
302
- const { registerModuleSubscriptions } = await import('../../services/module-subscriptions');
303
- registerModuleSubscriptions(newManifest, installedPath);
304
- } catch (error) {
305
- const msg = error instanceof Error ? error.message : String(error);
306
- log.warn(` ${moduleId}: failed to register event-bus subscriptions: ${msg}`);
307
- log.warn(
308
- ' Module upgraded, but reactive flows on the event bus will not fire until this is fixed.',
309
- );
310
- }
311
-
312
- if (!opts.quiet) {
313
- log.success(`Upgraded ${moduleId} (${previousVersion} → ${newVersion})`);
314
- }
315
- return { status: 'success', moduleId, previousVersion, newVersion };
185
+ /** Latest registry version for a module, or null when absent. */
186
+ async function latestRegistryVersion(
187
+ client: RegistryClient,
188
+ moduleId: string,
189
+ ): Promise<string | null> {
190
+ const entries = await client.getIndex(moduleId);
191
+ return entries.length > 0 ? (client.latestVersion(entries)?.vers ?? null) : null;
316
192
  }
317
193
 
318
194
  /**
319
- * Handle module upgrade command
320
- *
321
- * @param args - Command arguments: [path, path, ...]
322
- * @returns Command result
195
+ * The CD poll (ISS-0139): upgrade every INSTALLED module that opted into
196
+ * auto_upgrade and has a newer registry version. This is what the timer-driven
197
+ * CD job runs (install-mode gating ISS-0142 applies at the scheduling layer).
323
198
  */
324
- export async function handleModuleUpgrade(
325
- args: string[],
326
- flags: Record<string, string | boolean> = {},
199
+ async function runRegistryPoll(
200
+ db: DbClient,
201
+ flags: Record<string, string | boolean>,
327
202
  ): Promise<CommandResult> {
328
- const db = getDb();
203
+ const client = new RegistryClient(getFlag(flags, 'registry', '') || undefined);
204
+ const installed = db.select().from(modules).all();
329
205
 
330
- // Zero args = registry sweep: walk every installed module, pick up
331
- // any non-breaking update from the registry automatically, and
332
- // prompt per-module for breaking (semver-major) updates.
333
- if (args.length === 0) {
334
- return runRegistrySweep(db, flags);
206
+ const candidates: PollCandidate[] = [];
207
+ const rowById = new Map<string, typeof modules.$inferSelect>();
208
+ for (const mod of installed) {
209
+ rowById.set(mod.id, mod);
210
+ candidates.push({
211
+ moduleId: mod.id,
212
+ installed: mod.version,
213
+ latest: await latestRegistryVersion(client, mod.id),
214
+ autoUpgrade: resolveAutoUpgrade(mod.id, mod.manifestData as ModuleManifest),
215
+ });
335
216
  }
336
217
 
337
- const results: UpgradeOutcome[] = [];
338
-
339
- for (const path of args) {
340
- const result = await upgradeOne(path, db, flags);
341
- results.push(result);
218
+ const targets = selectPollTargets(candidates);
219
+ if (targets.length === 0) {
220
+ return { success: true, message: 'Registry poll: all auto_upgrade modules are up to date.' };
342
221
  }
343
222
 
344
- const succeeded = results.filter(
345
- (r): r is Extract<UpgradeOutcome, { status: 'success' }> => r.status === 'success',
346
- );
347
- const failed = results.filter(
348
- (r): r is Extract<UpgradeOutcome, { status: 'failed' }> => r.status === 'failed',
349
- );
350
- const skipped = results.filter(
351
- (r): r is Extract<UpgradeOutcome, { status: 'skipped' }> => r.status === 'skipped',
352
- );
353
-
354
- // Skips that fall under a wildcard expansion (e.g. modules/* picking
355
- // up `modules/archive/`) shouldn't even be mentioned — they're not
356
- // signal. Skips for "module not installed" ARE signal because the
357
- // user explicitly named the path; surface those.
358
- const meaningfulSkips = skipped.filter((r) => !r.reason.startsWith('not a module directory'));
359
-
360
- if (failed.length > 0) {
361
- const errors = failed.map((r) => ` ${r.moduleId}: ${r.error}`).join('\n');
362
- const parts: string[] = [];
363
- if (succeeded.length > 0) {
364
- parts.push(
365
- `Upgraded ${succeeded.length} module(s): ${succeeded.map((r) => r.moduleId).join(', ')}`,
366
- );
367
- }
368
- if (meaningfulSkips.length > 0) {
369
- const skipLines = meaningfulSkips.map((r) => ` ${r.moduleId}: ${r.reason}`).join('\n');
370
- parts.push(`Skipped ${meaningfulSkips.length}:\n${skipLines}`);
371
- }
372
- parts.push(`Failed ${failed.length}:\n${errors}`);
373
- return { success: false, error: parts.join('\n\n') };
223
+ log.info(`Registry poll: ${targets.length} module(s) to upgrade.`);
224
+ const upgraded: string[] = [];
225
+ const failed: string[] = [];
226
+ // Serial, in installed order. (Strict provider-before-consumer ordering via
227
+ // topologicalOrder is a refinement; the poll is idempotent + re-runs.)
228
+ for (const t of targets) {
229
+ const mod = rowById.get(t.moduleId);
230
+ if (!mod) continue;
231
+ const result = await upgradeOneModule(mod, t.to, client, db, flags);
232
+ if (result.success) upgraded.push(`${t.moduleId}→${t.to}`);
233
+ else failed.push(`${t.moduleId}: ${result.error}`);
374
234
  }
375
235
 
376
- const lines: string[] = [];
377
- if (succeeded.length > 0) {
378
- lines.push(
379
- `Updated ${succeeded.length} module(s): ${succeeded.map((r) => r.moduleId).join(', ')}`,
380
- );
381
- }
382
- if (meaningfulSkips.length > 0) {
383
- const skipLines = meaningfulSkips.map((r) => ` ${r.moduleId}: ${r.reason}`).join('\n');
384
- lines.push(`Skipped ${meaningfulSkips.length}:\n${skipLines}`);
385
- }
386
- if (lines.length === 0) {
387
- // Every arg was a non-module sibling — odd but not a failure.
388
- lines.push(
389
- `No modules to update (${results.length} path(s) skipped — none had a manifest.yml)`,
390
- );
236
+ if (failed.length > 0) {
237
+ return {
238
+ success: false,
239
+ error: `Registry poll: upgraded ${upgraded.length}, FAILED ${failed.length}:\n ${failed.join('\n ')}`,
240
+ };
391
241
  }
392
- return { success: true, message: lines.join('\n\n') };
393
- }
394
-
395
- interface UpdatePlan {
396
- moduleId: string;
397
- installedVersion: string;
398
- targetVersion: string;
399
- classification: Exclude<VersionChangeKind, 'up-to-date' | 'ahead'>;
242
+ return {
243
+ success: true,
244
+ message: `Registry poll: upgraded ${upgraded.length} — ${upgraded.join(', ')}.`,
245
+ };
400
246
  }
401
247
 
402
- /**
403
- * Walk every installed module, query the registry, and produce a plan.
404
- * Auto-apply non-breaking updates (patch/minor); prompt per-module for
405
- * breaking (major) updates. Modules absent from the registry are
406
- * surfaced as a skip — typically these are local-only modules the
407
- * operator imported from a path, never published.
408
- */
409
- async function runRegistrySweep(
410
- db: ReturnType<typeof getDb>,
411
- flags: Record<string, string | boolean>,
248
+ export async function handleModuleUpgrade(
249
+ args: string[],
250
+ flags: Record<string, string | boolean> = {},
412
251
  ): Promise<CommandResult> {
413
- const installed = db.select().from(modules).all();
414
- if (installed.length === 0) {
415
- return { success: true, message: 'No modules installed.' };
416
- }
417
-
418
- const registryUrl = getFlag(flags, 'registry', '');
419
- const client = new RegistryClient(registryUrl || undefined);
420
-
421
- log.info(`Checking ${installed.length} installed module(s) against the registry…`);
422
-
423
- const plans: UpdatePlan[] = [];
424
- const upToDate: string[] = [];
425
- const notInRegistry: string[] = [];
426
- const errored: Array<{ moduleId: string; error: string }> = [];
252
+ const db = getDb();
253
+ const moduleId = getArg(args, 0);
427
254
 
428
- for (const mod of installed) {
429
- try {
430
- const entries = await client.getIndex(mod.id);
431
- if (entries.length === 0) {
432
- notInRegistry.push(mod.id);
433
- continue;
434
- }
435
- const latest = client.latestVersion(entries);
436
- if (!latest) {
437
- // All versions yanked.
438
- notInRegistry.push(mod.id);
439
- continue;
440
- }
441
- const cmp = classifyVersionChange(mod.version, latest.vers);
442
- if (cmp === 'up-to-date' || cmp === 'ahead') {
443
- upToDate.push(mod.id);
444
- continue;
445
- }
446
- plans.push({
447
- moduleId: mod.id,
448
- installedVersion: mod.version,
449
- targetVersion: latest.vers,
450
- classification: cmp,
451
- });
452
- } catch (err) {
453
- errored.push({
454
- moduleId: mod.id,
455
- error: err instanceof Error ? err.message : String(err),
456
- });
457
- }
255
+ // No name the CD poll over all auto_upgrade modules.
256
+ if (!moduleId) {
257
+ return runRegistryPoll(db, flags);
458
258
  }
459
259
 
460
- if (plans.length === 0) {
461
- const lines: string[] = ['All installed modules are up to date.'];
462
- if (notInRegistry.length > 0) {
463
- lines.push(`Not in registry: ${notInRegistry.join(', ')}`);
464
- }
465
- if (errored.length > 0) {
466
- lines.push(`Registry errors: ${errored.map((e) => `${e.moduleId} (${e.error})`).join('; ')}`);
467
- }
468
- return { success: true, message: lines.join('\n') };
260
+ const mod = db.select().from(modules).where(eq(modules.id, moduleId)).get();
261
+ if (!mod) {
262
+ return { success: false, error: `Module not found: ${moduleId}` };
469
263
  }
470
264
 
471
- const nonBreaking = plans.filter((p) => p.classification !== 'major');
472
- const breaking = plans.filter((p) => p.classification === 'major');
473
-
474
- let appliedNonBreaking = 0;
475
- const failed: Array<{ moduleId: string; error: string }> = [];
476
-
477
- if (nonBreaking.length > 0) {
478
- log.info(`Auto-applying ${nonBreaking.length} non-breaking update(s):`);
479
- for (const plan of nonBreaking) {
480
- const result = await fetchAndUpgrade(client, plan.moduleId, plan.targetVersion, db, flags);
481
- if (result.status === 'failed') {
482
- failed.push({ moduleId: plan.moduleId, error: result.error });
483
- console.log(
484
- ` ✗ ${plan.moduleId.padEnd(30)} ${plan.installedVersion} → ${plan.targetVersion} (${plan.classification}, FAILED)`,
485
- );
486
- } else if (result.status === 'success') {
487
- appliedNonBreaking++;
488
- console.log(
489
- ` ✓ ${plan.moduleId.padEnd(30)} ${result.previousVersion} → ${result.newVersion} (${plan.classification})`,
490
- );
491
- }
492
- // status === 'skipped' shouldn't happen for registry-fetched packages
493
- // (we know the module is installed; the package definitely has a
494
- // manifest), but treat it as a no-op if it does.
495
- }
265
+ const client = new RegistryClient(getFlag(flags, 'registry', '') || undefined);
266
+ const latest = await latestRegistryVersion(client, moduleId);
267
+ if (!latest) {
268
+ return { success: false, error: `${moduleId} is not on the registry — nothing to upgrade to.` };
496
269
  }
497
-
498
- let appliedBreaking = 0;
499
- let skippedBreaking = 0;
500
-
501
- if (breaking.length > 0) {
502
- log.info('\nBreaking updates available — review required (semver-major bump):');
503
- for (const plan of breaking) {
504
- console.log(
505
- ` ⚠ ${plan.moduleId.padEnd(30)} ${plan.installedVersion} → ${plan.targetVersion}`,
506
- );
507
- }
508
- log.message('Each breaking update will be applied only on explicit confirmation.\n');
509
-
510
- for (const plan of breaking) {
511
- const proceed = await withInterviewSession(() =>
512
- askConfirm({
513
- scope: `module-upgrade:${plan.moduleId}`,
514
- key: 'apply_breaking',
515
- message: `Apply breaking update for ${plan.moduleId} (${plan.installedVersion} → ${plan.targetVersion})?`,
516
- defaultValue: false,
517
- }),
518
- );
519
- if (!proceed) {
520
- skippedBreaking++;
521
- continue;
522
- }
523
- const result = await fetchAndUpgrade(client, plan.moduleId, plan.targetVersion, db, flags);
524
- if (result.status === 'failed') {
525
- failed.push({ moduleId: plan.moduleId, error: result.error });
526
- console.log(
527
- ` ✗ ${plan.moduleId.padEnd(30)} ${plan.installedVersion} → ${plan.targetVersion} (major, FAILED)`,
528
- );
529
- } else if (result.status === 'success') {
530
- appliedBreaking++;
531
- console.log(
532
- ` ✓ ${plan.moduleId.padEnd(30)} ${result.previousVersion} → ${result.newVersion} (major)`,
533
- );
534
- }
535
- }
270
+ const change = classifyVersionChange(mod.version, latest);
271
+ if (change === 'up-to-date' || change === 'ahead') {
272
+ return { success: true, message: `${moduleId} is already up to date (${mod.version}).` };
536
273
  }
537
274
 
538
- // Summary
539
- const summary: string[] = [];
540
- const totalApplied = appliedNonBreaking + appliedBreaking;
541
- if (totalApplied > 0) {
542
- const parts = [`${appliedNonBreaking} non-breaking`];
543
- if (appliedBreaking > 0) parts.push(`${appliedBreaking} breaking`);
544
- summary.push(`Applied ${totalApplied} update(s) (${parts.join(', ')}).`);
545
- } else {
546
- summary.push('No updates applied.');
547
- }
548
- if (skippedBreaking > 0) {
549
- summary.push(`Skipped ${skippedBreaking} breaking update(s) (operator declined).`);
550
- }
551
- if (notInRegistry.length > 0) {
552
- summary.push(`Not in registry (${notInRegistry.length}): ${notInRegistry.join(', ')}`);
553
- }
554
- if (errored.length > 0) {
555
- summary.push(
556
- `Registry errors (${errored.length}): ${errored.map((e) => `${e.moduleId} — ${e.error}`).join('; ')}`,
557
- );
558
- }
559
- if (failed.length > 0) {
560
- return {
561
- success: false,
562
- error: [
563
- ...summary,
564
- '',
565
- 'Failures:',
566
- ...failed.map((f) => ` ${f.moduleId}: ${f.error}`),
567
- ].join('\n'),
568
- };
569
- }
570
- return { success: true, message: summary.join('\n') };
275
+ return upgradeOneModule(mod, latest, client, db, flags);
571
276
  }