@celilo/cli 0.5.0-alpha.9 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }