@celilo/cli 2.3.0 → 3.1.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 (96) 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/capability-loader.ts +49 -1
  29. package/src/hooks/executor.test.ts +5 -2
  30. package/src/hooks/hook-jail-toolchain-reach.test.ts +8 -3
  31. package/src/hooks/hook-jail-unreachability.test.ts +10 -2
  32. package/src/hooks/hook-trespass.test.ts +13 -4
  33. package/src/hooks/run-named-hook.ts +19 -16
  34. package/src/hooks/test-fixtures/artifact-writing-hook.ts +0 -1
  35. package/src/hooks/test-fixtures/capability-calling-hook.ts +20 -13
  36. package/src/hooks/test-fixtures/jail-probe-hook.ts +9 -1
  37. package/src/hooks/test-fixtures/jail-toolchain-hook.ts +7 -2
  38. package/src/hooks/test-fixtures/runaway-hook.ts +0 -1
  39. package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +0 -1
  40. package/src/hooks/test-fixtures/silent-hook.ts +0 -1
  41. package/src/hooks/test-fixtures/store-writing-hook.ts +20 -11
  42. package/src/hooks/test-fixtures/success-hook.ts +4 -4
  43. package/src/manifest/contracts/v1.ts +19 -14
  44. package/src/manifest/json-schema-roundtrip.test.ts +1 -1
  45. package/src/manifest/schema.ts +66 -16
  46. package/src/manifest/validate.test.ts +47 -0
  47. package/src/policy/capability-shape-baseline.ts +69 -21
  48. package/src/policy/capability-shape-drift.test.ts +53 -1
  49. package/src/policy/capability-shape.test.ts +105 -0
  50. package/src/policy/capability-shape.ts +283 -2
  51. package/src/policy/module-business-baseline.ts +17 -0
  52. package/src/registry/client.test.ts +65 -0
  53. package/src/registry/client.ts +7 -7
  54. package/src/secrets/storage.test.ts +69 -3
  55. package/src/secrets/storage.ts +71 -1
  56. package/src/services/audit/health.test.ts +58 -0
  57. package/src/services/audit/health.ts +15 -3
  58. package/src/services/audit/index.test.ts +1 -1
  59. package/src/services/audit/interface-classification.test.ts +16 -5
  60. package/src/services/audit/interface-classification.ts +25 -2
  61. package/src/services/audit/module-versions.ts +5 -1
  62. package/src/services/audit/public-dns.test.ts +20 -0
  63. package/src/services/audit/public-dns.ts +7 -2
  64. package/src/services/audit/recurrence-gate.test.ts +225 -0
  65. package/src/services/audit/trusted-sources.test.ts +17 -0
  66. package/src/services/audit/trusted-sources.ts +21 -0
  67. package/src/services/build-bus/hook-dispatch-executor.test.ts +269 -0
  68. package/src/services/build-bus/hook-dispatch-mgmt.test.ts +102 -150
  69. package/src/services/build-bus/hook-dispatch-path.test.ts +95 -0
  70. package/src/services/build-bus/hook-dispatch.test.ts +86 -116
  71. package/src/services/build-bus/hook-dispatch.ts +99 -121
  72. package/src/services/build-bus/hook-dispatcher.ts +106 -28
  73. package/src/services/build-bus/receiver-daemon.test.ts +189 -0
  74. package/src/services/build-bus/receiver-daemon.ts +355 -0
  75. package/src/services/build-bus/self-update.ts +156 -0
  76. package/src/services/capability-compat.test.ts +90 -0
  77. package/src/services/capability-compat.ts +128 -0
  78. package/src/services/deploy-terraform.ts +38 -1
  79. package/src/services/events-daemon.test.ts +57 -0
  80. package/src/services/events-daemon.ts +76 -0
  81. package/src/services/firewall-reach.ts +21 -8
  82. package/src/services/fleet-checks.test.ts +37 -1
  83. package/src/services/fleet-checks.ts +31 -1
  84. package/src/services/health-runner.ts +43 -5
  85. package/src/services/module-deploy-prune.test.ts +89 -0
  86. package/src/services/module-deploy.ts +72 -125
  87. package/src/services/module-types-drift.test.ts +1 -1
  88. package/src/services/module-validator/capability-versions.test.ts +13 -2
  89. package/src/services/provider-converge.test.ts +209 -0
  90. package/src/services/provider-converge.ts +241 -0
  91. package/src/services/static-content-converge.test.ts +39 -0
  92. package/src/services/static-content-converge.ts +26 -5
  93. package/src/services/terraform-safety.test.ts +83 -0
  94. package/src/services/terraform-safety.ts +53 -0
  95. package/src/services/update/orchestrator.test.ts +1 -1
  96. package/tsconfig.json +2 -13
@@ -0,0 +1,269 @@
1
+ /**
2
+ * The slice-7 wiring, end to end: build-bus dispatcher → runNamedHook →
3
+ * invokeHook → a real defineHook child, with the verified PublishEvent
4
+ * arriving as the hook's `event` input (tasks.md 7.3/7.4).
5
+ *
6
+ * The scratch-module pattern is hook-state-dir.test.ts's: a temp module
7
+ * whose scripts resolve the WORKSPACE @celilo/capabilities through a
8
+ * symlink, because a real module in the store carries its own bundled copy
9
+ * (modules run their bundled capabilities, celilo#173) and the jail binds
10
+ * the module's tree and nothing above it.
11
+ *
12
+ * The database is an isolated client at a temp path, never the operator's
13
+ * live store.
14
+ */
15
+
16
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
17
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
18
+ import { tmpdir } from 'node:os';
19
+ import { join, resolve } from 'node:path';
20
+ import type { PublishEvent } from '@celilo/event-bus/build-bus';
21
+ import { type DbClient, createDbClient } from '../../db/client';
22
+ import { buildBusHookRuns, modules } from '../../db/schema';
23
+ import { startHookDispatcher } from './hook-dispatcher';
24
+
25
+ const CAPABILITIES_PACKAGE = resolve(
26
+ __dirname,
27
+ '..',
28
+ '..',
29
+ '..',
30
+ '..',
31
+ '..',
32
+ 'packages',
33
+ 'capabilities',
34
+ );
35
+
36
+ function buildEvent(overrides: Partial<PublishEvent> = {}): PublishEvent {
37
+ return {
38
+ eventId: 'evt-1',
39
+ timestamp: new Date().toISOString(),
40
+ registry: 'npm',
41
+ tag: 'latest',
42
+ package: { name: '@celilo/cli', version: '0.5.0' },
43
+ ...overrides,
44
+ };
45
+ }
46
+
47
+ /**
48
+ * A module whose on_upstream_publish hook echoes the event fields it
49
+ * received back as outputs, so the test can assert exactly what crossed
50
+ * the executor boundary. When `failing` is true the handler throws
51
+ * instead, for the ledger test below.
52
+ */
53
+ function scratchUpstreamModule(failing = false): string {
54
+ const root = mkdtempSync(join(tmpdir(), 'celilo-upstream-module-'));
55
+ mkdirSync(join(root, 'scripts'), { recursive: true });
56
+ const handlerBody = failing
57
+ ? ` throw new Error('boom from the jailed hook');`
58
+ : ` const event = ctx.event as { package?: { name?: string; version?: string } };
59
+ // Two channels on purpose: the outputs come back through the broker,
60
+ // and the file is an independent read the test can make without
61
+ // trusting the dispatcher's result shape.
62
+ writeFileSync(
63
+ join(ctx.stateDir as string, 'received.json'),
64
+ JSON.stringify({ name: event?.package?.name ?? null, version: event?.package?.version ?? null }),
65
+ );
66
+ return { received_name: event?.package?.name ?? null, received_version: event?.package?.version ?? null };`;
67
+ writeFileSync(
68
+ join(root, 'scripts', 'on-upstream-publish.ts'),
69
+ `
70
+ import { defineHook } from '@celilo/capabilities';
71
+ import { writeFileSync } from 'node:fs';
72
+ import { join } from 'node:path';
73
+ export default defineHook({
74
+ requires: [] as const,
75
+ handler: async (ctx) => {
76
+ ${handlerBody}
77
+ },
78
+ });
79
+ `,
80
+ );
81
+ // See the file docblock: the fixture must carry its own capabilities link.
82
+ mkdirSync(join(root, 'node_modules', '@celilo'), { recursive: true });
83
+ symlinkSync(CAPABILITIES_PACKAGE, join(root, 'node_modules', '@celilo', 'capabilities'));
84
+ return root;
85
+ }
86
+
87
+ let db: DbClient;
88
+ let dir: string;
89
+
90
+ beforeEach(() => {
91
+ dir = mkdtempSync(join(tmpdir(), 'celilo-upstream-dispatch-'));
92
+ process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
93
+ process.env.CELILO_EVENT_BUS_PATH = join(dir, 'events.db');
94
+ process.env.CELILO_DATA_DIR = join(dir, 'data');
95
+ mkdirSync(join(dir, 'data'), { recursive: true });
96
+ db = createDbClient({ path: process.env.CELILO_DB_PATH });
97
+ });
98
+
99
+ afterEach(() => {
100
+ db.$client.close();
101
+ rmSync(dir, { recursive: true, force: true });
102
+ });
103
+
104
+ describe('the dispatcher dispatches on_upstream_publish through the ordinary executor', () => {
105
+ test('the hook receives the verified PublishEvent as its event input', async () => {
106
+ const moduleRoot = scratchUpstreamModule();
107
+ const hookEntry = {
108
+ name: 'echo-publish',
109
+ match: { registry: 'npm', tag: 'latest', package_pattern: '@celilo/cli' },
110
+ script: './scripts/on-upstream-publish.ts',
111
+ timeout: 60_000,
112
+ };
113
+ db.insert(modules)
114
+ .values({
115
+ id: 'upstream-fixture',
116
+ name: 'upstream-fixture',
117
+ version: '1.0.0',
118
+ state: 'INSTALLED',
119
+ sourcePath: moduleRoot,
120
+ manifestData: {
121
+ id: 'upstream-fixture',
122
+ name: 'upstream-fixture',
123
+ version: '1.0.0',
124
+ celilo_contract: '1.0',
125
+ provides: { capabilities: [] },
126
+ requires: { capabilities: [] },
127
+ hooks: { on_upstream_publish: [hookEntry] },
128
+ },
129
+ })
130
+ .run();
131
+
132
+ const dispatcher = await startHookDispatcher({
133
+ db,
134
+ // The loader reads manifest.yml from disk; the fixture module keeps its
135
+ // manifest in the DB row (runNamedHook's source) and the context here.
136
+ loadModules: () => [
137
+ { moduleId: 'upstream-fixture', sourcePath: moduleRoot, hooks: [hookEntry] },
138
+ ],
139
+ controlPlaneInstalled: () => false,
140
+ });
141
+ try {
142
+ const results = await dispatcher.handleEvent(
143
+ buildEvent({ package: { name: '@celilo/cli', version: '0.5.0' } }),
144
+ );
145
+ expect(results).toHaveLength(1);
146
+ expect(results[0].module).toBe('upstream-fixture');
147
+ expect(results[0].hookName).toBe('echo-publish');
148
+ expect(results[0].success).toBe(true);
149
+ // The event crossed the executor into the hook's typed context,
150
+ // proven by the hook's own file (independent of the dispatcher's
151
+ // result shape, which carries no outputs).
152
+ const received = JSON.parse(
153
+ readFileSync(join(moduleRoot, 'state', 'received.json'), 'utf-8'),
154
+ ) as { name: string | null; version: string | null };
155
+ expect(received).toEqual({ name: '@celilo/cli', version: '0.5.0' });
156
+ } finally {
157
+ await dispatcher.stop();
158
+ rmSync(moduleRoot, { recursive: true, force: true });
159
+ }
160
+ });
161
+
162
+ test('a non-matching event does not run the hook', async () => {
163
+ const moduleRoot = scratchUpstreamModule();
164
+ const hookEntry = {
165
+ name: 'echo-publish',
166
+ match: { registry: 'npm', tag: 'latest', package_pattern: '@celilo/cli' },
167
+ script: './scripts/on-upstream-publish.ts',
168
+ timeout: 60_000,
169
+ };
170
+ db.insert(modules)
171
+ .values({
172
+ id: 'upstream-fixture',
173
+ name: 'upstream-fixture',
174
+ version: '1.0.0',
175
+ state: 'INSTALLED',
176
+ sourcePath: moduleRoot,
177
+ manifestData: {
178
+ id: 'upstream-fixture',
179
+ name: 'upstream-fixture',
180
+ version: '1.0.0',
181
+ celilo_contract: '1.0',
182
+ provides: { capabilities: [] },
183
+ requires: { capabilities: [] },
184
+ hooks: { on_upstream_publish: [hookEntry] },
185
+ },
186
+ })
187
+ .run();
188
+
189
+ const dispatcher = await startHookDispatcher({
190
+ db,
191
+ loadModules: () => [
192
+ { moduleId: 'upstream-fixture', sourcePath: moduleRoot, hooks: [hookEntry] },
193
+ ],
194
+ controlPlaneInstalled: () => false,
195
+ });
196
+ try {
197
+ const results = await dispatcher.handleEvent(
198
+ buildEvent({ package: { name: '@celilo/e2e', version: '0.8.0' } }),
199
+ );
200
+ expect(results).toEqual([]);
201
+ } finally {
202
+ await dispatcher.stop();
203
+ rmSync(moduleRoot, { recursive: true, force: true });
204
+ }
205
+ });
206
+ });
207
+
208
+ /**
209
+ * The durable outcome record (celilo#1304): every dispatched run lands in
210
+ * the `build_bus_hook_runs` ledger via the production default recordRun —
211
+ * no injectable capture between the dispatcher and the DB. The jailed
212
+ * executor (slice 7) exposes no exit codes or captured stdio, so the row
213
+ * carries the script path (the result doesn't), a null exit code, and the
214
+ * executor's error text in stderr_tail.
215
+ */
216
+ test('a failing hook run lands in the build_bus_hook_runs ledger', async () => {
217
+ const moduleRoot = scratchUpstreamModule(true);
218
+ const hookEntry = {
219
+ name: 'echo-publish',
220
+ match: { registry: 'npm', tag: 'latest', package_pattern: '@celilo/cli' },
221
+ script: './scripts/on-upstream-publish.ts',
222
+ timeout: 60_000,
223
+ };
224
+ db.insert(modules)
225
+ .values({
226
+ id: 'upstream-fixture',
227
+ name: 'upstream-fixture',
228
+ version: '1.0.0',
229
+ state: 'INSTALLED',
230
+ sourcePath: moduleRoot,
231
+ manifestData: {
232
+ id: 'upstream-fixture',
233
+ name: 'upstream-fixture',
234
+ version: '1.0.0',
235
+ celilo_contract: '1.0',
236
+ provides: { capabilities: [] },
237
+ requires: { capabilities: [] },
238
+ hooks: { on_upstream_publish: [hookEntry] },
239
+ },
240
+ })
241
+ .run();
242
+
243
+ const dispatcher = await startHookDispatcher({
244
+ db,
245
+ loadModules: () => [
246
+ { moduleId: 'upstream-fixture', sourcePath: moduleRoot, hooks: [hookEntry] },
247
+ ],
248
+ controlPlaneInstalled: () => false,
249
+ });
250
+ try {
251
+ await dispatcher.handleEvent(
252
+ buildEvent({ package: { name: '@celilo/cli', version: '0.5.0' } }),
253
+ );
254
+
255
+ const rows = db.select().from(buildBusHookRuns).all();
256
+ expect(rows).toHaveLength(1);
257
+ const row = rows[0];
258
+ expect(row.eventId).toBe('evt-1');
259
+ expect(row.packageName).toBe('@celilo/cli');
260
+ expect(row.moduleId).toBe('upstream-fixture');
261
+ expect(row.hookName).toBe('echo-publish');
262
+ expect(row.scriptPath).toBe('./scripts/on-upstream-publish.ts');
263
+ expect(row.exitCode).toBeNull();
264
+ expect(row.stderrTail).toContain('boom from the jailed hook');
265
+ } finally {
266
+ await dispatcher.stop();
267
+ rmSync(moduleRoot, { recursive: true, force: true });
268
+ }
269
+ });
@@ -1,37 +1,30 @@
1
1
  /**
2
- * End-to-end test: build-bus hook dispatcher → modules/celilo-mgmt's
3
- * on_upstream_publish rules → self-update script (with the actual
4
- * `bun add -g` replaced by a noop so the test doesn't mutate the
5
- * operator's global install).
2
+ * End-to-end tests for the framework self-update on a build-bus publish
3
+ * event (module-orchestrator-primitives slice 7, tasks.md 7.2).
6
4
  *
7
- * Confirms the manifest's match rules pick the right hook for a
8
- * given event AND that the dispatcher passes the expected env vars
9
- * to the script.
5
+ * celilo-mgmt's on_upstream_publish bash hook is gone: self-update is
6
+ * framework work in the dispatcher now (design D6 — class H's answer).
7
+ * These tests pin the three things that must hold:
8
+ *
9
+ * 1. The real celilo-mgmt manifest declares NO on_upstream_publish — the
10
+ * work lives in the framework, and a hook creeping back is a second
11
+ * self-update racing the framework one.
12
+ * 2. planSelfUpdate reproduces the old manifest's match rules exactly
13
+ * (npm + latest + the two binary packages).
14
+ * 3. The dispatcher runs the update ONLY when the control-plane module
15
+ * is installed, and logs — never throws — when it fails.
10
16
  */
11
17
 
12
- import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
13
- import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
18
+ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
14
19
  import { readFileSync } from 'node:fs';
15
- import { tmpdir } from 'node:os';
16
20
  import { join } from 'node:path';
17
21
  import type { PublishEvent } from '@celilo/event-bus/build-bus';
18
22
  import { parse as parseYaml } from 'yaml';
19
- import type { ModuleHookContext, UpstreamHookEntry } from './hook-dispatch';
20
23
  import { startHookDispatcher } from './hook-dispatcher';
24
+ import { type SelfUpdatePlan, planSelfUpdate } from './self-update';
21
25
 
22
26
  const REPO_ROOT = join(__dirname, '..', '..', '..', '..', '..');
23
27
 
24
- function loadCeliloMgmtHooks(): UpstreamHookEntry[] {
25
- // Parse the real modules/celilo-mgmt/manifest.yml so this test
26
- // tracks the actual deployed manifest — drift here is a test
27
- // failure rather than silent breakage.
28
- const manifestPath = join(REPO_ROOT, 'modules', 'celilo-mgmt', 'manifest.yml');
29
- const parsed = parseYaml(readFileSync(manifestPath, 'utf-8')) as {
30
- hooks?: { on_upstream_publish?: UpstreamHookEntry[] };
31
- };
32
- return parsed.hooks?.on_upstream_publish ?? [];
33
- }
34
-
35
28
  function buildEvent(overrides: Partial<PublishEvent> = {}): PublishEvent {
36
29
  return {
37
30
  eventId: 'evt-1',
@@ -43,163 +36,122 @@ function buildEvent(overrides: Partial<PublishEvent> = {}): PublishEvent {
43
36
  };
44
37
  }
45
38
 
46
- describe('hook-dispatcher → modules/celilo-mgmt', () => {
47
- let tmpModuleDir: string;
48
-
49
- beforeEach(() => {
50
- // Use the real manifest's hooks but point them at a noop script
51
- // in a temp dir so we don't actually shell out to `bun add -g`.
52
- tmpModuleDir = mkdtempSync(join(tmpdir(), 'celilo-mgmt-hook-test-'));
53
- mkdirSync(join(tmpModuleDir, 'scripts'), { recursive: true });
54
- const scriptPath = join(tmpModuleDir, 'scripts', 'on_upstream_publish.sh');
55
- // Record the env we received so the test can verify the dispatcher
56
- // passed the right vars. Sentinel file lives in tmpModuleDir.
57
- const logPath = join(tmpModuleDir, 'invocations.log');
58
- writeFileSync(
59
- scriptPath,
60
- `#!/usr/bin/env bash
61
- echo "name=$CELILO_EVENT_PACKAGE_NAME version=$CELILO_EVENT_PACKAGE_VERSION tag=$CELILO_EVENT_TAG" >> ${logPath}
62
- exit 0
63
- `,
64
- );
65
- chmodSync(scriptPath, 0o755);
39
+ describe('celilo-mgmt declares no on_upstream_publish (self-update is framework work)', () => {
40
+ test('the real manifest has no on_upstream_publish block', () => {
41
+ const manifestPath = join(REPO_ROOT, 'modules', 'celilo-mgmt', 'manifest.yml');
42
+ const parsed = parseYaml(readFileSync(manifestPath, 'utf-8')) as {
43
+ hooks?: Record<string, unknown>;
44
+ };
45
+ expect(parsed.hooks?.on_upstream_publish).toBeUndefined();
46
+ // The module is still the canonical build-bus subscriber via its
47
+ // registry-poll subscription — that half did not move.
48
+ expect(Object.keys(parsed.hooks ?? {})).toContain('on_backup');
66
49
  });
50
+ });
67
51
 
68
- afterEach(() => {
69
- rmSync(tmpModuleDir, { recursive: true, force: true });
52
+ describe('planSelfUpdate (the old manifest match rules, now in the framework)', () => {
53
+ test('a @celilo/cli@latest npm event self-updates the cli binary', () => {
54
+ const plan = planSelfUpdate(buildEvent({ package: { name: '@celilo/cli', version: '0.5.0' } }));
55
+ expect(plan).toEqual({ packageName: '@celilo/cli', version: '0.5.0', binary: 'celilo' });
70
56
  });
71
57
 
72
- function buildModuleContext(): ModuleHookContext {
73
- const hooks = loadCeliloMgmtHooks();
74
- // Rewrite the script path to point at the temp noop. The match
75
- // rules from the real manifest are preserved — that's what we're
76
- // testing.
77
- return {
78
- moduleId: 'celilo-mgmt',
79
- sourcePath: tmpModuleDir,
80
- hooks,
81
- };
82
- }
58
+ test('a @celilo/e2e@latest npm event self-updates the e2e binary', () => {
59
+ const plan = planSelfUpdate(buildEvent({ package: { name: '@celilo/e2e', version: '0.8.0' } }));
60
+ expect(plan).toEqual({ packageName: '@celilo/e2e', version: '0.8.0', binary: 'cele2e' });
61
+ });
83
62
 
84
- test('the real manifest declares two on_upstream_publish hooks (cli + e2e)', () => {
85
- const hooks = loadCeliloMgmtHooks();
86
- expect(hooks).toHaveLength(2);
87
- const names = hooks.map((h) => h.name).sort();
88
- expect(names).toEqual(['self-update-celilo-cli', 'self-update-celilo-e2e']);
63
+ test('an alpha-tag event for @celilo/cli does NOT fire (tag must be "latest")', () => {
64
+ expect(
65
+ planSelfUpdate(
66
+ buildEvent({ tag: 'alpha', package: { name: '@celilo/cli', version: '0.5.0-alpha.0' } }),
67
+ ),
68
+ ).toBeNull();
89
69
  });
90
70
 
91
- test('a @celilo/cli@latest event fires the celilo-cli hook only', async () => {
92
- const dispatcher = await startHookDispatcher({
93
- loadModules: () => [buildModuleContext()],
94
- });
95
- const results = await dispatcher.handleEvent(
96
- buildEvent({ package: { name: '@celilo/cli', version: '0.5.0' } }),
97
- );
98
- await dispatcher.stop();
71
+ test('a non-celilo package does NOT fire', () => {
72
+ expect(
73
+ planSelfUpdate(buildEvent({ package: { name: 'left-pad', version: '1.3.0' } })),
74
+ ).toBeNull();
75
+ });
99
76
 
100
- expect(results).toHaveLength(1);
101
- expect(results[0].hookName).toBe('self-update-celilo-cli');
102
- expect(results[0].exitCode).toBe(0);
77
+ test('a transitive @celilo package (no standalone binary) does NOT fire', () => {
78
+ expect(
79
+ planSelfUpdate(buildEvent({ package: { name: '@celilo/event-bus', version: '0.2.0' } })),
80
+ ).toBeNull();
81
+ });
103
82
 
104
- // The log written by the noop script should show the env it received.
105
- const log = readFileSync(join(tmpModuleDir, 'invocations.log'), 'utf-8');
106
- expect(log).toContain('name=@celilo/cli version=0.5.0 tag=latest');
83
+ test('a non-npm registry event does NOT fire', () => {
84
+ expect(
85
+ planSelfUpdate(
86
+ buildEvent({
87
+ registry: 'celilo-registry',
88
+ package: { name: '@celilo/cli', version: '0.5.0' },
89
+ }),
90
+ ),
91
+ ).toBeNull();
107
92
  });
93
+ });
108
94
 
109
- test('a @celilo/e2e@latest event fires the celilo-e2e hook only', async () => {
110
- const dispatcher = await startHookDispatcher({
111
- loadModules: () => [buildModuleContext()],
112
- });
113
- const results = await dispatcher.handleEvent(
114
- buildEvent({ package: { name: '@celilo/e2e', version: '0.8.0' } }),
115
- );
116
- await dispatcher.stop();
95
+ describe('dispatcher runs the framework self-update behind the control-plane gate', () => {
96
+ let selfUpdateCalls: SelfUpdatePlan[];
117
97
 
118
- expect(results).toHaveLength(1);
119
- expect(results[0].hookName).toBe('self-update-celilo-e2e');
98
+ beforeEach(() => {
99
+ selfUpdateCalls = [];
120
100
  });
121
101
 
122
- test('an alpha-tag event for @celilo/cli does NOT fire (tag must be "latest")', async () => {
123
- const dispatcher = await startHookDispatcher({
124
- loadModules: () => [buildModuleContext()],
125
- });
126
- const results = await dispatcher.handleEvent(
127
- buildEvent({ tag: 'alpha', package: { name: '@celilo/cli', version: '0.5.0-alpha.0' } }),
128
- );
129
- await dispatcher.stop();
130
- expect(results).toEqual([]);
102
+ afterEach(() => {
103
+ mock.restore();
131
104
  });
132
105
 
133
- test('a non-celilo package (e.g. @celilo/event-bus) does NOT fire', async () => {
134
- const dispatcher = await startHookDispatcher({
135
- loadModules: () => [buildModuleContext()],
106
+ function start(controls: { installed: boolean }) {
107
+ return startHookDispatcher({
108
+ loadModules: () => [],
109
+ controlPlaneInstalled: () => controls.installed,
110
+ selfUpdate: (plan) => {
111
+ selfUpdateCalls.push(plan);
112
+ return {
113
+ packageName: plan.packageName,
114
+ version: plan.version,
115
+ beforeVersion: '0.4.0',
116
+ updated: true,
117
+ verified: true,
118
+ };
119
+ },
136
120
  });
121
+ }
122
+
123
+ test('control-plane installed + matching event → update runs with the right plan', async () => {
124
+ const dispatcher = await start({ installed: true });
137
125
  const results = await dispatcher.handleEvent(
138
- buildEvent({ package: { name: '@celilo/event-bus', version: '0.2.0' } }),
126
+ buildEvent({ package: { name: '@celilo/cli', version: '0.5.0' } }),
139
127
  );
140
128
  await dispatcher.stop();
141
129
  expect(results).toEqual([]);
130
+ expect(selfUpdateCalls).toEqual([
131
+ { packageName: '@celilo/cli', version: '0.5.0', binary: 'celilo' },
132
+ ]);
142
133
  });
143
134
 
144
- test('a non-npm registry event (e.g. celilo-registry module publish) does NOT fire', async () => {
145
- const dispatcher = await startHookDispatcher({
146
- loadModules: () => [buildModuleContext()],
147
- });
148
- const results = await dispatcher.handleEvent(
149
- buildEvent({
150
- registry: 'celilo-registry',
151
- package: { name: '@celilo/cli', version: '0.5.0' },
152
- }),
135
+ test('control-plane NOT installed → no self-update even on a matching event', async () => {
136
+ const dispatcher = await start({ installed: false });
137
+ await dispatcher.handleEvent(
138
+ buildEvent({ package: { name: '@celilo/cli', version: '0.5.0' } }),
153
139
  );
154
140
  await dispatcher.stop();
155
- expect(results).toEqual([]);
141
+ expect(selfUpdateCalls).toEqual([]);
156
142
  });
157
- });
158
-
159
- /**
160
- * The durable outcome record (celilo#1304): every dispatched run lands in
161
- * the `build_bus_hook_runs` ledger via the injectable recordRun. Without it
162
- * a failed self-update existed only as daemon console output, which no
163
- * surface reads.
164
- */
165
- describe('hook-dispatcher outcome record', () => {
166
- test('a failing script records exit code, stderr tail, and the event identity', async () => {
167
- const failDir = mkdtempSync(join(tmpdir(), 'hook-record-test-'));
168
- const scriptPath = join(failDir, 'fail.sh');
169
- writeFileSync(scriptPath, '#!/usr/bin/env bash\necho "boom" >&2\nexit 7\n');
170
- chmodSync(scriptPath, 0o755);
171
-
172
- const recorded: Array<{
173
- event: PublishEvent;
174
- result: { exitCode: number | null; stderr: string };
175
- }> = [];
176
- const dispatcher = await startHookDispatcher({
177
- loadModules: () => [
178
- {
179
- moduleId: 'celilo-mgmt',
180
- sourcePath: failDir,
181
- hooks: [
182
- {
183
- name: 'self-update-celilo-cli',
184
- match: { registry: 'npm', tag: 'latest', package_pattern: '@celilo/cli' },
185
- script: 'fail.sh',
186
- },
187
- ],
188
- },
189
- ],
190
- recordRun: (event, result) => recorded.push({ event, result }),
191
- });
192
143
 
144
+ test('a non-binary package (e.g. @celilo/event-bus) never self-updates', async () => {
145
+ const dispatcher = await start({ installed: true });
193
146
  await dispatcher.handleEvent(
194
- buildEvent({ package: { name: '@celilo/cli', version: '2.2.1' } }),
147
+ buildEvent({ package: { name: '@celilo/event-bus', version: '0.2.0' } }),
195
148
  );
196
149
  await dispatcher.stop();
197
- rmSync(failDir, { recursive: true, force: true });
198
-
199
- expect(recorded).toHaveLength(1);
200
- expect(recorded[0].event.eventId).toBe('evt-1');
201
- expect(recorded[0].event.package.name).toBe('@celilo/cli');
202
- expect(recorded[0].result.exitCode).toBe(7);
203
- expect(recorded[0].result.stderr).toContain('boom');
150
+ expect(selfUpdateCalls).toEqual([]);
204
151
  });
205
152
  });
153
+
154
+ // The durable outcome record (celilo#1304) is exercised in
155
+ // hook-dispatch-executor.test.ts, against the real ledger and the real
156
+ // jailed executor — the old injectable-capture form here asserted the
157
+ // bash path's exit code and stderr, which slice 7 deleted.
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The recurrence gate for module-orchestrator-primitives slice 7
3
+ * (tasks.md 7.1): exactly ONE hook dispatch path.
4
+ *
5
+ * A module-declared hook is anything a manifest declares under `hooks:`.
6
+ * Since slice 7, every such hook — including `on_upstream_publish` — is
7
+ * executed by the ordinary executor (`apps/celilo/src/hooks/executor.ts`
8
+ * → `hook-runner.ts`, jailed). No other celilo service may spawn a
9
+ * process to run one. That second path is how `bash <script>` with the
10
+ * whole of celilo's `process.env` reached a module-supplied script
11
+ * (design D6).
12
+ *
13
+ * The scan asserts on the DISPATCH, not on an interpreter name: it flags
14
+ * any file that (a) reaches a child process AND (b) consumes what a
15
+ * module declares under `hooks:`. Asserting on the literal `bash` would
16
+ * pass the moment someone renamed the interpreter, and would miss a
17
+ * second path built on `sh`, `bun run` or a direct `Bun.spawn`.
18
+ *
19
+ * `apps/celilo/src/hooks/**` is the one sanctioned path and is exempt:
20
+ * `executor.ts` and `hook-runner.ts` are where module-declared hooks are
21
+ * supposed to be spawned, jailed. Every other service file that both
22
+ * spawns and reads hook declarations is a second dispatch path.
23
+ *
24
+ * Seen-fail note (Rule 7.6): this scan was written RED against
25
+ * `hook-dispatch.ts` at the spawn it then contained, and re-proven RED in
26
+ * task 7.5 by restoring that spawn in a scratch copy of the tree.
27
+ */
28
+
29
+ import { describe, expect, test } from 'bun:test';
30
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
31
+ import { join } from 'node:path';
32
+
33
+ const SRC_ROOT = join(__dirname, '..', '..');
34
+
35
+ /** Directories that ARE the one hook dispatch path. Spawning there is the design. */
36
+ const EXEMPT_DIRS = new Set(['hooks']);
37
+
38
+ /**
39
+ * (a) The file reaches a child process. Covers `node:child_process` in
40
+ * every spelling plus Bun's own spawner.
41
+ */
42
+ const CHILD_PROCESS_REACH = /node:child_process|Bun\.spawn/;
43
+
44
+ /**
45
+ * (b) The file consumes a hook's declared SCRIPT for dispatch — the input
46
+ * side of a hook execution. Deliberately narrow: a service that merely
47
+ * PEEKS at the manifest hooks block to gate a framework call (e.g.
48
+ * `system update` reading `hooks?.on_backup` to skip a backup) reads hook
49
+ * declarations without dispatching one, and stays out of scope. Comment
50
+ * mentions are stripped before matching: a docblock naming the hook this
51
+ * gate replaced is prose, not a dispatch.
52
+ */
53
+ const HOOK_DISPATCH_MARKERS = ['UpstreamHookEntry', 'planHookDispatch', 'hook.script'];
54
+
55
+ /**
56
+ * Strip line and block comments so a prose mention of a marker does not
57
+ * flag a file. Conservative on purpose: strings containing `//` (URLs) can
58
+ * lose a tail, which is safe here — it can only REMOVE a match, and a
59
+ * string carrying a hook-dispatch marker is not itself a dispatch.
60
+ */
61
+ function stripComments(src: string): string {
62
+ return src.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/^\s*\/\/.*$/gm, ' ');
63
+ }
64
+
65
+ function listTsFiles(dir: string, out: string[] = []): string[] {
66
+ for (const entry of readdirSync(dir)) {
67
+ const full = join(dir, entry);
68
+ if (statSync(full).isDirectory()) {
69
+ if (EXEMPT_DIRS.has(entry)) continue;
70
+ listTsFiles(full, out);
71
+ } else if (entry.endsWith('.ts') && !entry.endsWith('.test.ts')) {
72
+ out.push(full);
73
+ }
74
+ }
75
+ return out;
76
+ }
77
+
78
+ describe('exactly one hook dispatch path (module-orchestrator-primitives 7.1)', () => {
79
+ test('no service outside apps/celilo/src/hooks spawns a process for a module-declared hook', () => {
80
+ const violations: string[] = [];
81
+ for (const file of listTsFiles(SRC_ROOT)) {
82
+ const src = stripComments(readFileSync(file, 'utf-8'));
83
+ if (!CHILD_PROCESS_REACH.test(src)) continue;
84
+ if (!HOOK_DISPATCH_MARKERS.some((m) => src.includes(m))) continue;
85
+ violations.push(file.replace(`${SRC_ROOT}/`, ''));
86
+ }
87
+ expect(violations).toEqual([]);
88
+ });
89
+
90
+ test('the one sanctioned path still exists (executor.ts)', () => {
91
+ const executor = readFileSync(join(SRC_ROOT, 'hooks', 'executor.ts'), 'utf-8');
92
+ expect(executor).toContain('export async function invokeHook');
93
+ expect(executor).toContain('export async function executeHookScript');
94
+ });
95
+ });