@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
@@ -0,0 +1,355 @@
1
+ /**
2
+ * Supervisor-unit installer for the build-bus webhook receiver —
3
+ * the `celilo subscribers serve` daemon. Linux gets a systemd unit;
4
+ * macOS gets a launchd plist. Mirrors `events-daemon.ts` (the
5
+ * dispatcher's installer) shape-for-shape so the two daemons read as
6
+ * one family: render via `planReceiverInstall` (the CLI's `--print`
7
+ * mode and the celilo-mgmt Ansible role), write via
8
+ * `installReceiverDaemon`, remove via `uninstallReceiverDaemon`.
9
+ *
10
+ * Why this exists: celilo#1304. The receiver shipped as a hand-run
11
+ * daemon — nothing in any deploy installed it, so on a management
12
+ * host the dispatcher ran while the receiver did not, and
13
+ * `celilo system doctor` could only say "start the receiver under a
14
+ * supervisor unit". The dispatcher got an installer (ISS-0086); this
15
+ * is the receiver's.
16
+ *
17
+ * Two differences from the dispatcher unit, both deliberate:
18
+ *
19
+ * 1. `--no-dispatch` is the DEFAULT. On the management plane the
20
+ * standing dispatcher (`celilo-events.service`) owns dispatch; a
21
+ * second dispatcher polling the same SQLite bus is the exact
22
+ * two-daemons-one-bus shape that produced celilo#580/#610. Boxes
23
+ * with no events daemon can render a combined unit with
24
+ * `--dispatch` instead.
25
+ *
26
+ * 2. The unit file carries a secret (the shared HMAC secret the
27
+ * publisher signs webhooks with), so it is written mode 0600 and
28
+ * the secret goes in an `Environment=` line / EnvironmentVariables
29
+ * dict rather than ExecStart — process args are readable in `ps`,
30
+ * unit environments are not.
31
+ */
32
+
33
+ import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs';
34
+ import { homedir } from 'node:os';
35
+ import { dirname, join } from 'node:path';
36
+ import { getEventBusPath } from '../../config/paths';
37
+ import {
38
+ type SupervisorPlatform,
39
+ type SupervisorScope,
40
+ detectPlatform,
41
+ resolveCeliloPath,
42
+ resolveRunAsUser,
43
+ } from '../events-daemon';
44
+
45
+ export const RECEIVER_UNIT_NAME = 'celilo-build-bus-receiver.service';
46
+ export const RECEIVER_LAUNCHD_LABEL = 'com.celilo.build-bus-receiver';
47
+ export const DEFAULT_RECEIVER_PORT = 8123;
48
+
49
+ export interface ReceiverInstallOptions {
50
+ /** Shared HMAC secret publishers sign webhooks with. Required. */
51
+ secret?: string;
52
+ /** TCP port the receiver listens on. Default 8123. */
53
+ port?: number;
54
+ /** Unit scope. Defaults to `user`. */
55
+ scope?: SupervisorScope;
56
+ /**
57
+ * Render the COMBINED daemon (receiver + in-process dispatcher).
58
+ * Default false — see the module comment for why the standing
59
+ * dispatcher must own dispatch on the management plane.
60
+ */
61
+ dispatch?: boolean;
62
+ celiloPath?: string;
63
+ /** Override platform detection. Mainly for tests. */
64
+ platform?: SupervisorPlatform;
65
+ /** Override the home directory used to compute install paths. */
66
+ home?: string;
67
+ /** Run-as user for system-scope units; defaults to the state-dir owner. */
68
+ runAsUser?: string;
69
+ /** Prefix for system-scope paths. Test seam — see getReceiverUnitPath. */
70
+ systemRoot?: string;
71
+ }
72
+
73
+ export interface ReceiverInstallPlan {
74
+ platform: SupervisorPlatform;
75
+ scope: SupervisorScope;
76
+ unitPath: string;
77
+ unitContent: string;
78
+ celiloPath: string;
79
+ port: number;
80
+ dispatch: boolean;
81
+ /** Set for system scope: the user the unit runs as. */
82
+ runAsUser?: string;
83
+ /** True when the OTHER scope already has a receiver unit installed. */
84
+ conflict?: { scope: SupervisorScope; unitPath: string };
85
+ nextSteps: string[];
86
+ }
87
+
88
+ export interface ReceiverUninstallResult {
89
+ platform: SupervisorPlatform;
90
+ scope: SupervisorScope;
91
+ unitPath: string;
92
+ removed: boolean;
93
+ nextSteps: string[];
94
+ }
95
+
96
+ interface UnitInputs {
97
+ celiloPath: string;
98
+ port: number;
99
+ secret: string;
100
+ scope: SupervisorScope;
101
+ dispatch: boolean;
102
+ /** Optional: only the launchd user-scope log paths read it. */
103
+ home?: string;
104
+ /** Required for system scope; ignored for user scope. */
105
+ runAsUser?: string;
106
+ }
107
+
108
+ export function getReceiverUnitPath(
109
+ platform: SupervisorPlatform,
110
+ home: string,
111
+ scope: SupervisorScope = 'user',
112
+ systemRoot = '/',
113
+ ): string {
114
+ if (platform === 'linux') {
115
+ return scope === 'system'
116
+ ? join(systemRoot, 'etc/systemd/system', RECEIVER_UNIT_NAME)
117
+ : join(home, '.config', 'systemd', 'user', RECEIVER_UNIT_NAME);
118
+ }
119
+ return scope === 'system'
120
+ ? join(systemRoot, 'Library/LaunchDaemons', `${RECEIVER_LAUNCHD_LABEL}.plist`)
121
+ : join(home, 'Library', 'LaunchAgents', `${RECEIVER_LAUNCHD_LABEL}.plist`);
122
+ }
123
+
124
+ export function renderReceiverSystemdUnit(input: UnitInputs): string {
125
+ const userLine = input.scope === 'system' && input.runAsUser ? `User=${input.runAsUser}\n` : '';
126
+ const dispatchFlag = input.dispatch ? '' : ' --no-dispatch';
127
+ const journalHint =
128
+ input.scope === 'system'
129
+ ? `journalctl -u ${RECEIVER_UNIT_NAME}`
130
+ : `journalctl --user -u ${RECEIVER_UNIT_NAME}`;
131
+ return `[Unit]
132
+ Description=Celilo Build-Bus Webhook Receiver
133
+ Documentation=https://github.com/psbanka/infra/blob/main/openspec/changes/build-bus-poll-cd/proposal.md
134
+ After=network.target
135
+
136
+ [Service]
137
+ Type=simple
138
+ ${userLine}ExecStart=${input.celiloPath} subscribers serve --port ${input.port}${dispatchFlag}
139
+ Restart=on-failure
140
+ RestartSec=10s
141
+ Environment=CELILO_BUS_SECRET=${input.secret}
142
+ # stdout/stderr are captured by ${journalHint}.
143
+ StandardOutput=journal
144
+ StandardError=journal
145
+
146
+ [Install]
147
+ WantedBy=${input.scope === 'system' ? 'multi-user.target' : 'default.target'}
148
+ `;
149
+ }
150
+
151
+ export function renderReceiverLaunchdPlist(input: UnitInputs): string {
152
+ const home = input.home ?? homedir();
153
+ const logDir = input.scope === 'system' ? '/Library/Logs' : join(home, 'Library', 'Logs');
154
+ const userNameBlock =
155
+ input.scope === 'system' && input.runAsUser
156
+ ? ` <key>UserName</key>\n <string>${input.runAsUser}</string>\n`
157
+ : '';
158
+ const dispatchFlag = input.dispatch ? '' : '\n <string>--no-dispatch</string>';
159
+ return `<?xml version="1.0" encoding="UTF-8"?>
160
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
161
+ <plist version="1.0">
162
+ <dict>
163
+ <key>Label</key>
164
+ <string>${RECEIVER_LAUNCHD_LABEL}</string>
165
+ ${userNameBlock} <key>ProgramArguments</key>
166
+ <array>
167
+ <string>${input.celiloPath}</string>
168
+ <string>subscribers</string>
169
+ <string>serve</string>
170
+ <string>--port</string>
171
+ <string>${input.port}</string>${dispatchFlag}
172
+ </array>
173
+ <key>EnvironmentVariables</key>
174
+ <dict>
175
+ <key>CELILO_BUS_SECRET</key>
176
+ <string>${input.secret}</string>
177
+ </dict>
178
+ <key>RunAtLoad</key>
179
+ <true/>
180
+ <key>KeepAlive</key>
181
+ <true/>
182
+ <key>StandardOutPath</key>
183
+ <string>${join(logDir, 'celilo-build-bus-receiver.out.log')}</string>
184
+ <key>StandardErrorPath</key>
185
+ <string>${join(logDir, 'celilo-build-bus-receiver.err.log')}</string>
186
+ </dict>
187
+ </plist>
188
+ `;
189
+ }
190
+
191
+ function otherScopeReceiverUnit(
192
+ platform: SupervisorPlatform,
193
+ home: string,
194
+ scope: SupervisorScope,
195
+ systemRoot?: string,
196
+ ): { scope: SupervisorScope; unitPath: string } | undefined {
197
+ const other = scope === 'user' ? 'system' : 'user';
198
+ const path = getReceiverUnitPath(platform, home, other, systemRoot);
199
+ return existsSync(path) ? { scope: other, unitPath: path } : undefined;
200
+ }
201
+
202
+ function nextStepsFor(
203
+ platform: SupervisorPlatform,
204
+ scope: SupervisorScope,
205
+ unitPath: string,
206
+ ): string[] {
207
+ if (platform === 'linux') {
208
+ return scope === 'system'
209
+ ? [
210
+ 'Reload systemd: sudo systemctl daemon-reload',
211
+ `Enable + start: sudo systemctl enable --now ${RECEIVER_UNIT_NAME}`,
212
+ `Tail logs: journalctl -u ${RECEIVER_UNIT_NAME} -f`,
213
+ `Status: systemctl status ${RECEIVER_UNIT_NAME}`,
214
+ ]
215
+ : [
216
+ 'Reload systemd: systemctl --user daemon-reload',
217
+ `Enable + start: systemctl --user enable --now ${RECEIVER_UNIT_NAME}`,
218
+ `Tail logs: journalctl --user -u ${RECEIVER_UNIT_NAME} -f`,
219
+ `Status: systemctl --user status ${RECEIVER_UNIT_NAME}`,
220
+ ];
221
+ }
222
+ return scope === 'system'
223
+ ? [
224
+ `Load + start: sudo launchctl bootstrap system ${unitPath}`,
225
+ 'Tail stdout: tail -f /Library/Logs/celilo-build-bus-receiver.out.log',
226
+ 'Tail stderr: tail -f /Library/Logs/celilo-build-bus-receiver.err.log',
227
+ `Status: sudo launchctl print system/${RECEIVER_LAUNCHD_LABEL}`,
228
+ ]
229
+ : [
230
+ `Load + start: launchctl load -w ${unitPath}`,
231
+ 'Tail stdout: tail -f ~/Library/Logs/celilo-build-bus-receiver.out.log',
232
+ 'Tail stderr: tail -f ~/Library/Logs/celilo-build-bus-receiver.err.log',
233
+ `Status: launchctl list | grep ${RECEIVER_LAUNCHD_LABEL}`,
234
+ ];
235
+ }
236
+
237
+ /**
238
+ * Resolve everything an install would do — platform, paths, run-as
239
+ * user, rendered unit content — WITHOUT writing anything. This is the
240
+ * single renderer behind the CLI's `--print` mode, which the
241
+ * celilo-mgmt Ansible role uses (the deb wrapper runs celilo as the
242
+ * unprivileged celilo user, so the role performs the root-owned write).
243
+ *
244
+ * The secret is REQUIRED and never defaulted: there is no safe
245
+ * invented value for a shared HMAC secret, and a receiver that starts
246
+ * with a secret the publisher does not know silently 401s every
247
+ * delivery. Render-only calls (`--print`) refuse on a missing secret
248
+ * rather than wedge the deploy that is trying to recover.
249
+ */
250
+ export function planReceiverInstall(opts: ReceiverInstallOptions = {}): ReceiverInstallPlan {
251
+ const platform = opts.platform ?? detectPlatform();
252
+ const scope = opts.scope ?? 'user';
253
+ const home = opts.home ?? homedir();
254
+ const secret = opts.secret;
255
+ if (typeof secret !== 'string' || !secret) {
256
+ throw new Error(
257
+ 'celilo subscribers install-daemon: a shared secret is required (--secret <hex> or CELILO_BUS_SECRET). The publisher signs webhooks with this same value — there is no safe default.',
258
+ );
259
+ }
260
+ const celiloPath = resolveCeliloPath(opts.celiloPath);
261
+ const port = opts.port ?? DEFAULT_RECEIVER_PORT;
262
+ const dispatch = opts.dispatch ?? false;
263
+ const runAsUser =
264
+ scope === 'system' ? resolveRunAsUser(getEventBusPath(), opts.runAsUser) : undefined;
265
+
266
+ const unitPath = getReceiverUnitPath(platform, home, scope, opts.systemRoot);
267
+ const unitInputs: UnitInputs = { celiloPath, port, secret, scope, dispatch, home, runAsUser };
268
+ const unitContent =
269
+ platform === 'linux'
270
+ ? renderReceiverSystemdUnit(unitInputs)
271
+ : renderReceiverLaunchdPlist(unitInputs);
272
+
273
+ return {
274
+ platform,
275
+ scope,
276
+ unitPath,
277
+ unitContent,
278
+ celiloPath,
279
+ port,
280
+ dispatch,
281
+ runAsUser,
282
+ conflict: otherScopeReceiverUnit(platform, home, scope, opts.systemRoot),
283
+ nextSteps: nextStepsFor(platform, scope, unitPath),
284
+ };
285
+ }
286
+
287
+ /**
288
+ * Write the supervisor unit file. Idempotent: rewrites if present.
289
+ * Mode 0600 — the file carries the shared HMAC secret.
290
+ *
291
+ * System scope writes root-owned paths (/etc/systemd/system,
292
+ * /Library/LaunchDaemons) — run as root or the write fails loudly.
293
+ */
294
+ export function installReceiverDaemon(opts: ReceiverInstallOptions = {}): ReceiverInstallPlan {
295
+ const plan = planReceiverInstall(opts);
296
+ if (plan.conflict) {
297
+ throw new Error(
298
+ [
299
+ `celilo subscribers daemon: a ${plan.conflict.scope}-scope receiver unit is already installed at ${plan.conflict.unitPath}.`,
300
+ 'Both scopes use the same unit name, so installing this one would create a SECOND receiver.',
301
+ `Remove the other first: \`celilo subscribers uninstall-daemon${plan.conflict.scope === 'system' ? ' --system' : ''}\`,`,
302
+ 'or keep the existing one.',
303
+ ].join(' '),
304
+ );
305
+ }
306
+ mkdirSync(dirname(plan.unitPath), { recursive: true });
307
+ writeFileSync(plan.unitPath, plan.unitContent, { mode: 0o600 });
308
+ return plan;
309
+ }
310
+
311
+ export function uninstallReceiverDaemon(
312
+ opts: {
313
+ platform?: SupervisorPlatform;
314
+ scope?: SupervisorScope;
315
+ home?: string;
316
+ systemRoot?: string;
317
+ } = {},
318
+ ): ReceiverUninstallResult {
319
+ const platform = opts.platform ?? detectPlatform();
320
+ const scope = opts.scope ?? 'user';
321
+ const home = opts.home ?? homedir();
322
+ const unitPath = getReceiverUnitPath(platform, home, scope, opts.systemRoot);
323
+
324
+ let removed = false;
325
+ if (existsSync(unitPath)) {
326
+ unlinkSync(unitPath);
327
+ removed = true;
328
+ }
329
+
330
+ let nextSteps: string[];
331
+ if (!removed) {
332
+ nextSteps =
333
+ platform === 'linux'
334
+ ? ['No unit file present; nothing to clean up.']
335
+ : ['No plist present; nothing to clean up.'];
336
+ } else if (platform === 'linux') {
337
+ nextSteps =
338
+ scope === 'system'
339
+ ? [
340
+ `Disable + stop: sudo systemctl disable --now ${RECEIVER_UNIT_NAME}`,
341
+ 'Reload systemd: sudo systemctl daemon-reload',
342
+ ]
343
+ : [
344
+ `Disable + stop: systemctl --user disable --now ${RECEIVER_UNIT_NAME}`,
345
+ 'Reload systemd: systemctl --user daemon-reload',
346
+ ];
347
+ } else {
348
+ nextSteps =
349
+ scope === 'system'
350
+ ? [`Unload: sudo launchctl bootout system/${RECEIVER_LAUNCHD_LABEL}`]
351
+ : [`Unload: launchctl unload ${unitPath}`];
352
+ }
353
+
354
+ return { platform, scope, unitPath, removed, nextSteps };
355
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Framework self-update on an upstream publish (module-orchestrator-primitives
3
+ * slice 7, tasks.md 7.2 / design D6).
4
+ *
5
+ * This was `modules/celilo-mgmt/scripts/on_upstream_publish.sh` — a
6
+ * module-declared bash hook spawned detached by the build-bus dispatcher with
7
+ * the whole of celilo's `process.env` (design D6's second execution path). The
8
+ * work is celilo replacing celilo's own binaries on the management host, which
9
+ * is class H one more time: none of it wanted to be a module hook, and the
10
+ * same publish event reaches this function in celilo's own process.
11
+ *
12
+ * The trigger survives; the execution path does not. The dispatcher calls
13
+ * `planSelfUpdate` on every verified PublishEvent and runs the update only
14
+ * where the control-plane module is installed — the same gate that decided
15
+ * whether celilo-mgmt's hook existed at all, since the module is deployed
16
+ * exactly once, on the management host.
17
+ *
18
+ * Pure planning (`planSelfUpdate`) is split from execution (`runSelfUpdate`)
19
+ * so a test can assert the match rules without spawning a package manager,
20
+ * and the package-manager calls are injectable (Rule 2.3) so no test touches
21
+ * the operator's real global install.
22
+ */
23
+
24
+ import { spawnSync } from 'node:child_process';
25
+ import { existsSync, readFileSync } from 'node:fs';
26
+ import { join } from 'node:path';
27
+ import type { PublishEvent } from '@celilo/event-bus/build-bus';
28
+
29
+ /**
30
+ * The binary packages celilo keeps globally installed on the management host.
31
+ * Only these self-update: the other `@celilo/*` packages are transitive deps
32
+ * of the CLI's bundled install, have no standalone global binary, and a
33
+ * `bun add -g` of them would be a no-op. This was celilo-mgmt's manifest
34
+ * comment ("two entries instead of one packagePattern='@celilo/*'"); it moves
35
+ * here with the work.
36
+ */
37
+ const BINARY_PACKAGES = {
38
+ '@celilo/cli': 'celilo',
39
+ '@celilo/e2e': 'cele2e',
40
+ } as const satisfies Record<string, string>;
41
+
42
+ export type BinaryPackage = keyof typeof BINARY_PACKAGES;
43
+
44
+ /** What the update will do. Carries everything `runSelfUpdate` needs. */
45
+ export interface SelfUpdatePlan {
46
+ packageName: BinaryPackage;
47
+ version: string;
48
+ /** The binary whose `--version` proves the update took. */
49
+ binary: (typeof BINARY_PACKAGES)[BinaryPackage];
50
+ }
51
+
52
+ /**
53
+ * Pure: does this publish event self-update the management host?
54
+ * The old manifest's match rules (registry npm, tag latest, package in the
55
+ * binary pair) are unchanged — they moved from YAML into the framework with
56
+ * the rest of the work.
57
+ */
58
+ export function planSelfUpdate(event: PublishEvent): SelfUpdatePlan | null {
59
+ if (event.registry !== 'npm' || event.tag !== 'latest') return null;
60
+ const binary = BINARY_PACKAGES[event.package.name as BinaryPackage];
61
+ if (!binary) return null;
62
+ return {
63
+ packageName: event.package.name as BinaryPackage,
64
+ version: event.package.version,
65
+ binary,
66
+ };
67
+ }
68
+
69
+ export interface SelfUpdateResult {
70
+ packageName: BinaryPackage;
71
+ version: string;
72
+ /** Globally installed version before the update; null when not installed. */
73
+ beforeVersion: string | null;
74
+ /** False when the install step failed — the operator follows up. */
75
+ updated: boolean;
76
+ /** True when the binary answered `--version` after the update. */
77
+ verified: boolean;
78
+ error?: string;
79
+ }
80
+
81
+ /**
82
+ * The seams a test injects. Defaults do the real work against
83
+ * `$HOME/.bun/install/global/node_modules`.
84
+ */
85
+ export interface SelfUpdateDeps {
86
+ /** Root of the bun global install. Default: `$HOME/.bun/install/global/node_modules`. */
87
+ globalRoot?: string;
88
+ install?: (packageName: BinaryPackage, version: string) => { ok: boolean; output: string };
89
+ verify?: (binary: string) => { ok: boolean; output: string };
90
+ }
91
+
92
+ function defaultGlobalRoot(): string {
93
+ return join(process.env.HOME ?? '', '.bun', 'install', 'global', 'node_modules');
94
+ }
95
+
96
+ function defaultInstall(packageName: BinaryPackage, version: string) {
97
+ const result = spawnSync('bun', ['add', '-g', `${packageName}@${version}`], {
98
+ encoding: 'utf-8',
99
+ });
100
+ return { ok: result.status === 0, output: `${result.stdout ?? ''}${result.stderr ?? ''}` };
101
+ }
102
+
103
+ function defaultVerify(binary: string) {
104
+ const result = spawnSync(binary, ['--version'], { encoding: 'utf-8' });
105
+ return { ok: result.status === 0, output: `${result.stdout ?? ''}${result.stderr ?? ''}`.trim() };
106
+ }
107
+
108
+ /**
109
+ * Run one self-update plan. Never throws — the outcome is the operator's
110
+ * signal, not a publish blocker (the old hook's exit-code semantics, kept).
111
+ *
112
+ * Records the installed version first so a failed update leaves the operator
113
+ * a `bun add -g <pkg>@<beforeVersion>` rollback target, exactly as the bash
114
+ * hook printed it.
115
+ */
116
+ export function runSelfUpdate(plan: SelfUpdatePlan, deps: SelfUpdateDeps = {}): SelfUpdateResult {
117
+ const globalRoot = deps.globalRoot ?? defaultGlobalRoot();
118
+ const install = deps.install ?? defaultInstall;
119
+ const verify = deps.verify ?? defaultVerify;
120
+
121
+ const base: SelfUpdateResult = {
122
+ packageName: plan.packageName,
123
+ version: plan.version,
124
+ beforeVersion: null,
125
+ updated: false,
126
+ verified: false,
127
+ };
128
+
129
+ const pkgJsonPath = join(globalRoot, plan.packageName, 'package.json');
130
+ if (existsSync(pkgJsonPath)) {
131
+ try {
132
+ const parsed = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')) as { version?: string };
133
+ if (typeof parsed.version === 'string') base.beforeVersion = parsed.version;
134
+ } catch (error) {
135
+ // A global package.json that does not parse is unusual but must not
136
+ // block the update — record it and carry on (Rule 6.2: the swallow is
137
+ // surfaced in the result the dispatcher logs).
138
+ base.error = `could not read current version: ${(error as Error).message}`;
139
+ }
140
+ }
141
+
142
+ const installed = install(plan.packageName, plan.version);
143
+ if (!installed.ok) {
144
+ return { ...base, error: `bun add -g failed: ${installed.output.trim().slice(0, 500)}` };
145
+ }
146
+ base.updated = true;
147
+
148
+ const checked = verify(plan.binary);
149
+ if (!checked.ok) {
150
+ return {
151
+ ...base,
152
+ error: `${plan.binary} --version failed after update: ${checked.output.slice(0, 300)}`,
153
+ };
154
+ }
155
+ return { ...base, verified: true };
156
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The registry-poll capability gate (celilo#1361): a consumer upgrade whose
3
+ * required capability no deployed provider serves must be DEFERRED before any
4
+ * state changes, not deployed into a provider that rejects its calls.
5
+ *
6
+ * celilo-website burned six release revisions (+1 through +6) on exactly
7
+ * this: every poll attempt updated the stored version, failed in the publish
8
+ * hook ("sourceDir is required" against an old caddy), recorded the new
9
+ * version beside the old VERIFIED state, and re-triggered on the next poll.
10
+ */
11
+
12
+ import { describe, expect, test } from 'bun:test';
13
+ import {
14
+ type DeployedCapabilityProvider,
15
+ unservedCapabilityRequirements,
16
+ } from './capability-compat';
17
+
18
+ function provider(
19
+ capabilityName: string,
20
+ moduleId: string,
21
+ version: string,
22
+ ): DeployedCapabilityProvider {
23
+ return { capabilityName, moduleId, version };
24
+ }
25
+
26
+ describe('unservedCapabilityRequirements', () => {
27
+ test('a requirement any deployed provider serves is not a blocker', () => {
28
+ const providers = [provider('public_web', 'caddy', '4.0.0')];
29
+ expect(
30
+ unservedCapabilityRequirements([{ name: 'public_web', version: '4.0.0' }], providers),
31
+ ).toEqual([]);
32
+ });
33
+
34
+ test('a major the deployed provider has not reached is a blocker, naming it', () => {
35
+ const providers = [provider('public_web', 'caddy', '3.1.0')];
36
+ const result = unservedCapabilityRequirements(
37
+ [{ name: 'public_web', version: '4.0.0' }],
38
+ providers,
39
+ );
40
+ expect(result).toHaveLength(1);
41
+ expect(result[0].providerModuleId).toBe('caddy');
42
+ expect(result[0].providerVersion).toBe('3.1.0');
43
+ expect(result[0].reason).toBe('major_mismatch_higher');
44
+ expect(result[0].message).toContain('public_web@4.0.0');
45
+ expect(result[0].message).toContain('caddy');
46
+ });
47
+
48
+ test('no provider at all is a blocker with its own reason', () => {
49
+ const result = unservedCapabilityRequirements([{ name: 'public_web', version: '4.0.0' }], []);
50
+ expect(result).toHaveLength(1);
51
+ expect(result[0].reason).toBe('no_provider');
52
+ expect(result[0].providerModuleId).toBeNull();
53
+ });
54
+
55
+ test('ANY compatible provider serves the requirement (zone-scoped providers)', () => {
56
+ const providers = [
57
+ provider('dns_registrar', 'namecheap', '4.0.0'),
58
+ provider('dns_registrar', 'bind-internal', '3.0.0'),
59
+ ];
60
+ expect(
61
+ unservedCapabilityRequirements([{ name: 'dns_registrar', version: '4.0.0' }], providers),
62
+ ).toEqual([]);
63
+ });
64
+
65
+ test('a newer minor than the provider ships is caller_minor_too_old', () => {
66
+ const providers = [provider('public_web', 'caddy', '4.0.0')];
67
+ const result = unservedCapabilityRequirements(
68
+ [{ name: 'public_web', version: '4.1.0' }],
69
+ providers,
70
+ );
71
+ expect(result[0]?.reason).toBe('caller_minor_too_old');
72
+ });
73
+
74
+ test('a consumer behind the provider is compatible (framework back-compat)', () => {
75
+ const providers = [provider('public_web', 'caddy', '4.0.0')];
76
+ expect(
77
+ unservedCapabilityRequirements([{ name: 'public_web', version: '4.0.0' }], providers),
78
+ ).toEqual([]);
79
+ });
80
+
81
+ test('privileged capabilities are skipped (not provider-backed)', () => {
82
+ expect(
83
+ unservedCapabilityRequirements([{ name: 'cross_module_read', version: '9.9.9' }], []),
84
+ ).toEqual([]);
85
+ });
86
+
87
+ test('undefined requirements yield no blockers', () => {
88
+ expect(unservedCapabilityRequirements(undefined, [])).toEqual([]);
89
+ });
90
+ });