@celilo/cli 0.5.0 → 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.
- package/package.json +2 -2
- package/src/cli/command-registry.ts +19 -0
- package/src/cli/commands/module-update.test.ts +252 -0
- package/src/cli/commands/module-update.ts +571 -0
- package/src/cli/commands/module-upgrade.test.ts +55 -225
- package/src/cli/commands/module-upgrade.ts +205 -500
- package/src/cli/commands/system-update.ts +3 -3
- package/src/cli/completion.ts +1 -0
- package/src/cli/index.ts +3 -0
- package/src/hooks/capability-loader.ts +7 -3
- package/src/manifest/schema.ts +3 -1
- package/src/services/deploy-posture.test.ts +106 -0
- package/src/services/deploy-posture.ts +87 -0
- package/src/services/module-subscriptions.test.ts +32 -2
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module update command
|
|
3
|
+
*
|
|
4
|
+
* Updates module code (manifest, scripts, templates) while preserving state
|
|
5
|
+
* (configs, secrets, infrastructure, capabilities).
|
|
6
|
+
*
|
|
7
|
+
* Usage: celilo module update <path>
|
|
8
|
+
*
|
|
9
|
+
* The module ID is read from the manifest at the given path.
|
|
10
|
+
*/
|
|
11
|
+
|
|
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
|
+
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';
|
|
22
|
+
import type { ModuleManifest } from '../../manifest/schema';
|
|
23
|
+
import { cleanupTempDir, extractPackage } from '../../module/packaging/extract';
|
|
24
|
+
import { RegistryClient } from '../../registry/client';
|
|
25
|
+
import { askConfirm, withInterviewSession } from '../../services/bus-interview';
|
|
26
|
+
import { getFlag } from '../parser';
|
|
27
|
+
import { log } from '../prompts';
|
|
28
|
+
import type { CommandResult } from '../types';
|
|
29
|
+
|
|
30
|
+
type UpdateOutcome =
|
|
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 };
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Tunables for `updateOne`. 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 UpdateOpts {
|
|
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 updateOne 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
|
+
export async function fetchAndUpdate(
|
|
112
|
+
client: RegistryClient,
|
|
113
|
+
moduleId: string,
|
|
114
|
+
version: string,
|
|
115
|
+
db: ReturnType<typeof getDb>,
|
|
116
|
+
flags: Record<string, string | boolean>,
|
|
117
|
+
): Promise<UpdateOutcome> {
|
|
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 updateOne'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 updateOne(
|
|
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
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Upgrade a single module from a source path
|
|
153
|
+
*/
|
|
154
|
+
export async function updateOne(
|
|
155
|
+
sourcePath: string,
|
|
156
|
+
db: ReturnType<typeof getDb>,
|
|
157
|
+
flags: Record<string, string | boolean> = {},
|
|
158
|
+
opts: UpdateOpts = {},
|
|
159
|
+
): Promise<UpdateOutcome> {
|
|
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
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
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);
|
|
192
|
+
return {
|
|
193
|
+
status: 'failed',
|
|
194
|
+
moduleId: sourcePath,
|
|
195
|
+
error: verifyResult.error || 'Package verification failed',
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
} else if (!opts.quiet) {
|
|
199
|
+
log.warn('Skipping package signature verification (--skip-verify)');
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
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.
|
|
209
|
+
return {
|
|
210
|
+
status: 'skipped',
|
|
211
|
+
moduleId: sourcePath,
|
|
212
|
+
reason: 'not a module directory (no manifest.yml)',
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
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.
|
|
236
|
+
return {
|
|
237
|
+
status: 'skipped',
|
|
238
|
+
moduleId,
|
|
239
|
+
reason: `not installed (run 'celilo module import ${sourcePath}' to add)`,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
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 };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Handle module upgrade command
|
|
320
|
+
*
|
|
321
|
+
* @param args - Command arguments: [path, path, ...]
|
|
322
|
+
* @returns Command result
|
|
323
|
+
*/
|
|
324
|
+
export async function handleModuleUpdate(
|
|
325
|
+
args: string[],
|
|
326
|
+
flags: Record<string, string | boolean> = {},
|
|
327
|
+
): Promise<CommandResult> {
|
|
328
|
+
const db = getDb();
|
|
329
|
+
|
|
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);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const results: UpdateOutcome[] = [];
|
|
338
|
+
|
|
339
|
+
for (const path of args) {
|
|
340
|
+
const result = await updateOne(path, db, flags);
|
|
341
|
+
results.push(result);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const succeeded = results.filter(
|
|
345
|
+
(r): r is Extract<UpdateOutcome, { status: 'success' }> => r.status === 'success',
|
|
346
|
+
);
|
|
347
|
+
const failed = results.filter(
|
|
348
|
+
(r): r is Extract<UpdateOutcome, { status: 'failed' }> => r.status === 'failed',
|
|
349
|
+
);
|
|
350
|
+
const skipped = results.filter(
|
|
351
|
+
(r): r is Extract<UpdateOutcome, { 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') };
|
|
374
|
+
}
|
|
375
|
+
|
|
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
|
+
);
|
|
391
|
+
}
|
|
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'>;
|
|
400
|
+
}
|
|
401
|
+
|
|
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>,
|
|
412
|
+
): 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 }> = [];
|
|
427
|
+
|
|
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
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
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') };
|
|
469
|
+
}
|
|
470
|
+
|
|
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 fetchAndUpdate(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
|
+
}
|
|
496
|
+
}
|
|
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 fetchAndUpdate(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
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
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') };
|
|
571
|
+
}
|