@celilo/cli 0.3.18 → 0.3.20

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.3.18",
3
+ "version": "0.3.20",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -303,11 +303,12 @@ export const COMMANDS: CommandDef[] = [
303
303
  },
304
304
  {
305
305
  name: 'update',
306
- description: 'Update module code while preserving state',
306
+ description:
307
+ 'Update modules. No args = sweep registry (auto-apply non-breaking, prompt for breaking). With args = update from local path(s).',
307
308
  args: [
308
309
  {
309
310
  name: 'path',
310
- description: 'Path to updated module source',
311
+ description: 'Path to updated module source (omit to sweep registry)',
311
312
  completion: 'directories',
312
313
  variadic: true,
313
314
  },
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { describe, expect, test } from 'bun:test';
10
- import { classifyImportArg } from './module-import';
10
+ import { classifyImportArg, handleModuleImport } from './module-import';
11
11
 
12
12
  describe('classifyImportArg', () => {
13
13
  test('routes bare kebab names to the registry', () => {
@@ -50,3 +50,31 @@ describe('classifyImportArg', () => {
50
50
  expect(classifyImportArg('caddy@1.0.0')).toBe('name');
51
51
  });
52
52
  });
53
+
54
+ describe('legacy subcommand migration hints', () => {
55
+ test('catches `module import file <path>` and suggests the new form', async () => {
56
+ const result = await handleModuleImport(['file', 'celilo-registry'], {});
57
+ if (result.success) throw new Error('expected failure');
58
+ expect(result.error).toContain("'file' is no longer a subcommand");
59
+ // 'file' was the LOCAL form; a bare name now goes to the registry,
60
+ // so the hint must lead the operator to a path-shaped form.
61
+ expect(result.error).toContain('./celilo-registry');
62
+ expect(result.error).toContain('/abs/path/to/celilo-registry');
63
+ });
64
+
65
+ test('catches `module import public-registry <name>` and suggests the new form', async () => {
66
+ const result = await handleModuleImport(['public-registry', 'caddy'], {});
67
+ if (result.success) throw new Error('expected failure');
68
+ expect(result.error).toContain("'public-registry' is no longer a subcommand");
69
+ expect(result.error).toContain('celilo module import caddy');
70
+ });
71
+
72
+ test('does not catch a real registry name that happens to share no characters with legacy commands', async () => {
73
+ // Sanity: caddy → registry lookup. We can't fully exercise this without
74
+ // network mocks, but we can confirm it doesn't fall through the legacy
75
+ // branch (the error message would mention "no longer a subcommand").
76
+ const result = await handleModuleImport(['caddy'], { registry: 'http://0.0.0.0:1' });
77
+ if (result.success) throw new Error('expected failure');
78
+ expect(result.error).not.toContain('no longer a subcommand');
79
+ });
80
+ });
@@ -66,6 +66,34 @@ export async function handleModuleImport(
66
66
  return { success: false, error: `Module name or path required.\n\n${USAGE}` };
67
67
  }
68
68
 
69
+ // Catch the legacy subcommand-style invocation (`module import file <path>`,
70
+ // `module import public-registry <name>`) before routing. Without this guard
71
+ // 'file' would round-trip to the registry as a name and surface a confusing
72
+ // "module not found" — the user thinks they're using a real subcommand.
73
+ // Both forms have the second arg in the same slot regardless of which the
74
+ // operator used, so the migration hint can offer the exact fix.
75
+ if (arg === 'file' || arg === 'public-registry') {
76
+ const next = getArg(args, 1);
77
+ // 'file' was the local-path form. A bare name now routes to the
78
+ // registry, so we have to suggest a path-shaped form (./, /, etc.)
79
+ // to preserve the original intent.
80
+ // 'public-registry' was the registry form. A bare name does what
81
+ // the operator wanted, so the hint is straightforward.
82
+ const example =
83
+ arg === 'public-registry'
84
+ ? ` celilo module import ${next ?? '<name>'} # registry (bare name)`
85
+ : ` celilo module import ./${next ?? '<dir>'} # local directory (leading ./)
86
+ celilo module import /abs/path/to/${next ?? '<dir>'} # absolute path`;
87
+ return {
88
+ success: false,
89
+ error: `'${arg}' is no longer a subcommand of 'module import'. The command auto-routes by argument shape now:
90
+
91
+ ${example}
92
+
93
+ A bare name (no leading ./, /, ~) is treated as a registry name. See 'celilo module import --help' for the full routing rules.`,
94
+ };
95
+ }
96
+
69
97
  if (classifyImportArg(arg) === 'name') {
70
98
  if (!KEBAB_NAME.test(arg)) {
71
99
  return {
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Unit tests for the version-change classifier used by `module update`'s
3
+ * registry-sweep mode.
4
+ */
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+ import { classifyVersionChange } from './module-upgrade';
8
+
9
+ describe('classifyVersionChange', () => {
10
+ test('identical versions are up-to-date', () => {
11
+ expect(classifyVersionChange('1.0.0', '1.0.0')).toBe('up-to-date');
12
+ expect(classifyVersionChange('1.0.0+3', '1.0.0+3')).toBe('up-to-date');
13
+ expect(classifyVersionChange('2.4.7+5', '2.4.7+5')).toBe('up-to-date');
14
+ });
15
+
16
+ test('major bump → breaking', () => {
17
+ expect(classifyVersionChange('1.0.0+3', '2.0.0+1')).toBe('major');
18
+ expect(classifyVersionChange('1.5.9', '2.0.0')).toBe('major');
19
+ // Even a tiny step into the next major counts as breaking; the
20
+ // operator decides whether to take it.
21
+ expect(classifyVersionChange('1.0.0+9', '2.0.0+0')).toBe('major');
22
+ });
23
+
24
+ test('minor bump → non-breaking', () => {
25
+ expect(classifyVersionChange('1.0.0+3', '1.1.0+1')).toBe('minor');
26
+ expect(classifyVersionChange('2.5.0', '2.6.0')).toBe('minor');
27
+ });
28
+
29
+ test('patch bump → non-breaking', () => {
30
+ expect(classifyVersionChange('1.0.0+3', '1.0.1+1')).toBe('patch');
31
+ expect(classifyVersionChange('2.5.7', '2.5.8')).toBe('patch');
32
+ });
33
+
34
+ test('revision-only bump (+N) → patch (non-breaking)', () => {
35
+ // Exact case the user is hitting today: same code, fresh publish.
36
+ expect(classifyVersionChange('1.0.0+3', '1.0.0+4')).toBe('patch');
37
+ expect(classifyVersionChange('namecheap-1.0.0', 'namecheap-1.0.0+5')).not.toBe('major');
38
+ expect(classifyVersionChange('3.1.0+0', '3.1.0+9')).toBe('patch');
39
+ });
40
+
41
+ test('installed ahead of registry → ahead (skip silently)', () => {
42
+ // Operator pushed locally without publishing — registry is stale.
43
+ expect(classifyVersionChange('2.0.0+1', '1.5.0+9')).toBe('ahead');
44
+ expect(classifyVersionChange('1.0.1', '1.0.0')).toBe('ahead');
45
+ expect(classifyVersionChange('1.0.0+5', '1.0.0+3')).toBe('ahead');
46
+ });
47
+
48
+ test('tolerates `v` / `=` prefixes', () => {
49
+ expect(classifyVersionChange('v1.0.0+3', 'v1.0.0+4')).toBe('patch');
50
+ expect(classifyVersionChange('=1.0.0', '=1.1.0')).toBe('minor');
51
+ });
52
+
53
+ test('missing patch / revision segments default to 0', () => {
54
+ expect(classifyVersionChange('1.0', '1.0.1')).toBe('patch');
55
+ expect(classifyVersionChange('1.0', '2.0')).toBe('major');
56
+ expect(classifyVersionChange('1.0.0', '1.0.0+1')).toBe('patch');
57
+ });
58
+ });
@@ -10,7 +10,10 @@
10
10
  */
11
11
 
12
12
  import { cpSync, existsSync, readFileSync, readdirSync } from 'node:fs';
13
+ import { unlink } from 'node:fs/promises';
14
+ import { tmpdir } from 'node:os';
13
15
  import { join, resolve } from 'node:path';
16
+ import * as p from '@clack/prompts';
14
17
  import { eq } from 'drizzle-orm';
15
18
  import { parse as parseYaml } from 'yaml';
16
19
  import { registerModuleCapabilities } from '../../capabilities/registration';
@@ -19,11 +22,22 @@ import { capabilities, modules } from '../../db/schema';
19
22
  import { ModuleManifestSchema } from '../../manifest/schema';
20
23
  import type { ModuleManifest } from '../../manifest/schema';
21
24
  import { cleanupTempDir, extractPackage } from '../../module/packaging/extract';
25
+ import { RegistryClient } from '../../registry/client';
26
+ import { getFlag } from '../parser';
22
27
  import { log } from '../prompts';
23
28
  import type { CommandResult } from '../types';
24
29
 
25
30
  type UpgradeOutcome =
26
- | { status: 'success'; moduleId: string }
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
+ }
27
41
  | { status: 'failed'; moduleId: string; error: string }
28
42
  // `skipped` means the path expanded from a glob but isn't an
29
43
  // upgradable target — either no manifest at all (probably a non-
@@ -32,6 +46,108 @@ type UpgradeOutcome =
32
46
  // `celilo module update modules/*` does what users expect.
33
47
  | { status: 'skipped'; moduleId: string; reason: string };
34
48
 
49
+ /**
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.
55
+ */
56
+ interface UpgradeOpts {
57
+ quiet?: boolean;
58
+ displayVersion?: string;
59
+ }
60
+
61
+ /**
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.
67
+ */
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)];
77
+ }
78
+
79
+ export type VersionChangeKind = 'up-to-date' | 'ahead' | 'patch' | 'minor' | 'major';
80
+
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';
104
+ }
105
+
106
+ /**
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.
110
+ */
111
+ 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 {}
148
+ }
149
+ }
150
+
35
151
  /**
36
152
  * Upgrade a single module from a source path
37
153
  */
@@ -39,6 +155,7 @@ async function upgradeOne(
39
155
  sourcePath: string,
40
156
  db: ReturnType<typeof getDb>,
41
157
  flags: Record<string, string | boolean> = {},
158
+ opts: UpgradeOpts = {},
42
159
  ): Promise<UpgradeOutcome> {
43
160
  const originalCwd = process.env.CELILO_ORIGINAL_CWD || process.cwd();
44
161
  const importPath = resolve(originalCwd, sourcePath);
@@ -78,7 +195,7 @@ async function upgradeOne(
78
195
  error: verifyResult.error || 'Package verification failed',
79
196
  };
80
197
  }
81
- } else {
198
+ } else if (!opts.quiet) {
82
199
  log.warn('Skipping package signature verification (--skip-verify)');
83
200
  }
84
201
  }
@@ -123,8 +240,17 @@ async function upgradeOne(
123
240
  };
124
241
  }
125
242
 
126
- const oldManifest = module.manifestData as ModuleManifest;
127
- log.info(`Upgrading ${moduleId}: v${oldManifest.version} v${newManifest.version}`);
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
+ }
128
254
 
129
255
  // Copy new module files, preserving generated output and state
130
256
  const installedPath = module.sourcePath;
@@ -142,11 +268,13 @@ async function upgradeOne(
142
268
  // Clean up temp dir if we extracted a .netapp
143
269
  if (tempDir) await cleanupTempDir(tempDir);
144
270
 
145
- // Update manifest in database
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.
146
274
  db.update(modules)
147
275
  .set({
148
276
  manifestData: newManifest as unknown as Record<string, unknown>,
149
- version: newManifest.version,
277
+ version: newVersion,
150
278
  name: newManifest.name,
151
279
  })
152
280
  .where(eq(modules.id, moduleId))
@@ -157,13 +285,18 @@ async function upgradeOne(
157
285
 
158
286
  if (newManifest.provides?.capabilities && newManifest.provides.capabilities.length > 0) {
159
287
  const regResult = await registerModuleCapabilities(moduleId, newManifest, db.$client);
160
- if (!regResult.success) {
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.
161
292
  log.warn(` ${moduleId}: capability re-registration warning: ${regResult.error}`);
162
293
  }
163
294
  }
164
295
 
165
- log.success(`Upgraded ${moduleId} (v${oldManifest.version} → v${newManifest.version})`);
166
- return { status: 'success', moduleId };
296
+ if (!opts.quiet) {
297
+ log.success(`Upgraded ${moduleId} (${previousVersion} ${newVersion})`);
298
+ }
299
+ return { status: 'success', moduleId, previousVersion, newVersion };
167
300
  }
168
301
 
169
302
  /**
@@ -176,14 +309,15 @@ export async function handleModuleUpgrade(
176
309
  args: string[],
177
310
  flags: Record<string, string | boolean> = {},
178
311
  ): Promise<CommandResult> {
312
+ const db = getDb();
313
+
314
+ // Zero args = registry sweep: walk every installed module, pick up
315
+ // any non-breaking update from the registry automatically, and
316
+ // prompt per-module for breaking (semver-major) updates.
179
317
  if (args.length === 0) {
180
- return {
181
- success: false,
182
- error: 'At least one module path is required\n\nUsage: celilo module update <path> [path...]',
183
- };
318
+ return runRegistrySweep(db, flags);
184
319
  }
185
320
 
186
- const db = getDb();
187
321
  const results: UpgradeOutcome[] = [];
188
322
 
189
323
  for (const path of args) {
@@ -241,3 +375,177 @@ export async function handleModuleUpgrade(
241
375
  }
242
376
  return { success: true, message: lines.join('\n\n') };
243
377
  }
378
+
379
+ interface UpdatePlan {
380
+ moduleId: string;
381
+ installedVersion: string;
382
+ targetVersion: string;
383
+ classification: Exclude<VersionChangeKind, 'up-to-date' | 'ahead'>;
384
+ }
385
+
386
+ /**
387
+ * Walk every installed module, query the registry, and produce a plan.
388
+ * Auto-apply non-breaking updates (patch/minor); prompt per-module for
389
+ * breaking (major) updates. Modules absent from the registry are
390
+ * surfaced as a skip — typically these are local-only modules the
391
+ * operator imported from a path, never published.
392
+ */
393
+ async function runRegistrySweep(
394
+ db: ReturnType<typeof getDb>,
395
+ flags: Record<string, string | boolean>,
396
+ ): Promise<CommandResult> {
397
+ const installed = db.select().from(modules).all();
398
+ if (installed.length === 0) {
399
+ return { success: true, message: 'No modules installed.' };
400
+ }
401
+
402
+ const registryUrl = getFlag(flags, 'registry', '');
403
+ const client = new RegistryClient(registryUrl || undefined);
404
+
405
+ log.info(`Checking ${installed.length} installed module(s) against the registry…`);
406
+
407
+ const plans: UpdatePlan[] = [];
408
+ const upToDate: string[] = [];
409
+ const notInRegistry: string[] = [];
410
+ const errored: Array<{ moduleId: string; error: string }> = [];
411
+
412
+ for (const mod of installed) {
413
+ try {
414
+ const entries = await client.getIndex(mod.id);
415
+ if (entries.length === 0) {
416
+ notInRegistry.push(mod.id);
417
+ continue;
418
+ }
419
+ const latest = client.latestVersion(entries);
420
+ if (!latest) {
421
+ // All versions yanked.
422
+ notInRegistry.push(mod.id);
423
+ continue;
424
+ }
425
+ const cmp = classifyVersionChange(mod.version, latest.vers);
426
+ if (cmp === 'up-to-date' || cmp === 'ahead') {
427
+ upToDate.push(mod.id);
428
+ continue;
429
+ }
430
+ plans.push({
431
+ moduleId: mod.id,
432
+ installedVersion: mod.version,
433
+ targetVersion: latest.vers,
434
+ classification: cmp,
435
+ });
436
+ } catch (err) {
437
+ errored.push({
438
+ moduleId: mod.id,
439
+ error: err instanceof Error ? err.message : String(err),
440
+ });
441
+ }
442
+ }
443
+
444
+ if (plans.length === 0) {
445
+ const lines: string[] = ['All installed modules are up to date.'];
446
+ if (notInRegistry.length > 0) {
447
+ lines.push(`Not in registry: ${notInRegistry.join(', ')}`);
448
+ }
449
+ if (errored.length > 0) {
450
+ lines.push(`Registry errors: ${errored.map((e) => `${e.moduleId} (${e.error})`).join('; ')}`);
451
+ }
452
+ return { success: true, message: lines.join('\n') };
453
+ }
454
+
455
+ const nonBreaking = plans.filter((p) => p.classification !== 'major');
456
+ const breaking = plans.filter((p) => p.classification === 'major');
457
+
458
+ let appliedNonBreaking = 0;
459
+ const failed: Array<{ moduleId: string; error: string }> = [];
460
+
461
+ if (nonBreaking.length > 0) {
462
+ log.info(`Auto-applying ${nonBreaking.length} non-breaking update(s):`);
463
+ for (const plan of nonBreaking) {
464
+ const result = await fetchAndUpgrade(client, plan.moduleId, plan.targetVersion, db, flags);
465
+ if (result.status === 'failed') {
466
+ failed.push({ moduleId: plan.moduleId, error: result.error });
467
+ console.log(
468
+ ` ✗ ${plan.moduleId.padEnd(30)} ${plan.installedVersion} → ${plan.targetVersion} (${plan.classification}, FAILED)`,
469
+ );
470
+ } else if (result.status === 'success') {
471
+ appliedNonBreaking++;
472
+ console.log(
473
+ ` ✓ ${plan.moduleId.padEnd(30)} ${result.previousVersion} → ${result.newVersion} (${plan.classification})`,
474
+ );
475
+ }
476
+ // status === 'skipped' shouldn't happen for registry-fetched packages
477
+ // (we know the module is installed; the package definitely has a
478
+ // manifest), but treat it as a no-op if it does.
479
+ }
480
+ }
481
+
482
+ let appliedBreaking = 0;
483
+ let skippedBreaking = 0;
484
+
485
+ if (breaking.length > 0) {
486
+ log.info('\nBreaking updates available — review required (semver-major bump):');
487
+ for (const plan of breaking) {
488
+ console.log(
489
+ ` ⚠ ${plan.moduleId.padEnd(30)} ${plan.installedVersion} → ${plan.targetVersion}`,
490
+ );
491
+ }
492
+ log.message('Each breaking update will be applied only on explicit confirmation.\n');
493
+
494
+ for (const plan of breaking) {
495
+ const proceed = await p.confirm({
496
+ message: `Apply breaking update for ${plan.moduleId} (${plan.installedVersion} → ${plan.targetVersion})?`,
497
+ initialValue: false,
498
+ });
499
+ if (p.isCancel(proceed) || !proceed) {
500
+ skippedBreaking++;
501
+ continue;
502
+ }
503
+ const result = await fetchAndUpgrade(client, plan.moduleId, plan.targetVersion, db, flags);
504
+ if (result.status === 'failed') {
505
+ failed.push({ moduleId: plan.moduleId, error: result.error });
506
+ console.log(
507
+ ` ✗ ${plan.moduleId.padEnd(30)} ${plan.installedVersion} → ${plan.targetVersion} (major, FAILED)`,
508
+ );
509
+ } else if (result.status === 'success') {
510
+ appliedBreaking++;
511
+ console.log(
512
+ ` ✓ ${plan.moduleId.padEnd(30)} ${result.previousVersion} → ${result.newVersion} (major)`,
513
+ );
514
+ }
515
+ }
516
+ }
517
+
518
+ // Summary
519
+ const summary: string[] = [];
520
+ const totalApplied = appliedNonBreaking + appliedBreaking;
521
+ if (totalApplied > 0) {
522
+ const parts = [`${appliedNonBreaking} non-breaking`];
523
+ if (appliedBreaking > 0) parts.push(`${appliedBreaking} breaking`);
524
+ summary.push(`Applied ${totalApplied} update(s) (${parts.join(', ')}).`);
525
+ } else {
526
+ summary.push('No updates applied.');
527
+ }
528
+ if (skippedBreaking > 0) {
529
+ summary.push(`Skipped ${skippedBreaking} breaking update(s) (operator declined).`);
530
+ }
531
+ if (notInRegistry.length > 0) {
532
+ summary.push(`Not in registry (${notInRegistry.length}): ${notInRegistry.join(', ')}`);
533
+ }
534
+ if (errored.length > 0) {
535
+ summary.push(
536
+ `Registry errors (${errored.length}): ${errored.map((e) => `${e.moduleId} — ${e.error}`).join('; ')}`,
537
+ );
538
+ }
539
+ if (failed.length > 0) {
540
+ return {
541
+ success: false,
542
+ error: [
543
+ ...summary,
544
+ '',
545
+ 'Failures:',
546
+ ...failed.map((f) => ` ${f.moduleId}: ${f.error}`),
547
+ ].join('\n'),
548
+ };
549
+ }
550
+ return { success: true, message: summary.join('\n') };
551
+ }
package/src/cli/index.ts CHANGED
@@ -357,7 +357,10 @@ Subcommands:
357
357
  Options:
358
358
  --debug Run with visible browser (Playwright hooks)
359
359
 
360
- update <path> [path...] Update module code while preserving state (configs, secrets, infra)
360
+ update Sweep registry: auto-apply non-breaking updates, prompt for breaking
361
+ update <path> [path...] Update module(s) from local path(s) (preserves config/secrets/infra)
362
+ Options:
363
+ --registry <url> Use a custom registry (sweep mode only)
361
364
 
362
365
  Registry:
363
366
  search [query] Search the registry for modules
@@ -29,6 +29,9 @@ export interface ValidationResult {
29
29
  options?: Array<{ value: string; label: string; hint?: string }>;
30
30
  per_selection?: { key_pattern: string; prompt: string; type?: string; derive_from?: string };
31
31
  generate?: { method: string; length: number; encoding: string };
32
+ /** For `type: string-map` only — labels shown in the add-loop prompt. */
33
+ key_label?: string;
34
+ value_label?: string;
32
35
  }>;
33
36
  }
34
37
 
@@ -383,6 +386,9 @@ async function findMissingRequiredVariables(
383
386
  type?: string;
384
387
  options?: Array<{ value: string; label: string; hint?: string }>;
385
388
  per_selection?: { key_pattern: string; prompt: string; type?: string; derive_from?: string };
389
+ generate?: { method: string; length: number; encoding: string };
390
+ key_label?: string;
391
+ value_label?: string;
386
392
  }>
387
393
  > {
388
394
  const missing: Array<{
@@ -394,6 +400,8 @@ async function findMissingRequiredVariables(
394
400
  options?: Array<{ value: string; label: string; hint?: string }>;
395
401
  per_selection?: { key_pattern: string; prompt: string; type?: string; derive_from?: string };
396
402
  generate?: { method: string; length: number; encoding: string };
403
+ key_label?: string;
404
+ value_label?: string;
397
405
  }> = [];
398
406
 
399
407
  // Check declared variables (user config, capability, system, infrastructure)
@@ -460,6 +468,12 @@ async function findMissingRequiredVariables(
460
468
  source: 'secret',
461
469
  description: secret.description,
462
470
  generate: secret.generate,
471
+ // Pass through manifest-declared type + add-loop labels so the
472
+ // bus payload can drive the right responder UX (e.g. string-map
473
+ // gets the per-key add-loop instead of the JSON-blob prompt).
474
+ type: secret.type,
475
+ key_label: secret.key_label,
476
+ value_label: secret.value_label,
463
477
  });
464
478
  }
465
479
  }