@celilo/cli 2.2.1 → 3.0.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.
Files changed (173) hide show
  1. package/CELILO_CORE_MODULES.md +2 -1
  2. package/CELILO_SUBSYSTEMS.md +1 -1
  3. package/README.md +1 -1
  4. package/drizzle/0032_module_jail_policies.sql +28 -0
  5. package/drizzle/0033_build_bus_hook_runs.sql +37 -0
  6. package/drizzle/meta/_journal.json +15 -1
  7. package/package.json +3 -3
  8. package/src/__integration__/container-services-cli.integration.test.ts +1 -1
  9. package/src/api/serve.ts +13 -1
  10. package/src/api/sessions.test.ts +3 -3
  11. package/src/api-clients/proxmox.ts +10 -7
  12. package/src/cli/backup-rename.test.ts +3 -3
  13. package/src/cli/cli.test.ts +1 -1
  14. package/src/cli/commands/events.test.ts +2 -2
  15. package/src/cli/commands/firewall-interface-list.test.ts +2 -2
  16. package/src/cli/commands/machine-list.test.ts +57 -0
  17. package/src/cli/commands/machine-list.ts +35 -4
  18. package/src/cli/commands/module-health.test.ts +35 -0
  19. package/src/cli/commands/module-health.ts +12 -3
  20. package/src/cli/commands/module-import-registry.test.ts +1 -1
  21. package/src/cli/commands/module-jail.test.ts +242 -0
  22. package/src/cli/commands/module-jail.ts +227 -0
  23. package/src/cli/commands/module-list-jail.test.ts +136 -0
  24. package/src/cli/commands/module-list.ts +30 -3
  25. package/src/cli/commands/module-publish.test.ts +28 -6
  26. package/src/cli/commands/module-publish.ts +14 -12
  27. package/src/cli/commands/module-update.test.ts +22 -7
  28. package/src/cli/commands/module-update.ts +73 -18
  29. package/src/cli/commands/module-upgrade-gate.test.ts +154 -0
  30. package/src/cli/commands/module-upgrade.test.ts +115 -1
  31. package/src/cli/commands/module-upgrade.ts +66 -4
  32. package/src/cli/commands/module-verify.test.ts +1 -1
  33. package/src/cli/commands/publish/execute.ts +4 -1
  34. package/src/cli/commands/publish/helpers.ts +4 -3
  35. package/src/cli/commands/publish/index.ts +23 -1
  36. package/src/cli/commands/publish/module-registry.test.ts +24 -1
  37. package/src/cli/commands/publish/module-registry.ts +23 -4
  38. package/src/cli/commands/publish/plan.test.ts +64 -0
  39. package/src/cli/commands/publish/plan.ts +52 -19
  40. package/src/cli/commands/publish/types.ts +20 -0
  41. package/src/cli/commands/registry-owner.test.ts +1 -1
  42. package/src/cli/commands/registry-token.test.ts +1 -1
  43. package/src/cli/commands/subscribers-install-daemon.test.ts +44 -0
  44. package/src/cli/commands/subscribers-install-daemon.ts +107 -0
  45. package/src/cli/commands/subscribers-serve.test.ts +22 -0
  46. package/src/cli/commands/subscribers-serve.ts +22 -4
  47. package/src/cli/commands/system-audit.ts +34 -11
  48. package/src/cli/commands/system-doctor-remediation-gate.test.ts +295 -0
  49. package/src/cli/commands/system-doctor.test.ts +76 -10
  50. package/src/cli/commands/system-doctor.ts +34 -3
  51. package/src/cli/commands/system-init-deprecation.test.ts +1 -1
  52. package/src/cli/commands/system-update.ts +16 -6
  53. package/src/cli/completion.ts +18 -3
  54. package/src/cli/flag-surface-gate.test.ts +279 -0
  55. package/src/cli/fuel-gauge.ts +12 -4
  56. package/src/cli/index.ts +80 -2
  57. package/src/cli/json-output.test.ts +81 -0
  58. package/src/cli/parser.test.ts +37 -1
  59. package/src/cli/restore-command.test.ts +3 -3
  60. package/src/cli/restore-migration-failure.test.ts +2 -2
  61. package/src/cli/tui/audit-state.ts +2 -0
  62. package/src/cli/types.ts +9 -0
  63. package/src/config/paths.test.ts +19 -19
  64. package/src/db/client.test.ts +46 -1
  65. package/src/db/client.ts +26 -0
  66. package/src/db/schema.ts +63 -0
  67. package/src/hooks/broker.test.ts +9 -3
  68. package/src/hooks/capability-loader-firewall.test.ts +9 -1
  69. package/src/hooks/capability-loader.ts +8 -0
  70. package/src/hooks/executor.test.ts +91 -14
  71. package/src/hooks/executor.ts +31 -1
  72. package/src/hooks/hook-jail-toolchain-reach.test.ts +8 -3
  73. package/src/hooks/hook-jail-unreachability.test.ts +27 -20
  74. package/src/hooks/hook-trespass.test.ts +22 -6
  75. package/src/hooks/jail.test.ts +105 -17
  76. package/src/hooks/jail.ts +47 -10
  77. package/src/hooks/run-named-hook.ts +19 -16
  78. package/src/hooks/test-fixtures/artifact-writing-hook.ts +0 -1
  79. package/src/hooks/test-fixtures/capability-calling-hook.ts +20 -13
  80. package/src/hooks/test-fixtures/jail-probe-hook.ts +9 -1
  81. package/src/hooks/test-fixtures/jail-toolchain-hook.ts +7 -2
  82. package/src/hooks/test-fixtures/runaway-hook.ts +0 -1
  83. package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +0 -1
  84. package/src/hooks/test-fixtures/silent-hook.ts +0 -1
  85. package/src/hooks/test-fixtures/store-writing-hook.ts +20 -11
  86. package/src/hooks/test-fixtures/success-hook.ts +4 -4
  87. package/src/manifest/contracts/v1.ts +32 -14
  88. package/src/manifest/json-schema-roundtrip.test.ts +1 -1
  89. package/src/manifest/schema.ts +66 -16
  90. package/src/manifest/validate.test.ts +47 -0
  91. package/src/policy/capability-shape-baseline.ts +63 -21
  92. package/src/policy/capability-shape-drift.test.ts +53 -1
  93. package/src/policy/capability-shape.test.ts +105 -0
  94. package/src/policy/capability-shape.ts +283 -2
  95. package/src/policy/module-script-scan.ts +52 -68
  96. package/src/policy/no-hand-built-ssh.test.ts +5 -1
  97. package/src/policy/no-swallowed-refusal.test.ts +14 -14
  98. package/src/registry/client.test.ts +67 -2
  99. package/src/registry/client.ts +7 -7
  100. package/src/secrets/storage.test.ts +70 -4
  101. package/src/secrets/storage.ts +71 -1
  102. package/src/services/alerting/keys.test.ts +4 -0
  103. package/src/services/alerting/keys.ts +12 -2
  104. package/src/services/audit/health.test.ts +155 -2
  105. package/src/services/audit/health.ts +76 -2
  106. package/src/services/audit/index.test.ts +23 -1
  107. package/src/services/audit/index.ts +8 -2
  108. package/src/services/audit/interface-classification.test.ts +16 -5
  109. package/src/services/audit/interface-classification.ts +25 -2
  110. package/src/services/audit/jail-exemptions.test.ts +42 -0
  111. package/src/services/audit/jail-exemptions.ts +44 -0
  112. package/src/services/audit/module-integrity.test.ts +23 -1
  113. package/src/services/audit/module-integrity.ts +7 -2
  114. package/src/services/audit/module-versions.ts +5 -1
  115. package/src/services/audit/public-dns.test.ts +20 -0
  116. package/src/services/audit/public-dns.ts +7 -2
  117. package/src/services/audit/recurrence-gate.test.ts +225 -0
  118. package/src/services/audit/trusted-sources.test.ts +17 -0
  119. package/src/services/audit/trusted-sources.ts +21 -0
  120. package/src/services/audit/types.ts +2 -1
  121. package/src/services/backup-create.ts +9 -0
  122. package/src/services/backup-envelope-roundtrip.test.ts +1 -1
  123. package/src/services/backup-in-flight-refusal.test.ts +1 -1
  124. package/src/services/build-bus/hook-dispatch-executor.test.ts +269 -0
  125. package/src/services/build-bus/hook-dispatch-mgmt.test.ts +105 -105
  126. package/src/services/build-bus/hook-dispatch-path.test.ts +95 -0
  127. package/src/services/build-bus/hook-dispatch.test.ts +86 -116
  128. package/src/services/build-bus/hook-dispatch.ts +99 -121
  129. package/src/services/build-bus/hook-dispatcher.ts +143 -17
  130. package/src/services/build-bus/receiver-daemon.test.ts +189 -0
  131. package/src/services/build-bus/receiver-daemon.ts +355 -0
  132. package/src/services/build-bus/self-update.ts +156 -0
  133. package/src/services/bus-ensure-flow.test.ts +1 -1
  134. package/src/services/bus-interview-park.test.ts +2 -2
  135. package/src/services/bus-interview.test.ts +2 -2
  136. package/src/services/bus-secret-flow.test.ts +1 -1
  137. package/src/services/capability-compat.test.ts +90 -0
  138. package/src/services/capability-compat.ts +128 -0
  139. package/src/services/celilo-events.test.ts +1 -1
  140. package/src/services/celilo-mgmt-hooks.test.ts +23 -5
  141. package/src/services/container-service.test.ts +1 -1
  142. package/src/services/cross-module-read.test.ts +2 -2
  143. package/src/services/deploy-preflight.ts +7 -0
  144. package/src/services/deploy-terraform.ts +38 -1
  145. package/src/services/deployed-systems.ts +1 -1
  146. package/src/services/events-daemon.test.ts +57 -0
  147. package/src/services/events-daemon.ts +76 -0
  148. package/src/services/firewall-reach.ts +21 -8
  149. package/src/services/fleet-checks.test.ts +159 -4
  150. package/src/services/fleet-checks.ts +206 -3
  151. package/src/services/health-runner.test.ts +87 -2
  152. package/src/services/health-runner.ts +83 -16
  153. package/src/services/infrastructure-selector.test.ts +1 -1
  154. package/src/services/jail-exemptions.test.ts +125 -0
  155. package/src/services/jail-exemptions.ts +81 -0
  156. package/src/services/machine-pool.test.ts +1 -1
  157. package/src/services/module-deploy-prune.test.ts +89 -0
  158. package/src/services/module-deploy.ts +84 -129
  159. package/src/services/module-subscriptions.test.ts +2 -2
  160. package/src/services/module-types-drift.test.ts +1 -1
  161. package/src/services/module-validator/capability-versions.test.ts +13 -2
  162. package/src/services/network-discovery.test.ts +64 -1
  163. package/src/services/network-discovery.ts +30 -5
  164. package/src/services/responder-probe.test.ts +1 -1
  165. package/src/services/restore-from-file.test.ts +4 -4
  166. package/src/services/restore-preflight.test.ts +1 -1
  167. package/src/services/ssh-key-manager.test.ts +2 -2
  168. package/src/services/system-state-stage.test.ts +2 -2
  169. package/src/services/terraform-safety.test.ts +83 -0
  170. package/src/services/terraform-safety.ts +53 -0
  171. package/src/services/update/orchestrator.test.ts +3 -1
  172. package/src/test-utils/bus-responder.ts +1 -1
  173. package/tsconfig.json +2 -13
@@ -43,7 +43,7 @@ describe('handleModulePublish — validation', () => {
43
43
  // will skip the validation path it's trying to exercise.
44
44
  const origToken = process.env.CELILO_PUBLISH_TOKEN;
45
45
  const origDataDir = process.env.CELILO_DATA_DIR;
46
- process.env.CELILO_PUBLISH_TOKEN = undefined;
46
+ delete process.env.CELILO_PUBLISH_TOKEN;
47
47
  process.env.CELILO_DB_PATH = `/tmp/test-celilo-no-token-${Date.now()}.db`;
48
48
  process.env.CELILO_DATA_DIR = `/tmp/test-celilo-data-${Date.now()}`;
49
49
  try {
@@ -54,7 +54,7 @@ describe('handleModulePublish — validation', () => {
54
54
  if (origToken !== undefined) process.env.CELILO_PUBLISH_TOKEN = origToken;
55
55
  resetTestDbPath();
56
56
  if (origDataDir !== undefined) process.env.CELILO_DATA_DIR = origDataDir;
57
- else process.env.CELILO_DATA_DIR = undefined;
57
+ else delete process.env.CELILO_DATA_DIR;
58
58
  }
59
59
  });
60
60
 
@@ -99,6 +99,28 @@ describe('handleModulePublish — manifest reading', () => {
99
99
  expect(result.success).toBe(false);
100
100
  if (!result.success) expect(result.error).toContain('manifest.yml missing id or version');
101
101
  });
102
+
103
+ test('a failed module does not stop the sweep — every module is attempted and every failure is named', async () => {
104
+ // celilo#1369: the loop bailed at the first failed module, so modules
105
+ // after it were never attempted (release run 6705 left nine modules
106
+ // unpublished). Three dirs that each fail before any registry access,
107
+ // at three different checks, prove the loop reaches all of them: the
108
+ // returned error must name every dir, not only the first.
109
+ const dirA = join(TEST_DIR, 'a-missing-id');
110
+ const dirB = join(TEST_DIR, 'b-missing-manifest');
111
+ const dirC = join(TEST_DIR, 'c-missing-version');
112
+ for (const d of [dirA, dirB, dirC]) await mkdir(d, { recursive: true });
113
+ await writeFile(join(dirA, 'manifest.yml'), 'version: "1.0.0"\n');
114
+ await writeFile(join(dirC, 'manifest.yml'), 'id: c-module\n');
115
+
116
+ const result = await handleModulePublish([dirA, dirB, dirC], { token: 'tok' });
117
+ expect(result.success).toBe(false);
118
+ if (!result.success) {
119
+ expect(result.error).toContain(dirA);
120
+ expect(result.error).toContain(dirB);
121
+ expect(result.error).toContain(dirC);
122
+ }
123
+ });
102
124
  });
103
125
 
104
126
  // ── Revision auto-detection algorithm ────────────────────────────────────────
@@ -169,7 +191,7 @@ describe('resolveToken — precedence', () => {
169
191
  expect(t).toBe('flag-token');
170
192
  } finally {
171
193
  if (origEnv !== undefined) process.env.CELILO_PUBLISH_TOKEN = origEnv;
172
- else process.env.CELILO_PUBLISH_TOKEN = undefined;
194
+ else delete process.env.CELILO_PUBLISH_TOKEN;
173
195
  resetTestDbPath();
174
196
  }
175
197
  });
@@ -183,7 +205,7 @@ describe('resolveToken — precedence', () => {
183
205
  expect(t).toBe('env-token');
184
206
  } finally {
185
207
  if (origEnv !== undefined) process.env.CELILO_PUBLISH_TOKEN = origEnv;
186
- else process.env.CELILO_PUBLISH_TOKEN = undefined;
208
+ else delete process.env.CELILO_PUBLISH_TOKEN;
187
209
  resetTestDbPath();
188
210
  }
189
211
  });
@@ -193,7 +215,7 @@ describe('resolveToken — precedence', () => {
193
215
  // DB and returns null gracefully.
194
216
  const origEnv = process.env.CELILO_PUBLISH_TOKEN;
195
217
  const origDataDir = process.env.CELILO_DATA_DIR;
196
- process.env.CELILO_PUBLISH_TOKEN = undefined;
218
+ delete process.env.CELILO_PUBLISH_TOKEN;
197
219
  process.env.CELILO_DB_PATH = `/tmp/test-celilo-resolve-empty-${Date.now()}.db`;
198
220
  process.env.CELILO_DATA_DIR = `/tmp/test-celilo-data-empty-${Date.now()}`;
199
221
  try {
@@ -203,7 +225,7 @@ describe('resolveToken — precedence', () => {
203
225
  if (origEnv !== undefined) process.env.CELILO_PUBLISH_TOKEN = origEnv;
204
226
  resetTestDbPath();
205
227
  if (origDataDir !== undefined) process.env.CELILO_DATA_DIR = origDataDir;
206
- else process.env.CELILO_DATA_DIR = undefined;
228
+ else delete process.env.CELILO_DATA_DIR;
207
229
  }
208
230
  });
209
231
  });
@@ -385,23 +385,25 @@ export async function handleModulePublish(
385
385
  for (const moduleDir of args) {
386
386
  const outcome = await publishOneModule(moduleDir, opts);
387
387
  outcomes.push(outcome);
388
- if (outcome.status === 'failed') {
389
- // Print summary so far, then bail. Publishes are non-destructive so
390
- // re-running after fixing the underlying issue picks up where we left
391
- // off (already-published modules are skipped at the registry level
392
- // when --revision is explicit, and auto-revision just picks the next
393
- // slot — nothing duplicates).
394
- printSummary(outcomes);
395
- return {
396
- success: false,
397
- error: outcome.message,
398
- };
399
- }
400
388
  console.log(outcome.message);
401
389
  }
402
390
 
391
+ // celilo#1369: every module is attempted even after a failure, and every
392
+ // failure is reported together at the end. A publish is non-destructive so
393
+ // re-running after fixing the underlying issue picks up where we left off
394
+ // (already-published modules are skipped at the registry level when
395
+ // --revision is explicit, and auto-revision just picks the next slot —
396
+ // nothing duplicates).
403
397
  printSummary(outcomes);
404
398
 
399
+ const failed = outcomes.filter((o) => o.status === 'failed');
400
+ if (failed.length > 0) {
401
+ return {
402
+ success: false,
403
+ error: failed.map((o) => o.message).join('\n'),
404
+ };
405
+ }
406
+
405
407
  if (args.length === 1) {
406
408
  // Backward-compat single-module shape — keep the existing message/data
407
409
  // contract for callers (and tests) that depend on it. By this point no
@@ -107,12 +107,15 @@ description: fixture
107
107
  // Seed the modules table with the "currently installed" record.
108
108
  // We deliberately put a registry-style version string in here so
109
109
  // displayVersion-less upgrades preserve the previousVersion field.
110
+ // celilo#1363: updatedAt is seeded frozen so the success assertions can
111
+ // prove the stamp actually happens whenever the version moves.
110
112
  db.insert(modules)
111
113
  .values({
112
114
  id: 'testmod',
113
115
  name: 'Test Module',
114
116
  sourcePath: installedDir,
115
117
  version: '1.0.0+5',
118
+ updatedAt: new Date('2026-04-25T16:26:31Z'),
116
119
  manifestData: {
117
120
  celilo_contract: '1.0',
118
121
  id: 'testmod',
@@ -126,7 +129,7 @@ description: fixture
126
129
  afterEach(() => {
127
130
  rmSync(tempDir, { recursive: true, force: true });
128
131
  resetTestDbPath();
129
- process.env.CELILO_ORIGINAL_CWD = undefined;
132
+ delete process.env.CELILO_ORIGINAL_CWD;
130
133
  });
131
134
 
132
135
  test('returns previousVersion + newVersion in the success outcome', async () => {
@@ -153,6 +156,18 @@ description: fixture
153
156
  expect(row.version).toBe('1.0.0+6');
154
157
  });
155
158
 
159
+ // celilo#1363: the version moved (1.0.5+5 → +6) while updatedAt stayed at
160
+ // 2026-04-25, so the audit read the module as untouched and fine. The stamp
161
+ // must happen in updateOne itself, where every version write lives.
162
+ test('writing a new version stamps updatedAt', async () => {
163
+ const result = await updateOne(srcDir, db, {}, { quiet: true, displayVersion: '1.0.0+6' });
164
+ expect(result.status).toBe('success');
165
+
166
+ const row = db.select().from(modules).all()[0];
167
+ expect(row.version).toBe('1.0.0+6');
168
+ expect(row.updatedAt?.getTime()).toBeGreaterThan(new Date('2026-04-25T16:26:31Z').getTime());
169
+ });
170
+
156
171
  test('quiet=true suppresses the per-call log lines (caller renders its own)', async () => {
157
172
  // We can't easily intercept the log helpers' output without adding test
158
173
  // hooks, so instead we exercise that the call simply succeeds and
@@ -257,7 +272,7 @@ subscriptions:
257
272
  bus.close();
258
273
  }
259
274
  } finally {
260
- process.env.EVENT_BUS_DB = undefined;
275
+ delete process.env.EVENT_BUS_DB;
261
276
  }
262
277
  });
263
278
  });
@@ -315,8 +330,8 @@ describe('registry sweep — an unanswered breaking update is not a decline', ()
315
330
  server.stop(true);
316
331
  rmSync(tempDir, { recursive: true, force: true });
317
332
  resetTestDbPath();
318
- process.env.CELILO_ORIGINAL_CWD = undefined;
319
- process.env.EVENT_BUS_DB = undefined;
333
+ delete process.env.CELILO_ORIGINAL_CWD;
334
+ delete process.env.EVENT_BUS_DB;
320
335
  });
321
336
 
322
337
  test('reports it as unanswered, never as declined, and fails the sweep', async () => {
@@ -389,7 +404,7 @@ description: fixture
389
404
  afterEach(() => {
390
405
  rmSync(tempDir, { recursive: true, force: true });
391
406
  resetTestDbPath();
392
- process.env.CELILO_ORIGINAL_CWD = undefined;
407
+ delete process.env.CELILO_ORIGINAL_CWD;
393
408
  });
394
409
 
395
410
  test('refuses by name, and writes NOTHING', async () => {
@@ -501,7 +516,7 @@ describe('updateOne — an update that fails partway leaves the install untouche
501
516
  afterEach(() => {
502
517
  rmSync(tempDir, { recursive: true, force: true });
503
518
  resetTestDbPath();
504
- process.env.CELILO_ORIGINAL_CWD = undefined;
519
+ delete process.env.CELILO_ORIGINAL_CWD;
505
520
  });
506
521
 
507
522
  test('a failed update leaves the installed tree byte-identical', async () => {
@@ -596,7 +611,7 @@ describe('updateOne — a .netapp package source', () => {
596
611
  afterEach(() => {
597
612
  rmSync(tempDir, { recursive: true, force: true });
598
613
  resetTestDbPath();
599
- process.env.CELILO_ORIGINAL_CWD = undefined;
614
+ delete process.env.CELILO_ORIGINAL_CWD;
600
615
  });
601
616
 
602
617
  // ce-h4of: the temp-dir cleanup ran BEFORE the staged swap, and
@@ -52,7 +52,7 @@ type UpdateOutcome =
52
52
  | { status: 'failed'; moduleId: string; error: string }
53
53
  // `skipped` means the path expanded from a glob but isn't an
54
54
  // upgradable target — either no manifest at all (probably a non-
55
- // module sibling like `modules/archive/`) or a real module that
55
+ // module sibling like `modules/__archive__/`) or a real module that
56
56
  // isn't installed in this celilo. Treated as a soft pass so
57
57
  // `celilo module update modules/*` does what users expect.
58
58
  | { status: 'skipped'; moduleId: string; reason: string };
@@ -115,34 +115,86 @@ export function classifyVersionChange(installed: string, latest: string): Versio
115
115
  }
116
116
 
117
117
  /**
118
- * Download a module package from the registry into a temp file and run
119
- * the standard updateOne path against it. Cleans the temp file in a
120
- * finally block so a mid-flight failure doesn't leak a tar.zst on disk.
118
+ * Download a registry package into a temp file. Shared by `fetchAndUpdate`
119
+ * (the full update path) and `fetchTargetManifest` (the upgrade gate that
120
+ * must read the TARGET manifest before it mutates anything). The index entry
121
+ * carries the published sha256; absence is tolerated, matching
122
+ * `fetchAndUpdate`'s original tolerance for entries predating cksum.
121
123
  */
122
- export async function fetchAndUpdate(
124
+ export async function downloadRegistryPackage(
123
125
  client: RegistryClient,
124
126
  moduleId: string,
125
127
  version: string,
126
- db: ReturnType<typeof getDb>,
127
- flags: Record<string, string | boolean>,
128
- ): Promise<UpdateOutcome> {
128
+ ): Promise<{ ok: true; tmpPath: string } | { ok: false; error: string }> {
129
129
  const tmpPath = join(tmpdir(), `${moduleId}-${version}-${Date.now()}.netapp`);
130
130
  try {
131
- // The index entry carries the published sha256. Look it up here rather than
132
- // thread it through five call sites, so a short download is caught as a bad
133
- // transfer instead of surfacing later as "zlib: unexpected end of file".
134
- // Absence is tolerated: an entry predating cksum still downloads.
135
131
  const entries = await client.getIndex(moduleId).catch(() => []);
136
132
  const cksum = entries.find((entry) => entry.vers === version)?.cksum;
137
133
  const pkgData = await client.download(moduleId, version, cksum);
138
134
  await Bun.write(tmpPath, pkgData);
135
+ return { ok: true, tmpPath };
139
136
  } catch (err) {
140
137
  return {
141
- status: 'failed',
142
- moduleId,
138
+ ok: false,
143
139
  error: `Download failed: ${err instanceof Error ? err.message : String(err)}`,
144
140
  };
145
141
  }
142
+ }
143
+
144
+ /**
145
+ * Read the manifest of a registry version WITHOUT installing it: download,
146
+ * extract to a temp dir, parse manifest.yml, clean up. Used by the upgrade
147
+ * path to gate on the target's capability requirements before any state
148
+ * changes (celilo#1361). Failure to fetch or parse is not itself fatal — the
149
+ * caller proceeds to `fetchAndUpdate`, which reports the same failure through
150
+ * its own channel.
151
+ */
152
+ export async function fetchTargetManifest(
153
+ client: RegistryClient,
154
+ moduleId: string,
155
+ version: string,
156
+ ): Promise<{ ok: true; manifest: ModuleManifest } | { ok: false }> {
157
+ const downloaded = await downloadRegistryPackage(client, moduleId, version);
158
+ if (!downloaded.ok) return { ok: false };
159
+ try {
160
+ const extractResult = await extractPackage(downloaded.tmpPath);
161
+ if (!extractResult.success || !extractResult.tempDir) return { ok: false };
162
+ try {
163
+ const manifestPath = join(extractResult.tempDir, 'manifest.yml');
164
+ if (!existsSync(manifestPath)) return { ok: false };
165
+ const parsed = parseYaml(readFileSync(manifestPath, 'utf-8'));
166
+ return { ok: true, manifest: ModuleManifestSchema.parse(parsed) };
167
+ } finally {
168
+ await cleanupTempDir(extractResult.tempDir);
169
+ }
170
+ } catch {
171
+ return { ok: false };
172
+ } finally {
173
+ try {
174
+ await unlink(downloaded.tmpPath);
175
+ } catch {}
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Download a module package from the registry into a temp file and run
181
+ * the standard updateOne path against it. Cleans the temp file in a
182
+ * finally block so a mid-flight failure doesn't leak a tar.zst on disk.
183
+ */
184
+ export async function fetchAndUpdate(
185
+ client: RegistryClient,
186
+ moduleId: string,
187
+ version: string,
188
+ db: ReturnType<typeof getDb>,
189
+ flags: Record<string, string | boolean>,
190
+ ): Promise<UpdateOutcome> {
191
+ // The download (index lookup, cksum verification, temp write) is shared
192
+ // with `fetchTargetManifest`, which reads the same package before this
193
+ // update path ever runs.
194
+ const downloaded = await downloadRegistryPackage(client, moduleId, version);
195
+ if (!downloaded.ok) {
196
+ return { status: 'failed', moduleId, error: downloaded.error };
197
+ }
146
198
  try {
147
199
  // Registry packages are pre-verified at publish time; skip the
148
200
  // signature check here to match `module import`'s registry path.
@@ -153,14 +205,14 @@ export async function fetchAndUpdate(
153
205
  // log line — without it, output would say "v1.0.0 → v1.0.0" because
154
206
  // the manifest semver doesn't include the +N revision.
155
207
  return await updateOne(
156
- tmpPath,
208
+ downloaded.tmpPath,
157
209
  db,
158
210
  { ...flags, 'skip-verify': true },
159
211
  { quiet: true, displayVersion: version },
160
212
  );
161
213
  } finally {
162
214
  try {
163
- await unlink(tmpPath);
215
+ await unlink(downloaded.tmpPath);
164
216
  } catch {}
165
217
  }
166
218
  }
@@ -475,12 +527,15 @@ export async function updateOne(
475
527
 
476
528
  // Update manifest in database. We persist the display version (with
477
529
  // +N when known) so subsequent `module list` / `module update` calls
478
- // see the same version string the registry reported.
530
+ // see the same version string the registry reported. updatedAt moves with
531
+ // the version: a frozen timestamp beside a new version read as "nothing
532
+ // happened here" to the audit (celilo#1363).
479
533
  db.update(modules)
480
534
  .set({
481
535
  manifestData: newManifest as unknown as Record<string, unknown>,
482
536
  version: newVersion,
483
537
  name: newManifest.name,
538
+ updatedAt: new Date(),
484
539
  })
485
540
  .where(eq(modules.id, moduleId))
486
541
  .run();
@@ -557,7 +612,7 @@ export async function handleModuleUpdate(
557
612
  );
558
613
 
559
614
  // Skips that fall under a wildcard expansion (e.g. modules/* picking
560
- // up `modules/archive/`) shouldn't even be mentioned — they're not
615
+ // up `modules/__archive__/`) shouldn't even be mentioned — they're not
561
616
  // signal. Skips for "module not installed" ARE signal because the
562
617
  // user explicitly named the path; surface those.
563
618
  const meaningfulSkips = skipped.filter((r) => !r.reason.startsWith('not a module directory'));
@@ -0,0 +1,154 @@
1
+ /**
2
+ * The registry-poll capability gate (celilo#1361): `module upgrade` reads the
3
+ * TARGET version's manifest BEFORE updating anything, and defers — without
4
+ * changing any state — when the deployed capability providers cannot serve
5
+ * its requirements.
6
+ *
7
+ * celilo-website burned six release revisions (+1 through +6) on exactly
8
+ * this: every poll attempt updated the stored version, failed in caddy's
9
+ * publish hook ("sourceDir is required" against a provider built before
10
+ * b67c9423), recorded the new version beside the old VERIFIED state, and
11
+ * re-triggered on the next tick. The gate makes that sequence impossible.
12
+ *
13
+ * The RegistryClient is spied (the established pattern in
14
+ * `module-import-registry.test.ts`); the download returns a REAL tar package
15
+ * carrying only a manifest, which is all `fetchTargetManifest` reads.
16
+ */
17
+
18
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
19
+ import type { Mock } from 'bun:test';
20
+ import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
21
+ import { writeFile } from 'node:fs/promises';
22
+ import { tmpdir } from 'node:os';
23
+ import { join } from 'node:path';
24
+ import { create as tarCreate } from 'tar';
25
+ import { getDb } from '../../db/client';
26
+ import { capabilities, modules } from '../../db/schema';
27
+ import type { IndexEntry } from '../../registry/client';
28
+ import { RegistryClient } from '../../registry/client';
29
+ import { resetTestDbPath } from '../../test-utils/db-path';
30
+ import { handleModuleUpgrade } from './module-upgrade';
31
+
32
+ function entry(vers: string): IndexEntry {
33
+ return { name: 'celilo-website', vers, deps: [], cksum: 'abc', yanked: false };
34
+ }
35
+
36
+ /** A minimal registry package: a tar whose root is manifest.yml. */
37
+ async function packageWithManifest(manifestYaml: string): Promise<ArrayBuffer> {
38
+ const dir = mkdtempSync(join(tmpdir(), 'celilo-upgrade-pkg-'));
39
+ await writeFile(join(dir, 'manifest.yml'), manifestYaml);
40
+ const tarPath = join(dir, 'pkg.netapp');
41
+ await tarCreate({ file: tarPath, cwd: dir, portable: true }, ['manifest.yml']);
42
+ const bytes = await Bun.file(tarPath).arrayBuffer();
43
+ rmSync(dir, { recursive: true, force: true });
44
+ return bytes;
45
+ }
46
+
47
+ const MANIFEST = (publicWebVersion: string) => `
48
+ celilo_contract: "1.0"
49
+ id: celilo-website
50
+ name: Celilo Website
51
+ version: 2.0.0
52
+ requires:
53
+ capabilities:
54
+ - name: public_web
55
+ version: ${publicWebVersion}
56
+ `;
57
+
58
+ let tempDir: string;
59
+ let getIndexSpy: Mock<(name: string) => Promise<IndexEntry[]>>;
60
+ let downloadSpy: Mock<(name: string, vers: string) => Promise<ArrayBuffer>>;
61
+
62
+ beforeEach(() => {
63
+ tempDir = mkdtempSync(join(tmpdir(), 'celilo-upgrade-test-'));
64
+ process.env.CELILO_DB_PATH = join(tempDir, 'test.db');
65
+ process.env.CELILO_DATA_DIR = tempDir;
66
+ getIndexSpy = spyOn(RegistryClient.prototype, 'getIndex').mockResolvedValue([entry('2.0.0')]);
67
+ downloadSpy = spyOn(RegistryClient.prototype, 'download').mockResolvedValue(new ArrayBuffer(0));
68
+ });
69
+
70
+ afterEach(() => {
71
+ getIndexSpy.mockRestore();
72
+ downloadSpy.mockRestore();
73
+ rmSync(tempDir, { recursive: true, force: true });
74
+ resetTestDbPath();
75
+ delete process.env.CELILO_DATA_DIR;
76
+ });
77
+
78
+ function seedFleet(caddyPublicWebVersion: string): void {
79
+ const db = getDb();
80
+ // sourcePath must be a real writable dir: when the gate does NOT fire, the
81
+ // flow proceeds into updateOne, which stages the new copy beside it.
82
+ const installDir = (id: string): string => {
83
+ const p = join(tempDir, 'modules', id);
84
+ mkdirSync(p, { recursive: true });
85
+ return p;
86
+ };
87
+ db.insert(modules)
88
+ .values({
89
+ id: 'celilo-website',
90
+ name: 'Celilo Website',
91
+ version: '1.0.5',
92
+ manifestData: { id: 'celilo-website', name: 'Celilo Website', version: '1.0.5' },
93
+ sourcePath: installDir('celilo-website'),
94
+ })
95
+ .run();
96
+ db.insert(modules)
97
+ .values({
98
+ id: 'caddy',
99
+ name: 'Caddy',
100
+ version: '2.3.3+1',
101
+ manifestData: { id: 'caddy', name: 'Caddy', version: '2.3.3' },
102
+ sourcePath: installDir('caddy'),
103
+ })
104
+ .run();
105
+ db.insert(capabilities)
106
+ .values({
107
+ moduleId: 'caddy',
108
+ capabilityName: 'public_web',
109
+ version: caddyPublicWebVersion,
110
+ data: {},
111
+ })
112
+ .run();
113
+ }
114
+
115
+ describe('handleModuleUpgrade — the capability gate (celilo#1361)', () => {
116
+ test('defers an upgrade whose requirement no deployed provider serves', async () => {
117
+ seedFleet('3.1.0');
118
+ downloadSpy.mockImplementation(async () => packageWithManifest(MANIFEST('4.0.0')));
119
+
120
+ const result = await handleModuleUpgrade(['celilo-website'], {});
121
+
122
+ expect(result.success).toBe(false);
123
+ if (!result.success) {
124
+ expect(result.deferred).toBe(true);
125
+ expect(result.error).toContain('deferred');
126
+ expect(result.error).toContain('public_web@4.0.0');
127
+ expect(result.error).toContain('caddy');
128
+ }
129
+ // The point of deferring: NO state changed. The stored version still
130
+ // reads the installed release and will re-read as up-to-date-candidate
131
+ // next tick instead of looking upgraded-but-broken (celilo#1363's shape).
132
+ const row = getDb()
133
+ .select()
134
+ .from(modules)
135
+ .all()
136
+ .find((m) => m.id === 'celilo-website');
137
+ expect(row?.version).toBe('1.0.5');
138
+ });
139
+
140
+ test('does not defer when the deployed provider serves the requirement', async () => {
141
+ seedFleet('3.1.0');
142
+ downloadSpy.mockImplementation(async () => packageWithManifest(MANIFEST('3.0.0')));
143
+
144
+ const result = await handleModuleUpgrade(['celilo-website'], {});
145
+
146
+ // Whatever happens downstream (the minimal package has no scripts), the
147
+ // gate itself must not have fired — `deferred` marks a capability wall.
148
+ if (!result.success) {
149
+ expect(result.deferred).toBeUndefined();
150
+ } else {
151
+ expect(result.success).toBe(true);
152
+ }
153
+ });
154
+ });
@@ -1,5 +1,16 @@
1
- import { describe, expect, test } from 'bun:test';
1
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
2
+ import type { Mock } from 'bun:test';
3
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { eq } from 'drizzle-orm';
7
+ import { create as tarCreate } from 'tar';
8
+ import { type DbClient, getDb } from '../../db/client';
9
+ import { type ModuleState, modules } from '../../db/schema';
2
10
  import type { ModuleManifest } from '../../manifest/schema';
11
+ import { RegistryClient } from '../../registry/client';
12
+ import * as moduleDeploy from '../../services/module-deploy';
13
+ import { resetTestDbPath } from '../../test-utils/db-path';
3
14
  import {
4
15
  type PollCandidate,
5
16
  isPollInvocation,
@@ -8,6 +19,7 @@ import {
8
19
  pickAutoUpgrade,
9
20
  pickUpgradePolicy,
10
21
  selectPollTargets,
22
+ upgradeOneModule,
11
23
  } from './module-upgrade';
12
24
 
13
25
  /** Minimal manifest fixture; only `hooks` matters for the backup gate. */
@@ -70,6 +82,7 @@ describe('selectPollTargets (ISS-0139 — opted-in + a newer registry version)',
70
82
  installed: '1.0.0',
71
83
  latest: '1.0.1',
72
84
  autoUpgrade: true,
85
+ inError: false,
73
86
  ...over,
74
87
  });
75
88
 
@@ -92,6 +105,13 @@ describe('selectPollTargets (ISS-0139 — opted-in + a newer registry version)',
92
105
  expect(selectPollTargets([base({ latest: null })])).toEqual([]);
93
106
  });
94
107
 
108
+ // celilo#1363: the poll burned five attempts redeploying celilo-website
109
+ // against a failure that never went away. A module in ERROR stays skipped
110
+ // until an attended health check clears it.
111
+ test('skips modules in ERROR state (a failed upgrade deploy)', () => {
112
+ expect(selectPollTargets([base({ inError: true })])).toEqual([]);
113
+ });
114
+
95
115
  test('picks only the eligible subset from a mixed set', () => {
96
116
  const targets = selectPollTargets([
97
117
  base({ moduleId: 'a', installed: '1.0.0', latest: '1.0.1', autoUpgrade: true }),
@@ -136,3 +156,97 @@ describe('needsPreUpgradeBackup (ISS-0168 — gate on the TARGET version manifes
136
156
  expect(needsPreUpgradeBackup('safe', manifest(undefined))).toBe(false);
137
157
  });
138
158
  });
159
+
160
+ // celilo#1363: fetchAndUpdate commits the new version to the store BEFORE the
161
+ // deploy runs, so a failed deploy left a new version recorded beside a stale
162
+ // VERIFIED state and a frozen updatedAt, and the audit read the module as
163
+ // fine. Option A: the row must say what happened — state ERROR with the
164
+ // error, and updatedAt moving whenever the version moves.
165
+ describe('upgradeOneModule — a failed deploy records the failure in the row (celilo#1363)', () => {
166
+ const FROZEN = new Date('2026-04-25T16:26:31Z');
167
+ let tempDir: string;
168
+ let installedDir: string;
169
+ let db: DbClient;
170
+ let getIndexSpy: Mock<(name: string) => Promise<unknown[]>>;
171
+ let downloadSpy: Mock<(name: string, vers: string, cksum?: string) => Promise<ArrayBuffer>>;
172
+ let deploySpy: Mock<typeof moduleDeploy.deployModule>;
173
+
174
+ beforeEach(async () => {
175
+ tempDir = mkdtempSync(join(tmpdir(), 'celilo-upgrade-'));
176
+ process.env.CELILO_DB_PATH = join(tempDir, 'test.db');
177
+ process.env.CELILO_ORIGINAL_CWD = tempDir;
178
+
179
+ installedDir = join(tempDir, 'installed', 'testmod');
180
+ mkdirSync(installedDir, { recursive: true });
181
+ writeFileSync(
182
+ join(installedDir, 'manifest.yml'),
183
+ 'celilo_contract: "1.0"\nid: testmod\nname: Test Module\nversion: 1.0.0\ndescription: fixture\n',
184
+ );
185
+
186
+ db = getDb();
187
+ db.insert(modules)
188
+ .values({
189
+ id: 'testmod',
190
+ name: 'Test Module',
191
+ sourcePath: installedDir,
192
+ version: '1.0.0+5',
193
+ state: 'VERIFIED',
194
+ updatedAt: FROZEN,
195
+ manifestData: {
196
+ celilo_contract: '1.0',
197
+ id: 'testmod',
198
+ name: 'Test Module',
199
+ version: '1.0.0',
200
+ },
201
+ })
202
+ .run();
203
+
204
+ // Build the registry package for a patch bump (1.0.0+5 → 1.0.0+6).
205
+ // Patch/revision → fast posture, so no snapshot and no backup are needed
206
+ // on the path to the deploy.
207
+ const srcDir = join(tempDir, 'pkg-src');
208
+ mkdirSync(srcDir, { recursive: true });
209
+ writeFileSync(
210
+ join(srcDir, 'manifest.yml'),
211
+ 'celilo_contract: "1.0"\nid: testmod\nname: Test Module\nversion: 1.0.0\ndescription: fixture v6\n',
212
+ );
213
+ const pkgPath = join(tempDir, 'testmod-1.0.0.netapp');
214
+ await tarCreate({ file: pkgPath, cwd: srcDir }, ['manifest.yml']);
215
+ const pkgBytes = new Uint8Array(await Bun.file(pkgPath).arrayBuffer());
216
+
217
+ getIndexSpy = spyOn(RegistryClient.prototype, 'getIndex').mockResolvedValue([
218
+ { name: 'testmod', vers: '1.0.0+6', deps: [], cksum: 'abc', yanked: false },
219
+ ]);
220
+ downloadSpy = spyOn(RegistryClient.prototype, 'download').mockResolvedValue(pkgBytes.buffer);
221
+ deploySpy = spyOn(moduleDeploy, 'deployModule').mockResolvedValue({
222
+ success: false,
223
+ error: 'ssh: connection refused',
224
+ phases: {},
225
+ });
226
+ });
227
+
228
+ afterEach(() => {
229
+ getIndexSpy.mockRestore();
230
+ downloadSpy.mockRestore();
231
+ deploySpy.mockRestore();
232
+ rmSync(tempDir, { recursive: true, force: true });
233
+ resetTestDbPath();
234
+ delete process.env.CELILO_ORIGINAL_CWD;
235
+ });
236
+
237
+ test('a deploy failure leaves state ERROR, the new version, and a moved updatedAt', async () => {
238
+ const mod = db.select().from(modules).where(eq(modules.id, 'testmod')).get();
239
+ if (!mod) throw new Error('seed row missing');
240
+
241
+ const result = await upgradeOneModule(mod, '1.0.0+6', new RegistryClient(''), db, {});
242
+
243
+ expect(result.success).toBe(false);
244
+ const row = db.select().from(modules).where(eq(modules.id, 'testmod')).get();
245
+ if (!row) throw new Error('row missing after upgrade');
246
+ expect(row.version).toBe('1.0.0+6');
247
+ expect(row.state).toBe<ModuleState>('ERROR');
248
+ expect(row.errorMessage).toContain('1.0.0+6');
249
+ expect(row.errorMessage).toContain('ssh: connection refused');
250
+ expect(row.updatedAt?.getTime()).toBeGreaterThan(FROZEN.getTime());
251
+ });
252
+ });