@celilo/cli 0.2.1 → 0.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,7 +13,13 @@
13
13
  "schemas/",
14
14
  "tsconfig.json"
15
15
  ],
16
- "keywords": ["celilo", "homelab", "orchestration", "ansible", "terraform"],
16
+ "keywords": [
17
+ "celilo",
18
+ "homelab",
19
+ "orchestration",
20
+ "ansible",
21
+ "terraform"
22
+ ],
17
23
  "license": "MIT",
18
24
  "repository": {
19
25
  "type": "git",
@@ -46,8 +52,9 @@
46
52
  },
47
53
  "dependencies": {
48
54
  "@aws-sdk/client-s3": "^3.1024.0",
49
- "@celilo/capabilities": "^0.1.9",
50
- "@celilo/cli-display": "^0.1.4",
55
+ "@celilo/capabilities": "^0.1.10",
56
+ "@celilo/cli-display": "^0.1.6",
57
+ "@celilo/event-bus": "^0.1.0",
51
58
  "@clack/prompts": "^1.1.0",
52
59
  "ajv": "^8.18.0",
53
60
  "drizzle-orm": "^0.36.4",
@@ -92,6 +92,120 @@ export const COMMANDS: CommandDef[] = [
92
92
  },
93
93
  ],
94
94
  },
95
+ {
96
+ name: 'events',
97
+ description: 'SQLite event-bus operations (status, tail, run dispatcher, etc.)',
98
+ subcommands: [
99
+ { name: 'status', description: 'Print bus health as JSON' },
100
+ {
101
+ name: 'tail',
102
+ description: 'Recent events as JSON',
103
+ flags: [
104
+ { name: 'type', description: 'Filter by event type', takesValue: true },
105
+ { name: 'limit', description: 'Max events to return', takesValue: true },
106
+ ],
107
+ },
108
+ { name: 'list-subscribers', description: 'List persistent bus subscribers' },
109
+ {
110
+ name: 'list-pending',
111
+ description: 'List pending deliveries',
112
+ flags: [
113
+ {
114
+ name: 'subscriber',
115
+ description: 'Restrict to a subscriber by name',
116
+ takesValue: true,
117
+ },
118
+ { name: 'limit', description: 'Max rows to return', takesValue: true },
119
+ ],
120
+ },
121
+ {
122
+ name: 'drain',
123
+ description: 'Process pending deliveries once and return',
124
+ flags: [{ name: 'concurrency', description: 'Max parallel handlers', takesValue: true }],
125
+ },
126
+ {
127
+ name: 'run',
128
+ description: 'Run the long-running dispatcher in the foreground',
129
+ flags: [
130
+ { name: 'poll-ms', description: 'Polling interval in ms', takesValue: true },
131
+ { name: 'concurrency', description: 'Max parallel handlers', takesValue: true },
132
+ {
133
+ name: 'halt-on-recovery',
134
+ description: 'Refuse to start if a prior crash is detected',
135
+ takesValue: false,
136
+ },
137
+ ],
138
+ },
139
+ {
140
+ name: 'emit',
141
+ description: 'Emit an event (operator/test path; bypasses schema)',
142
+ args: [
143
+ { name: 'type', description: 'Event type' },
144
+ { name: 'payload', description: 'Payload as JSON (optional)' },
145
+ ],
146
+ flags: [
147
+ { name: 'dedup-key', description: 'Idempotency key', takesValue: true },
148
+ { name: 'emitted-by', description: 'Audit tag', takesValue: true },
149
+ ],
150
+ },
151
+ {
152
+ name: 'ack',
153
+ description: 'Mark a running delivery succeeded',
154
+ args: [{ name: 'event_id', description: 'Event id from `tail`' }],
155
+ flags: [
156
+ {
157
+ name: 'subscriber',
158
+ description: 'Subscriber id (auto-detected when running under the dispatcher)',
159
+ takesValue: true,
160
+ },
161
+ ],
162
+ },
163
+ {
164
+ name: 'fail',
165
+ description: 'Mark a running delivery failed',
166
+ args: [{ name: 'event_id', description: 'Event id from `tail`' }],
167
+ flags: [
168
+ { name: 'error', description: 'Failure message', takesValue: true },
169
+ {
170
+ name: 'no-retry',
171
+ description: 'Abandon immediately instead of retrying',
172
+ takesValue: false,
173
+ },
174
+ { name: 'subscriber', description: 'Subscriber id', takesValue: true },
175
+ ],
176
+ },
177
+ {
178
+ name: 'repair',
179
+ description: 'Run the crash-recovery sweep without starting the dispatcher',
180
+ },
181
+ {
182
+ name: 'resume',
183
+ description: 'Acknowledge halt-on-recovery (alias for repair)',
184
+ },
185
+ {
186
+ name: 'install-daemon',
187
+ description: 'Write a systemd/launchd user unit for the dispatcher',
188
+ flags: [
189
+ {
190
+ name: 'celilo-path',
191
+ description: 'Override the celilo binary path (default: Bun.which("celilo"))',
192
+ takesValue: true,
193
+ valueHint: '_files',
194
+ },
195
+ { name: 'poll-ms', description: 'Polling interval in ms', takesValue: true },
196
+ { name: 'concurrency', description: 'Max parallel handlers', takesValue: true },
197
+ ],
198
+ },
199
+ {
200
+ name: 'uninstall-daemon',
201
+ description: 'Remove the installed supervisor unit',
202
+ },
203
+ {
204
+ name: 'show-daemon',
205
+ description: 'Print the currently installed unit file',
206
+ },
207
+ ],
208
+ },
95
209
  {
96
210
  name: 'capability',
97
211
  description: 'View registered module capabilities',
@@ -0,0 +1,156 @@
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
+ handleEventsAck,
8
+ handleEventsDrain,
9
+ handleEventsEmit,
10
+ handleEventsFail,
11
+ handleEventsListPending,
12
+ handleEventsListSubscribers,
13
+ handleEventsRepair,
14
+ handleEventsStatus,
15
+ handleEventsTail,
16
+ } from './events';
17
+
18
+ describe('celilo events command handlers', () => {
19
+ let dir: string;
20
+ let dbPath: string;
21
+
22
+ beforeEach(() => {
23
+ dir = mkdtempSync(join(tmpdir(), 'events-cmd-test-'));
24
+ dbPath = join(dir, 'events.db');
25
+ process.env.EVENT_BUS_DB = dbPath;
26
+ });
27
+ afterEach(() => {
28
+ process.env.EVENT_BUS_DB = undefined;
29
+ try {
30
+ rmSync(dir, { recursive: true, force: true });
31
+ } catch {
32
+ /* ignore */
33
+ }
34
+ });
35
+
36
+ it('status reports no_dispatcher on a fresh bus', async () => {
37
+ const result = await handleEventsStatus();
38
+ expect(result.success).toBe(true);
39
+ if (!result.success) throw new Error('expected success');
40
+ const data = result.data as { status: string };
41
+ expect(data.status).toBe('no_dispatcher');
42
+ });
43
+
44
+ it('emit + tail roundtrips an event', async () => {
45
+ const emit = await handleEventsEmit(['custom.event.foo', '{"x":1}'], { 'emitted-by': 'test' });
46
+ expect(emit.success).toBe(true);
47
+ const tail = await handleEventsTail([], { limit: '10' });
48
+ expect(tail.success).toBe(true);
49
+ if (!tail.success) throw new Error('expected success');
50
+ const events = tail.data as Array<{ type: string; payload: unknown }>;
51
+ expect(events).toHaveLength(1);
52
+ expect(events[0].type).toBe('custom.event.foo');
53
+ expect(events[0].payload).toEqual({ x: 1 });
54
+ });
55
+
56
+ it('list-subscribers and list-pending reflect bus state', async () => {
57
+ const setupBus = openBus({ dbPath, events: defineEvents({}) });
58
+ setupBus.subscribe({ name: 'test-sub', pattern: 'foo.*', handler: 'echo' });
59
+ setupBus.emitRaw('foo.bar', { v: 1 });
60
+ setupBus.close();
61
+
62
+ const subs = await handleEventsListSubscribers();
63
+ expect(subs.success).toBe(true);
64
+ if (!subs.success) throw new Error('expected success');
65
+ expect((subs.data as { name: string }[])[0].name).toBe('test-sub');
66
+
67
+ const pending = await handleEventsListPending([], {});
68
+ expect(pending.success).toBe(true);
69
+ if (!pending.success) throw new Error('expected success');
70
+ expect(pending.data as unknown[]).toHaveLength(1);
71
+ });
72
+
73
+ it('drain on an empty queue returns processed: 0', async () => {
74
+ // The CLI drain path opens its own bus, so it can't reach an in-process
75
+ // onEvent registered on a separate bus instance. Spawn-based handler
76
+ // exercise lives in the bus library's own integration tests; here we
77
+ // assert the CLI plumbing returns the bus result shape.
78
+ const result = await handleEventsDrain([], {});
79
+ expect(result.success).toBe(true);
80
+ if (!result.success) throw new Error('expected success');
81
+ expect((result.data as { processed: number }).processed).toBe(0);
82
+ });
83
+
84
+ it('ack marks a running delivery succeeded', async () => {
85
+ const setupBus = openBus({ dbPath, events: defineEvents({}) });
86
+ setupBus.subscribe({ name: 's', pattern: 'foo', handler: 'echo' });
87
+ setupBus.emitRaw('foo', {});
88
+ // Force delivery to running state as if the dispatcher had claimed it.
89
+ setupBus.db.run("UPDATE deliveries SET status = 'running', started_at = ?, attempts = 1", [
90
+ Date.now(),
91
+ ]);
92
+ setupBus.close();
93
+
94
+ const ack = await handleEventsAck(['1'], {});
95
+ expect(ack.success).toBe(true);
96
+
97
+ const verify = openBus({ dbPath, events: defineEvents({}) });
98
+ try {
99
+ const row = verify.db.query<{ status: string }, []>('SELECT status FROM deliveries').get();
100
+ expect(row?.status).toBe('succeeded');
101
+ } finally {
102
+ verify.close();
103
+ }
104
+ });
105
+
106
+ it('fail with --no-retry abandons the delivery', async () => {
107
+ const setupBus = openBus({ dbPath, events: defineEvents({}) });
108
+ setupBus.subscribe({ name: 's', pattern: 'foo', handler: 'echo' });
109
+ setupBus.emitRaw('foo', {});
110
+ setupBus.db.run("UPDATE deliveries SET status = 'running', started_at = ?, attempts = 3", [
111
+ Date.now(),
112
+ ]);
113
+ setupBus.close();
114
+
115
+ const result = await handleEventsFail(['1'], { error: 'bad', 'no-retry': true });
116
+ expect(result.success).toBe(true);
117
+
118
+ const verify = openBus({ dbPath, events: defineEvents({}) });
119
+ try {
120
+ const row = verify.db.query<{ status: string }, []>('SELECT status FROM deliveries').get();
121
+ expect(row?.status).toBe('abandoned');
122
+ } finally {
123
+ verify.close();
124
+ }
125
+ });
126
+
127
+ it('ack errors out with no running delivery and no --subscriber', async () => {
128
+ const setupBus = openBus({ dbPath, events: defineEvents({}) });
129
+ setupBus.emitRaw('orphan', {});
130
+ setupBus.close();
131
+ const result = await handleEventsAck(['1'], {});
132
+ expect(result.success).toBe(false);
133
+ });
134
+
135
+ it('repair runs the recovery sweep', async () => {
136
+ const setupBus = openBus({ dbPath, events: defineEvents({}) });
137
+ setupBus.subscribe({ name: 's', pattern: 'foo', handler: 'echo', timeoutMs: 100 });
138
+ setupBus.emitRaw('foo', {});
139
+ const ancient = Date.now() - 60_000;
140
+ setupBus.db.run("UPDATE deliveries SET status = 'running', started_at = ?, attempts = 1", [
141
+ ancient,
142
+ ]);
143
+ setupBus.db.run(
144
+ `INSERT INTO dispatcher_heartbeat (dispatcher_id, last_heartbeat, started_at, pid, version)
145
+ VALUES (?, ?, ?, ?, ?)`,
146
+ ['ghost', ancient, ancient, 99999, '0.0.0'],
147
+ );
148
+ setupBus.close();
149
+
150
+ const result = await handleEventsRepair();
151
+ expect(result.success).toBe(true);
152
+ if (!result.success) throw new Error('expected success');
153
+ expect((result.data as { recovered: boolean }).recovered).toBe(true);
154
+ expect((result.data as { stuckCount: number }).stuckCount).toBe(1);
155
+ });
156
+ });
@@ -0,0 +1,356 @@
1
+ /**
2
+ * `celilo events <subcommand>` — operator surface for the SQLite event
3
+ * bus. Thin wrappers over `@celilo/event-bus`'s programmatic API. The
4
+ * standalone `event-bus` CLI binary is still available; these commands
5
+ * exist so operators don't have to set EVENT_BUS_DB or remember a
6
+ * second tool.
7
+ *
8
+ * Subcommands:
9
+ * status bus.health() as JSON
10
+ * tail recent events
11
+ * list-subscribers persistent subscribers
12
+ * list-pending pending deliveries
13
+ * drain process pending deliveries once
14
+ * run long-running dispatcher (foreground; SIGINT to stop)
15
+ * emit <type> [json] emit an event (operator/test path; bypasses schema)
16
+ * ack <event_id> mark a running delivery succeeded
17
+ * fail <event_id> mark a running delivery failed
18
+ * repair crash-recovery sweep without starting the dispatcher
19
+ * resume alias for repair (acknowledges halt-on-recovery)
20
+ */
21
+
22
+ import {
23
+ defineEvents,
24
+ drainOnce,
25
+ openBus,
26
+ recoverFromCrash,
27
+ runDispatcher,
28
+ } from '@celilo/event-bus';
29
+ import { getEventBusPath, shortenPath } from '../../config/paths';
30
+ import { installDaemon, readInstalledUnit, uninstallDaemon } from '../../services/events-daemon';
31
+ import { getArg, hasFlag } from '../parser';
32
+ import type { CommandResult } from '../types';
33
+
34
+ const NO_SCHEMAS = defineEvents({});
35
+
36
+ function openCliBus() {
37
+ return openBus({ dbPath: getEventBusPath(), events: NO_SCHEMAS });
38
+ }
39
+
40
+ function jsonResult(data: unknown): CommandResult {
41
+ return {
42
+ success: true,
43
+ message: JSON.stringify(data, null, 2),
44
+ data,
45
+ };
46
+ }
47
+
48
+ export async function handleEventsStatus(): Promise<CommandResult> {
49
+ const bus = openCliBus();
50
+ try {
51
+ return jsonResult(bus.health());
52
+ } finally {
53
+ bus.close();
54
+ }
55
+ }
56
+
57
+ export async function handleEventsTail(
58
+ _args: string[],
59
+ flags: Record<string, string | boolean>,
60
+ ): Promise<CommandResult> {
61
+ const bus = openCliBus();
62
+ try {
63
+ const limit = flags.limit ? Number(flags.limit) : 50;
64
+ const type = typeof flags.type === 'string' ? flags.type : undefined;
65
+ return jsonResult(bus.recentEvents({ limit, type }));
66
+ } finally {
67
+ bus.close();
68
+ }
69
+ }
70
+
71
+ export async function handleEventsListSubscribers(): Promise<CommandResult> {
72
+ const bus = openCliBus();
73
+ try {
74
+ const rows = bus.db
75
+ .query<
76
+ {
77
+ name: string;
78
+ pattern: string;
79
+ handler: string;
80
+ max_attempts: number;
81
+ timeout_ms: number;
82
+ },
83
+ []
84
+ >('SELECT name, pattern, handler, max_attempts, timeout_ms FROM subscribers ORDER BY name')
85
+ .all();
86
+ return jsonResult(rows);
87
+ } finally {
88
+ bus.close();
89
+ }
90
+ }
91
+
92
+ export async function handleEventsListPending(
93
+ _args: string[],
94
+ flags: Record<string, string | boolean>,
95
+ ): Promise<CommandResult> {
96
+ const bus = openCliBus();
97
+ try {
98
+ const subscriber = typeof flags.subscriber === 'string' ? flags.subscriber : undefined;
99
+ const limit = flags.limit ? Number(flags.limit) : 100;
100
+ return jsonResult(bus.pendingDeliveries({ subscriber, limit }));
101
+ } finally {
102
+ bus.close();
103
+ }
104
+ }
105
+
106
+ export async function handleEventsDrain(
107
+ _args: string[],
108
+ flags: Record<string, string | boolean>,
109
+ ): Promise<CommandResult> {
110
+ const bus = openCliBus();
111
+ try {
112
+ const concurrency = flags.concurrency ? Number(flags.concurrency) : undefined;
113
+ const result = await drainOnce(bus, { concurrency });
114
+ return jsonResult({ ...result, dbPath: shortenPath(getEventBusPath()) });
115
+ } finally {
116
+ bus.close();
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Run the long-running dispatcher in the foreground. Blocks until
122
+ * SIGINT/SIGTERM. Pair with `celilo events status` from another shell
123
+ * to confirm it's healthy.
124
+ */
125
+ export async function handleEventsRun(
126
+ _args: string[],
127
+ flags: Record<string, string | boolean>,
128
+ ): Promise<CommandResult> {
129
+ const bus = openCliBus();
130
+ const handle = runDispatcher(bus, {
131
+ pollIntervalMs: flags['poll-ms'] ? Number(flags['poll-ms']) : undefined,
132
+ concurrency: flags.concurrency ? Number(flags.concurrency) : undefined,
133
+ haltOnRecovery: hasFlag(flags, 'halt-on-recovery'),
134
+ });
135
+ console.error(
136
+ `[celilo events] dispatcher running (pid ${process.pid}, db ${shortenPath(getEventBusPath())}). Ctrl-C to stop.`,
137
+ );
138
+
139
+ let stopping = false;
140
+ const stop = async (signal: string) => {
141
+ if (stopping) return;
142
+ stopping = true;
143
+ console.error(`[celilo events] received ${signal}, stopping...`);
144
+ await handle.stop();
145
+ bus.close();
146
+ process.exit(0);
147
+ };
148
+ process.on('SIGINT', () => stop('SIGINT'));
149
+ process.on('SIGTERM', () => stop('SIGTERM'));
150
+ await new Promise(() => {}); // hold open
151
+ return { success: true, message: '' }; // unreachable
152
+ }
153
+
154
+ export async function handleEventsEmit(
155
+ args: string[],
156
+ flags: Record<string, string | boolean>,
157
+ ): Promise<CommandResult> {
158
+ const type = getArg(args, 0);
159
+ if (!type) {
160
+ return { success: false, error: 'Usage: celilo events emit <type> [<payload-json>]' };
161
+ }
162
+ const payloadRaw = getArg(args, 1);
163
+ let payload: unknown = undefined;
164
+ if (payloadRaw !== undefined) {
165
+ try {
166
+ payload = JSON.parse(payloadRaw);
167
+ } catch (err) {
168
+ return {
169
+ success: false,
170
+ error: `Invalid JSON payload: ${err instanceof Error ? err.message : String(err)}`,
171
+ };
172
+ }
173
+ }
174
+ const bus = openCliBus();
175
+ try {
176
+ const event = bus.emitRaw(type, payload, {
177
+ dedupKey: typeof flags['dedup-key'] === 'string' ? flags['dedup-key'] : undefined,
178
+ emittedBy: typeof flags['emitted-by'] === 'string' ? flags['emitted-by'] : 'celilo-cli',
179
+ });
180
+ return jsonResult(event);
181
+ } finally {
182
+ bus.close();
183
+ }
184
+ }
185
+
186
+ export async function handleEventsAck(
187
+ args: string[],
188
+ flags: Record<string, string | boolean>,
189
+ ): Promise<CommandResult> {
190
+ const eventId = getArg(args, 0);
191
+ if (!eventId) return { success: false, error: 'Usage: celilo events ack <event_id>' };
192
+ const eId = Number(eventId);
193
+ if (!Number.isFinite(eId)) return { success: false, error: 'event_id must be a number' };
194
+ const bus = openCliBus();
195
+ try {
196
+ const subscriberId = resolveSubscriberId(bus, eId, flags);
197
+ if (subscriberId === null) {
198
+ return {
199
+ success: false,
200
+ error: `No running delivery for event ${eId}; pass --subscriber <id>`,
201
+ };
202
+ }
203
+ bus.markSucceeded({ eventId: eId, subscriberId });
204
+ return jsonResult({ ok: true, eventId: eId, subscriberId });
205
+ } finally {
206
+ bus.close();
207
+ }
208
+ }
209
+
210
+ export async function handleEventsFail(
211
+ args: string[],
212
+ flags: Record<string, string | boolean>,
213
+ ): Promise<CommandResult> {
214
+ const eventId = getArg(args, 0);
215
+ if (!eventId)
216
+ return { success: false, error: 'Usage: celilo events fail <event_id> --error <msg>' };
217
+ const eId = Number(eventId);
218
+ if (!Number.isFinite(eId)) return { success: false, error: 'event_id must be a number' };
219
+ const errorMsg = typeof flags.error === 'string' ? flags.error : 'handler failed';
220
+ const noRetry = hasFlag(flags, 'no-retry');
221
+ const bus = openCliBus();
222
+ try {
223
+ const subscriberId = resolveSubscriberId(bus, eId, flags);
224
+ if (subscriberId === null) {
225
+ return {
226
+ success: false,
227
+ error: `No running delivery for event ${eId}; pass --subscriber <id>`,
228
+ };
229
+ }
230
+ if (noRetry) {
231
+ bus.markFailed({ eventId: eId, subscriberId }, new Error(errorMsg), { abandoned: true });
232
+ } else {
233
+ bus.markFailed({ eventId: eId, subscriberId }, new Error(errorMsg));
234
+ }
235
+ return jsonResult({ ok: true, eventId: eId, subscriberId, abandoned: noRetry });
236
+ } finally {
237
+ bus.close();
238
+ }
239
+ }
240
+
241
+ /**
242
+ * `celilo events repair` / `celilo events resume` — run the
243
+ * crash-recovery sweep. Resets stuck `running` deliveries to `pending`
244
+ * so the next dispatcher tick picks them up. `resume` is the
245
+ * operator-facing alias for the halt-on-recovery acknowledgment flow.
246
+ */
247
+ export async function handleEventsRepair(): Promise<CommandResult> {
248
+ const bus = openCliBus();
249
+ try {
250
+ const result = recoverFromCrash(bus);
251
+ return jsonResult({
252
+ recovered: result.recovered,
253
+ stuckCount: result.stuckCount,
254
+ lastHeartbeatAgeMs: result.lastHeartbeatAgeMs,
255
+ });
256
+ } finally {
257
+ bus.close();
258
+ }
259
+ }
260
+
261
+ /**
262
+ * `celilo events install-daemon` — write a systemd user unit (Linux)
263
+ * or launchd plist (macOS) that runs the dispatcher under supervision.
264
+ * Writes the file but doesn't touch supervisor state — the operator
265
+ * runs `systemctl --user enable --now ...` themselves so any change
266
+ * is visible.
267
+ */
268
+ export async function handleEventsInstallDaemon(
269
+ _args: string[],
270
+ flags: Record<string, string | boolean>,
271
+ ): Promise<CommandResult> {
272
+ try {
273
+ const result = installDaemon({
274
+ celiloPath: typeof flags['celilo-path'] === 'string' ? flags['celilo-path'] : undefined,
275
+ pollMs: flags['poll-ms'] ? Number(flags['poll-ms']) : undefined,
276
+ concurrency: flags.concurrency ? Number(flags.concurrency) : undefined,
277
+ });
278
+ const lines = [
279
+ `Wrote ${result.platform} unit: ${shortenPath(result.unitPath)}`,
280
+ ` celilo: ${shortenPath(result.celiloPath)}`,
281
+ ` bus DB: ${shortenPath(result.busDbPath)}`,
282
+ '',
283
+ 'Next steps (run these yourself — install does not touch supervisor state):',
284
+ ...result.nextSteps.map((s) => ` ${s}`),
285
+ ];
286
+ return { success: true, message: lines.join('\n'), data: result };
287
+ } catch (err) {
288
+ return {
289
+ success: false,
290
+ error: err instanceof Error ? err.message : String(err),
291
+ };
292
+ }
293
+ }
294
+
295
+ /**
296
+ * `celilo events uninstall-daemon` — remove the supervisor unit file.
297
+ * Symmetrical: prints the operator-facing disable steps, doesn't run
298
+ * them.
299
+ */
300
+ export async function handleEventsUninstallDaemon(): Promise<CommandResult> {
301
+ try {
302
+ const result = uninstallDaemon();
303
+ const lines = [
304
+ result.removed
305
+ ? `Removed unit: ${shortenPath(result.unitPath)}`
306
+ : `No unit file at ${shortenPath(result.unitPath)} — nothing to remove.`,
307
+ '',
308
+ 'Next steps:',
309
+ ...result.nextSteps.map((s) => ` ${s}`),
310
+ ];
311
+ return { success: true, message: lines.join('\n'), data: result };
312
+ } catch (err) {
313
+ return {
314
+ success: false,
315
+ error: err instanceof Error ? err.message : String(err),
316
+ };
317
+ }
318
+ }
319
+
320
+ /**
321
+ * `celilo events show-daemon` — print whatever unit file is currently
322
+ * installed so the operator can see exactly what the supervisor will
323
+ * run, without grepping into platform-specific paths.
324
+ */
325
+ export async function handleEventsShowDaemon(): Promise<CommandResult> {
326
+ const result = readInstalledUnit();
327
+ if (!result.exists) {
328
+ return {
329
+ success: true,
330
+ message: `No supervisor unit installed at ${shortenPath(result.path)}\n\nRun \`celilo events install-daemon\` to create one.`,
331
+ };
332
+ }
333
+ return {
334
+ success: true,
335
+ message: `${shortenPath(result.path)}:\n\n${result.content}`,
336
+ data: { path: result.path, content: result.content },
337
+ };
338
+ }
339
+
340
+ function resolveSubscriberId(
341
+ bus: ReturnType<typeof openCliBus>,
342
+ eventId: number,
343
+ flags: Record<string, string | boolean>,
344
+ ): number | null {
345
+ if (typeof flags.subscriber === 'string' || typeof flags.subscriber === 'number') {
346
+ return Number(flags.subscriber);
347
+ }
348
+ const row = bus.db
349
+ .query<{ subscriber_id: number }, [number]>(
350
+ `SELECT subscriber_id FROM deliveries
351
+ WHERE event_id = ? AND status = 'running'
352
+ ORDER BY started_at DESC LIMIT 1`,
353
+ )
354
+ .get(eventId);
355
+ return row?.subscriber_id ?? null;
356
+ }
@@ -232,6 +232,20 @@ export async function handleModuleRemove(
232
232
  // Deallocate IPAM resources (if any)
233
233
  await deallocateForModule(moduleId, db);
234
234
 
235
+ // Tear down any event-bus subscriptions the module owned. Cascade-safe:
236
+ // best-effort, never blocks removal — a stuck bus is no reason to
237
+ // strand a module in a half-removed state.
238
+ try {
239
+ const { unregisterModuleSubscriptions } = await import('../../services/module-subscriptions');
240
+ const result = unregisterModuleSubscriptions(moduleId);
241
+ if (result.unregistered > 0) {
242
+ log.info(`Unregistered ${result.unregistered} event-bus subscription(s) for ${moduleId}`);
243
+ }
244
+ } catch (error) {
245
+ const msg = error instanceof Error ? error.message : String(error);
246
+ log.warn(`Failed to unregister event-bus subscriptions: ${msg}`);
247
+ }
248
+
235
249
  // Delete module (cascade will remove configs, secrets, capabilities, infrastructure records)
236
250
  db.delete(modules).where(eq(modules.id, moduleId)).run();
237
251