@celilo/cli 2.2.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/CELILO_CORE_MODULES.md +2 -1
  2. package/CELILO_SUBSYSTEMS.md +1 -1
  3. package/README.md +1 -1
  4. package/drizzle/0032_module_jail_policies.sql +28 -0
  5. package/drizzle/0033_build_bus_hook_runs.sql +37 -0
  6. package/drizzle/meta/_journal.json +15 -1
  7. package/package.json +3 -3
  8. package/src/__integration__/container-services-cli.integration.test.ts +1 -1
  9. package/src/api/serve.ts +13 -1
  10. package/src/api/sessions.test.ts +3 -3
  11. package/src/api-clients/proxmox.ts +10 -7
  12. package/src/cli/backup-rename.test.ts +3 -3
  13. package/src/cli/cli.test.ts +1 -1
  14. package/src/cli/commands/events.test.ts +2 -2
  15. package/src/cli/commands/firewall-interface-list.test.ts +2 -2
  16. package/src/cli/commands/machine-list.test.ts +57 -0
  17. package/src/cli/commands/machine-list.ts +35 -4
  18. package/src/cli/commands/module-health.test.ts +35 -0
  19. package/src/cli/commands/module-health.ts +12 -3
  20. package/src/cli/commands/module-import-registry.test.ts +1 -1
  21. package/src/cli/commands/module-jail.test.ts +242 -0
  22. package/src/cli/commands/module-jail.ts +227 -0
  23. package/src/cli/commands/module-list-jail.test.ts +136 -0
  24. package/src/cli/commands/module-list.ts +30 -3
  25. package/src/cli/commands/module-publish.test.ts +28 -6
  26. package/src/cli/commands/module-publish.ts +14 -12
  27. package/src/cli/commands/module-update.test.ts +22 -7
  28. package/src/cli/commands/module-update.ts +73 -18
  29. package/src/cli/commands/module-upgrade-gate.test.ts +154 -0
  30. package/src/cli/commands/module-upgrade.test.ts +115 -1
  31. package/src/cli/commands/module-upgrade.ts +66 -4
  32. package/src/cli/commands/module-verify.test.ts +1 -1
  33. package/src/cli/commands/publish/execute.ts +4 -1
  34. package/src/cli/commands/publish/helpers.ts +4 -3
  35. package/src/cli/commands/publish/index.ts +23 -1
  36. package/src/cli/commands/publish/module-registry.test.ts +24 -1
  37. package/src/cli/commands/publish/module-registry.ts +23 -4
  38. package/src/cli/commands/publish/plan.test.ts +64 -0
  39. package/src/cli/commands/publish/plan.ts +52 -19
  40. package/src/cli/commands/publish/types.ts +20 -0
  41. package/src/cli/commands/registry-owner.test.ts +1 -1
  42. package/src/cli/commands/registry-token.test.ts +1 -1
  43. package/src/cli/commands/subscribers-install-daemon.test.ts +44 -0
  44. package/src/cli/commands/subscribers-install-daemon.ts +107 -0
  45. package/src/cli/commands/subscribers-serve.test.ts +22 -0
  46. package/src/cli/commands/subscribers-serve.ts +22 -4
  47. package/src/cli/commands/system-audit.ts +34 -11
  48. package/src/cli/commands/system-doctor-remediation-gate.test.ts +295 -0
  49. package/src/cli/commands/system-doctor.test.ts +76 -10
  50. package/src/cli/commands/system-doctor.ts +34 -3
  51. package/src/cli/commands/system-init-deprecation.test.ts +1 -1
  52. package/src/cli/commands/system-update.ts +16 -6
  53. package/src/cli/completion.ts +18 -3
  54. package/src/cli/flag-surface-gate.test.ts +279 -0
  55. package/src/cli/fuel-gauge.ts +12 -4
  56. package/src/cli/index.ts +80 -2
  57. package/src/cli/json-output.test.ts +81 -0
  58. package/src/cli/parser.test.ts +37 -1
  59. package/src/cli/restore-command.test.ts +3 -3
  60. package/src/cli/restore-migration-failure.test.ts +2 -2
  61. package/src/cli/tui/audit-state.ts +2 -0
  62. package/src/cli/types.ts +9 -0
  63. package/src/config/paths.test.ts +19 -19
  64. package/src/db/client.test.ts +46 -1
  65. package/src/db/client.ts +26 -0
  66. package/src/db/schema.ts +63 -0
  67. package/src/hooks/broker.test.ts +9 -3
  68. package/src/hooks/capability-loader-firewall.test.ts +9 -1
  69. package/src/hooks/capability-loader.ts +8 -0
  70. package/src/hooks/executor.test.ts +91 -14
  71. package/src/hooks/executor.ts +31 -1
  72. package/src/hooks/hook-jail-toolchain-reach.test.ts +8 -3
  73. package/src/hooks/hook-jail-unreachability.test.ts +27 -20
  74. package/src/hooks/hook-trespass.test.ts +22 -6
  75. package/src/hooks/jail.test.ts +105 -17
  76. package/src/hooks/jail.ts +47 -10
  77. package/src/hooks/run-named-hook.ts +19 -16
  78. package/src/hooks/test-fixtures/artifact-writing-hook.ts +0 -1
  79. package/src/hooks/test-fixtures/capability-calling-hook.ts +20 -13
  80. package/src/hooks/test-fixtures/jail-probe-hook.ts +9 -1
  81. package/src/hooks/test-fixtures/jail-toolchain-hook.ts +7 -2
  82. package/src/hooks/test-fixtures/runaway-hook.ts +0 -1
  83. package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +0 -1
  84. package/src/hooks/test-fixtures/silent-hook.ts +0 -1
  85. package/src/hooks/test-fixtures/store-writing-hook.ts +20 -11
  86. package/src/hooks/test-fixtures/success-hook.ts +4 -4
  87. package/src/manifest/contracts/v1.ts +32 -14
  88. package/src/manifest/json-schema-roundtrip.test.ts +1 -1
  89. package/src/manifest/schema.ts +66 -16
  90. package/src/manifest/validate.test.ts +47 -0
  91. package/src/policy/capability-shape-baseline.ts +63 -21
  92. package/src/policy/capability-shape-drift.test.ts +53 -1
  93. package/src/policy/capability-shape.test.ts +105 -0
  94. package/src/policy/capability-shape.ts +283 -2
  95. package/src/policy/module-script-scan.ts +52 -68
  96. package/src/policy/no-hand-built-ssh.test.ts +5 -1
  97. package/src/policy/no-swallowed-refusal.test.ts +14 -14
  98. package/src/registry/client.test.ts +67 -2
  99. package/src/registry/client.ts +7 -7
  100. package/src/secrets/storage.test.ts +70 -4
  101. package/src/secrets/storage.ts +71 -1
  102. package/src/services/alerting/keys.test.ts +4 -0
  103. package/src/services/alerting/keys.ts +12 -2
  104. package/src/services/audit/health.test.ts +155 -2
  105. package/src/services/audit/health.ts +76 -2
  106. package/src/services/audit/index.test.ts +23 -1
  107. package/src/services/audit/index.ts +8 -2
  108. package/src/services/audit/interface-classification.test.ts +16 -5
  109. package/src/services/audit/interface-classification.ts +25 -2
  110. package/src/services/audit/jail-exemptions.test.ts +42 -0
  111. package/src/services/audit/jail-exemptions.ts +44 -0
  112. package/src/services/audit/module-integrity.test.ts +23 -1
  113. package/src/services/audit/module-integrity.ts +7 -2
  114. package/src/services/audit/module-versions.ts +5 -1
  115. package/src/services/audit/public-dns.test.ts +20 -0
  116. package/src/services/audit/public-dns.ts +7 -2
  117. package/src/services/audit/recurrence-gate.test.ts +225 -0
  118. package/src/services/audit/trusted-sources.test.ts +17 -0
  119. package/src/services/audit/trusted-sources.ts +21 -0
  120. package/src/services/audit/types.ts +2 -1
  121. package/src/services/backup-create.ts +9 -0
  122. package/src/services/backup-envelope-roundtrip.test.ts +1 -1
  123. package/src/services/backup-in-flight-refusal.test.ts +1 -1
  124. package/src/services/build-bus/hook-dispatch-executor.test.ts +269 -0
  125. package/src/services/build-bus/hook-dispatch-mgmt.test.ts +105 -105
  126. package/src/services/build-bus/hook-dispatch-path.test.ts +95 -0
  127. package/src/services/build-bus/hook-dispatch.test.ts +86 -116
  128. package/src/services/build-bus/hook-dispatch.ts +99 -121
  129. package/src/services/build-bus/hook-dispatcher.ts +143 -17
  130. package/src/services/build-bus/receiver-daemon.test.ts +189 -0
  131. package/src/services/build-bus/receiver-daemon.ts +355 -0
  132. package/src/services/build-bus/self-update.ts +156 -0
  133. package/src/services/bus-ensure-flow.test.ts +1 -1
  134. package/src/services/bus-interview-park.test.ts +2 -2
  135. package/src/services/bus-interview.test.ts +2 -2
  136. package/src/services/bus-secret-flow.test.ts +1 -1
  137. package/src/services/capability-compat.test.ts +90 -0
  138. package/src/services/capability-compat.ts +128 -0
  139. package/src/services/celilo-events.test.ts +1 -1
  140. package/src/services/celilo-mgmt-hooks.test.ts +23 -5
  141. package/src/services/container-service.test.ts +1 -1
  142. package/src/services/cross-module-read.test.ts +2 -2
  143. package/src/services/deploy-preflight.ts +7 -0
  144. package/src/services/deploy-terraform.ts +38 -1
  145. package/src/services/deployed-systems.ts +1 -1
  146. package/src/services/events-daemon.test.ts +57 -0
  147. package/src/services/events-daemon.ts +76 -0
  148. package/src/services/firewall-reach.ts +21 -8
  149. package/src/services/fleet-checks.test.ts +159 -4
  150. package/src/services/fleet-checks.ts +206 -3
  151. package/src/services/health-runner.test.ts +87 -2
  152. package/src/services/health-runner.ts +83 -16
  153. package/src/services/infrastructure-selector.test.ts +1 -1
  154. package/src/services/jail-exemptions.test.ts +125 -0
  155. package/src/services/jail-exemptions.ts +81 -0
  156. package/src/services/machine-pool.test.ts +1 -1
  157. package/src/services/module-deploy-prune.test.ts +89 -0
  158. package/src/services/module-deploy.ts +84 -129
  159. package/src/services/module-subscriptions.test.ts +2 -2
  160. package/src/services/module-types-drift.test.ts +1 -1
  161. package/src/services/module-validator/capability-versions.test.ts +13 -2
  162. package/src/services/network-discovery.test.ts +64 -1
  163. package/src/services/network-discovery.ts +30 -5
  164. package/src/services/responder-probe.test.ts +1 -1
  165. package/src/services/restore-from-file.test.ts +4 -4
  166. package/src/services/restore-preflight.test.ts +1 -1
  167. package/src/services/ssh-key-manager.test.ts +2 -2
  168. package/src/services/system-state-stage.test.ts +2 -2
  169. package/src/services/terraform-safety.test.ts +83 -0
  170. package/src/services/terraform-safety.ts +53 -0
  171. package/src/services/update/orchestrator.test.ts +3 -1
  172. package/src/test-utils/bus-responder.ts +1 -1
  173. package/tsconfig.json +2 -13
@@ -1,18 +1,21 @@
1
1
  /**
2
- * Hook-dispatch tests. The planner is pure (no I/O); the runner
3
- * spawns bash. We test:
2
+ * Hook-dispatch tests. The planner is pure (no I/O); the runner goes
3
+ * through the ordinary hook executor (module-orchestrator-primitives
4
+ * slice 7). We test:
4
5
  *
5
- * - planHookDispatch: rule matching, env construction, scriptPath
6
- * resolution.
7
- * - runHookDispatch: real bash spawn with a fixture script.
6
+ * - planHookDispatch: rule matching (match logic unchanged since the
7
+ * bash-spawn days).
8
+ * - runUpstreamHook: routes a matched entry through runNamedHook with
9
+ * the event as the `event` input and the matched entry's definition
10
+ * (script + timeout). The seam under the mock is run-named-hook;
11
+ * the real executor hop (child spawn, jail, context) is covered
12
+ * end-to-end by hook-dispatch-executor.test.ts.
8
13
  */
9
14
 
10
- import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
11
- import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
12
- import { tmpdir } from 'node:os';
13
- import { join } from 'node:path';
15
+ import { describe, expect, mock, test } from 'bun:test';
14
16
  import type { PublishEvent } from '@celilo/event-bus/build-bus';
15
- import { type ModuleHookContext, planHookDispatch, runHookDispatch } from './hook-dispatch';
17
+ import type { HookLogger } from '../../hooks/types';
18
+ import { type HookDispatchPlan, planHookDispatch, runUpstreamHook } from './hook-dispatch';
16
19
 
17
20
  function buildEvent(overrides: Partial<PublishEvent> = {}): PublishEvent {
18
21
  return {
@@ -25,24 +28,31 @@ function buildEvent(overrides: Partial<PublishEvent> = {}): PublishEvent {
25
28
  };
26
29
  }
27
30
 
31
+ const SILENT_LOGGER = {
32
+ info: () => {},
33
+ warn: () => {},
34
+ error: () => {},
35
+ success: () => {},
36
+ } as unknown as HookLogger;
37
+
28
38
  describe('planHookDispatch', () => {
29
39
  test('empty module list → empty plan', () => {
30
40
  expect(planHookDispatch(buildEvent(), [])).toEqual([]);
31
41
  });
32
42
 
33
43
  test('module with no matching hooks → empty plan', () => {
34
- const modules: ModuleHookContext[] = [
44
+ const modules = [
35
45
  {
36
46
  moduleId: 'lunacycle',
37
47
  sourcePath: '/modules/lunacycle',
38
- hooks: [{ match: { registry: 'celilo-registry' }, script: './reload.sh' }],
48
+ hooks: [{ match: { registry: 'celilo-registry' }, script: './reload.ts' }],
39
49
  },
40
50
  ];
41
51
  expect(planHookDispatch(buildEvent({ registry: 'npm' }), modules)).toEqual([]);
42
52
  });
43
53
 
44
- test('matching hook → plan with resolved scriptPath + env', () => {
45
- const modules: ModuleHookContext[] = [
54
+ test('matching hook → plan for that module + entry', () => {
55
+ const modules = [
46
56
  {
47
57
  moduleId: 'lunacycle',
48
58
  sourcePath: '/modules/lunacycle',
@@ -50,36 +60,31 @@ describe('planHookDispatch', () => {
50
60
  {
51
61
  name: 'rerun-e2e',
52
62
  match: { registry: 'npm', package_pattern: '@celilo/*' },
53
- script: './hooks/rerun.sh',
63
+ script: './hooks/rerun.ts',
54
64
  },
55
65
  ],
56
66
  },
57
67
  ];
58
68
  const plan = planHookDispatch(buildEvent(), modules);
59
69
  expect(plan).toHaveLength(1);
60
- expect(plan[0].scriptPath).toBe('/modules/lunacycle/hooks/rerun.sh');
61
- expect(plan[0].env.CELILO_EVENT_REGISTRY).toBe('npm');
62
- expect(plan[0].env.CELILO_EVENT_TAG).toBe('latest');
63
- expect(plan[0].env.CELILO_EVENT_PACKAGE_NAME).toBe('@celilo/cli');
64
- expect(plan[0].env.CELILO_EVENT_PACKAGE_VERSION).toBe('0.4.0');
65
- const payload = JSON.parse(plan[0].env.CELILO_EVENT_PAYLOAD) as PublishEvent;
66
- expect(payload.package.name).toBe('@celilo/cli');
70
+ expect(plan[0].module.moduleId).toBe('lunacycle');
71
+ expect(plan[0].hook.script).toBe('./hooks/rerun.ts');
67
72
  });
68
73
 
69
74
  test('multiple modules + hooks → one plan entry per match', () => {
70
- const modules: ModuleHookContext[] = [
75
+ const modules = [
71
76
  {
72
77
  moduleId: 'lunacycle',
73
78
  sourcePath: '/modules/lunacycle',
74
79
  hooks: [
75
- { match: { registry: 'npm', package_pattern: '@celilo/cli' }, script: './a.sh' },
76
- { match: { registry: 'npm', package_pattern: '@celilo/e2e' }, script: './b.sh' },
80
+ { match: { registry: 'npm', package_pattern: '@celilo/cli' }, script: './a.ts' },
81
+ { match: { registry: 'npm', package_pattern: '@celilo/e2e' }, script: './b.ts' },
77
82
  ],
78
83
  },
79
84
  {
80
85
  moduleId: 'mgmt',
81
86
  sourcePath: '/modules/mgmt',
82
- hooks: [{ match: { registry: 'celilo-registry' }, script: './c.sh' }],
87
+ hooks: [{ match: { registry: 'celilo-registry' }, script: './c.ts' }],
83
88
  },
84
89
  ];
85
90
  const plan = planHookDispatch(
@@ -88,16 +93,16 @@ describe('planHookDispatch', () => {
88
93
  );
89
94
  expect(plan).toHaveLength(1);
90
95
  expect(plan[0].module.moduleId).toBe('lunacycle');
91
- expect(plan[0].hook.script).toBe('./a.sh');
96
+ expect(plan[0].hook.script).toBe('./a.ts');
92
97
  });
93
98
 
94
99
  test('snake_case package_pattern in manifest maps to camelCase packagePattern in matcher', () => {
95
100
  // Documents the YAML↔JS naming bridge.
96
- const modules: ModuleHookContext[] = [
101
+ const modules = [
97
102
  {
98
103
  moduleId: 'x',
99
104
  sourcePath: '/m',
100
- hooks: [{ match: { package_pattern: '@celilo/*' }, script: './h.sh' }],
105
+ hooks: [{ match: { package_pattern: '@celilo/*' }, script: './h.ts' }],
101
106
  },
102
107
  ];
103
108
  expect(planHookDispatch(buildEvent(), modules)).toHaveLength(1);
@@ -107,101 +112,66 @@ describe('planHookDispatch', () => {
107
112
  });
108
113
  });
109
114
 
110
- describe('runHookDispatch', () => {
111
- let scriptDir: string;
112
-
113
- beforeEach(() => {
114
- scriptDir = mkdtempSync(join(tmpdir(), 'celilo-hook-dispatch-'));
115
- });
116
-
117
- afterEach(() => {
118
- rmSync(scriptDir, { recursive: true, force: true });
119
- });
115
+ describe('runUpstreamHook (routes through runNamedHook)', () => {
116
+ const calls: Array<{ moduleId: string; hookName: string; options: Record<string, unknown> }> = [];
120
117
 
121
- function writeScript(name: string, body: string): string {
122
- const path = join(scriptDir, name);
123
- writeFileSync(path, body);
124
- chmodSync(path, 0o755);
125
- return path;
126
- }
118
+ mock.module('../../hooks/run-named-hook', () => ({
119
+ runNamedHook: async (
120
+ moduleId: string,
121
+ hookName: string,
122
+ _db: unknown,
123
+ _logger: unknown,
124
+ options: Record<string, unknown>,
125
+ ) => {
126
+ calls.push({ moduleId, hookName, options });
127
+ return { success: true, outputs: {}, duration: 7 };
128
+ },
129
+ }));
127
130
 
128
- test('runs a script and captures exit 0 + stdout', async () => {
129
- const scriptPath = writeScript(
130
- 'h.sh',
131
- '#!/bin/bash\necho "hello $CELILO_EVENT_PACKAGE_NAME"\n',
132
- );
133
- const result = await runHookDispatch({
134
- module: { moduleId: 'm', sourcePath: scriptDir, hooks: [] },
135
- hook: { match: {}, script: 'h.sh' },
136
- scriptPath,
137
- env: {
138
- CELILO_EVENT_PAYLOAD: '{}',
139
- CELILO_EVENT_REGISTRY: 'npm',
140
- CELILO_EVENT_TAG: 'latest',
141
- CELILO_EVENT_PACKAGE_NAME: '@celilo/cli',
142
- CELILO_EVENT_PACKAGE_VERSION: '0.4.0',
143
- },
131
+ test('passes the event as input and the matched entry as the hook definition', async () => {
132
+ calls.length = 0;
133
+ const plan: HookDispatchPlan = {
134
+ module: { moduleId: 'lunacycle', sourcePath: '/m', hooks: [] },
135
+ hook: { name: 'rerun-e2e', match: {}, script: './hooks/rerun.ts', timeout: 90_000 },
136
+ };
137
+ const event = buildEvent();
138
+ const result = await runUpstreamHook(plan, event, {
139
+ db: {} as Parameters<typeof runUpstreamHook>[2]['db'],
140
+ logger: SILENT_LOGGER,
144
141
  });
145
- expect(result.exitCode).toBe(0);
146
- expect(result.stdout.trim()).toBe('hello @celilo/cli');
147
- expect(result.timedOut).toBe(false);
148
- });
149
142
 
150
- test('captures non-zero exit + stderr', async () => {
151
- const scriptPath = writeScript('fail.sh', '#!/bin/bash\necho "boom" >&2\nexit 17\n');
152
- const result = await runHookDispatch({
153
- module: { moduleId: 'm', sourcePath: scriptDir, hooks: [] },
154
- hook: { match: {}, script: 'fail.sh' },
155
- scriptPath,
156
- env: {
157
- CELILO_EVENT_PAYLOAD: '{}',
158
- CELILO_EVENT_REGISTRY: 'npm',
159
- CELILO_EVENT_TAG: 'latest',
160
- CELILO_EVENT_PACKAGE_NAME: '@celilo/cli',
161
- CELILO_EVENT_PACKAGE_VERSION: '0.4.0',
162
- },
143
+ expect(result).toEqual({
144
+ module: 'lunacycle',
145
+ hookName: 'rerun-e2e',
146
+ success: true,
147
+ durationMs: expect.any(Number),
163
148
  });
164
- expect(result.exitCode).toBe(17);
165
- expect(result.stderr.trim()).toBe('boom');
149
+ expect(calls).toHaveLength(1);
150
+ expect(calls[0].moduleId).toBe('lunacycle');
151
+ expect(calls[0].hookName).toBe('on_upstream_publish');
152
+ expect(calls[0].options.inputs).toEqual({ event });
153
+ expect(calls[0].options.hookDefinition).toEqual({
154
+ script: './hooks/rerun.ts',
155
+ timeout: 90_000,
156
+ });
157
+ expect(calls[0].options.timeoutMs).toBe(90_000);
166
158
  });
167
159
 
168
- test('respects timeout (kills the script and reports timedOut=true)', async () => {
169
- const scriptPath = writeScript('slow.sh', '#!/bin/bash\nsleep 5\n');
170
- const result = await runHookDispatch({
171
- module: { moduleId: 'm', sourcePath: scriptDir, hooks: [] },
172
- hook: { match: {}, script: 'slow.sh', timeout: 1 },
173
- scriptPath,
174
- env: {
175
- CELILO_EVENT_PAYLOAD: '{}',
176
- CELILO_EVENT_REGISTRY: 'npm',
177
- CELILO_EVENT_TAG: 'latest',
178
- CELILO_EVENT_PACKAGE_NAME: '@celilo/cli',
179
- CELILO_EVENT_PACKAGE_VERSION: '0.4.0',
160
+ test('a runNamedHook throw becomes an unsuccessful result, not a rejection', async () => {
161
+ mock.module('../../hooks/run-named-hook', () => ({
162
+ runNamedHook: async () => {
163
+ throw new Error('boom');
180
164
  },
181
- });
182
- expect(result.timedOut).toBe(true);
183
- // SIGTERM + 500ms grace → SIGKILL means the inner `sleep` should
184
- // be reaped within ~2s of the 1-second timeout firing. Give a
185
- // generous 4s ceiling so we're not flaky on slow CI hosts.
186
- expect(result.durationMs).toBeLessThan(4000);
187
- }, 10000);
188
-
189
- test('records spawn error when the script does not exist', async () => {
190
- const result = await runHookDispatch({
191
- module: { moduleId: 'm', sourcePath: scriptDir, hooks: [] },
192
- hook: { match: {}, script: 'does-not-exist.sh' },
193
- scriptPath: join(scriptDir, 'does-not-exist.sh'),
194
- env: {
195
- CELILO_EVENT_PAYLOAD: '{}',
196
- CELILO_EVENT_REGISTRY: 'npm',
197
- CELILO_EVENT_TAG: 'latest',
198
- CELILO_EVENT_PACKAGE_NAME: '@celilo/cli',
199
- CELILO_EVENT_PACKAGE_VERSION: '0.4.0',
165
+ }));
166
+ const result = await runUpstreamHook(
167
+ {
168
+ module: { moduleId: 'lunacycle', sourcePath: '/m', hooks: [] },
169
+ hook: { match: {}, script: './hooks/rerun.ts' },
200
170
  },
201
- });
202
- // bash exits with non-zero when the script file doesn't exist
203
- // (status 127). Either non-zero exit or a null/spawn-error is
204
- // acceptable here; we just want NOT-success.
205
- expect(result.exitCode === 0).toBe(false);
171
+ buildEvent(),
172
+ { db: {} as never, logger: SILENT_LOGGER },
173
+ );
174
+ expect(result.success).toBe(false);
175
+ expect(result.error).toContain('boom');
206
176
  });
207
177
  });
@@ -1,32 +1,39 @@
1
1
  /**
2
2
  * Hook-dispatch planning + execution for the build bus
3
- * ([[openspec/changes/build-bus-poll-cd/proposal.md]] Phase 4).
3
+ * ([[openspec/changes/build-bus-poll-cd/proposal.md]] Phase 4, reshaped by
4
+ * module-orchestrator-primitives slice 7).
4
5
  *
5
6
  * Pure: planHookDispatch(event, modules) → HookDispatchPlan[].
6
- * Decides which module hooks match the event without spawning
7
- * anything; the executor (runHookDispatch) takes a plan and
8
- * spawns the actual scripts.
7
+ * Decides which module hooks match the event; the executor
8
+ * (runUpstreamHook) takes a plan plus the event and runs the matched
9
+ * entry through the ORDINARY hook executor — `runNamedHook` →
10
+ * `invokeHook`, jailed, with the verified PublishEvent as the hook's
11
+ * `event` input.
9
12
  *
10
- * The hook script gets the event in its environment:
11
- * CELILO_EVENT_PAYLOAD — full PublishEvent as JSON
12
- * CELILO_EVENT_REGISTRY — convenience (also in payload)
13
- * CELILO_EVENT_TAG — convenience
14
- * CELILO_EVENT_PACKAGE_NAME — convenience
15
- * CELILO_EVENT_PACKAGE_VERSION — convenience
13
+ * There is exactly one hook dispatch path in celilo (tasks.md 7.1):
14
+ * `apps/celilo/src/hooks/executor.ts`. This module used to carry the
15
+ * second one — `runHookDispatch` spawned `bash <scriptPath>` detached
16
+ * with `env: { ...process.env, ...plan.env }`, no jail, no broker — and
17
+ * slice 7 deleted it. `planHookDispatch`'s match logic is unchanged.
16
18
  *
17
- * Non-zero exit is logged but doesn't gate the receiver — failures
18
- * are operational signal for the operator, not a publish blocker.
19
+ * Non-zero hook outcomes are logged by the dispatcher but don't gate the
20
+ * receiver — failures are operational signal for the operator, not a
21
+ * publish blocker.
19
22
  */
20
23
 
21
- import { spawn } from 'node:child_process';
22
- import { join } from 'node:path';
24
+ import type { HookDefinition } from '@celilo/capabilities';
23
25
  import { type PublishEvent, matchesRule } from '@celilo/event-bus/build-bus';
26
+ import { type DbClient, getDb } from '../../db/client';
27
+ import { runNamedHook } from '../../hooks/run-named-hook';
28
+ import type { HookLogger } from '../../hooks/types';
24
29
 
25
30
  /** Mirrors UpstreamPublishHookSchema in the manifest schema. */
26
31
  export interface UpstreamHookEntry {
27
32
  name?: string;
28
33
  match: { registry?: string; tag?: string; package_pattern?: string };
34
+ /** Path to a TypeScript `defineHook` script, relative to the module directory. */
29
35
  script: string;
36
+ /** Milliseconds before the hook is killed. */
30
37
  timeout?: number;
31
38
  }
32
39
 
@@ -45,16 +52,15 @@ export interface ModuleHookContext {
45
52
  export interface HookDispatchPlan {
46
53
  module: ModuleHookContext;
47
54
  hook: UpstreamHookEntry;
48
- /** Absolute path the executor will spawn. */
49
- scriptPath: string;
50
- /** Env vars to pass to the script. */
51
- env: Record<string, string>;
52
55
  }
53
56
 
57
+ /** Matches the old bash path's default (600s), in milliseconds. */
58
+ const DEFAULT_TIMEOUT_MS = 600_000;
59
+
54
60
  /**
55
61
  * Pure: walk every (module, hook) combination, keep the ones whose
56
62
  * match rule fires for this event. Same world → same plan; injects
57
- * nothing.
63
+ * nothing. The match logic is unchanged since the bash-spawn days.
58
64
  *
59
65
  * The manifest's match uses snake_case (`package_pattern`) to match
60
66
  * YAML conventions, but the @celilo/event-bus/build-bus matcher
@@ -66,7 +72,6 @@ export function planHookDispatch(
66
72
  modules: ModuleHookContext[],
67
73
  ): HookDispatchPlan[] {
68
74
  const plans: HookDispatchPlan[] = [];
69
- const env = buildHookEnv(event);
70
75
  for (const module of modules) {
71
76
  for (const hook of module.hooks) {
72
77
  const rule = {
@@ -75,124 +80,97 @@ export function planHookDispatch(
75
80
  packagePattern: hook.match.package_pattern,
76
81
  };
77
82
  if (!matchesRule(event, rule)) continue;
78
- plans.push({
79
- module,
80
- hook,
81
- scriptPath: join(module.sourcePath, hook.script),
82
- env,
83
- });
83
+ plans.push({ module, hook });
84
84
  }
85
85
  }
86
86
  return plans;
87
87
  }
88
88
 
89
- function buildHookEnv(event: PublishEvent): Record<string, string> {
90
- return {
91
- CELILO_EVENT_PAYLOAD: JSON.stringify(event),
92
- CELILO_EVENT_REGISTRY: event.registry,
93
- CELILO_EVENT_TAG: event.tag,
94
- CELILO_EVENT_PACKAGE_NAME: event.package.name,
95
- CELILO_EVENT_PACKAGE_VERSION: event.package.version,
96
- };
97
- }
98
-
99
- const DEFAULT_TIMEOUT_SEC = 600;
100
-
101
- export interface HookRunResult {
89
+ export interface UpstreamHookResult {
102
90
  module: string;
103
91
  hookName: string;
104
- scriptPath: string;
105
- exitCode: number | null;
92
+ success: boolean;
93
+ error?: string;
106
94
  durationMs: number;
107
- stdout: string;
108
- stderr: string;
109
- timedOut: boolean;
95
+ }
96
+
97
+ /** What running one plan entry needs. Injectable so tests don't need a daemon. */
98
+ export interface UpstreamHookDeps {
99
+ db: DbClient;
100
+ logger: HookLogger;
110
101
  }
111
102
 
112
103
  /**
113
- * Run one plan entry. Spawns `bash -c <scriptPath>` so shell features
114
- * (pipe, redirect, env-var expansion) work inside the hook. Captures
115
- * stdout/stderr for the operator log; kills the process on timeout.
104
+ * Run one plan entry through the ordinary executor (module-orchestrator-
105
+ * primitives slice 7, tasks.md 7.4). The matched entry's script runs as a
106
+ * `defineHook` under `invokeHook`, jailed, with the verified PublishEvent
107
+ * as its `event` input — the same path `celilo module run-hook` uses for
108
+ * every other hook.
116
109
  *
117
110
  * Doesn't throw — the result carries the outcome. Caller (dispatcher)
118
111
  * logs failures and keeps going to the next plan entry.
119
112
  */
120
- export async function runHookDispatch(plan: HookDispatchPlan): Promise<HookRunResult> {
121
- const timeoutSec = plan.hook.timeout ?? DEFAULT_TIMEOUT_SEC;
113
+ export async function runUpstreamHook(
114
+ plan: HookDispatchPlan,
115
+ event: PublishEvent,
116
+ deps: UpstreamHookDeps,
117
+ ): Promise<UpstreamHookResult> {
122
118
  const hookName = plan.hook.name ?? plan.hook.script;
123
119
  const startedAt = Date.now();
124
-
125
- return new Promise((resolve) => {
126
- // `detached: true` puts bash + its children in their own
127
- // process group so the timeout can kill the whole subtree (not
128
- // just the bash parent, which would otherwise leave a `sleep`
129
- // running). `process.kill(-pid, …)` signals the group.
130
- const child = spawn('bash', [plan.scriptPath], {
131
- env: { ...process.env, ...plan.env },
132
- stdio: ['ignore', 'pipe', 'pipe'],
133
- detached: true,
134
- });
135
- const pgid = child.pid; // process group leader == its own PID
136
-
137
- let stdout = '';
138
- let stderr = '';
139
- let timedOut = false;
140
- let killTimer: ReturnType<typeof setTimeout> | undefined;
141
- const killGroup = (signal: NodeJS.Signals) => {
142
- if (pgid == null) return;
143
- try {
144
- process.kill(-pgid, signal);
145
- } catch {
146
- // Group already gone — fall back to direct child signal.
147
- try {
148
- child.kill(signal);
149
- } catch {
150
- // ignore
151
- }
152
- }
120
+ // `HookDefinition.timeout` is milliseconds; the manifest entry declares
121
+ // milliseconds too (schema: "Milliseconds before the hook is killed").
122
+ const definition: HookDefinition = {
123
+ script: plan.hook.script,
124
+ timeout: plan.hook.timeout ?? DEFAULT_TIMEOUT_MS,
125
+ };
126
+ try {
127
+ const result = await runNamedHook(
128
+ plan.module.moduleId,
129
+ 'on_upstream_publish',
130
+ deps.db,
131
+ deps.logger,
132
+ {
133
+ inputs: { event },
134
+ timeoutMs: definition.timeout,
135
+ hookDefinition: definition,
136
+ },
137
+ );
138
+ return {
139
+ module: plan.module.moduleId,
140
+ hookName,
141
+ success: result.success,
142
+ error: result.error,
143
+ durationMs: Date.now() - startedAt,
153
144
  };
154
- const timer = setTimeout(() => {
155
- timedOut = true;
156
- killGroup('SIGTERM');
157
- // SIGTERM may not be honored by long-running children
158
- // (`sleep`, network waits). Follow up with SIGKILL after a
159
- // brief grace period.
160
- killTimer = setTimeout(() => killGroup('SIGKILL'), 200);
161
- }, timeoutSec * 1000);
145
+ } catch (error) {
146
+ return {
147
+ module: plan.module.moduleId,
148
+ hookName,
149
+ success: false,
150
+ error: error instanceof Error ? error.message : String(error),
151
+ durationMs: Date.now() - startedAt,
152
+ };
153
+ }
154
+ }
162
155
 
163
- child.stdout?.on('data', (chunk: Buffer) => {
164
- stdout += chunk.toString('utf-8');
165
- });
166
- child.stderr?.on('data', (chunk: Buffer) => {
167
- stderr += chunk.toString('utf-8');
168
- });
169
- child.on('error', (err) => {
170
- clearTimeout(timer);
171
- if (killTimer) clearTimeout(killTimer);
172
- resolve({
173
- module: plan.module.moduleId,
174
- hookName,
175
- scriptPath: plan.scriptPath,
176
- exitCode: null,
177
- durationMs: Date.now() - startedAt,
178
- stdout,
179
- stderr: `${stderr}\nspawn error: ${err.message}`,
180
- timedOut: false,
181
- });
182
- });
183
- child.on('close', (code) => {
184
- clearTimeout(timer);
185
- if (killTimer) clearTimeout(killTimer);
186
- resolve({
187
- module: plan.module.moduleId,
188
- hookName,
189
- scriptPath: plan.scriptPath,
190
- exitCode: code,
191
- durationMs: Date.now() - startedAt,
192
- stdout,
193
- stderr,
194
- timedOut,
195
- });
196
- });
197
- });
156
+ /**
157
+ * Convenience for the dispatcher: deps over the process's database. Tests
158
+ * inject `runUpstreamHook` instead so they never touch it.
159
+ */
160
+ export function defaultUpstreamHookDeps(db?: DbClient): UpstreamHookDeps {
161
+ return {
162
+ get db() {
163
+ return db ?? getDb();
164
+ },
165
+ logger: consoleLogger(),
166
+ };
167
+ }
168
+
169
+ function consoleLogger(): HookLogger {
170
+ return {
171
+ info: (message: string) => console.log(message),
172
+ warn: (message: string) => console.warn(message),
173
+ error: (message: string) => console.error(message),
174
+ success: (message: string) => console.log(message),
175
+ };
198
176
  }