@celilo/cli 0.3.30 → 0.4.0-alpha.1

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 (155) hide show
  1. package/drizzle/0005_module_operations.sql +12 -0
  2. package/drizzle/0006_base_module_aspects.sql +15 -0
  3. package/drizzle/0007_module_systems.sql +17 -0
  4. package/drizzle/meta/_journal.json +21 -0
  5. package/package.json +6 -5
  6. package/schemas/system_config.json +14 -28
  7. package/src/ansible/inventory.test.ts +46 -62
  8. package/src/ansible/inventory.ts +48 -25
  9. package/src/capabilities/registration.ts +25 -7
  10. package/src/capabilities/validation.test.ts +30 -0
  11. package/src/capabilities/validation.ts +8 -0
  12. package/src/cli/backup-rename.test.ts +95 -0
  13. package/src/cli/cli.test.ts +17 -23
  14. package/src/cli/command-registry.ts +199 -0
  15. package/src/cli/commands/backup-list.ts +1 -1
  16. package/src/cli/commands/events.ts +96 -0
  17. package/src/cli/commands/machine-add.ts +103 -59
  18. package/src/cli/commands/module-import.ts +153 -4
  19. package/src/cli/commands/module-remove.ts +86 -17
  20. package/src/cli/commands/module-status.ts +6 -2
  21. package/src/cli/commands/publish/alpha.test.ts +185 -0
  22. package/src/cli/commands/publish/alpha.ts +226 -0
  23. package/src/cli/commands/publish/changesets.test.ts +89 -0
  24. package/src/cli/commands/publish/changesets.ts +144 -0
  25. package/src/cli/commands/publish/consumer-pins.test.ts +155 -0
  26. package/src/cli/commands/publish/consumer-pins.ts +149 -0
  27. package/src/cli/commands/publish/execute.ts +131 -0
  28. package/src/cli/commands/publish/global-install.test.ts +154 -0
  29. package/src/cli/commands/publish/global-install.ts +171 -0
  30. package/src/cli/commands/publish/helpers.ts +227 -0
  31. package/src/cli/commands/publish/index.ts +365 -0
  32. package/src/cli/commands/publish/module-registry.test.ts +40 -0
  33. package/src/cli/commands/publish/module-registry.ts +64 -0
  34. package/src/cli/commands/publish/plan.ts +107 -0
  35. package/src/cli/commands/publish/preflight.ts +238 -0
  36. package/src/cli/commands/publish/types.ts +264 -0
  37. package/src/cli/commands/publish/workspace.test.ts +323 -0
  38. package/src/cli/commands/publish/workspace.ts +596 -0
  39. package/src/cli/commands/restore.ts +126 -0
  40. package/src/cli/commands/storage-add-local.ts +1 -1
  41. package/src/cli/commands/storage-add-s3.ts +1 -1
  42. package/src/cli/commands/subscribers-add.ts +68 -0
  43. package/src/cli/commands/subscribers-list.ts +48 -0
  44. package/src/cli/commands/subscribers-remove.ts +38 -0
  45. package/src/cli/commands/subscribers-serve.ts +77 -0
  46. package/src/cli/commands/subscribers-status.ts +33 -0
  47. package/src/cli/commands/subscribers-test.ts +71 -0
  48. package/src/cli/commands/system-apply-config-equivalence.test.ts +108 -0
  49. package/src/cli/commands/system-apply-config.test.ts +70 -0
  50. package/src/cli/commands/system-apply-config.ts +130 -0
  51. package/src/cli/commands/system-audit.ts +2 -1
  52. package/src/cli/commands/system-init-deprecation.test.ts +90 -0
  53. package/src/cli/commands/system-init.ts +36 -70
  54. package/src/cli/commands/system-update.ts +3 -2
  55. package/src/cli/completion.ts +22 -1
  56. package/src/cli/index.ts +214 -6
  57. package/src/cli/interactive-config.test.ts +19 -0
  58. package/src/cli/restore-command.test.ts +131 -0
  59. package/src/db/client.ts +42 -0
  60. package/src/db/schema.test.ts +13 -16
  61. package/src/db/schema.ts +161 -9
  62. package/src/hooks/capability-loader-firewall.test.ts +6 -15
  63. package/src/hooks/capability-loader.test.ts +2 -3
  64. package/src/hooks/capability-loader.ts +36 -2
  65. package/src/hooks/define-hook.test.ts +4 -0
  66. package/src/hooks/executor.test.ts +18 -0
  67. package/src/hooks/executor.ts +21 -2
  68. package/src/hooks/load-hook-config.test.ts +26 -24
  69. package/src/hooks/load-hook-config.ts +11 -2
  70. package/src/hooks/run-named-hook.ts +16 -0
  71. package/src/hooks/types.ts +9 -1
  72. package/src/manifest/contracts/v1.ts +70 -0
  73. package/src/manifest/schema.ts +262 -16
  74. package/src/manifest/validate-privileged.test.ts +84 -0
  75. package/src/manifest/validate.test.ts +156 -0
  76. package/src/manifest/validate.ts +69 -0
  77. package/src/module/import.ts +12 -0
  78. package/src/services/aspect-approvals.test.ts +231 -0
  79. package/src/services/aspect-approvals.ts +120 -0
  80. package/src/services/aspect-runner.test.ts +493 -0
  81. package/src/services/aspect-runner.ts +438 -0
  82. package/src/services/aspect-template-resolver.test.ts +101 -0
  83. package/src/services/aspect-template-resolver.ts +122 -0
  84. package/src/services/backup-create.ts +104 -25
  85. package/src/services/backup-envelope-roundtrip.test.ts +199 -0
  86. package/src/services/backup-in-flight-refusal.test.ts +163 -0
  87. package/src/services/backup-manifest.test.ts +115 -0
  88. package/src/services/backup-manifest.ts +163 -0
  89. package/src/services/backup-restore.ts +154 -19
  90. package/src/services/build-bus/delivery-events.ts +92 -0
  91. package/src/services/build-bus/event-factory.ts +54 -0
  92. package/src/services/build-bus/fan-out.test.ts +279 -0
  93. package/src/services/build-bus/fan-out.ts +161 -0
  94. package/src/services/build-bus/hook-dispatch-mgmt.test.ts +157 -0
  95. package/src/services/build-bus/hook-dispatch.test.ts +207 -0
  96. package/src/services/build-bus/hook-dispatch.ts +198 -0
  97. package/src/services/build-bus/hook-dispatcher.ts +115 -0
  98. package/src/services/build-bus/index.ts +41 -0
  99. package/src/services/build-bus/receiver-server.test.ts +179 -0
  100. package/src/services/build-bus/receiver-server.ts +159 -0
  101. package/src/services/build-bus/status.test.ts +212 -0
  102. package/src/services/build-bus/status.ts +213 -0
  103. package/src/services/build-bus/subscriber-store.ts +113 -0
  104. package/src/services/celilo-events.test.ts +70 -0
  105. package/src/services/celilo-events.ts +92 -0
  106. package/src/services/celilo-mgmt-hooks.test.ts +296 -0
  107. package/src/services/config-interview.ts +13 -95
  108. package/src/services/cross-module-data-manager.ts +2 -31
  109. package/src/services/cross-module-read.test.ts +250 -0
  110. package/src/services/cross-module-read.ts +232 -0
  111. package/src/services/deploy-validation.ts +7 -0
  112. package/src/services/deployed-systems.test.ts +235 -0
  113. package/src/services/deployed-systems.ts +308 -0
  114. package/src/services/dns-provider-backfill.ts +75 -0
  115. package/src/services/health-runner.ts +19 -3
  116. package/src/services/infrastructure-variable-resolver.test.ts +6 -32
  117. package/src/services/infrastructure-variable-resolver.ts +3 -13
  118. package/src/services/machine-detector.ts +104 -48
  119. package/src/services/machine-pool.ts +145 -2
  120. package/src/services/module-config.ts +78 -120
  121. package/src/services/module-deploy.ts +113 -40
  122. package/src/services/module-operations.test.ts +154 -0
  123. package/src/services/module-operations.ts +154 -0
  124. package/src/services/module-subscriptions.test.ts +58 -0
  125. package/src/services/module-subscriptions.ts +24 -1
  126. package/src/services/module-types-generator.test.ts +3 -3
  127. package/src/services/module-types-generator.ts +7 -2
  128. package/src/services/proxmox-reconcile.test.ts +333 -0
  129. package/src/services/proxmox-reconcile.ts +156 -0
  130. package/src/services/proxmox-state-recovery.ts +3 -24
  131. package/src/services/restore-from-file.test.ts +177 -0
  132. package/src/services/restore-from-file.ts +355 -0
  133. package/src/services/restore-preflight.test.ts +127 -0
  134. package/src/services/restore-preflight.ts +118 -0
  135. package/src/services/storage-providers/s3.ts +10 -2
  136. package/src/services/system-identity.ts +30 -0
  137. package/src/services/system-init.test.ts +64 -21
  138. package/src/services/system-init.ts +28 -26
  139. package/src/templates/generator.test.ts +7 -16
  140. package/src/templates/generator.ts +28 -115
  141. package/src/test-utils/integration.ts +5 -2
  142. package/src/types/infrastructure.ts +8 -0
  143. package/src/variables/computed/computed-integration.test.ts +191 -0
  144. package/src/variables/computed/computed.test.ts +177 -0
  145. package/src/variables/computed/evaluate.ts +271 -0
  146. package/src/variables/computed/marker.ts +53 -0
  147. package/src/variables/computed/parse.ts +262 -0
  148. package/src/variables/computed/provider-lookup.ts +130 -0
  149. package/src/variables/context.test.ts +89 -28
  150. package/src/variables/context.ts +196 -191
  151. package/src/variables/parser.ts +3 -3
  152. package/src/variables/resolver.test.ts +61 -0
  153. package/src/variables/resolver.ts +81 -0
  154. package/src/variables/types.ts +23 -1
  155. package/src/services/dns-auto-register.ts +0 -211
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Hook-dispatch tests. The planner is pure (no I/O); the runner
3
+ * spawns bash. We test:
4
+ *
5
+ * - planHookDispatch: rule matching, env construction, scriptPath
6
+ * resolution.
7
+ * - runHookDispatch: real bash spawn with a fixture script.
8
+ */
9
+
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';
14
+ import type { PublishEvent } from '@celilo/event-bus/build-bus';
15
+ import { type ModuleHookContext, planHookDispatch, runHookDispatch } from './hook-dispatch';
16
+
17
+ function buildEvent(overrides: Partial<PublishEvent> = {}): PublishEvent {
18
+ return {
19
+ eventId: 'evt-1',
20
+ timestamp: new Date().toISOString(),
21
+ registry: 'npm',
22
+ tag: 'latest',
23
+ package: { name: '@celilo/cli', version: '0.4.0' },
24
+ ...overrides,
25
+ };
26
+ }
27
+
28
+ describe('planHookDispatch', () => {
29
+ test('empty module list → empty plan', () => {
30
+ expect(planHookDispatch(buildEvent(), [])).toEqual([]);
31
+ });
32
+
33
+ test('module with no matching hooks → empty plan', () => {
34
+ const modules: ModuleHookContext[] = [
35
+ {
36
+ moduleId: 'lunacycle',
37
+ sourcePath: '/modules/lunacycle',
38
+ hooks: [{ match: { registry: 'celilo-registry' }, script: './reload.sh' }],
39
+ },
40
+ ];
41
+ expect(planHookDispatch(buildEvent({ registry: 'npm' }), modules)).toEqual([]);
42
+ });
43
+
44
+ test('matching hook → plan with resolved scriptPath + env', () => {
45
+ const modules: ModuleHookContext[] = [
46
+ {
47
+ moduleId: 'lunacycle',
48
+ sourcePath: '/modules/lunacycle',
49
+ hooks: [
50
+ {
51
+ name: 'rerun-e2e',
52
+ match: { registry: 'npm', package_pattern: '@celilo/*' },
53
+ script: './hooks/rerun.sh',
54
+ },
55
+ ],
56
+ },
57
+ ];
58
+ const plan = planHookDispatch(buildEvent(), modules);
59
+ 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');
67
+ });
68
+
69
+ test('multiple modules + hooks → one plan entry per match', () => {
70
+ const modules: ModuleHookContext[] = [
71
+ {
72
+ moduleId: 'lunacycle',
73
+ sourcePath: '/modules/lunacycle',
74
+ hooks: [
75
+ { match: { registry: 'npm', package_pattern: '@celilo/cli' }, script: './a.sh' },
76
+ { match: { registry: 'npm', package_pattern: '@celilo/e2e' }, script: './b.sh' },
77
+ ],
78
+ },
79
+ {
80
+ moduleId: 'mgmt',
81
+ sourcePath: '/modules/mgmt',
82
+ hooks: [{ match: { registry: 'celilo-registry' }, script: './c.sh' }],
83
+ },
84
+ ];
85
+ const plan = planHookDispatch(
86
+ buildEvent({ package: { name: '@celilo/cli', version: '0.4.0' } }),
87
+ modules,
88
+ );
89
+ expect(plan).toHaveLength(1);
90
+ expect(plan[0].module.moduleId).toBe('lunacycle');
91
+ expect(plan[0].hook.script).toBe('./a.sh');
92
+ });
93
+
94
+ test('snake_case package_pattern in manifest maps to camelCase packagePattern in matcher', () => {
95
+ // Documents the YAML↔JS naming bridge.
96
+ const modules: ModuleHookContext[] = [
97
+ {
98
+ moduleId: 'x',
99
+ sourcePath: '/m',
100
+ hooks: [{ match: { package_pattern: '@celilo/*' }, script: './h.sh' }],
101
+ },
102
+ ];
103
+ expect(planHookDispatch(buildEvent(), modules)).toHaveLength(1);
104
+ expect(
105
+ planHookDispatch(buildEvent({ package: { name: '@other/x', version: '1.0.0' } }), modules),
106
+ ).toHaveLength(0);
107
+ });
108
+ });
109
+
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
+ });
120
+
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
+ }
127
+
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
+ },
144
+ });
145
+ expect(result.exitCode).toBe(0);
146
+ expect(result.stdout.trim()).toBe('hello @celilo/cli');
147
+ expect(result.timedOut).toBe(false);
148
+ });
149
+
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
+ },
163
+ });
164
+ expect(result.exitCode).toBe(17);
165
+ expect(result.stderr.trim()).toBe('boom');
166
+ });
167
+
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',
180
+ },
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',
200
+ },
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);
206
+ });
207
+ });
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Hook-dispatch planning + execution for the build bus
3
+ * ([[v2/BUILD_BUS.md]] Phase 4).
4
+ *
5
+ * 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.
9
+ *
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
16
+ *
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
+ */
20
+
21
+ import { spawn } from 'node:child_process';
22
+ import { join } from 'node:path';
23
+ import { type PublishEvent, matchesRule } from '@celilo/event-bus/build-bus';
24
+
25
+ /** Mirrors UpstreamPublishHookSchema in the manifest schema. */
26
+ export interface UpstreamHookEntry {
27
+ name?: string;
28
+ match: { registry?: string; tag?: string; package_pattern?: string };
29
+ script: string;
30
+ timeout?: number;
31
+ }
32
+
33
+ /**
34
+ * What the dispatcher knows about a single installed module that
35
+ * declares on_upstream_publish hooks. Built by the dispatcher's
36
+ * loader (which reads the celilo modules table + each module's
37
+ * manifest.yml); injected as a list into planHookDispatch.
38
+ */
39
+ export interface ModuleHookContext {
40
+ moduleId: string;
41
+ sourcePath: string;
42
+ hooks: UpstreamHookEntry[];
43
+ }
44
+
45
+ export interface HookDispatchPlan {
46
+ module: ModuleHookContext;
47
+ 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
+ }
53
+
54
+ /**
55
+ * Pure: walk every (module, hook) combination, keep the ones whose
56
+ * match rule fires for this event. Same world → same plan; injects
57
+ * nothing.
58
+ *
59
+ * The manifest's match uses snake_case (`package_pattern`) to match
60
+ * YAML conventions, but the @celilo/event-bus/build-bus matcher
61
+ * uses camelCase (`packagePattern`). Bridge that here so the
62
+ * manifest stays YAML-idiomatic and the typed-API stays JS-idiomatic.
63
+ */
64
+ export function planHookDispatch(
65
+ event: PublishEvent,
66
+ modules: ModuleHookContext[],
67
+ ): HookDispatchPlan[] {
68
+ const plans: HookDispatchPlan[] = [];
69
+ const env = buildHookEnv(event);
70
+ for (const module of modules) {
71
+ for (const hook of module.hooks) {
72
+ const rule = {
73
+ registry: hook.match.registry,
74
+ tag: hook.match.tag,
75
+ packagePattern: hook.match.package_pattern,
76
+ };
77
+ if (!matchesRule(event, rule)) continue;
78
+ plans.push({
79
+ module,
80
+ hook,
81
+ scriptPath: join(module.sourcePath, hook.script),
82
+ env,
83
+ });
84
+ }
85
+ }
86
+ return plans;
87
+ }
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 {
102
+ module: string;
103
+ hookName: string;
104
+ scriptPath: string;
105
+ exitCode: number | null;
106
+ durationMs: number;
107
+ stdout: string;
108
+ stderr: string;
109
+ timedOut: boolean;
110
+ }
111
+
112
+ /**
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.
116
+ *
117
+ * Doesn't throw — the result carries the outcome. Caller (dispatcher)
118
+ * logs failures and keeps going to the next plan entry.
119
+ */
120
+ export async function runHookDispatch(plan: HookDispatchPlan): Promise<HookRunResult> {
121
+ const timeoutSec = plan.hook.timeout ?? DEFAULT_TIMEOUT_SEC;
122
+ const hookName = plan.hook.name ?? plan.hook.script;
123
+ 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
+ }
153
+ };
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);
162
+
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
+ });
198
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Build-bus hook dispatcher service.
3
+ *
4
+ * Stateful wrapper around `planHookDispatch` + `runHookDispatch`:
5
+ * loads installed-module manifests from the celilo DB, dispatches
6
+ * matching `on_upstream_publish` hooks when a verified PublishEvent
7
+ * arrives, logs outcomes.
8
+ *
9
+ * Started by the `celilo subscribers serve` daemon (in-process,
10
+ * sharing the same lifetime); the receiver-server calls
11
+ * `dispatcher.handleEvent(event)` on every verified webhook.
12
+ *
13
+ * Module loading is injectable so tests can drive the dispatcher
14
+ * with synthetic ModuleHookContext lists. Production loads from the
15
+ * DB + filesystem (manifest.yml under each module's source_path).
16
+ */
17
+
18
+ import { readFileSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+ import type { PublishEvent } from '@celilo/event-bus/build-bus';
21
+ import { parse as parseYaml } from 'yaml';
22
+ import { getDb } from '../../db/client';
23
+ import { modules } from '../../db/schema';
24
+ import {
25
+ type HookRunResult,
26
+ type ModuleHookContext,
27
+ type UpstreamHookEntry,
28
+ planHookDispatch,
29
+ runHookDispatch,
30
+ } from './hook-dispatch';
31
+
32
+ export interface HookDispatcher {
33
+ /** Run the matching hooks for a single event. Returns the per-hook results. */
34
+ handleEvent(event: PublishEvent): Promise<HookRunResult[]>;
35
+ /** Clean up (currently a no-op; included for symmetry with the receiver). */
36
+ stop(): Promise<void>;
37
+ }
38
+
39
+ export interface HookDispatcherOptions {
40
+ /**
41
+ * Override the module-loading step. Tests inject a fixture; in
42
+ * production we omit and the dispatcher reads from the celilo
43
+ * modules table.
44
+ */
45
+ loadModules?: () => ModuleHookContext[];
46
+ }
47
+
48
+ export async function startHookDispatcher(
49
+ opts: HookDispatcherOptions = {},
50
+ ): Promise<HookDispatcher> {
51
+ const loadModules = opts.loadModules ?? loadInstalledModulesWithHooks;
52
+
53
+ return {
54
+ async handleEvent(event) {
55
+ const moduleContexts = loadModules();
56
+ const plans = planHookDispatch(event, moduleContexts);
57
+ if (plans.length === 0) {
58
+ console.log(
59
+ `[build-bus] ${event.package.name}@${event.package.version} (${event.tag}) — no module hooks match`,
60
+ );
61
+ return [];
62
+ }
63
+ const results: HookRunResult[] = [];
64
+ for (const plan of plans) {
65
+ console.log(
66
+ `[build-bus] ${event.package.name}@${event.package.version} → ${plan.module.moduleId} (${plan.hook.name ?? plan.hook.script})`,
67
+ );
68
+ const result = await runHookDispatch(plan);
69
+ results.push(result);
70
+ if (result.exitCode === 0) {
71
+ console.log(` ✓ exit 0 in ${result.durationMs}ms`);
72
+ } else if (result.timedOut) {
73
+ console.warn(` ✗ timed out after ${result.durationMs}ms`);
74
+ } else {
75
+ console.warn(` ✗ exit ${result.exitCode} in ${result.durationMs}ms`);
76
+ if (result.stderr) console.warn(` stderr: ${result.stderr.trim().slice(0, 500)}`);
77
+ }
78
+ }
79
+ return results;
80
+ },
81
+ async stop() {
82
+ // No long-running connections to clean up — modules are
83
+ // re-loaded per event so we can hot-pick up new installs.
84
+ },
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Load every installed module's `on_upstream_publish` hooks from the
90
+ * celilo DB + each module's manifest.yml. Modules without the hook
91
+ * type are filtered out. Modules whose manifest fails to parse are
92
+ * silently skipped — the operator sees this when they next run
93
+ * `celilo module check`.
94
+ */
95
+ function loadInstalledModulesWithHooks(): ModuleHookContext[] {
96
+ const db = getDb();
97
+ const rows = db.select({ id: modules.id, sourcePath: modules.sourcePath }).from(modules).all();
98
+ const out: ModuleHookContext[] = [];
99
+ for (const row of rows) {
100
+ if (!row.sourcePath) continue;
101
+ try {
102
+ const parsed = parseYaml(readFileSync(join(row.sourcePath, 'manifest.yml'), 'utf-8')) as {
103
+ hooks?: { on_upstream_publish?: UpstreamHookEntry[] };
104
+ };
105
+ const hooks = parsed.hooks?.on_upstream_publish ?? [];
106
+ if (hooks.length === 0) continue;
107
+ out.push({ moduleId: row.id, sourcePath: row.sourcePath, hooks });
108
+ } catch {
109
+ // Skip modules whose manifests can't be loaded — log when this
110
+ // turns out to be a real problem rather than during routine
111
+ // event dispatch.
112
+ }
113
+ }
114
+ return out;
115
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Build-bus service surface for the celilo app.
3
+ *
4
+ * `@celilo/event-bus/build-bus` carries the wire types + crypto;
5
+ * this layer adds the operator-side concerns:
6
+ *
7
+ * - subscriber-store: read/write the static-config subscriber
8
+ * list under getDataDir()
9
+ * - fan-out: signed HTTP delivery with retry
10
+ * - event-factory: build PublishEvents from the publish flow's
11
+ * output
12
+ */
13
+
14
+ export {
15
+ subscriberStorePath,
16
+ loadSubscribers,
17
+ saveSubscribers,
18
+ addSubscriber,
19
+ removeSubscriberByUrl,
20
+ } from './subscriber-store';
21
+
22
+ export { fanOut, formatDeliveryResult } from './fan-out';
23
+ export type { FanOutOptions } from './fan-out';
24
+
25
+ export { eventsForPublished } from './event-factory';
26
+ export type { EventFactoryInput, PublishedItem } from './event-factory';
27
+
28
+ export {
29
+ recordDeliveryOutcome,
30
+ WEBHOOK_DELIVERED_EVENT,
31
+ WEBHOOK_FAILED_EVENT,
32
+ } from './delivery-events';
33
+ export type { WebhookDeliveryPayload } from './delivery-events';
34
+
35
+ export {
36
+ aggregateSubscriberStatus,
37
+ describeAgo,
38
+ formatStatus,
39
+ loadSubscriberStatus,
40
+ } from './status';
41
+ export type { DeliveryRecord, FailureSnapshot, SubscriberStatus } from './status';