@celilo/cli 0.3.18 → 0.3.19

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.19",
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,6 +22,8 @@ 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
 
@@ -32,6 +37,85 @@ type UpgradeOutcome =
32
37
  // `celilo module update modules/*` does what users expect.
33
38
  | { status: 'skipped'; moduleId: string; reason: string };
34
39
 
40
+ /**
41
+ * Parse a celilo version string into [major, minor, patch, revision].
42
+ * Celilo's published versions look like `1.0.0+3` — semver core plus a
43
+ * publish revision suffix (the +N resets on every semver bump). Missing
44
+ * segments default to 0; non-numeric segments are clamped to 0 so we
45
+ * never throw on weird upstream input.
46
+ */
47
+ function parseModuleVersion(v: string): [number, number, number, number] {
48
+ const cleaned = v.replace(/^[v=]+/, '');
49
+ const [core, rev] = cleaned.split('+');
50
+ const parts = (core ?? '').split('.');
51
+ const num = (s: string | undefined) => {
52
+ const n = Number(s ?? '0');
53
+ return Number.isNaN(n) ? 0 : n;
54
+ };
55
+ return [num(parts[0]), num(parts[1]), num(parts[2]), num(rev)];
56
+ }
57
+
58
+ export type VersionChangeKind = 'up-to-date' | 'ahead' | 'patch' | 'minor' | 'major';
59
+
60
+ /**
61
+ * Classify a registry-side update relative to the installed version.
62
+ * `major` = breaking (semver-major bump). Operator must approve.
63
+ * `minor` = additive feature. Auto-applied.
64
+ * `patch` = bugfix or revision-only (+N) bump. Auto-applied.
65
+ * `up-to-date` = identical version.
66
+ * `ahead` = installed is newer than registry. Skip silently — usually
67
+ * means the operator pushed locally without publishing.
68
+ *
69
+ * Exported for unit tests.
70
+ */
71
+ export function classifyVersionChange(installed: string, latest: string): VersionChangeKind {
72
+ const [aMaj, aMin, aPat, aRev] = parseModuleVersion(installed);
73
+ const [bMaj, bMin, bPat, bRev] = parseModuleVersion(latest);
74
+ if (bMaj > aMaj) return 'major';
75
+ if (bMaj < aMaj) return 'ahead';
76
+ if (bMin > aMin) return 'minor';
77
+ if (bMin < aMin) return 'ahead';
78
+ if (bPat > aPat) return 'patch';
79
+ if (bPat < aPat) return 'ahead';
80
+ if (bRev > aRev) return 'patch';
81
+ if (bRev < aRev) return 'ahead';
82
+ return 'up-to-date';
83
+ }
84
+
85
+ /**
86
+ * Download a module package from the registry into a temp file and run
87
+ * the standard upgradeOne path against it. Cleans the temp file in a
88
+ * finally block so a mid-flight failure doesn't leak a tar.zst on disk.
89
+ */
90
+ async function fetchAndUpgrade(
91
+ client: RegistryClient,
92
+ moduleId: string,
93
+ version: string,
94
+ db: ReturnType<typeof getDb>,
95
+ flags: Record<string, string | boolean>,
96
+ ): Promise<UpgradeOutcome> {
97
+ const tmpPath = join(tmpdir(), `${moduleId}-${version}-${Date.now()}.netapp`);
98
+ try {
99
+ const pkgData = await client.download(moduleId, version);
100
+ await Bun.write(tmpPath, pkgData);
101
+ } catch (err) {
102
+ return {
103
+ status: 'failed',
104
+ moduleId,
105
+ error: `Download failed: ${err instanceof Error ? err.message : String(err)}`,
106
+ };
107
+ }
108
+ try {
109
+ // Registry packages are pre-verified at publish time; skip the
110
+ // signature check here to match `module import`'s registry path.
111
+ return await upgradeOne(tmpPath, db, { ...flags, 'skip-verify': true });
112
+ } finally {
113
+ try {
114
+ await unlink(tmpPath);
115
+ } catch {}
116
+ }
117
+ }
118
+
35
119
  /**
36
120
  * Upgrade a single module from a source path
37
121
  */
@@ -176,14 +260,15 @@ export async function handleModuleUpgrade(
176
260
  args: string[],
177
261
  flags: Record<string, string | boolean> = {},
178
262
  ): Promise<CommandResult> {
263
+ const db = getDb();
264
+
265
+ // Zero args = registry sweep: walk every installed module, pick up
266
+ // any non-breaking update from the registry automatically, and
267
+ // prompt per-module for breaking (semver-major) updates.
179
268
  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
- };
269
+ return runRegistrySweep(db, flags);
184
270
  }
185
271
 
186
- const db = getDb();
187
272
  const results: UpgradeOutcome[] = [];
188
273
 
189
274
  for (const path of args) {
@@ -241,3 +326,170 @@ export async function handleModuleUpgrade(
241
326
  }
242
327
  return { success: true, message: lines.join('\n\n') };
243
328
  }
329
+
330
+ interface UpdatePlan {
331
+ moduleId: string;
332
+ installedVersion: string;
333
+ targetVersion: string;
334
+ classification: Exclude<VersionChangeKind, 'up-to-date' | 'ahead'>;
335
+ }
336
+
337
+ /**
338
+ * Walk every installed module, query the registry, and produce a plan.
339
+ * Auto-apply non-breaking updates (patch/minor); prompt per-module for
340
+ * breaking (major) updates. Modules absent from the registry are
341
+ * surfaced as a skip — typically these are local-only modules the
342
+ * operator imported from a path, never published.
343
+ */
344
+ async function runRegistrySweep(
345
+ db: ReturnType<typeof getDb>,
346
+ flags: Record<string, string | boolean>,
347
+ ): Promise<CommandResult> {
348
+ const installed = db.select().from(modules).all();
349
+ if (installed.length === 0) {
350
+ return { success: true, message: 'No modules installed.' };
351
+ }
352
+
353
+ const registryUrl = getFlag(flags, 'registry', '');
354
+ const client = new RegistryClient(registryUrl || undefined);
355
+
356
+ log.info(`Checking ${installed.length} installed module(s) against the registry…`);
357
+
358
+ const plans: UpdatePlan[] = [];
359
+ const upToDate: string[] = [];
360
+ const notInRegistry: string[] = [];
361
+ const errored: Array<{ moduleId: string; error: string }> = [];
362
+
363
+ for (const mod of installed) {
364
+ try {
365
+ const entries = await client.getIndex(mod.id);
366
+ if (entries.length === 0) {
367
+ notInRegistry.push(mod.id);
368
+ continue;
369
+ }
370
+ const latest = client.latestVersion(entries);
371
+ if (!latest) {
372
+ // All versions yanked.
373
+ notInRegistry.push(mod.id);
374
+ continue;
375
+ }
376
+ const cmp = classifyVersionChange(mod.version, latest.vers);
377
+ if (cmp === 'up-to-date' || cmp === 'ahead') {
378
+ upToDate.push(mod.id);
379
+ continue;
380
+ }
381
+ plans.push({
382
+ moduleId: mod.id,
383
+ installedVersion: mod.version,
384
+ targetVersion: latest.vers,
385
+ classification: cmp,
386
+ });
387
+ } catch (err) {
388
+ errored.push({
389
+ moduleId: mod.id,
390
+ error: err instanceof Error ? err.message : String(err),
391
+ });
392
+ }
393
+ }
394
+
395
+ if (plans.length === 0) {
396
+ const lines: string[] = ['All installed modules are up to date.'];
397
+ if (notInRegistry.length > 0) {
398
+ lines.push(`Not in registry: ${notInRegistry.join(', ')}`);
399
+ }
400
+ if (errored.length > 0) {
401
+ lines.push(`Registry errors: ${errored.map((e) => `${e.moduleId} (${e.error})`).join('; ')}`);
402
+ }
403
+ return { success: true, message: lines.join('\n') };
404
+ }
405
+
406
+ const nonBreaking = plans.filter((p) => p.classification !== 'major');
407
+ const breaking = plans.filter((p) => p.classification === 'major');
408
+
409
+ let appliedNonBreaking = 0;
410
+ const failed: Array<{ moduleId: string; error: string }> = [];
411
+
412
+ if (nonBreaking.length > 0) {
413
+ log.info(`Auto-applying ${nonBreaking.length} non-breaking update(s):`);
414
+ for (const plan of nonBreaking) {
415
+ console.log(
416
+ ` ↑ ${plan.moduleId.padEnd(30)} ${plan.installedVersion} → ${plan.targetVersion} (${plan.classification})`,
417
+ );
418
+ }
419
+ for (const plan of nonBreaking) {
420
+ const result = await fetchAndUpgrade(client, plan.moduleId, plan.targetVersion, db, flags);
421
+ if (result.status === 'failed') {
422
+ failed.push({ moduleId: plan.moduleId, error: result.error });
423
+ } else if (result.status === 'success') {
424
+ appliedNonBreaking++;
425
+ }
426
+ // status === 'skipped' shouldn't happen for registry-fetched packages
427
+ // (we know the module is installed; the package definitely has a
428
+ // manifest), but treat it as a no-op if it does.
429
+ }
430
+ }
431
+
432
+ let appliedBreaking = 0;
433
+ let skippedBreaking = 0;
434
+
435
+ if (breaking.length > 0) {
436
+ log.info('\nBreaking updates available — review required (semver-major bump):');
437
+ for (const plan of breaking) {
438
+ console.log(
439
+ ` ⚠ ${plan.moduleId.padEnd(30)} ${plan.installedVersion} → ${plan.targetVersion}`,
440
+ );
441
+ }
442
+ log.message('Each breaking update will be applied only on explicit confirmation.\n');
443
+
444
+ for (const plan of breaking) {
445
+ const proceed = await p.confirm({
446
+ message: `Apply breaking update for ${plan.moduleId} (${plan.installedVersion} → ${plan.targetVersion})?`,
447
+ initialValue: false,
448
+ });
449
+ if (p.isCancel(proceed) || !proceed) {
450
+ skippedBreaking++;
451
+ continue;
452
+ }
453
+ const result = await fetchAndUpgrade(client, plan.moduleId, plan.targetVersion, db, flags);
454
+ if (result.status === 'failed') {
455
+ failed.push({ moduleId: plan.moduleId, error: result.error });
456
+ } else if (result.status === 'success') {
457
+ appliedBreaking++;
458
+ }
459
+ }
460
+ }
461
+
462
+ // Summary
463
+ const summary: string[] = [];
464
+ const totalApplied = appliedNonBreaking + appliedBreaking;
465
+ if (totalApplied > 0) {
466
+ const parts = [`${appliedNonBreaking} non-breaking`];
467
+ if (appliedBreaking > 0) parts.push(`${appliedBreaking} breaking`);
468
+ summary.push(`Applied ${totalApplied} update(s) (${parts.join(', ')}).`);
469
+ } else {
470
+ summary.push('No updates applied.');
471
+ }
472
+ if (skippedBreaking > 0) {
473
+ summary.push(`Skipped ${skippedBreaking} breaking update(s) (operator declined).`);
474
+ }
475
+ if (notInRegistry.length > 0) {
476
+ summary.push(`Not in registry (${notInRegistry.length}): ${notInRegistry.join(', ')}`);
477
+ }
478
+ if (errored.length > 0) {
479
+ summary.push(
480
+ `Registry errors (${errored.length}): ${errored.map((e) => `${e.moduleId} — ${e.error}`).join('; ')}`,
481
+ );
482
+ }
483
+ if (failed.length > 0) {
484
+ return {
485
+ success: false,
486
+ error: [
487
+ ...summary,
488
+ '',
489
+ 'Failures:',
490
+ ...failed.map((f) => ` ${f.moduleId}: ${f.error}`),
491
+ ].join('\n'),
492
+ };
493
+ }
494
+ return { success: true, message: summary.join('\n') };
495
+ }
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