@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 +1 -1
- package/src/cli/command-registry.ts +3 -2
- package/src/cli/commands/module-import-routing.test.ts +29 -1
- package/src/cli/commands/module-import.ts +28 -0
- package/src/cli/commands/module-upgrade.test.ts +58 -0
- package/src/cli/commands/module-upgrade.ts +257 -5
- package/src/cli/index.ts +4 -1
package/package.json
CHANGED
|
@@ -303,11 +303,12 @@ export const COMMANDS: CommandDef[] = [
|
|
|
303
303
|
},
|
|
304
304
|
{
|
|
305
305
|
name: 'update',
|
|
306
|
-
description:
|
|
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
|
|
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
|