@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,10 +1,16 @@
1
1
  /**
2
2
  * Build-bus hook dispatcher service.
3
3
  *
4
- * Stateful wrapper around `planHookDispatch` + `runHookDispatch`:
4
+ * Stateful wrapper around `planHookDispatch` + `runUpstreamHook`:
5
5
  * loads installed-module manifests from the celilo DB, dispatches
6
6
  * matching `on_upstream_publish` hooks when a verified PublishEvent
7
- * arrives, logs outcomes.
7
+ * arrives, and records every run durably in the `build_bus_hook_runs`
8
+ * ledger (celilo#1304) — without which a failed run exists only as
9
+ * daemon console output, which no surface reads. Since
10
+ * module-orchestrator-primitives slice 7 it also performs the FRAMEWORK
11
+ * self-update on the same events (tasks.md 7.2): celilo replacing its
12
+ * own binaries on the management host is class H work that stopped
13
+ * being a module hook (design D6).
8
14
  *
9
15
  * Started by the `celilo subscribers serve` daemon (in-process,
10
16
  * sharing the same lifetime); the receiver-server calls
@@ -18,62 +24,167 @@
18
24
  import { readFileSync } from 'node:fs';
19
25
  import { join } from 'node:path';
20
26
  import type { PublishEvent } from '@celilo/event-bus/build-bus';
27
+ import { eq } from 'drizzle-orm';
21
28
  import { parse as parseYaml } from 'yaml';
22
29
  import { getDb } from '../../db/client';
23
- import { modules } from '../../db/schema';
30
+ import { buildBusHookRuns, modules } from '../../db/schema';
31
+ import { CONTROL_PLANE_MODULE_ID } from '../deployed-systems';
24
32
  import {
25
- type HookRunResult,
33
+ type HookDispatchPlan,
26
34
  type ModuleHookContext,
27
35
  type UpstreamHookEntry,
36
+ type UpstreamHookResult,
37
+ defaultUpstreamHookDeps,
28
38
  planHookDispatch,
29
- runHookDispatch,
39
+ runUpstreamHook,
30
40
  } from './hook-dispatch';
41
+ import { type SelfUpdatePlan, planSelfUpdate, runSelfUpdate } from './self-update';
31
42
 
32
43
  export interface HookDispatcher {
33
44
  /** Run the matching hooks for a single event. Returns the per-hook results. */
34
- handleEvent(event: PublishEvent): Promise<HookRunResult[]>;
45
+ handleEvent(event: PublishEvent): Promise<UpstreamHookResult[]>;
35
46
  /** Clean up (currently a no-op; included for symmetry with the receiver). */
36
47
  stop(): Promise<void>;
37
48
  }
38
49
 
39
50
  export interface HookDispatcherOptions {
51
+ /**
52
+ * Database for the default executor hop. Production omits it and the
53
+ * process database opens lazily; tests inject an isolated client.
54
+ */
55
+ db?: Parameters<typeof defaultUpstreamHookDeps>[0];
40
56
  /**
41
57
  * Override the module-loading step. Tests inject a fixture; in
42
58
  * production we omit and the dispatcher reads from the celilo
43
59
  * modules table.
44
60
  */
45
61
  loadModules?: () => ModuleHookContext[];
62
+ /**
63
+ * Override the executor hop for one matched upstream-publish entry.
64
+ * Production omits it; `runUpstreamHook` over the process database runs.
65
+ */
66
+ runUpstreamHook?: (plan: HookDispatchPlan, event: PublishEvent) => Promise<UpstreamHookResult>;
67
+ /**
68
+ * Override the durable outcome record. The production default writes one
69
+ * `build_bus_hook_runs` row per run — without it a failed hook run or
70
+ * self-update exists only as daemon console output, which no surface
71
+ * reads (celilo#1304). Tests inject a capture to stay DB-free. The plan
72
+ * is passed because the jailed executor result does not carry the
73
+ * script path; the ledger's `script_path` column is NOT NULL.
74
+ */
75
+ recordRun?: (event: PublishEvent, plan: HookDispatchPlan, result: UpstreamHookResult) => void;
76
+ /**
77
+ * Override "is the control-plane module installed", the gate for the
78
+ * framework self-update. Production reads the modules table; tests
79
+ * inject the answer.
80
+ */
81
+ controlPlaneInstalled?: () => boolean;
82
+ /** Override the self-update runner. Production: `runSelfUpdate`. */
83
+ selfUpdate?: (plan: SelfUpdatePlan) => ReturnType<typeof runSelfUpdate>;
84
+ }
85
+
86
+ /** Bound on error tails so one chatty hook cannot bloat the ledger. */
87
+ const TAIL_CHARS = 2000;
88
+
89
+ function tail(s: string): string {
90
+ return s.length > TAIL_CHARS ? s.slice(-TAIL_CHARS) : s;
91
+ }
92
+
93
+ /**
94
+ * The production outcome record: one `build_bus_hook_runs` row per run.
95
+ * Throwing here would 500 nothing (the webhook already succeeded) but
96
+ * would kill dispatch of later plans, so a record failure is logged and
97
+ * swallowed — the console outcome lines below remain the fallback signal.
98
+ *
99
+ * The jailed executor path (slice 7) does not expose exit codes or
100
+ * captured stdio, so `exit_code` is always null — the column's "the
101
+ * script never reached an exit" semantics now hold of the path itself —
102
+ * and the executor's error text lands in `stderr_tail`.
103
+ */
104
+ function recordHookRun(
105
+ event: PublishEvent,
106
+ plan: HookDispatchPlan,
107
+ result: UpstreamHookResult,
108
+ ): void {
109
+ try {
110
+ getDb()
111
+ .insert(buildBusHookRuns)
112
+ .values({
113
+ eventId: event.eventId,
114
+ packageName: event.package.name,
115
+ packageVersion: event.package.version,
116
+ tag: event.tag,
117
+ moduleId: result.module,
118
+ hookName: result.hookName,
119
+ scriptPath: plan.hook.script,
120
+ exitCode: null,
121
+ timedOut: false,
122
+ durationMs: result.durationMs,
123
+ stdoutTail: null,
124
+ stderrTail: result.error ? tail(result.error) : null,
125
+ })
126
+ .run();
127
+ } catch (err) {
128
+ console.warn(
129
+ `[build-bus] could not record hook run outcome: ${err instanceof Error ? err.message : String(err)}`,
130
+ );
131
+ }
46
132
  }
47
133
 
48
134
  export async function startHookDispatcher(
49
135
  opts: HookDispatcherOptions = {},
50
136
  ): Promise<HookDispatcher> {
51
137
  const loadModules = opts.loadModules ?? loadInstalledModulesWithHooks;
138
+ const runEntry =
139
+ opts.runUpstreamHook ??
140
+ ((plan, event) => runUpstreamHook(plan, event, defaultUpstreamHookDeps(opts.db)));
141
+ const recordRun = opts.recordRun ?? recordHookRun;
142
+ const isControlPlaneInstalled = opts.controlPlaneInstalled ?? defaultControlPlaneInstalled;
143
+ const selfUpdateRunner = opts.selfUpdate ?? ((plan) => runSelfUpdate(plan));
52
144
 
53
145
  return {
54
146
  async handleEvent(event) {
147
+ // Framework self-update first (design D6): celilo replacing celilo's
148
+ // own binaries is not a module hook, and the dispatcher is the
149
+ // component that already receives the publish event.
150
+ const update = planSelfUpdate(event);
151
+ if (update && isControlPlaneInstalled()) {
152
+ console.log(
153
+ `[build-bus] self-update: ${update.packageName}@${update.version} (@${event.tag})`,
154
+ );
155
+ const result = selfUpdateRunner(update);
156
+ if (result.updated && result.verified) {
157
+ console.log(
158
+ ` ✓ ${update.binary} reports ${result.version} (was ${result.beforeVersion ?? 'not installed'})`,
159
+ );
160
+ } else {
161
+ console.warn(` ✗ self-update did not complete: ${result.error ?? 'unknown error'}`);
162
+ }
163
+ }
164
+
55
165
  const moduleContexts = loadModules();
56
166
  const plans = planHookDispatch(event, moduleContexts);
57
167
  if (plans.length === 0) {
58
- console.log(
59
- `[build-bus] ${event.package.name}@${event.package.version} (${event.tag}) — no module hooks match`,
60
- );
168
+ if (!update) {
169
+ console.log(
170
+ `[build-bus] ${event.package.name}@${event.package.version} (${event.tag}) — no module hooks match`,
171
+ );
172
+ }
61
173
  return [];
62
174
  }
63
- const results: HookRunResult[] = [];
175
+ const results: UpstreamHookResult[] = [];
64
176
  for (const plan of plans) {
65
177
  console.log(
66
178
  `[build-bus] ${event.package.name}@${event.package.version} → ${plan.module.moduleId} (${plan.hook.name ?? plan.hook.script})`,
67
179
  );
68
- const result = await runHookDispatch(plan);
180
+ const result = await runEntry(plan, event);
181
+ recordRun(event, plan, result);
69
182
  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`);
183
+ if (result.success) {
184
+ console.log(` ✓ success in ${result.durationMs}ms`);
74
185
  } else {
75
- console.warn(` ✗ exit ${result.exitCode} in ${result.durationMs}ms`);
76
- if (result.stderr) console.warn(` stderr: ${result.stderr.trim().slice(0, 500)}`);
186
+ console.warn(` ✗ failed in ${result.durationMs}ms`);
187
+ if (result.error) console.warn(` ${result.error.trim().slice(0, 500)}`);
77
188
  }
78
189
  }
79
190
  return results;
@@ -85,6 +196,21 @@ export async function startHookDispatcher(
85
196
  };
86
197
  }
87
198
 
199
+ /**
200
+ * The framework self-update runs only where the control-plane module is
201
+ * deployed — the same population celilo-mgmt's hook covered, since the
202
+ * module is installed on the management host and nowhere else.
203
+ */
204
+ function defaultControlPlaneInstalled(): boolean {
205
+ const db = getDb();
206
+ const row = db
207
+ .select({ id: modules.id })
208
+ .from(modules)
209
+ .where(eq(modules.id, CONTROL_PLANE_MODULE_ID))
210
+ .get();
211
+ return row != null;
212
+ }
213
+
88
214
  /**
89
215
  * Load every installed module's `on_upstream_publish` hooks from the
90
216
  * celilo DB + each module's manifest.yml. Modules without the hook
@@ -0,0 +1,189 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import {
6
+ getReceiverUnitPath,
7
+ installReceiverDaemon,
8
+ planReceiverInstall,
9
+ renderReceiverLaunchdPlist,
10
+ renderReceiverSystemdUnit,
11
+ uninstallReceiverDaemon,
12
+ } from './receiver-daemon';
13
+
14
+ const SECRET = 'a1b2c3d4e5f6secret';
15
+
16
+ // resolveCeliloPath existence-checks explicit overrides, so plan tests need
17
+ // a path that exists — a fake binary in a temp dir.
18
+ const FAKE_CELOLILO = join(mkdtempSync(join(tmpdir(), 'celilo-fake-bin-')), 'celilo');
19
+ writeFileSync(FAKE_CELOLILO, '#!/bin/sh\n', { mode: 0o755 });
20
+
21
+ describe('renderReceiverSystemdUnit', () => {
22
+ it('runs `subscribers serve` with --no-dispatch and the secret as an Environment line', () => {
23
+ const out = renderReceiverSystemdUnit({
24
+ celiloPath: FAKE_CELOLILO,
25
+ port: 8123,
26
+ secret: SECRET,
27
+ scope: 'user',
28
+ dispatch: false,
29
+ });
30
+ expect(out).toContain('subscribers serve --port 8123 --no-dispatch');
31
+ expect(out).toContain(`Environment=CELILO_BUS_SECRET=${SECRET}`);
32
+ expect(out).toContain('Restart=on-failure');
33
+ expect(out).toContain('WantedBy=default.target');
34
+ expect(out).not.toContain('User=');
35
+ });
36
+
37
+ it('honors the port and a --dispatch render for boxes without the events daemon', () => {
38
+ const out = renderReceiverSystemdUnit({
39
+ celiloPath: '/c',
40
+ port: 9999,
41
+ secret: SECRET,
42
+ scope: 'user',
43
+ dispatch: true,
44
+ });
45
+ expect(out).toContain('ExecStart=/c subscribers serve --port 9999');
46
+ expect(out).not.toContain('--no-dispatch');
47
+ });
48
+
49
+ it('system scope sets an explicit User= and multi-user.target', () => {
50
+ const out = renderReceiverSystemdUnit({
51
+ celiloPath: FAKE_CELOLILO,
52
+ port: 8123,
53
+ secret: SECRET,
54
+ scope: 'system',
55
+ runAsUser: 'celilo',
56
+ dispatch: false,
57
+ });
58
+ expect(out).toContain('User=celilo');
59
+ expect(out).toContain('WantedBy=multi-user.target');
60
+ expect(out).toContain('journalctl -u celilo-build-bus-receiver.service');
61
+ expect(out).not.toContain('journalctl --user');
62
+ });
63
+ });
64
+
65
+ describe('renderReceiverLaunchdPlist', () => {
66
+ it('carries the secret under EnvironmentVariables and --no-dispatch by default', () => {
67
+ const out = renderReceiverLaunchdPlist({
68
+ celiloPath: FAKE_CELOLILO,
69
+ port: 8123,
70
+ secret: SECRET,
71
+ scope: 'system',
72
+ home: '/root',
73
+ dispatch: false,
74
+ });
75
+ expect(out).toContain('<key>Label</key>');
76
+ expect(out).toContain('com.celilo.build-bus-receiver');
77
+ expect(out).toContain('<key>CELILO_BUS_SECRET</key>');
78
+ expect(out).toContain(`<string>${SECRET}</string>`);
79
+ expect(out).toContain('<string>--no-dispatch</string>');
80
+ expect(out).toContain('<string>subscribers</string>');
81
+ });
82
+ });
83
+
84
+ describe('getReceiverUnitPath', () => {
85
+ it('names the unit distinctly from the dispatcher unit in every platform/scope', () => {
86
+ expect(getReceiverUnitPath('linux', '/home/op', 'user')).toBe(
87
+ join('/home/op/.config/systemd/user', 'celilo-build-bus-receiver.service'),
88
+ );
89
+ expect(getReceiverUnitPath('linux', '/root', 'system')).toBe(
90
+ '/etc/systemd/system/celilo-build-bus-receiver.service',
91
+ );
92
+ expect(getReceiverUnitPath('darwin', '/root', 'system')).toBe(
93
+ '/Library/LaunchDaemons/com.celilo.build-bus-receiver.plist',
94
+ );
95
+ expect(getReceiverUnitPath('darwin', '/home/op', 'user')).toBe(
96
+ '/home/op/Library/LaunchAgents/com.celilo.build-bus-receiver.plist',
97
+ );
98
+ });
99
+ });
100
+
101
+ describe('planReceiverInstall', () => {
102
+ it('requires a secret and refuses to invent one', () => {
103
+ expect(() => planReceiverInstall({ secret: undefined, home: '/h' })).toThrow(/secret/i);
104
+ expect(() => planReceiverInstall({ secret: '', home: '/h' })).toThrow(/secret/i);
105
+ });
106
+
107
+ it('plans a user-scope unit that runs the receiver receiver-only by default', () => {
108
+ const plan = planReceiverInstall({
109
+ secret: SECRET,
110
+ home: '/home/op',
111
+ platform: 'linux',
112
+ celiloPath: FAKE_CELOLILO,
113
+ });
114
+ expect(plan.platform).toBe('linux');
115
+ expect(plan.scope).toBe('user');
116
+ expect(plan.unitContent).toContain('--no-dispatch');
117
+ expect(plan.unitContent).toContain(`CELILO_BUS_SECRET=${SECRET}`);
118
+ });
119
+
120
+ it('plans a system-scope unit with an explicit run-as user', () => {
121
+ const plan = planReceiverInstall({
122
+ secret: SECRET,
123
+ platform: 'linux',
124
+ scope: 'system',
125
+ home: '/root',
126
+ runAsUser: 'celilo',
127
+ celiloPath: FAKE_CELOLILO,
128
+ });
129
+ expect(plan.unitContent).toContain('User=celilo');
130
+ });
131
+
132
+ it('system scope falls back to the bus-dir owner when no run-as user is given', () => {
133
+ // events-daemon's resolveRunAsUser is the shared seam; here we only pin
134
+ // that the receiver unit passes a runAsUser through rather than dropping it.
135
+ const plan = planReceiverInstall({
136
+ secret: SECRET,
137
+ platform: 'linux',
138
+ scope: 'system',
139
+ home: '/root',
140
+ runAsUser: 'celilo',
141
+ celiloPath: FAKE_CELOLILO,
142
+ });
143
+ expect(plan.runAsUser).toBe('celilo');
144
+ });
145
+ });
146
+
147
+ describe('installReceiverDaemon / uninstallReceiverDaemon', () => {
148
+ const home = mkdtempSync(join(tmpdir(), 'celilo-receiver-daemon-'));
149
+
150
+ it('writes the unit file mode 0600 (it carries a secret) and idempotently rewrites', () => {
151
+ // celiloPath is passed explicitly: the unit under test must not
152
+ // consult the ambient PATH (resolveCeliloPath throws where no global
153
+ // celilo is installed — forge runners). Every planReceiverInstall test
154
+ // above injects FAKE_CELOLILO; the install path resolves the same way.
155
+ const first = installReceiverDaemon({
156
+ secret: SECRET,
157
+ platform: 'linux',
158
+ scope: 'user',
159
+ home,
160
+ celiloPath: FAKE_CELOLILO,
161
+ });
162
+ expect(existsSync(first.unitPath)).toBe(true);
163
+ expect(statSync(first.unitPath).mode & 0o777).toBe(0o600);
164
+ const content = readFileSync(first.unitPath, 'utf-8');
165
+ expect(content).toContain('--no-dispatch');
166
+ expect(content).toContain(`CELILO_BUS_SECRET=${SECRET}`);
167
+
168
+ // Idempotent second install.
169
+ const second = installReceiverDaemon({
170
+ secret: SECRET,
171
+ platform: 'linux',
172
+ scope: 'user',
173
+ home,
174
+ celiloPath: FAKE_CELOLILO,
175
+ });
176
+ expect(second.unitPath).toBe(first.unitPath);
177
+
178
+ const removed = uninstallReceiverDaemon({ platform: 'linux', scope: 'user', home });
179
+ expect(removed.removed).toBe(true);
180
+ expect(existsSync(first.unitPath)).toBe(false);
181
+ });
182
+
183
+ it('uninstall of a missing unit reports removed=false', () => {
184
+ const empty = mkdtempSync(join(tmpdir(), 'celilo-receiver-daemon-'));
185
+ const result = uninstallReceiverDaemon({ platform: 'linux', scope: 'user', home: empty });
186
+ expect(result.removed).toBe(false);
187
+ rmSync(empty, { recursive: true, force: true });
188
+ });
189
+ });