@celilo/cli 2.3.0 → 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 (90) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/package.json +3 -3
  3. package/src/api/serve.ts +13 -1
  4. package/src/cli/commands/module-health.test.ts +35 -0
  5. package/src/cli/commands/module-health.ts +11 -3
  6. package/src/cli/commands/module-publish.test.ts +22 -0
  7. package/src/cli/commands/module-publish.ts +14 -12
  8. package/src/cli/commands/module-update.ts +69 -17
  9. package/src/cli/commands/module-upgrade-gate.test.ts +154 -0
  10. package/src/cli/commands/module-upgrade.ts +43 -3
  11. package/src/cli/commands/publish/helpers.ts +4 -3
  12. package/src/cli/commands/publish/index.ts +13 -1
  13. package/src/cli/commands/publish/plan.test.ts +64 -0
  14. package/src/cli/commands/publish/plan.ts +52 -19
  15. package/src/cli/commands/publish/types.ts +16 -3
  16. package/src/cli/commands/subscribers-install-daemon.test.ts +44 -0
  17. package/src/cli/commands/subscribers-install-daemon.ts +107 -0
  18. package/src/cli/commands/subscribers-serve.test.ts +22 -0
  19. package/src/cli/commands/subscribers-serve.ts +22 -4
  20. package/src/cli/commands/system-audit.ts +14 -5
  21. package/src/cli/commands/system-update.ts +11 -6
  22. package/src/cli/completion.ts +10 -1
  23. package/src/cli/fuel-gauge.ts +12 -4
  24. package/src/cli/index.ts +12 -0
  25. package/src/cli/json-output.test.ts +81 -0
  26. package/src/cli/types.ts +9 -0
  27. package/src/hooks/broker.test.ts +9 -3
  28. package/src/hooks/executor.test.ts +5 -2
  29. package/src/hooks/hook-jail-toolchain-reach.test.ts +8 -3
  30. package/src/hooks/hook-jail-unreachability.test.ts +10 -2
  31. package/src/hooks/hook-trespass.test.ts +13 -4
  32. package/src/hooks/run-named-hook.ts +19 -16
  33. package/src/hooks/test-fixtures/artifact-writing-hook.ts +0 -1
  34. package/src/hooks/test-fixtures/capability-calling-hook.ts +20 -13
  35. package/src/hooks/test-fixtures/jail-probe-hook.ts +9 -1
  36. package/src/hooks/test-fixtures/jail-toolchain-hook.ts +7 -2
  37. package/src/hooks/test-fixtures/runaway-hook.ts +0 -1
  38. package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +0 -1
  39. package/src/hooks/test-fixtures/silent-hook.ts +0 -1
  40. package/src/hooks/test-fixtures/store-writing-hook.ts +20 -11
  41. package/src/hooks/test-fixtures/success-hook.ts +4 -4
  42. package/src/manifest/contracts/v1.ts +19 -14
  43. package/src/manifest/json-schema-roundtrip.test.ts +1 -1
  44. package/src/manifest/schema.ts +66 -16
  45. package/src/manifest/validate.test.ts +47 -0
  46. package/src/policy/capability-shape-baseline.ts +63 -21
  47. package/src/policy/capability-shape-drift.test.ts +53 -1
  48. package/src/policy/capability-shape.test.ts +105 -0
  49. package/src/policy/capability-shape.ts +283 -2
  50. package/src/registry/client.test.ts +65 -0
  51. package/src/registry/client.ts +7 -7
  52. package/src/secrets/storage.test.ts +69 -3
  53. package/src/secrets/storage.ts +71 -1
  54. package/src/services/audit/health.test.ts +58 -0
  55. package/src/services/audit/health.ts +15 -3
  56. package/src/services/audit/index.test.ts +1 -1
  57. package/src/services/audit/interface-classification.test.ts +16 -5
  58. package/src/services/audit/interface-classification.ts +25 -2
  59. package/src/services/audit/module-versions.ts +5 -1
  60. package/src/services/audit/public-dns.test.ts +20 -0
  61. package/src/services/audit/public-dns.ts +7 -2
  62. package/src/services/audit/recurrence-gate.test.ts +225 -0
  63. package/src/services/audit/trusted-sources.test.ts +17 -0
  64. package/src/services/audit/trusted-sources.ts +21 -0
  65. package/src/services/build-bus/hook-dispatch-executor.test.ts +269 -0
  66. package/src/services/build-bus/hook-dispatch-mgmt.test.ts +102 -150
  67. package/src/services/build-bus/hook-dispatch-path.test.ts +95 -0
  68. package/src/services/build-bus/hook-dispatch.test.ts +86 -116
  69. package/src/services/build-bus/hook-dispatch.ts +99 -121
  70. package/src/services/build-bus/hook-dispatcher.ts +106 -28
  71. package/src/services/build-bus/receiver-daemon.test.ts +189 -0
  72. package/src/services/build-bus/receiver-daemon.ts +355 -0
  73. package/src/services/build-bus/self-update.ts +156 -0
  74. package/src/services/capability-compat.test.ts +90 -0
  75. package/src/services/capability-compat.ts +128 -0
  76. package/src/services/deploy-terraform.ts +38 -1
  77. package/src/services/events-daemon.test.ts +57 -0
  78. package/src/services/events-daemon.ts +76 -0
  79. package/src/services/firewall-reach.ts +21 -8
  80. package/src/services/fleet-checks.test.ts +37 -1
  81. package/src/services/fleet-checks.ts +31 -1
  82. package/src/services/health-runner.ts +43 -5
  83. package/src/services/module-deploy-prune.test.ts +89 -0
  84. package/src/services/module-deploy.ts +72 -125
  85. package/src/services/module-types-drift.test.ts +1 -1
  86. package/src/services/module-validator/capability-versions.test.ts +13 -2
  87. package/src/services/terraform-safety.test.ts +83 -0
  88. package/src/services/terraform-safety.ts +53 -0
  89. package/src/services/update/orchestrator.test.ts +1 -1
  90. package/tsconfig.json +2 -13
@@ -85,4 +85,4 @@ Each entry: `module id` — what it is — **provides** / **requires** capabilit
85
85
 
86
86
  ## Archived / superseded
87
87
 
88
- `modules/archive/` holds retired modules — **dns-external** (VPS authoritative DNS + WireGuard, superseded by the `dns_internal`/`dns_registrar` split), **gmail** (email-reading capability), **namecheap-api** (registrar-config via Namecheap API, superseded by the **namecheap** DDNS module). Reference only; not deployed.
88
+ `modules/__archive__/` holds retired modules — **dns-external** (VPS authoritative DNS + WireGuard, superseded by the `dns_internal`/`dns_registrar` split), **gmail** (email-reading capability), **namecheap-api** (registrar-config via Namecheap API, superseded by the **namecheap** DDNS module). Reference only; not deployed.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "2.3.0",
3
+ "version": "3.0.0",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -58,9 +58,9 @@
58
58
  "dependencies": {
59
59
  "@aws-sdk/client-s3": "^3.1109.0",
60
60
  "@aws-sdk/lib-storage": "^3.1101.0",
61
- "@celilo/capabilities": "^4.3.0",
61
+ "@celilo/capabilities": "^5.0.0",
62
62
  "@celilo/cli-display": "^0.2.0",
63
- "@celilo/core": "^0.12.0",
63
+ "@celilo/core": "^0.13.0",
64
64
  "@celilo/event-bus": "^0.6.0",
65
65
  "ajv": "^8.18.0",
66
66
  "drizzle-orm": "^0.36.4",
package/src/api/serve.ts CHANGED
@@ -139,7 +139,19 @@ async function runCommand(argv: string[], forward: (msg: ServerMessage) => void)
139
139
  stderr: 'pipe',
140
140
  });
141
141
 
142
- await Promise.all([pumpLines(child.stdout, forward), pumpLines(child.stderr, forward)]);
142
+ // Each stream is pumped separately and the log message names its origin,
143
+ // so a client can keep stderr distinguishable from the command's result
144
+ // (celilo#1362 — the two used to merge into one undifferentiated channel).
145
+ const markStream =
146
+ (stream: 'stdout' | 'stderr') =>
147
+ (msg: ServerMessage): void => {
148
+ forward(msg.type === 'log' ? { ...msg, stream } : msg);
149
+ };
150
+
151
+ await Promise.all([
152
+ pumpLines(child.stdout, markStream('stdout')),
153
+ pumpLines(child.stderr, markStream('stderr')),
154
+ ]);
143
155
  const exitCode = await child.exited;
144
156
  forward(resultMessage(exitCode === 0, exitCode));
145
157
  return exitCode;
@@ -0,0 +1,35 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { formatResult } from './module-health';
3
+
4
+ describe('module health rendering — the waiver annotation', () => {
5
+ test('a waived no-checks module renders the waiver beside the verdict, not instead of it', () => {
6
+ const output = formatResult({
7
+ moduleId: 'namecheap',
8
+ status: 'no-checks',
9
+ checks: [],
10
+ waiver: {
11
+ reason: 'API-only: a real check is hard and may not be worth forcing',
12
+ by: 'peba',
13
+ at: '2026-09-09',
14
+ },
15
+ });
16
+
17
+ // The unmeasured verdict is still stated.
18
+ expect(output).toContain('no health check defined');
19
+ // The annotation names the human's words, who, and when.
20
+ expect(output).toContain(
21
+ 'waived: API-only: a real check is hard and may not be worth forcing (by peba at 2026-09-09)',
22
+ );
23
+ });
24
+
25
+ test('a no-checks module without a waiver renders no waiver line', () => {
26
+ const output = formatResult({
27
+ moduleId: 'iptables',
28
+ status: 'no-checks',
29
+ checks: [],
30
+ });
31
+
32
+ expect(output).toContain('no health check defined');
33
+ expect(output).not.toContain('waived:');
34
+ });
35
+ });
@@ -26,12 +26,20 @@ const CHECK_ICONS: Record<string, string> = {
26
26
  skip: '○',
27
27
  };
28
28
 
29
- function formatResult(result: HealthCheckResult): string {
29
+ export function formatResult(result: HealthCheckResult): string {
30
30
  const icon = STATUS_ICONS[result.status] || '?';
31
31
  const lines: string[] = [];
32
32
 
33
33
  if (result.status === 'no-checks') {
34
34
  lines.push(` ${result.moduleId} ${icon} no health check defined`);
35
+ // Annotate the waiver beside the verdict, never instead of it: the
36
+ // module is still unmeasured, a human just decided it needs no check
37
+ // (openspec/changes/health-waiver-mechanism, D3).
38
+ if (result.waiver) {
39
+ lines.push(
40
+ ` waived: ${result.waiver.reason} (by ${result.waiver.by} at ${result.waiver.at})`,
41
+ );
42
+ }
35
43
  return lines.join('\n');
36
44
  }
37
45
 
@@ -70,10 +78,10 @@ export async function handleModuleHealth(
70
78
  let results: HealthCheckResult[];
71
79
 
72
80
  if (moduleId) {
73
- const result = await runModuleHealthCheck(moduleId, db, { debug });
81
+ const result = await runModuleHealthCheck(moduleId, db, { debug, quiet: jsonOutput });
74
82
  results = [result];
75
83
  } else {
76
- results = await runAllHealthChecks(db, { debug });
84
+ results = await runAllHealthChecks(db, { debug, quiet: jsonOutput });
77
85
  }
78
86
 
79
87
  if (jsonOutput) {
@@ -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 ────────────────────────────────────────
@@ -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
@@ -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
  }
@@ -560,7 +612,7 @@ export async function handleModuleUpdate(
560
612
  );
561
613
 
562
614
  // Skips that fall under a wildcard expansion (e.g. modules/* picking
563
- // up `modules/archive/`) shouldn't even be mentioned — they're not
615
+ // up `modules/__archive__/`) shouldn't even be mentioned — they're not
564
616
  // signal. Skips for "module not installed" ARE signal because the
565
617
  // user explicitly named the path; surface those.
566
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
+ });
@@ -22,6 +22,7 @@ import type { ModuleManifest } from '../../manifest/schema';
22
22
  import { RegistryClient } from '../../registry/client';
23
23
  import { createModuleBackup, createSystemStateBackup } from '../../services/backup-create';
24
24
  import { getDefaultBackupStorage } from '../../services/backup-storage';
25
+ import { capabilityBlockersForManifest } from '../../services/capability-compat';
25
26
  import {
26
27
  type DeployPosture,
27
28
  type UpgradePolicy,
@@ -33,6 +34,7 @@ import { deployModule } from '../../services/module-deploy';
33
34
  import { getArg, getFlag } from '../parser';
34
35
  import { log } from '../prompts';
35
36
  import type { CommandResult } from '../types';
37
+ import { fetchTargetManifest } from './module-update';
36
38
  import { classifyVersionChange, fetchAndUpdate } from './module-update';
37
39
 
38
40
  const VALID_POLICIES: readonly UpgradePolicy[] = ['by-semver', 'always-safe', 'always-fast'];
@@ -181,6 +183,28 @@ export async function upgradeOneModule(
181
183
  ): Promise<CommandResult> {
182
184
  const moduleId = mod.id;
183
185
 
186
+ // GATE (celilo#1361): read the TARGET manifest BEFORE anything mutates, and
187
+ // refuse an upgrade whose capability requirements no deployed provider
188
+ // serves. Deploying first and failing in the module's publish hook is how
189
+ // celilo-website burned six release revisions (+1 through +6): each failure
190
+ // recorded the new version, re-triggered the poll, and changed nothing.
191
+ // A deferral changes no state and retries once the provider lands.
192
+ const targetManifestCheck = await fetchTargetManifest(client, moduleId, targetVersion);
193
+ if (targetManifestCheck.ok) {
194
+ const blockers = capabilityBlockersForManifest(db, targetManifestCheck.manifest);
195
+ if (blockers.length > 0) {
196
+ const why = blockers.map((b) => b.message).join('; ');
197
+ return {
198
+ success: false,
199
+ deferred: true,
200
+ error: `${moduleId} upgrade to ${targetVersion} deferred: the deployed providers cannot serve its capability requirements: ${why}. Upgrade the provider module first; the registry poll retries automatically.`,
201
+ details: blockers,
202
+ };
203
+ }
204
+ }
205
+ // A failed fetch here is NOT deferred — `fetchAndUpdate` below reports the
206
+ // same failure through its own channel with the real error text.
207
+
184
208
  // Update FIRST (refresh stored def). Every downstream decision — the
185
209
  // backup gate and the posture policy — must consult the TARGET version's
186
210
  // manifest, not the installed one. Gating on the pre-update manifest skipped
@@ -320,6 +344,7 @@ async function runRegistryPoll(
320
344
 
321
345
  log.info(`Registry poll: ${targets.length} module(s) to upgrade.`);
322
346
  const upgraded: string[] = [];
347
+ const deferred: string[] = [];
323
348
  const failed: string[] = [];
324
349
  // Serial, in installed order. (Strict provider-before-consumer ordering via
325
350
  // topologicalOrder is a refinement; the poll is idempotent + re-runs.)
@@ -327,14 +352,29 @@ async function runRegistryPoll(
327
352
  const mod = rowById.get(t.moduleId);
328
353
  if (!mod) continue;
329
354
  const result = await upgradeOneModule(mod, t.to, client, db, flags);
330
- if (result.success) upgraded.push(`${t.moduleId}→${t.to}`);
331
- else failed.push(`${t.moduleId}: ${result.error}`);
355
+ if (result.success) {
356
+ upgraded.push(`${t.moduleId}→${t.to}`);
357
+ } else if (result.deferred) {
358
+ // Expected steady state while a provider upgrade is still pending: the
359
+ // consumer retries next tick. Not a failure — celilo#1361.
360
+ deferred.push(`${t.moduleId}: ${result.error}`);
361
+ } else {
362
+ failed.push(`${t.moduleId}: ${result.error}`);
363
+ }
332
364
  }
333
365
 
334
366
  if (failed.length > 0) {
335
367
  return {
336
368
  success: false,
337
- error: `Registry poll: upgraded ${upgraded.length}, FAILED ${failed.length}:\n ${failed.join('\n ')}`,
369
+ error:
370
+ `Registry poll: upgraded ${upgraded.length}, deferred ${deferred.length}, FAILED ${failed.length}:\n` +
371
+ ` ${[...failed, ...(deferred.length > 0 ? [`(deferred) ${deferred.join('\n (deferred) ')}`] : [])].join('\n ')}`,
372
+ };
373
+ }
374
+ if (deferred.length > 0) {
375
+ return {
376
+ success: true,
377
+ message: `Registry poll: upgraded ${upgraded.length}, deferred ${deferred.length} (capability requirements not yet served — retries next poll):\n ${deferred.join('\n ')}`,
338
378
  };
339
379
  }
340
380
  return {
@@ -91,8 +91,9 @@ export function buildWorkspaceVersionMap(): WorkspaceVersionMap {
91
91
  }
92
92
 
93
93
  /**
94
- * List every publishable module under modules/. Excludes `archive/`
95
- * (operator-confirmed: not publishable) and any dir lacking a
94
+ * List every publishable module under modules/. Excludes `__archive__/`
95
+ * (operator-confirmed: not publishable, and renamed with the dunder prefix so
96
+ * nothing mistakes it for a module) and any dir lacking a
96
97
  * manifest.yml.
97
98
  */
98
99
  export function listModuleDirs(): string[] {
@@ -100,7 +101,7 @@ export function listModuleDirs(): string[] {
100
101
  if (!existsSync(modulesRoot)) return [];
101
102
  const out: string[] = [];
102
103
  for (const name of readdirSync(modulesRoot)) {
103
- if (name === 'archive') continue;
104
+ if (name === '__archive__') continue;
104
105
  const dir = join(modulesRoot, name);
105
106
  let st: ReturnType<typeof statSync>;
106
107
  try {
@@ -120,6 +120,12 @@ export function parseOptions(argv: string[]): ParsedFlags {
120
120
  const trackAlphaFlag = argv.includes('--track-alpha');
121
121
  const alphaModulesFlag = argv.includes('--alpha-modules');
122
122
  const skipChangesets = argv.includes('--skip-changesets');
123
+ const skipModulesFlag = argv.includes('--skip-modules');
124
+ const modulesOnlyFlag = argv.includes('--modules-only');
125
+ if (skipModulesFlag && modulesOnlyFlag) {
126
+ console.error('✗ --skip-modules and --modules-only are mutually exclusive.');
127
+ process.exit(1);
128
+ }
123
129
  const promoteArg = takeFlagValue('--promote');
124
130
  const skippedModules: string[] = [];
125
131
  for (let i = argv.indexOf('--skip-module'); i >= 0; i = argv.indexOf('--skip-module', i + 1)) {
@@ -162,7 +168,13 @@ export function parseOptions(argv: string[]): ParsedFlags {
162
168
  }
163
169
 
164
170
  return {
165
- options: { allowStale, autoYes: yes, mode, skippedModules },
171
+ options: {
172
+ allowStale,
173
+ autoYes: yes,
174
+ mode,
175
+ skippedModules,
176
+ modulePhase: modulesOnlyFlag ? 'only' : skipModulesFlag ? 'skip' : 'run',
177
+ },
166
178
  dryRun,
167
179
  releaseTouch,
168
180
  skipChangesets,