@celilo/cli 2.3.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/package.json +3 -3
  3. package/src/api/serve.ts +13 -1
  4. package/src/cli/commands/module-health.test.ts +35 -0
  5. package/src/cli/commands/module-health.ts +11 -3
  6. package/src/cli/commands/module-publish.test.ts +22 -0
  7. package/src/cli/commands/module-publish.ts +14 -12
  8. package/src/cli/commands/module-update.ts +69 -17
  9. package/src/cli/commands/module-upgrade-gate.test.ts +154 -0
  10. package/src/cli/commands/module-upgrade.ts +43 -3
  11. package/src/cli/commands/publish/helpers.ts +4 -3
  12. package/src/cli/commands/publish/index.ts +13 -1
  13. package/src/cli/commands/publish/plan.test.ts +64 -0
  14. package/src/cli/commands/publish/plan.ts +52 -19
  15. package/src/cli/commands/publish/types.ts +16 -3
  16. package/src/cli/commands/subscribers-install-daemon.test.ts +44 -0
  17. package/src/cli/commands/subscribers-install-daemon.ts +107 -0
  18. package/src/cli/commands/subscribers-serve.test.ts +22 -0
  19. package/src/cli/commands/subscribers-serve.ts +22 -4
  20. package/src/cli/commands/system-audit.ts +14 -5
  21. package/src/cli/commands/system-update.ts +11 -6
  22. package/src/cli/completion.ts +10 -1
  23. package/src/cli/fuel-gauge.ts +12 -4
  24. package/src/cli/index.ts +12 -0
  25. package/src/cli/json-output.test.ts +81 -0
  26. package/src/cli/types.ts +9 -0
  27. package/src/hooks/broker.test.ts +9 -3
  28. package/src/hooks/executor.test.ts +5 -2
  29. package/src/hooks/hook-jail-toolchain-reach.test.ts +8 -3
  30. package/src/hooks/hook-jail-unreachability.test.ts +10 -2
  31. package/src/hooks/hook-trespass.test.ts +13 -4
  32. package/src/hooks/run-named-hook.ts +19 -16
  33. package/src/hooks/test-fixtures/artifact-writing-hook.ts +0 -1
  34. package/src/hooks/test-fixtures/capability-calling-hook.ts +20 -13
  35. package/src/hooks/test-fixtures/jail-probe-hook.ts +9 -1
  36. package/src/hooks/test-fixtures/jail-toolchain-hook.ts +7 -2
  37. package/src/hooks/test-fixtures/runaway-hook.ts +0 -1
  38. package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +0 -1
  39. package/src/hooks/test-fixtures/silent-hook.ts +0 -1
  40. package/src/hooks/test-fixtures/store-writing-hook.ts +20 -11
  41. package/src/hooks/test-fixtures/success-hook.ts +4 -4
  42. package/src/manifest/contracts/v1.ts +19 -14
  43. package/src/manifest/json-schema-roundtrip.test.ts +1 -1
  44. package/src/manifest/schema.ts +66 -16
  45. package/src/manifest/validate.test.ts +47 -0
  46. package/src/policy/capability-shape-baseline.ts +63 -21
  47. package/src/policy/capability-shape-drift.test.ts +53 -1
  48. package/src/policy/capability-shape.test.ts +105 -0
  49. package/src/policy/capability-shape.ts +283 -2
  50. package/src/registry/client.test.ts +65 -0
  51. package/src/registry/client.ts +7 -7
  52. package/src/secrets/storage.test.ts +69 -3
  53. package/src/secrets/storage.ts +71 -1
  54. package/src/services/audit/health.test.ts +58 -0
  55. package/src/services/audit/health.ts +15 -3
  56. package/src/services/audit/index.test.ts +1 -1
  57. package/src/services/audit/interface-classification.test.ts +16 -5
  58. package/src/services/audit/interface-classification.ts +25 -2
  59. package/src/services/audit/module-versions.ts +5 -1
  60. package/src/services/audit/public-dns.test.ts +20 -0
  61. package/src/services/audit/public-dns.ts +7 -2
  62. package/src/services/audit/recurrence-gate.test.ts +225 -0
  63. package/src/services/audit/trusted-sources.test.ts +17 -0
  64. package/src/services/audit/trusted-sources.ts +21 -0
  65. package/src/services/build-bus/hook-dispatch-executor.test.ts +269 -0
  66. package/src/services/build-bus/hook-dispatch-mgmt.test.ts +102 -150
  67. package/src/services/build-bus/hook-dispatch-path.test.ts +95 -0
  68. package/src/services/build-bus/hook-dispatch.test.ts +86 -116
  69. package/src/services/build-bus/hook-dispatch.ts +99 -121
  70. package/src/services/build-bus/hook-dispatcher.ts +106 -28
  71. package/src/services/build-bus/receiver-daemon.test.ts +189 -0
  72. package/src/services/build-bus/receiver-daemon.ts +355 -0
  73. package/src/services/build-bus/self-update.ts +156 -0
  74. package/src/services/capability-compat.test.ts +90 -0
  75. package/src/services/capability-compat.ts +128 -0
  76. package/src/services/deploy-terraform.ts +38 -1
  77. package/src/services/events-daemon.test.ts +57 -0
  78. package/src/services/events-daemon.ts +76 -0
  79. package/src/services/firewall-reach.ts +21 -8
  80. package/src/services/fleet-checks.test.ts +37 -1
  81. package/src/services/fleet-checks.ts +31 -1
  82. package/src/services/health-runner.ts +43 -5
  83. package/src/services/module-deploy-prune.test.ts +89 -0
  84. package/src/services/module-deploy.ts +72 -125
  85. package/src/services/module-types-drift.test.ts +1 -1
  86. package/src/services/module-validator/capability-versions.test.ts +13 -2
  87. package/src/services/terraform-safety.test.ts +83 -0
  88. package/src/services/terraform-safety.ts +53 -0
  89. package/src/services/update/orchestrator.test.ts +1 -1
  90. package/tsconfig.json +2 -13
@@ -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,41 +24,66 @@
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
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>;
46
67
  /**
47
68
  * Override the durable outcome record. The production default writes one
48
- * `build_bus_hook_runs` row per run — without it a failed self-update
49
- * exists only as daemon console output, which no surface reads
50
- * (celilo#1304). Tests inject a capture to stay DB-free.
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.
51
80
  */
52
- recordRun?: (event: PublishEvent, result: HookRunResult) => void;
81
+ controlPlaneInstalled?: () => boolean;
82
+ /** Override the self-update runner. Production: `runSelfUpdate`. */
83
+ selfUpdate?: (plan: SelfUpdatePlan) => ReturnType<typeof runSelfUpdate>;
53
84
  }
54
85
 
55
- /** Bound on stdout/stderr tails so one chatty hook cannot bloat the ledger. */
86
+ /** Bound on error tails so one chatty hook cannot bloat the ledger. */
56
87
  const TAIL_CHARS = 2000;
57
88
 
58
89
  function tail(s: string): string {
@@ -64,8 +95,17 @@ function tail(s: string): string {
64
95
  * Throwing here would 500 nothing (the webhook already succeeded) but
65
96
  * would kill dispatch of later plans, so a record failure is logged and
66
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`.
67
103
  */
68
- function recordHookRun(event: PublishEvent, result: HookRunResult): void {
104
+ function recordHookRun(
105
+ event: PublishEvent,
106
+ plan: HookDispatchPlan,
107
+ result: UpstreamHookResult,
108
+ ): void {
69
109
  try {
70
110
  getDb()
71
111
  .insert(buildBusHookRuns)
@@ -76,12 +116,12 @@ function recordHookRun(event: PublishEvent, result: HookRunResult): void {
76
116
  tag: event.tag,
77
117
  moduleId: result.module,
78
118
  hookName: result.hookName,
79
- scriptPath: result.scriptPath,
80
- exitCode: result.exitCode,
81
- timedOut: result.timedOut,
119
+ scriptPath: plan.hook.script,
120
+ exitCode: null,
121
+ timedOut: false,
82
122
  durationMs: result.durationMs,
83
- stdoutTail: result.stdout ? tail(result.stdout) : null,
84
- stderrTail: result.stderr ? tail(result.stderr) : null,
123
+ stdoutTail: null,
124
+ stderrTail: result.error ? tail(result.error) : null,
85
125
  })
86
126
  .run();
87
127
  } catch (err) {
@@ -95,33 +135,56 @@ export async function startHookDispatcher(
95
135
  opts: HookDispatcherOptions = {},
96
136
  ): Promise<HookDispatcher> {
97
137
  const loadModules = opts.loadModules ?? loadInstalledModulesWithHooks;
138
+ const runEntry =
139
+ opts.runUpstreamHook ??
140
+ ((plan, event) => runUpstreamHook(plan, event, defaultUpstreamHookDeps(opts.db)));
98
141
  const recordRun = opts.recordRun ?? recordHookRun;
142
+ const isControlPlaneInstalled = opts.controlPlaneInstalled ?? defaultControlPlaneInstalled;
143
+ const selfUpdateRunner = opts.selfUpdate ?? ((plan) => runSelfUpdate(plan));
99
144
 
100
145
  return {
101
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
+
102
165
  const moduleContexts = loadModules();
103
166
  const plans = planHookDispatch(event, moduleContexts);
104
167
  if (plans.length === 0) {
105
- console.log(
106
- `[build-bus] ${event.package.name}@${event.package.version} (${event.tag}) — no module hooks match`,
107
- );
168
+ if (!update) {
169
+ console.log(
170
+ `[build-bus] ${event.package.name}@${event.package.version} (${event.tag}) — no module hooks match`,
171
+ );
172
+ }
108
173
  return [];
109
174
  }
110
- const results: HookRunResult[] = [];
175
+ const results: UpstreamHookResult[] = [];
111
176
  for (const plan of plans) {
112
177
  console.log(
113
178
  `[build-bus] ${event.package.name}@${event.package.version} → ${plan.module.moduleId} (${plan.hook.name ?? plan.hook.script})`,
114
179
  );
115
- const result = await runHookDispatch(plan);
116
- recordRun(event, result);
180
+ const result = await runEntry(plan, event);
181
+ recordRun(event, plan, result);
117
182
  results.push(result);
118
- if (result.exitCode === 0) {
119
- console.log(` ✓ exit 0 in ${result.durationMs}ms`);
120
- } else if (result.timedOut) {
121
- console.warn(` ✗ timed out after ${result.durationMs}ms`);
183
+ if (result.success) {
184
+ console.log(` ✓ success in ${result.durationMs}ms`);
122
185
  } else {
123
- console.warn(` ✗ exit ${result.exitCode} in ${result.durationMs}ms`);
124
- 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)}`);
125
188
  }
126
189
  }
127
190
  return results;
@@ -133,6 +196,21 @@ export async function startHookDispatcher(
133
196
  };
134
197
  }
135
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
+
136
214
  /**
137
215
  * Load every installed module's `on_upstream_publish` hooks from the
138
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
+ });