@celilo/cli 0.2.1 → 0.3.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.
package/src/cli/index.ts CHANGED
@@ -10,6 +10,21 @@ import { COMMANDS, type CommandDef } from './command-registry';
10
10
  import { handleCapabilityInfo } from './commands/capability-info';
11
11
  import { handleCapabilityList } from './commands/capability-list';
12
12
  import { handleCompletion } from './commands/completion';
13
+ import {
14
+ handleEventsAck,
15
+ handleEventsDrain,
16
+ handleEventsEmit,
17
+ handleEventsFail,
18
+ handleEventsInstallDaemon,
19
+ handleEventsListPending,
20
+ handleEventsListSubscribers,
21
+ handleEventsRepair,
22
+ handleEventsRun,
23
+ handleEventsShowDaemon,
24
+ handleEventsStatus,
25
+ handleEventsTail,
26
+ handleEventsUninstallDaemon,
27
+ } from './commands/events';
13
28
  import { handleHookRun } from './commands/hook-run';
14
29
  import {
15
30
  handleIpamIpListReservations,
@@ -212,6 +227,45 @@ Related Commands:
212
227
  /**
213
228
  * Display capability command help
214
229
  */
230
+ function displayEventsHelp(): CommandResult {
231
+ const helpText = `
232
+ Celilo - SQLite Event Bus
233
+
234
+ Usage:
235
+ celilo events <subcommand> [args...]
236
+
237
+ Subcommands:
238
+ status Print bus health as JSON
239
+ tail [--type T] [--limit N] Recent events as JSON
240
+ list-subscribers List persistent bus subscribers
241
+ list-pending [--subscriber] List pending deliveries
242
+ drain [--concurrency N] Process pending deliveries once and return
243
+ run [--poll-ms N] Run the long-running dispatcher (foreground)
244
+ emit <type> [<payload>] Emit an event (operator/test path)
245
+ ack <event_id> Mark a running delivery succeeded
246
+ fail <event_id> --error MSG Mark a running delivery failed
247
+ repair Crash-recovery sweep without starting the dispatcher
248
+ resume Alias for repair (acknowledges halt-on-recovery)
249
+ install-daemon Write a systemd/launchd user unit for the dispatcher
250
+ uninstall-daemon Remove the installed supervisor unit
251
+ show-daemon Print the currently installed unit file
252
+
253
+ Description:
254
+ The event bus is a SQLite-backed pub/sub layer for celilo modules.
255
+ Modules declare \`subscriptions:\` in their manifests; \`celilo module deploy\`
256
+ emits lifecycle events that subscribers react to.
257
+
258
+ See infra/design/SQLITE_EVENT_BUS.md for the full design.
259
+
260
+ Examples:
261
+ celilo events run # foreground dispatcher
262
+ celilo events status # is anything stuck?
263
+ celilo events tail --type deploy.completed.lunacycle # filter by type
264
+ celilo events emit deploy.completed.lunacycle '{}' # operator-fired event
265
+ `;
266
+ return { success: true, message: helpText.trim() };
267
+ }
268
+
215
269
  function displayCapabilityHelp(): CommandResult {
216
270
  const helpText = `
217
271
  Celilo - Capability Management
@@ -918,6 +972,55 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
918
972
  };
919
973
  }
920
974
 
975
+ if (parsed.command === 'events') {
976
+ if (parsed.flags.help || parsed.flags.h) {
977
+ return displayEventsHelp();
978
+ }
979
+ if (!parsed.subcommand) {
980
+ return {
981
+ success: false,
982
+ error: 'Events subcommand required\n\nRun "celilo events --help" for usage',
983
+ };
984
+ }
985
+ const evFlagError = checkFlags('events', parsed.subcommand, parsed.flags, parsed.args);
986
+ if (evFlagError) return evFlagError;
987
+
988
+ switch (parsed.subcommand) {
989
+ case 'status':
990
+ return handleEventsStatus();
991
+ case 'tail':
992
+ return handleEventsTail(parsed.args, parsed.flags);
993
+ case 'list-subscribers':
994
+ return handleEventsListSubscribers();
995
+ case 'list-pending':
996
+ return handleEventsListPending(parsed.args, parsed.flags);
997
+ case 'drain':
998
+ return handleEventsDrain(parsed.args, parsed.flags);
999
+ case 'run':
1000
+ return handleEventsRun(parsed.args, parsed.flags);
1001
+ case 'emit':
1002
+ return handleEventsEmit(parsed.args, parsed.flags);
1003
+ case 'ack':
1004
+ return handleEventsAck(parsed.args, parsed.flags);
1005
+ case 'fail':
1006
+ return handleEventsFail(parsed.args, parsed.flags);
1007
+ case 'repair':
1008
+ case 'resume':
1009
+ return handleEventsRepair();
1010
+ case 'install-daemon':
1011
+ return handleEventsInstallDaemon(parsed.args, parsed.flags);
1012
+ case 'uninstall-daemon':
1013
+ return handleEventsUninstallDaemon();
1014
+ case 'show-daemon':
1015
+ return handleEventsShowDaemon();
1016
+ default:
1017
+ return {
1018
+ success: false,
1019
+ error: `Unknown events subcommand: ${parsed.subcommand}\n\nRun "celilo events --help" for usage`,
1020
+ };
1021
+ }
1022
+ }
1023
+
921
1024
  if (parsed.command === 'module') {
922
1025
  // Handle module --help
923
1026
  if (parsed.flags.help || parsed.flags.h) {
@@ -92,6 +92,25 @@ export function getDbPath(): string {
92
92
  return join(getDataDir(), 'celilo.db');
93
93
  }
94
94
 
95
+ /**
96
+ * Get the SQLite event-bus database file path.
97
+ *
98
+ * Priority:
99
+ * 1. CELILO_EVENT_BUS_PATH environment variable (explicit override)
100
+ * 2. <data-dir>/events.db (platform-specific)
101
+ *
102
+ * Kept separate from the main celilo.db so the bus library remains
103
+ * standalone — the bus owns its own schema and migrations.
104
+ *
105
+ * @returns Absolute path to event-bus database
106
+ */
107
+ export function getEventBusPath(): string {
108
+ if (process.env.CELILO_EVENT_BUS_PATH) {
109
+ return process.env.CELILO_EVENT_BUS_PATH;
110
+ }
111
+ return join(getDataDir(), 'events.db');
112
+ }
113
+
95
114
  /**
96
115
  * Shorten a path by replacing the celilo data directory with $CELILO_DATA
97
116
  * For display purposes in CLI output.
@@ -260,6 +260,32 @@ export const AnsibleCollectionSchema = z.object({
260
260
  reason: z.string().optional(),
261
261
  });
262
262
 
263
+ /**
264
+ * Event-bus subscription declaration in a module manifest.
265
+ *
266
+ * Each entry becomes a row in the bus's `subscribers` table when the
267
+ * module is imported, and is removed when the module is removed.
268
+ *
269
+ * Substitutions resolved at subscribe time:
270
+ * - `$self` in `pattern` → the module's id
271
+ * - `${MODULE_PATH}` in `handler` → the module's installed path
272
+ */
273
+ export const ModuleSubscriptionSchema = z.object({
274
+ name: z
275
+ .string()
276
+ .min(1)
277
+ .regex(
278
+ /^[a-z][a-z0-9_-]*$/,
279
+ 'Subscription name must be kebab/snake_case (lowercase, alphanumeric, dash/underscore)',
280
+ ),
281
+ pattern: z.string().min(1),
282
+ handler: z.string().min(1),
283
+ max_attempts: z.number().int().positive().optional(),
284
+ timeout_ms: z.number().int().positive().optional(),
285
+ });
286
+
287
+ export type ModuleSubscription = z.infer<typeof ModuleSubscriptionSchema>;
288
+
263
289
  /**
264
290
  * Module manifest schema (v2 shape).
265
291
  *
@@ -424,6 +450,14 @@ export const ModuleManifestSchema = z
424
450
  tests_dir: z.string().min(1),
425
451
  })
426
452
  .optional(),
453
+
454
+ /**
455
+ * Event-bus subscriptions this module wants registered. See
456
+ * `infra/design/SQLITE_EVENT_BUS.md`. Truly optional so existing
457
+ * test fixtures and manifests don't need a churn-pass; readers
458
+ * use `manifest.subscriptions ?? []`.
459
+ */
460
+ subscriptions: z.array(ModuleSubscriptionSchema).optional(),
427
461
  })
428
462
  .strict();
429
463
 
@@ -1246,3 +1246,78 @@ secrets:
1246
1246
  expect(result.success).toBe(false);
1247
1247
  });
1248
1248
  });
1249
+
1250
+ describe('subscriptions field', () => {
1251
+ test('manifests without subscriptions still validate', () => {
1252
+ const yaml = `
1253
+ ${CONTRACT_LINE}
1254
+ id: test
1255
+ name: Test
1256
+ version: 1.0.0
1257
+ `;
1258
+ const result = validateManifest(yaml);
1259
+ expect(result.success).toBe(true);
1260
+ if (result.success) {
1261
+ expect(result.data.subscriptions).toBeUndefined();
1262
+ }
1263
+ });
1264
+
1265
+ test('parses a valid subscriptions block', () => {
1266
+ const yaml = `
1267
+ ${CONTRACT_LINE}
1268
+ id: test
1269
+ name: Test
1270
+ version: 1.0.0
1271
+
1272
+ subscriptions:
1273
+ - name: smoke-after-deploy
1274
+ pattern: deploy.completed.$self
1275
+ handler: bun \${MODULE_PATH}/celilo/scripts/smoke.ts
1276
+ timeout_ms: 120000
1277
+ - name: cert-rotated
1278
+ pattern: cert.rotated
1279
+ handler: echo
1280
+ max_attempts: 5
1281
+ `;
1282
+ const result = validateManifest(yaml);
1283
+ expect(result.success).toBe(true);
1284
+ if (result.success) {
1285
+ expect(result.data.subscriptions).toHaveLength(2);
1286
+ expect(result.data.subscriptions?.[0].name).toBe('smoke-after-deploy');
1287
+ expect(result.data.subscriptions?.[0].timeout_ms).toBe(120000);
1288
+ expect(result.data.subscriptions?.[1].max_attempts).toBe(5);
1289
+ }
1290
+ });
1291
+
1292
+ test('rejects subscription names with invalid characters', () => {
1293
+ const yaml = `
1294
+ ${CONTRACT_LINE}
1295
+ id: test
1296
+ name: Test
1297
+ version: 1.0.0
1298
+
1299
+ subscriptions:
1300
+ - name: SmokeAfterDeploy
1301
+ pattern: deploy.$self
1302
+ handler: echo
1303
+ `;
1304
+ const result = validateManifest(yaml);
1305
+ expect(result.success).toBe(false);
1306
+ });
1307
+
1308
+ test('requires pattern and handler to be non-empty', () => {
1309
+ const yaml = `
1310
+ ${CONTRACT_LINE}
1311
+ id: test
1312
+ name: Test
1313
+ version: 1.0.0
1314
+
1315
+ subscriptions:
1316
+ - name: smoke
1317
+ pattern: ""
1318
+ handler: echo
1319
+ `;
1320
+ const result = validateManifest(yaml);
1321
+ expect(result.success).toBe(false);
1322
+ });
1323
+ });
@@ -599,6 +599,21 @@ export async function importModule(options: ModuleImportOptions): Promise<Module
599
599
  }
600
600
  }
601
601
 
602
+ // Execution: Register event-bus subscriptions declared in the manifest.
603
+ // Best-effort: a bus problem shouldn't wedge an import — the operator
604
+ // can re-run the import after fixing the bus, and bus.subscribe is
605
+ // idempotent.
606
+ try {
607
+ const { registerModuleSubscriptions } = await import('../services/module-subscriptions');
608
+ registerModuleSubscriptions(manifest, targetPath);
609
+ } catch (error) {
610
+ const msg = error instanceof Error ? error.message : String(error);
611
+ log.warn(`Failed to register event-bus subscriptions: ${msg}`);
612
+ log.warn(
613
+ 'Module imported, but reactive flows on the event bus will not fire until this is fixed.',
614
+ );
615
+ }
616
+
602
617
  // Execution: Store integrity data
603
618
  // For .netapp packages: store checksums + signature
604
619
  // For directory imports: calculate and store checksums (no signature)
@@ -0,0 +1,98 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { defineEvents, openBus } from '@celilo/event-bus';
6
+ import {
7
+ emitDeployCompleted,
8
+ emitDeployFailed,
9
+ emitDeployStarted,
10
+ emitHealthCheckFailed,
11
+ } from './celilo-events';
12
+
13
+ describe('celilo lifecycle events', () => {
14
+ let dir: string;
15
+ let dbPath: string;
16
+
17
+ beforeEach(() => {
18
+ dir = mkdtempSync(join(tmpdir(), 'celilo-events-test-'));
19
+ dbPath = join(dir, 'events.db');
20
+ process.env.CELILO_EVENT_BUS_PATH = dbPath;
21
+ });
22
+ afterEach(() => {
23
+ process.env.CELILO_EVENT_BUS_PATH = undefined;
24
+ try {
25
+ rmSync(dir, { recursive: true, force: true });
26
+ } catch {
27
+ /* ignore */
28
+ }
29
+ });
30
+
31
+ function recentEvents() {
32
+ const bus = openBus({ dbPath, events: defineEvents({}) });
33
+ try {
34
+ return bus.recentEvents({ limit: 100 });
35
+ } finally {
36
+ bus.close();
37
+ }
38
+ }
39
+
40
+ it('emits deploy.started.<module> with expected payload', () => {
41
+ emitDeployStarted({ module: 'lunacycle', startedAt: 1234 });
42
+ const events = recentEvents();
43
+ expect(events).toHaveLength(1);
44
+ expect(events[0].type).toBe('deploy.started.lunacycle');
45
+ expect(events[0].payload).toEqual({ module: 'lunacycle', startedAt: 1234 });
46
+ });
47
+
48
+ it('emits deploy.completed.<module> with duration', () => {
49
+ emitDeployCompleted({ module: 'authentik', startedAt: 1000, durationMs: 5500 });
50
+ const events = recentEvents();
51
+ expect(events[0].type).toBe('deploy.completed.authentik');
52
+ expect((events[0].payload as { durationMs: number }).durationMs).toBe(5500);
53
+ });
54
+
55
+ it('emits deploy.failed.<module> with error', () => {
56
+ emitDeployFailed({
57
+ module: 'authentik',
58
+ startedAt: 1000,
59
+ durationMs: 200,
60
+ error: 'oops',
61
+ });
62
+ const events = recentEvents();
63
+ expect(events[0].type).toBe('deploy.failed.authentik');
64
+ expect((events[0].payload as { error: string }).error).toBe('oops');
65
+ });
66
+
67
+ it('emits health-check.failed.<module>', () => {
68
+ emitHealthCheckFailed({ module: 'lunacycle', reason: 'http 503' });
69
+ const events = recentEvents();
70
+ expect(events[0].type).toBe('health-check.failed.lunacycle');
71
+ });
72
+
73
+ it('triggers persistent subscribers and creates deliveries', () => {
74
+ // Pre-register a subscriber that targets all completed deploys.
75
+ const setupBus = openBus({ dbPath, events: defineEvents({}) });
76
+ setupBus.subscribe({
77
+ name: 'test-watcher',
78
+ pattern: 'deploy.completed.*',
79
+ handler: 'unused',
80
+ });
81
+ setupBus.close();
82
+
83
+ emitDeployCompleted({ module: 'lunacycle', startedAt: 1, durationMs: 2 });
84
+
85
+ const bus = openBus({ dbPath, events: defineEvents({}) });
86
+ try {
87
+ const pending = bus.pendingDeliveries();
88
+ expect(pending).toHaveLength(1);
89
+ } finally {
90
+ bus.close();
91
+ }
92
+ });
93
+
94
+ it('does not throw when the bus path is unwritable', () => {
95
+ process.env.CELILO_EVENT_BUS_PATH = '/proc/no/such/place/events.db';
96
+ expect(() => emitDeployStarted({ module: 'x', startedAt: 0 })).not.toThrow();
97
+ });
98
+ });
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Celilo's own lifecycle events. Emitted on every `module deploy`
3
+ * (started / completed / failed) so subscribers can react — production
4
+ * smoke tests, alerting, dashboards, etc.
5
+ *
6
+ * The event types are dot-segmented with the module id as the last
7
+ * segment: `deploy.started.lunacycle`, `deploy.completed.lunacycle`.
8
+ * This lets subscribers target one module (`deploy.completed.lunacycle`)
9
+ * or fan out (`deploy.completed.*`).
10
+ *
11
+ * Bus errors are best-effort: a sick bus shouldn't break a deploy. Each
12
+ * helper opens-emits-closes; per-event overhead is a few ms.
13
+ */
14
+
15
+ import { defineEvents, openBus } from '@celilo/event-bus';
16
+ import { z } from 'zod';
17
+ import { getEventBusPath } from '../config/paths';
18
+
19
+ const NO_SCHEMAS = defineEvents({});
20
+
21
+ export interface DeployStartedPayload {
22
+ module: string;
23
+ startedAt: number;
24
+ }
25
+
26
+ export interface DeployCompletedPayload {
27
+ module: string;
28
+ startedAt: number;
29
+ durationMs: number;
30
+ }
31
+
32
+ export interface DeployFailedPayload {
33
+ module: string;
34
+ startedAt: number;
35
+ durationMs: number;
36
+ error: string;
37
+ }
38
+
39
+ export interface HealthCheckFailedPayload {
40
+ module: string;
41
+ reason: string;
42
+ }
43
+
44
+ const DeployStartedSchema = z.object({
45
+ module: z.string().min(1),
46
+ startedAt: z.number().int().nonnegative(),
47
+ });
48
+
49
+ const DeployCompletedSchema = z.object({
50
+ module: z.string().min(1),
51
+ startedAt: z.number().int().nonnegative(),
52
+ durationMs: z.number().int().nonnegative(),
53
+ });
54
+
55
+ const DeployFailedSchema = z.object({
56
+ module: z.string().min(1),
57
+ startedAt: z.number().int().nonnegative(),
58
+ durationMs: z.number().int().nonnegative(),
59
+ error: z.string(),
60
+ });
61
+
62
+ const HealthCheckFailedSchema = z.object({
63
+ module: z.string().min(1),
64
+ reason: z.string(),
65
+ });
66
+
67
+ export function emitDeployStarted(payload: DeployStartedPayload): void {
68
+ DeployStartedSchema.parse(payload);
69
+ emitBest(`deploy.started.${payload.module}`, payload);
70
+ }
71
+
72
+ export function emitDeployCompleted(payload: DeployCompletedPayload): void {
73
+ DeployCompletedSchema.parse(payload);
74
+ emitBest(`deploy.completed.${payload.module}`, payload);
75
+ }
76
+
77
+ export function emitDeployFailed(payload: DeployFailedPayload): void {
78
+ DeployFailedSchema.parse(payload);
79
+ emitBest(`deploy.failed.${payload.module}`, payload);
80
+ }
81
+
82
+ export function emitHealthCheckFailed(payload: HealthCheckFailedPayload): void {
83
+ HealthCheckFailedSchema.parse(payload);
84
+ emitBest(`health-check.failed.${payload.module}`, payload);
85
+ }
86
+
87
+ /**
88
+ * Open the bus, emit, close. Errors are caught and logged so a
89
+ * misbehaving bus never wedges the caller. The empty-registry mode
90
+ * skips bus-side payload validation; we validate above with our own
91
+ * schemas before calling.
92
+ */
93
+ function emitBest(type: string, payload: unknown): void {
94
+ let bus: ReturnType<typeof openBus> | undefined;
95
+ try {
96
+ bus = openBus({ dbPath: getEventBusPath(), events: NO_SCHEMAS });
97
+ bus.emitRaw(type, payload);
98
+ } catch (err) {
99
+ const msg = err instanceof Error ? err.message : String(err);
100
+ console.warn(`[celilo] failed to emit ${type}: ${msg}`);
101
+ } finally {
102
+ bus?.close();
103
+ }
104
+ }
@@ -0,0 +1,184 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
+ import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import {
6
+ getDaemonUnitPath,
7
+ installDaemon,
8
+ readInstalledUnit,
9
+ renderLaunchdPlist,
10
+ renderSystemdUnit,
11
+ uninstallDaemon,
12
+ } from './events-daemon';
13
+
14
+ describe('renderSystemdUnit', () => {
15
+ it('produces a unit with the expected ExecStart and Environment lines', () => {
16
+ const out = renderSystemdUnit({
17
+ celiloPath: '/usr/local/bin/celilo',
18
+ busDbPath: '/var/lib/celilo/events.db',
19
+ pollMs: 1000,
20
+ concurrency: 4,
21
+ home: '/home/op',
22
+ });
23
+ expect(out).toContain(
24
+ 'ExecStart=/usr/local/bin/celilo events run --poll-ms 1000 --concurrency 4',
25
+ );
26
+ expect(out).toContain('Environment=CELILO_EVENT_BUS_PATH=/var/lib/celilo/events.db');
27
+ expect(out).toContain('Restart=on-failure');
28
+ expect(out).toContain('WantedBy=default.target');
29
+ });
30
+
31
+ it('honors --poll-ms and --concurrency overrides', () => {
32
+ const out = renderSystemdUnit({
33
+ celiloPath: '/c',
34
+ busDbPath: '/db',
35
+ pollMs: 250,
36
+ concurrency: 8,
37
+ home: '/h',
38
+ });
39
+ expect(out).toContain('--poll-ms 250 --concurrency 8');
40
+ });
41
+ });
42
+
43
+ describe('renderLaunchdPlist', () => {
44
+ it('puts log files under the user Library/Logs', () => {
45
+ const out = renderLaunchdPlist({
46
+ celiloPath: '/c',
47
+ busDbPath: '/db',
48
+ pollMs: 1000,
49
+ concurrency: 4,
50
+ home: '/Users/op',
51
+ });
52
+ expect(out).toContain('<string>/Users/op/Library/Logs/celilo-events.out.log</string>');
53
+ expect(out).toContain('<string>/Users/op/Library/Logs/celilo-events.err.log</string>');
54
+ expect(out).toContain('<key>Label</key>');
55
+ expect(out).toContain('<string>com.celilo.events</string>');
56
+ expect(out).toContain('<key>RunAtLoad</key>');
57
+ expect(out).toContain('<key>KeepAlive</key>');
58
+ });
59
+ });
60
+
61
+ describe('getDaemonUnitPath', () => {
62
+ it('returns the systemd user path on linux', () => {
63
+ expect(getDaemonUnitPath('linux', '/home/op')).toBe(
64
+ '/home/op/.config/systemd/user/celilo-events.service',
65
+ );
66
+ });
67
+ it('returns the LaunchAgents path on darwin', () => {
68
+ expect(getDaemonUnitPath('darwin', '/Users/op')).toBe(
69
+ '/Users/op/Library/LaunchAgents/com.celilo.events.plist',
70
+ );
71
+ });
72
+ });
73
+
74
+ describe('installDaemon / uninstallDaemon roundtrip', () => {
75
+ let dir: string;
76
+ let celiloPath: string;
77
+
78
+ beforeEach(() => {
79
+ dir = mkdtempSync(join(tmpdir(), 'daemon-test-'));
80
+ celiloPath = join(dir, 'fake-celilo');
81
+ // Touch a fake celilo executable so resolveCeliloPath via override is happy.
82
+ writeFileSync(celiloPath, '#!/bin/sh\nexit 0\n');
83
+ chmodSync(celiloPath, 0o755);
84
+ });
85
+ afterEach(() => {
86
+ try {
87
+ rmSync(dir, { recursive: true, force: true });
88
+ } catch {
89
+ /* ignore */
90
+ }
91
+ });
92
+
93
+ it('writes a systemd unit and uninstall removes it', () => {
94
+ const home = join(dir, 'home');
95
+ const installed = installDaemon({
96
+ platform: 'linux',
97
+ home,
98
+ celiloPath,
99
+ busDbPath: '/var/lib/celilo/events.db',
100
+ });
101
+ expect(existsSync(installed.unitPath)).toBe(true);
102
+ expect(installed.unitPath).toBe(join(home, '.config/systemd/user/celilo-events.service'));
103
+ expect(installed.nextSteps[0]).toContain('systemctl --user daemon-reload');
104
+
105
+ const removed = uninstallDaemon({ platform: 'linux', home });
106
+ expect(removed.removed).toBe(true);
107
+ expect(existsSync(installed.unitPath)).toBe(false);
108
+ });
109
+
110
+ it('writes a launchd plist and uninstall removes it', () => {
111
+ const home = join(dir, 'home');
112
+ const installed = installDaemon({
113
+ platform: 'darwin',
114
+ home,
115
+ celiloPath,
116
+ busDbPath: '/Users/op/celilo/events.db',
117
+ });
118
+ expect(existsSync(installed.unitPath)).toBe(true);
119
+ expect(installed.unitPath).toBe(join(home, 'Library/LaunchAgents/com.celilo.events.plist'));
120
+ expect(readFileSync(installed.unitPath, 'utf-8')).toContain('com.celilo.events');
121
+ expect(installed.nextSteps[0]).toContain('launchctl load');
122
+
123
+ const removed = uninstallDaemon({ platform: 'darwin', home });
124
+ expect(removed.removed).toBe(true);
125
+ });
126
+
127
+ it('install is idempotent — second call overwrites with new content', () => {
128
+ const home = join(dir, 'home');
129
+ installDaemon({
130
+ platform: 'linux',
131
+ home,
132
+ celiloPath,
133
+ busDbPath: '/db1',
134
+ pollMs: 1000,
135
+ });
136
+ const second = installDaemon({
137
+ platform: 'linux',
138
+ home,
139
+ celiloPath,
140
+ busDbPath: '/db2',
141
+ pollMs: 500,
142
+ });
143
+ const written = readFileSync(second.unitPath, 'utf-8');
144
+ expect(written).toContain('CELILO_EVENT_BUS_PATH=/db2');
145
+ expect(written).toContain('--poll-ms 500');
146
+ expect(written).not.toContain('CELILO_EVENT_BUS_PATH=/db1');
147
+ });
148
+
149
+ it('uninstall on a missing unit reports not-removed', () => {
150
+ const home = join(dir, 'home');
151
+ const result = uninstallDaemon({ platform: 'linux', home });
152
+ expect(result.removed).toBe(false);
153
+ expect(result.nextSteps[0]).toContain('nothing to clean up');
154
+ });
155
+
156
+ it('readInstalledUnit returns content when present, exists:false when not', () => {
157
+ const home = join(dir, 'home');
158
+ const before = readInstalledUnit({ platform: 'linux', home });
159
+ expect(before.exists).toBe(false);
160
+
161
+ installDaemon({
162
+ platform: 'linux',
163
+ home,
164
+ celiloPath,
165
+ busDbPath: '/db',
166
+ });
167
+ const after = readInstalledUnit({ platform: 'linux', home });
168
+ expect(after.exists).toBe(true);
169
+ if (after.exists) {
170
+ expect(after.content).toContain('events run');
171
+ }
172
+ });
173
+
174
+ it('rejects --celilo-path overrides that do not exist', () => {
175
+ expect(() =>
176
+ installDaemon({
177
+ platform: 'linux',
178
+ home: join(dir, 'home'),
179
+ celiloPath: '/no/such/celilo',
180
+ busDbPath: '/db',
181
+ }),
182
+ ).toThrow(/does not exist/);
183
+ });
184
+ });