@celilo/cli 0.1.4 → 0.1.6

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.
Files changed (161) hide show
  1. package/drizzle/0004_caddy_hostname_list.sql +25 -0
  2. package/drizzle/meta/_journal.json +14 -0
  3. package/package.json +9 -2
  4. package/src/ansible/inventory.test.ts +9 -8
  5. package/src/ansible/inventory.ts +9 -7
  6. package/src/capabilities/public-web-helpers.test.ts +2 -2
  7. package/src/capabilities/public-web-publish.test.ts +45 -12
  8. package/src/capabilities/registration.test.ts +6 -6
  9. package/src/capabilities/well-known.test.ts +2 -2
  10. package/src/capabilities/well-known.ts +5 -5
  11. package/src/cli/cli.test.ts +2 -2
  12. package/src/cli/command-registry.ts +146 -3
  13. package/src/cli/command-tree-parser.test.ts +1 -1
  14. package/src/cli/command-tree-parser.ts +9 -8
  15. package/src/cli/commands/hook-run.ts +15 -66
  16. package/src/cli/commands/module-audit.ts +14 -44
  17. package/src/cli/commands/module-deploy.ts +4 -1
  18. package/src/cli/commands/module-import-registry.test.ts +115 -0
  19. package/src/cli/commands/module-import.ts +106 -22
  20. package/src/cli/commands/module-publish.test.ts +235 -0
  21. package/src/cli/commands/module-publish.ts +234 -0
  22. package/src/cli/commands/module-remove.ts +82 -2
  23. package/src/cli/commands/module-search.ts +57 -0
  24. package/src/cli/commands/module-secret-get.ts +59 -0
  25. package/src/cli/commands/module-show.ts +1 -1
  26. package/src/cli/commands/module-terraform-unlock.ts +57 -0
  27. package/src/cli/commands/module-verify.test.ts +59 -0
  28. package/src/cli/commands/module-verify.ts +53 -0
  29. package/src/cli/commands/status.ts +30 -20
  30. package/src/cli/commands/system-audit.test.ts +138 -0
  31. package/src/cli/commands/system-audit.ts +571 -0
  32. package/src/cli/commands/system-update.ts +391 -0
  33. package/src/cli/completion.ts +15 -1
  34. package/src/cli/fuel-gauge.ts +68 -3
  35. package/src/cli/generate-zsh-completion.ts +13 -3
  36. package/src/cli/index.ts +112 -5
  37. package/src/cli/parser.ts +11 -0
  38. package/src/cli/prompts.ts +36 -5
  39. package/src/cli/tui/audit-state.test.ts +246 -0
  40. package/src/cli/tui/audit-state.ts +525 -0
  41. package/src/cli/tui/audit-tui.test.tsx +135 -0
  42. package/src/cli/tui/audit-tui.tsx +624 -0
  43. package/src/cli/tui/celebration.tsx +29 -0
  44. package/src/cli/tui/clipboard.test.ts +94 -0
  45. package/src/cli/tui/clipboard.ts +101 -0
  46. package/src/cli/tui/icons.ts +22 -0
  47. package/src/cli/tui/keybar.tsx +65 -0
  48. package/src/cli/tui/keymap.test.ts +105 -0
  49. package/src/cli/tui/keymap.ts +70 -0
  50. package/src/cli/tui/modals/analyzing.tsx +75 -0
  51. package/src/cli/tui/modals/celebration.tsx +44 -0
  52. package/src/cli/tui/modals/reaudit-prompt.tsx +35 -0
  53. package/src/cli/tui/modals/remediate.tsx +44 -0
  54. package/src/cli/tui/modals.test.ts +137 -0
  55. package/src/cli/tui/mouse.test.ts +78 -0
  56. package/src/cli/tui/mouse.ts +114 -0
  57. package/src/cli/tui/panes/categories.tsx +62 -0
  58. package/src/cli/tui/panes/command-log.tsx +87 -0
  59. package/src/cli/tui/panes/detail.tsx +175 -0
  60. package/src/cli/tui/panes/findings.tsx +97 -0
  61. package/src/cli/tui/panes/summary.tsx +64 -0
  62. package/src/cli/tui/spawn.ts +130 -0
  63. package/src/cli/tui/theme.ts +42 -0
  64. package/src/cli/tui/wrap.test.ts +43 -0
  65. package/src/cli/tui/wrap.ts +45 -0
  66. package/src/cli/types.ts +5 -0
  67. package/src/db/client.ts +55 -2
  68. package/src/db/schema.test.ts +3 -3
  69. package/src/db/schema.ts +26 -17
  70. package/src/hooks/capability-loader.ts +135 -72
  71. package/src/hooks/define-hook.test.ts +11 -3
  72. package/src/hooks/executor.ts +22 -1
  73. package/src/hooks/load-hook-config.test.ts +165 -0
  74. package/src/hooks/load-hook-config.ts +60 -0
  75. package/src/hooks/logger.ts +42 -12
  76. package/src/hooks/run-named-hook.ts +128 -0
  77. package/src/hooks/types.ts +19 -0
  78. package/src/manifest/ensure-schema.test.ts +115 -0
  79. package/src/manifest/schema.ts +76 -0
  80. package/src/manifest/template-validator.test.ts +1 -1
  81. package/src/manifest/template-validator.ts +1 -1
  82. package/src/manifest/validate.test.ts +1 -1
  83. package/src/module/import.ts +20 -12
  84. package/src/module/packaging/build.ts +121 -25
  85. package/src/module/packaging/release-metadata.test.ts +103 -0
  86. package/src/module/packaging/release-metadata.ts +145 -0
  87. package/src/registry/client.test.ts +228 -0
  88. package/src/registry/client.ts +157 -0
  89. package/src/services/audit/backups.test.ts +233 -0
  90. package/src/services/audit/backups.ts +128 -0
  91. package/src/services/audit/capability-abi.test.ts +153 -0
  92. package/src/services/audit/capability-abi.ts +204 -0
  93. package/src/services/audit/cli-version.test.ts +60 -0
  94. package/src/services/audit/cli-version.ts +87 -0
  95. package/src/services/audit/health.test.ts +84 -0
  96. package/src/services/audit/health.ts +43 -0
  97. package/src/services/audit/index.test.ts +99 -0
  98. package/src/services/audit/index.ts +118 -0
  99. package/src/services/audit/machines-reachable.test.ts +87 -0
  100. package/src/services/audit/machines-reachable.ts +87 -0
  101. package/src/services/audit/module-configs.test.ts +131 -0
  102. package/src/services/audit/module-configs.ts +80 -0
  103. package/src/services/audit/module-versions.test.ts +99 -0
  104. package/src/services/audit/module-versions.ts +154 -0
  105. package/src/services/audit/schema.test.ts +68 -0
  106. package/src/services/audit/schema.ts +115 -0
  107. package/src/services/audit/secrets-decryptable.test.ts +82 -0
  108. package/src/services/audit/secrets-decryptable.ts +97 -0
  109. package/src/services/audit/services-credentials.test.ts +54 -0
  110. package/src/services/audit/services-credentials.ts +64 -0
  111. package/src/services/audit/services-reachable.test.ts +60 -0
  112. package/src/services/audit/services-reachable.ts +64 -0
  113. package/src/services/audit/terraform-plan.test.ts +127 -0
  114. package/src/services/audit/terraform-plan.ts +153 -0
  115. package/src/services/audit/types.test.ts +36 -0
  116. package/src/services/audit/types.ts +90 -0
  117. package/src/services/audit/unconfigured-modules.test.ts +48 -0
  118. package/src/services/audit/unconfigured-modules.ts +71 -0
  119. package/src/services/audit/undeployed-modules.test.ts +66 -0
  120. package/src/services/audit/undeployed-modules.ts +72 -0
  121. package/src/services/build-stream.ts +122 -122
  122. package/src/services/config-interview.ts +407 -2
  123. package/src/services/deploy-ansible.ts +73 -7
  124. package/src/services/deploy-planner.ts +5 -5
  125. package/src/services/deploy-preflight.ts +45 -4
  126. package/src/services/deploy-terraform.ts +31 -24
  127. package/src/services/deploy-validation.ts +167 -23
  128. package/src/services/dns-auto-register.ts +4 -4
  129. package/src/services/ensure-interview.test.ts +245 -0
  130. package/src/services/health-runner.ts +110 -38
  131. package/src/services/infrastructure-variable-resolver.test.ts +1 -1
  132. package/src/services/infrastructure-variable-resolver.ts +3 -3
  133. package/src/services/module-build.ts +11 -13
  134. package/src/services/module-deploy.ts +372 -61
  135. package/src/services/proxmox-state-recovery.ts +6 -6
  136. package/src/services/ssh-key-manager.test.ts +1 -1
  137. package/src/services/ssh-key-manager.ts +3 -2
  138. package/src/services/terraform-env.ts +62 -0
  139. package/src/services/update/dep-graph.test.ts +214 -0
  140. package/src/services/update/dep-graph.ts +215 -0
  141. package/src/services/update/orchestrator.test.ts +463 -0
  142. package/src/services/update/orchestrator.ts +359 -0
  143. package/src/services/update/progress.ts +49 -0
  144. package/src/services/update/self-update.test.ts +68 -0
  145. package/src/services/update/self-update.ts +57 -0
  146. package/src/services/update/types.ts +94 -0
  147. package/src/templates/generator.test.ts +3 -3
  148. package/src/templates/generator.ts +43 -2
  149. package/src/test-utils/completion-harness.test.ts +1 -1
  150. package/src/test-utils/completion-harness.ts +4 -4
  151. package/src/variables/capability-self-ref.test.ts +203 -0
  152. package/src/variables/context.test.ts +31 -31
  153. package/src/variables/context.ts +65 -17
  154. package/src/variables/declarative-derivation.test.ts +306 -0
  155. package/src/variables/declarative-derivation.ts +4 -2
  156. package/src/variables/parser.test.ts +64 -9
  157. package/src/variables/parser.ts +47 -6
  158. package/src/variables/resolver.test.ts +14 -14
  159. package/src/variables/resolver.ts +27 -9
  160. package/src/variables/types.ts +1 -1
  161. package/tsconfig.json +1 -0
@@ -0,0 +1,463 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { DbClient } from '../../db/client';
3
+ import type { ModuleManifest } from '../../manifest/schema';
4
+ import type { AuditDeps } from '../audit';
5
+ import { buildModuleGraph } from './dep-graph';
6
+ import {
7
+ type ModuleSnapshot,
8
+ type OrchestratorOps,
9
+ consumerSkipReason,
10
+ runSystemUpdate,
11
+ } from './orchestrator';
12
+ import { capturingProgress } from './progress';
13
+
14
+ const fakeDb = {} as DbClient;
15
+
16
+ function makeManifest(
17
+ id: string,
18
+ opts: { provides?: string[]; requires?: string[] } = {},
19
+ ): ModuleManifest {
20
+ return {
21
+ id,
22
+ name: id,
23
+ version: '1.0.0',
24
+ celilo_contract: '1.0',
25
+ provides: opts.provides
26
+ ? {
27
+ capabilities: opts.provides.map((name) => ({
28
+ name,
29
+ version: '1.0.0',
30
+ data: {},
31
+ functions: [],
32
+ })),
33
+ }
34
+ : { capabilities: [] },
35
+ requires: opts.requires
36
+ ? { capabilities: opts.requires.map((name) => ({ name, version: '1.0.0' })) }
37
+ : { capabilities: [] },
38
+ } as unknown as ModuleManifest;
39
+ }
40
+
41
+ function makeSnapshot(
42
+ id: string,
43
+ opts: {
44
+ installed?: string;
45
+ latest?: string | null;
46
+ provides?: Record<string, string>;
47
+ requires?: Record<string, string>;
48
+ } = {},
49
+ ): ModuleSnapshot {
50
+ return {
51
+ id,
52
+ installedVersion: opts.installed ?? '1.0.0',
53
+ latestVersion: opts.latest === undefined ? '1.1.0' : opts.latest,
54
+ installedProvides: opts.provides ?? {},
55
+ pendingRequires: opts.requires ?? {},
56
+ };
57
+ }
58
+
59
+ function makeOps(overrides: Partial<OrchestratorOps> = {}): OrchestratorOps {
60
+ return {
61
+ backup: async () => ({ ok: true }),
62
+ upgrade: async () => ({ ok: true }),
63
+ deploy: async () => ({ ok: true }),
64
+ health: async () => ({ status: 'healthy' }),
65
+ snapshotCeliloDb: async () => ({ ok: true }),
66
+ ...overrides,
67
+ };
68
+ }
69
+
70
+ const cleanAudit: AuditDeps = {
71
+ cliVersion: { installedVersion: '0.1.5', fetcher: async () => '0.1.5' },
72
+ schema: { journal: () => null, applied: () => [], db: fakeDb },
73
+ capabilityAbi: { modules: [] },
74
+ terraformPlan: { modules: [], run: async () => ({ exitCode: 0, stdout: '', stderr: '' }) },
75
+ moduleVersions: { installed: [], fetcher: async () => ({ latest: null }) },
76
+ moduleConfigs: { modules: [] },
77
+ health: { results: [] },
78
+ backups: { modules: [] },
79
+ undeployedModules: { modules: [] },
80
+ unconfiguredModules: { modules: [] },
81
+ servicesCredentials: { results: [] },
82
+ secretsDecryptable: { results: [] },
83
+ servicesReachable: { results: [] },
84
+ machinesReachable: { results: [] },
85
+ };
86
+
87
+ describe('consumerSkipReason', () => {
88
+ const graph = buildModuleGraph([
89
+ makeManifest('iptables', { provides: ['firewall'] }),
90
+ makeManifest('caddy', { requires: ['firewall'] }),
91
+ ]);
92
+
93
+ test('returns null when consumer requires same major as provider provides', () => {
94
+ const provider = makeSnapshot('iptables', { provides: { firewall: '1.0.0' } });
95
+ const consumer = makeSnapshot('caddy', { requires: { firewall: '1.2.0' } });
96
+ expect(consumerSkipReason(consumer, provider, graph)).toBeNull();
97
+ });
98
+
99
+ test('returns reason when consumer requires a newer major than provider provides', () => {
100
+ const provider = makeSnapshot('iptables', { provides: { firewall: '1.0.0' } });
101
+ const consumer = makeSnapshot('caddy', { requires: { firewall: '2.0.0' } });
102
+ const reason = consumerSkipReason(consumer, provider, graph);
103
+ expect(reason).toContain('blocked by iptables');
104
+ expect(reason).toContain("firewall@1.0.0 doesn't satisfy firewall@2.0.0");
105
+ });
106
+
107
+ test('returns conservative reason when no shared capability is declared', () => {
108
+ const provider = makeSnapshot('iptables', { provides: { firewall: '1.0.0' } });
109
+ const consumer = makeSnapshot('caddy', { requires: { something_else: '1.0.0' } });
110
+ const reason = consumerSkipReason(consumer, provider, graph);
111
+ expect(reason).toContain('cannot verify compatibility');
112
+ });
113
+ });
114
+
115
+ describe('runSystemUpdate', () => {
116
+ test('bails before any work when audit is BLOCKED', async () => {
117
+ const blockingAudit: AuditDeps = {
118
+ ...cleanAudit,
119
+ schema: {
120
+ journal: () => ({
121
+ version: '5',
122
+ dialect: 'sqlite',
123
+ entries: [{ idx: 0, tag: '0001_pending', when: 1 }],
124
+ }),
125
+ applied: () => [],
126
+ db: fakeDb,
127
+ },
128
+ };
129
+ const ops = makeOps();
130
+ let upgradeCalls = 0;
131
+ ops.upgrade = async () => {
132
+ upgradeCalls++;
133
+ return { ok: true };
134
+ };
135
+
136
+ const result = await runSystemUpdate({
137
+ audit: blockingAudit,
138
+ graph: buildModuleGraph([]),
139
+ snapshots: new Map(),
140
+ ops,
141
+ selfUpdate: {
142
+ installedVersion: '0.1.5',
143
+ fetcher: async () => '0.1.5',
144
+ updater: async () => ({ ok: true, stderr: '' }),
145
+ },
146
+ progress: capturingProgress(),
147
+ now: () => new Date('2026-04-25T00:00:00Z'),
148
+ idGen: () => 'fixed-id',
149
+ });
150
+
151
+ expect(result.ok).toBe(false);
152
+ expect(result.audit.verdict).toBe('BLOCKED');
153
+ expect(result.modules).toEqual([]);
154
+ expect(upgradeCalls).toBe(0);
155
+ });
156
+
157
+ test('happy path: clean audit + one drifting module → backup, upgrade, deploy, health, done', async () => {
158
+ const ops = makeOps();
159
+ const calls: string[] = [];
160
+ ops.backup = async (id) => {
161
+ calls.push(`backup:${id}`);
162
+ return { ok: true };
163
+ };
164
+ ops.upgrade = async (id) => {
165
+ calls.push(`upgrade:${id}`);
166
+ return { ok: true };
167
+ };
168
+ ops.deploy = async (id) => {
169
+ calls.push(`deploy:${id}`);
170
+ return { ok: true };
171
+ };
172
+ ops.health = async (id) => {
173
+ calls.push(`health:${id}`);
174
+ return { status: 'healthy' };
175
+ };
176
+ ops.snapshotCeliloDb = async () => {
177
+ calls.push('snapshot-celilo-db');
178
+ return { ok: true };
179
+ };
180
+
181
+ const graph = buildModuleGraph([makeManifest('caddy', { provides: ['public_web'] })]);
182
+ const snapshots = new Map<string, ModuleSnapshot>([
183
+ ['caddy', makeSnapshot('caddy', { installed: '1.0.0', latest: '1.1.0' })],
184
+ ]);
185
+
186
+ const result = await runSystemUpdate({
187
+ audit: cleanAudit,
188
+ graph,
189
+ snapshots,
190
+ ops,
191
+ selfUpdate: {
192
+ installedVersion: '0.1.5',
193
+ fetcher: async () => '0.1.5',
194
+ updater: async () => ({ ok: true, stderr: '' }),
195
+ },
196
+ progress: capturingProgress(),
197
+ now: () => new Date('2026-04-25T00:00:00Z'),
198
+ idGen: () => 'fixed-id',
199
+ });
200
+
201
+ expect(result.ok).toBe(true);
202
+ expect(result.backupsCreated).toBe(true);
203
+ expect(result.modules).toHaveLength(1);
204
+ expect(result.modules[0].step).toBe('done');
205
+ expect(calls).toEqual([
206
+ 'snapshot-celilo-db',
207
+ 'backup:caddy',
208
+ 'upgrade:caddy',
209
+ 'deploy:caddy',
210
+ 'health:caddy',
211
+ ]);
212
+ });
213
+
214
+ test('skips backup when --no-backup is set', async () => {
215
+ const ops = makeOps();
216
+ let backupCalls = 0;
217
+ let snapCalls = 0;
218
+ ops.backup = async () => {
219
+ backupCalls++;
220
+ return { ok: true };
221
+ };
222
+ ops.snapshotCeliloDb = async () => {
223
+ snapCalls++;
224
+ return { ok: true };
225
+ };
226
+
227
+ await runSystemUpdate({
228
+ audit: cleanAudit,
229
+ graph: buildModuleGraph([makeManifest('caddy')]),
230
+ snapshots: new Map([['caddy', makeSnapshot('caddy')]]),
231
+ ops,
232
+ selfUpdate: {
233
+ installedVersion: '0.1.5',
234
+ fetcher: async () => '0.1.5',
235
+ updater: async () => ({ ok: true, stderr: '' }),
236
+ },
237
+ progress: capturingProgress(),
238
+ now: () => new Date('2026-04-25T00:00:00Z'),
239
+ idGen: () => 'id',
240
+ noBackup: true,
241
+ });
242
+
243
+ expect(backupCalls).toBe(0);
244
+ expect(snapCalls).toBe(0);
245
+ });
246
+
247
+ test('refuses to proceed when celilo-db snapshot fails', async () => {
248
+ const ops = makeOps({
249
+ snapshotCeliloDb: async () => ({ ok: false, error: 'disk full' }),
250
+ });
251
+ let upgradeCalls = 0;
252
+ ops.upgrade = async () => {
253
+ upgradeCalls++;
254
+ return { ok: true };
255
+ };
256
+
257
+ const result = await runSystemUpdate({
258
+ audit: cleanAudit,
259
+ graph: buildModuleGraph([makeManifest('caddy')]),
260
+ snapshots: new Map([['caddy', makeSnapshot('caddy')]]),
261
+ ops,
262
+ selfUpdate: {
263
+ installedVersion: '0.1.5',
264
+ fetcher: async () => '0.1.5',
265
+ updater: async () => ({ ok: true, stderr: '' }),
266
+ },
267
+ progress: capturingProgress(),
268
+ now: () => new Date('2026-04-25T00:00:00Z'),
269
+ idGen: () => 'id',
270
+ });
271
+
272
+ expect(result.ok).toBe(false);
273
+ expect(result.modules).toEqual([]);
274
+ expect(upgradeCalls).toBe(0);
275
+ });
276
+
277
+ test('module deploy failure → state.step = failed, downstream consumers skipped', async () => {
278
+ const graph = buildModuleGraph([
279
+ makeManifest('iptables', { provides: ['firewall'] }),
280
+ makeManifest('caddy', { requires: ['firewall'], provides: ['public_web'] }),
281
+ makeManifest('lunacycle', { requires: ['public_web'] }),
282
+ ]);
283
+
284
+ const snapshots = new Map<string, ModuleSnapshot>([
285
+ [
286
+ 'iptables',
287
+ makeSnapshot('iptables', {
288
+ installed: '1.0.0',
289
+ latest: '1.1.0',
290
+ provides: { firewall: '1.0.0' },
291
+ }),
292
+ ],
293
+ [
294
+ 'caddy',
295
+ makeSnapshot('caddy', {
296
+ installed: '1.0.0',
297
+ latest: '1.1.0',
298
+ provides: { public_web: '2.0.0' },
299
+ // caddy's NEW manifest still requires firewall@1.x → compatible w/ old iptables
300
+ requires: { firewall: '1.0.0' },
301
+ }),
302
+ ],
303
+ [
304
+ 'lunacycle',
305
+ makeSnapshot('lunacycle', {
306
+ installed: '1.0.0',
307
+ latest: '1.1.0',
308
+ // lunacycle's NEW manifest requires public_web@2.x → caddy still satisfies it
309
+ requires: { public_web: '2.0.0' },
310
+ }),
311
+ ],
312
+ ]);
313
+
314
+ let iptablesDeployed = false;
315
+ const ops = makeOps({
316
+ deploy: async (id) => {
317
+ if (id === 'iptables') {
318
+ iptablesDeployed = true;
319
+ return { ok: false, error: 'ssh refused' };
320
+ }
321
+ return { ok: true };
322
+ },
323
+ });
324
+
325
+ const result = await runSystemUpdate({
326
+ audit: cleanAudit,
327
+ graph,
328
+ snapshots,
329
+ ops,
330
+ selfUpdate: {
331
+ installedVersion: '0.1.5',
332
+ fetcher: async () => '0.1.5',
333
+ updater: async () => ({ ok: true, stderr: '' }),
334
+ },
335
+ progress: capturingProgress(),
336
+ now: () => new Date('2026-04-25T00:00:00Z'),
337
+ idGen: () => 'id',
338
+ });
339
+
340
+ expect(iptablesDeployed).toBe(true);
341
+ expect(result.ok).toBe(false);
342
+ const byId = new Map(result.modules.map((m) => [m.moduleId, m]));
343
+ expect(byId.get('iptables')?.step).toBe('failed');
344
+ // Caddy's new manifest requires firewall@1.x, iptables still has firewall@1.0.0
345
+ // installed → compatible, caddy proceeds.
346
+ expect(byId.get('caddy')?.step).toBe('done');
347
+ // Lunacycle requires public_web@2 from caddy which is now upgraded → done.
348
+ expect(byId.get('lunacycle')?.step).toBe('done');
349
+ });
350
+
351
+ test('consumer with incompatible new requirements is skipped on upstream failure', async () => {
352
+ const graph = buildModuleGraph([
353
+ makeManifest('iptables', { provides: ['firewall'] }),
354
+ makeManifest('caddy', { requires: ['firewall'] }),
355
+ ]);
356
+ const snapshots = new Map<string, ModuleSnapshot>([
357
+ [
358
+ 'iptables',
359
+ makeSnapshot('iptables', {
360
+ installed: '1.0.0',
361
+ latest: '2.0.0',
362
+ provides: { firewall: '1.0.0' }, // installed firewall@1
363
+ }),
364
+ ],
365
+ [
366
+ 'caddy',
367
+ makeSnapshot('caddy', {
368
+ installed: '1.0.0',
369
+ latest: '2.0.0',
370
+ requires: { firewall: '2.0.0' }, // caddy's NEW manifest needs firewall@2
371
+ }),
372
+ ],
373
+ ]);
374
+
375
+ const ops = makeOps({
376
+ deploy: async (id) => (id === 'iptables' ? { ok: false, error: 'oops' } : { ok: true }),
377
+ });
378
+
379
+ const result = await runSystemUpdate({
380
+ audit: cleanAudit,
381
+ graph,
382
+ snapshots,
383
+ ops,
384
+ selfUpdate: {
385
+ installedVersion: '0.1.5',
386
+ fetcher: async () => '0.1.5',
387
+ updater: async () => ({ ok: true, stderr: '' }),
388
+ },
389
+ progress: capturingProgress(),
390
+ now: () => new Date('2026-04-25T00:00:00Z'),
391
+ idGen: () => 'id',
392
+ });
393
+
394
+ const caddy = result.modules.find((m) => m.moduleId === 'caddy');
395
+ expect(caddy?.step).toBe('skipped');
396
+ expect(caddy?.skipReason).toContain('blocked by iptables');
397
+ });
398
+
399
+ test('emits progress events in the right order', async () => {
400
+ const progress = capturingProgress();
401
+ await runSystemUpdate({
402
+ audit: cleanAudit,
403
+ graph: buildModuleGraph([makeManifest('caddy')]),
404
+ snapshots: new Map([
405
+ ['caddy', makeSnapshot('caddy', { installed: '1.0.0', latest: '1.1.0' })],
406
+ ]),
407
+ ops: makeOps(),
408
+ selfUpdate: {
409
+ installedVersion: '0.1.5',
410
+ fetcher: async () => '0.1.5',
411
+ updater: async () => ({ ok: true, stderr: '' }),
412
+ },
413
+ progress,
414
+ now: () => new Date('2026-04-25T00:00:00Z'),
415
+ idGen: () => 'id',
416
+ });
417
+
418
+ const kinds = progress.events.map((e) => e.kind);
419
+ expect(kinds[0]).toBe('plan');
420
+ expect(kinds).toContain('self-update-skipped'); // already-current
421
+ expect(kinds).toContain('module-start');
422
+ expect(kinds.includes('module-step')).toBe(true);
423
+ expect(kinds[kinds.length - 1]).toBe('run-done');
424
+ });
425
+
426
+ test('preserves the same updateId across self-update, snapshot, and module-backup calls', async () => {
427
+ const seenIds = new Set<string>();
428
+ const ops = makeOps({
429
+ backup: async (_id, updateId) => {
430
+ seenIds.add(updateId);
431
+ return { ok: true };
432
+ },
433
+ snapshotCeliloDb: async (updateId) => {
434
+ seenIds.add(updateId);
435
+ return { ok: true };
436
+ },
437
+ });
438
+
439
+ const result = await runSystemUpdate({
440
+ audit: cleanAudit,
441
+ graph: buildModuleGraph([
442
+ makeManifest('iptables', { provides: ['firewall'] }),
443
+ makeManifest('caddy', { requires: ['firewall'] }),
444
+ ]),
445
+ snapshots: new Map([
446
+ ['iptables', makeSnapshot('iptables')],
447
+ ['caddy', makeSnapshot('caddy')],
448
+ ]),
449
+ ops,
450
+ selfUpdate: {
451
+ installedVersion: '0.1.5',
452
+ fetcher: async () => '0.1.5',
453
+ updater: async () => ({ ok: true, stderr: '' }),
454
+ },
455
+ progress: capturingProgress(),
456
+ now: () => new Date('2026-04-25T00:00:00Z'),
457
+ idGen: () => 'shared-id',
458
+ });
459
+
460
+ expect(result.updateId).toBe('shared-id');
461
+ expect([...seenIds]).toEqual(['shared-id']);
462
+ });
463
+ });