@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.
@@ -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=EVENT_BUS_DB=/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('EVENT_BUS_DB=/db2');
145
+ expect(written).toContain('--poll-ms 500');
146
+ expect(written).not.toContain('EVENT_BUS_DB=/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
+ });
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Generate and install / uninstall a per-user supervisor unit for the
3
+ * SQLite event-bus dispatcher. Linux gets a systemd user unit; macOS
4
+ * gets a launchd plist. The command WRITES the unit file but never
5
+ * touches the supervisor state — the operator runs `systemctl --user
6
+ * enable --now celilo-events` (or `launchctl load`) themselves so the
7
+ * effect is visible.
8
+ *
9
+ * Why split write-vs-enable: a celilo command silently flipping
10
+ * systemd state would be hard to reason about during incidents. Pure
11
+ * file-on-disk + printed next-steps keeps the operational surface
12
+ * predictable.
13
+ */
14
+
15
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
16
+ import { homedir, platform as nodePlatform } from 'node:os';
17
+ import { dirname, join } from 'node:path';
18
+ import { getEventBusPath } from '../config/paths';
19
+
20
+ export type SupervisorPlatform = 'linux' | 'darwin';
21
+
22
+ export interface InstallDaemonOptions {
23
+ celiloPath?: string;
24
+ pollMs?: number;
25
+ concurrency?: number;
26
+ /** Override platform detection. Mainly for tests. */
27
+ platform?: SupervisorPlatform;
28
+ /** Override the bus DB path. Defaults to celilo's getEventBusPath(). */
29
+ busDbPath?: string;
30
+ /** Override the home directory used to compute install paths. */
31
+ home?: string;
32
+ }
33
+
34
+ export interface InstallDaemonResult {
35
+ platform: SupervisorPlatform;
36
+ unitPath: string;
37
+ unitContent: string;
38
+ celiloPath: string;
39
+ busDbPath: string;
40
+ nextSteps: string[];
41
+ }
42
+
43
+ export interface UninstallDaemonResult {
44
+ platform: SupervisorPlatform;
45
+ unitPath: string;
46
+ removed: boolean;
47
+ nextSteps: string[];
48
+ }
49
+
50
+ const SYSTEMD_UNIT_NAME = 'celilo-events.service';
51
+ const LAUNCHD_LABEL = 'com.celilo.events';
52
+
53
+ /**
54
+ * Map a node `process.platform` to one of the supported supervisor
55
+ * platforms. We don't ship Windows or BSD support for v1.
56
+ */
57
+ export function detectPlatform(): SupervisorPlatform {
58
+ const p = nodePlatform();
59
+ if (p === 'linux') return 'linux';
60
+ if (p === 'darwin') return 'darwin';
61
+ throw new Error(
62
+ `celilo events daemon: unsupported platform "${p}". Only linux (systemd user) and darwin (launchd) are supported in v1.`,
63
+ );
64
+ }
65
+
66
+ /**
67
+ * Resolve where to write the unit file for a given platform + user.
68
+ * User-mode locations only; system-wide is opt-in for a future phase.
69
+ */
70
+ export function getDaemonUnitPath(platform: SupervisorPlatform, home: string): string {
71
+ if (platform === 'linux') {
72
+ return join(home, '.config', 'systemd', 'user', SYSTEMD_UNIT_NAME);
73
+ }
74
+ return join(home, 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`);
75
+ }
76
+
77
+ /**
78
+ * Find the celilo executable. Priority:
79
+ * 1. Explicit override
80
+ * 2. Bun.which('celilo')
81
+ * 3. error
82
+ *
83
+ * The unit file needs an absolute path because systemd does not resolve
84
+ * PATH for ExecStart and launchd is similarly literal.
85
+ */
86
+ export function resolveCeliloPath(override?: string): string {
87
+ if (override) {
88
+ if (!existsSync(override)) {
89
+ throw new Error(`celilo events daemon: --celilo-path "${override}" does not exist`);
90
+ }
91
+ return override;
92
+ }
93
+ // Bun.which returns null when not found.
94
+ const found = (Bun as unknown as { which: (cmd: string) => string | null }).which('celilo');
95
+ if (!found) {
96
+ throw new Error(
97
+ 'celilo events daemon: could not find `celilo` on PATH. Install it globally (`bun add -g @celilo/cli`) or pass --celilo-path <path>.',
98
+ );
99
+ }
100
+ return found;
101
+ }
102
+
103
+ interface UnitInputs {
104
+ celiloPath: string;
105
+ busDbPath: string;
106
+ pollMs: number;
107
+ concurrency: number;
108
+ home: string;
109
+ }
110
+
111
+ export function renderSystemdUnit(input: UnitInputs): string {
112
+ return `[Unit]
113
+ Description=Celilo SQLite Event Bus Dispatcher
114
+ Documentation=https://github.com/psbanka/infra/blob/main/design/SQLITE_EVENT_BUS.md
115
+ After=network.target
116
+
117
+ [Service]
118
+ Type=simple
119
+ ExecStart=${input.celiloPath} events run --poll-ms ${input.pollMs} --concurrency ${input.concurrency}
120
+ Restart=on-failure
121
+ RestartSec=10s
122
+ Environment=EVENT_BUS_DB=${input.busDbPath}
123
+ # stdout/stderr are captured by journalctl --user -u celilo-events.service.
124
+ StandardOutput=journal
125
+ StandardError=journal
126
+
127
+ [Install]
128
+ WantedBy=default.target
129
+ `;
130
+ }
131
+
132
+ export function renderLaunchdPlist(input: UnitInputs): string {
133
+ const logDir = join(input.home, 'Library', 'Logs');
134
+ // Indentation matters very little to launchd but we want it readable
135
+ // when an operator opens the file manually to debug.
136
+ return `<?xml version="1.0" encoding="UTF-8"?>
137
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
138
+ <plist version="1.0">
139
+ <dict>
140
+ <key>Label</key>
141
+ <string>${LAUNCHD_LABEL}</string>
142
+ <key>ProgramArguments</key>
143
+ <array>
144
+ <string>${input.celiloPath}</string>
145
+ <string>events</string>
146
+ <string>run</string>
147
+ <string>--poll-ms</string>
148
+ <string>${input.pollMs}</string>
149
+ <string>--concurrency</string>
150
+ <string>${input.concurrency}</string>
151
+ </array>
152
+ <key>EnvironmentVariables</key>
153
+ <dict>
154
+ <key>EVENT_BUS_DB</key>
155
+ <string>${input.busDbPath}</string>
156
+ </dict>
157
+ <key>RunAtLoad</key>
158
+ <true/>
159
+ <key>KeepAlive</key>
160
+ <true/>
161
+ <key>StandardOutPath</key>
162
+ <string>${join(logDir, 'celilo-events.out.log')}</string>
163
+ <key>StandardErrorPath</key>
164
+ <string>${join(logDir, 'celilo-events.err.log')}</string>
165
+ </dict>
166
+ </plist>
167
+ `;
168
+ }
169
+
170
+ /**
171
+ * Write the supervisor unit file. Idempotent: rewrites if present.
172
+ * Returns the path written + the operator-facing next steps.
173
+ */
174
+ export function installDaemon(opts: InstallDaemonOptions = {}): InstallDaemonResult {
175
+ const platform = opts.platform ?? detectPlatform();
176
+ const home = opts.home ?? homedir();
177
+ const celiloPath = resolveCeliloPath(opts.celiloPath);
178
+ const busDbPath = opts.busDbPath ?? getEventBusPath();
179
+ const pollMs = opts.pollMs ?? 1000;
180
+ const concurrency = opts.concurrency ?? 4;
181
+
182
+ const unitPath = getDaemonUnitPath(platform, home);
183
+ const unitInputs: UnitInputs = { celiloPath, busDbPath, pollMs, concurrency, home };
184
+ const unitContent =
185
+ platform === 'linux' ? renderSystemdUnit(unitInputs) : renderLaunchdPlist(unitInputs);
186
+
187
+ mkdirSync(dirname(unitPath), { recursive: true });
188
+ writeFileSync(unitPath, unitContent, { mode: 0o644 });
189
+
190
+ const nextSteps =
191
+ platform === 'linux'
192
+ ? [
193
+ 'Reload systemd: systemctl --user daemon-reload',
194
+ `Enable + start: systemctl --user enable --now ${SYSTEMD_UNIT_NAME}`,
195
+ `Tail logs: journalctl --user -u ${SYSTEMD_UNIT_NAME} -f`,
196
+ `Status: systemctl --user status ${SYSTEMD_UNIT_NAME}`,
197
+ ]
198
+ : [
199
+ `Load + start: launchctl load -w ${unitPath}`,
200
+ 'Tail stdout: tail -f ~/Library/Logs/celilo-events.out.log',
201
+ 'Tail stderr: tail -f ~/Library/Logs/celilo-events.err.log',
202
+ `Status: launchctl list | grep ${LAUNCHD_LABEL}`,
203
+ ];
204
+
205
+ return { platform, unitPath, unitContent, celiloPath, busDbPath, nextSteps };
206
+ }
207
+
208
+ export function uninstallDaemon(
209
+ opts: { platform?: SupervisorPlatform; home?: string } = {},
210
+ ): UninstallDaemonResult {
211
+ const platform = opts.platform ?? detectPlatform();
212
+ const home = opts.home ?? homedir();
213
+ const unitPath = getDaemonUnitPath(platform, home);
214
+
215
+ let removed = false;
216
+ if (existsSync(unitPath)) {
217
+ unlinkSync(unitPath);
218
+ removed = true;
219
+ }
220
+
221
+ const nextSteps =
222
+ platform === 'linux'
223
+ ? removed
224
+ ? [
225
+ `Disable + stop: systemctl --user disable --now ${SYSTEMD_UNIT_NAME}`,
226
+ 'Reload systemd: systemctl --user daemon-reload',
227
+ ]
228
+ : ['No unit file present; nothing to clean up.']
229
+ : removed
230
+ ? [`Unload: launchctl unload ${unitPath}`]
231
+ : ['No plist present; nothing to clean up.'];
232
+
233
+ return { platform, unitPath, removed, nextSteps };
234
+ }
235
+
236
+ export function readInstalledUnit(
237
+ opts: { platform?: SupervisorPlatform; home?: string } = {},
238
+ ): { exists: true; path: string; content: string } | { exists: false; path: string } {
239
+ const platform = opts.platform ?? detectPlatform();
240
+ const home = opts.home ?? homedir();
241
+ const unitPath = getDaemonUnitPath(platform, home);
242
+ if (!existsSync(unitPath)) return { exists: false, path: unitPath };
243
+ return { exists: true, path: unitPath, content: readFileSync(unitPath, 'utf-8') };
244
+ }
@@ -244,10 +244,61 @@ async function invokeHookWithEnsureRetry(
244
244
  * @param options - Deployment options
245
245
  * @returns Deployment result with phase tracking
246
246
  */
247
+ /**
248
+ * Public entry point. Wraps the core deploy work with event-bus
249
+ * lifecycle emits (`deploy.started.<id>`, `deploy.completed.<id>`,
250
+ * `deploy.failed.<id>`) so subscribers — production smoke tests,
251
+ * alerting, etc. — can react. Bus emit failures are best-effort and
252
+ * never affect the deploy outcome.
253
+ */
247
254
  export async function deployModule(
248
255
  moduleId: string,
249
256
  db: DbClient,
250
257
  options: DeployOptions = {},
258
+ ): Promise<DeployResult> {
259
+ const startedAt = Date.now();
260
+ const { emitDeployCompleted, emitDeployFailed, emitDeployStarted, emitHealthCheckFailed } =
261
+ await import('./celilo-events');
262
+
263
+ emitDeployStarted({ module: moduleId, startedAt });
264
+
265
+ let result: DeployResult;
266
+ try {
267
+ result = await deployModuleImpl(moduleId, db, options);
268
+ } catch (err) {
269
+ const error = err instanceof Error ? err.message : String(err);
270
+ emitDeployFailed({
271
+ module: moduleId,
272
+ startedAt,
273
+ durationMs: Date.now() - startedAt,
274
+ error,
275
+ });
276
+ throw err;
277
+ }
278
+
279
+ const durationMs = Date.now() - startedAt;
280
+ if (result.success) {
281
+ emitDeployCompleted({ module: moduleId, startedAt, durationMs });
282
+ } else {
283
+ emitDeployFailed({
284
+ module: moduleId,
285
+ startedAt,
286
+ durationMs,
287
+ error: result.error ?? 'unknown error',
288
+ });
289
+ // Health-check failures are a sub-class worth surfacing on their own
290
+ // channel so subscribers can target them without parsing error strings.
291
+ if (result.error?.toLowerCase().includes('health check')) {
292
+ emitHealthCheckFailed({ module: moduleId, reason: result.error });
293
+ }
294
+ }
295
+ return result;
296
+ }
297
+
298
+ async function deployModuleImpl(
299
+ moduleId: string,
300
+ db: DbClient,
301
+ options: DeployOptions = {},
251
302
  ): Promise<DeployResult> {
252
303
  const phases: DeployResult['phases'] = {};
253
304
 
@@ -0,0 +1,197 @@
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 type { ModuleManifest } from '../manifest/schema';
7
+ import {
8
+ registerModuleSubscriptions,
9
+ resolveSubscription,
10
+ unregisterModuleSubscriptions,
11
+ } from './module-subscriptions';
12
+
13
+ const baseManifest = (overrides: Partial<ModuleManifest> = {}): ModuleManifest => ({
14
+ celilo_contract: '1.0',
15
+ id: 'lunacycle',
16
+ name: 'Lunacycle',
17
+ version: '1.0.0',
18
+ requires: { capabilities: [] },
19
+ provides: { capabilities: [] },
20
+ variables: { owns: [], imports: [] },
21
+ ...overrides,
22
+ });
23
+
24
+ describe('resolveSubscription', () => {
25
+ it('substitutes $self in pattern and ${MODULE_PATH} in handler', () => {
26
+ const resolved = resolveSubscription(
27
+ {
28
+ name: 'smoke-after-deploy',
29
+ pattern: 'deploy.completed.$self',
30
+ handler: 'bun ${MODULE_PATH}/celilo/scripts/smoke.ts',
31
+ },
32
+ 'lunacycle',
33
+ '/var/lib/celilo/modules/lunacycle',
34
+ );
35
+
36
+ expect(resolved.name).toBe('lunacycle.smoke-after-deploy');
37
+ expect(resolved.pattern).toBe('deploy.completed.lunacycle');
38
+ expect(resolved.handler).toBe('bun /var/lib/celilo/modules/lunacycle/celilo/scripts/smoke.ts');
39
+ expect(resolved.registeredBy).toBe('lunacycle');
40
+ });
41
+
42
+ it('only substitutes $self when followed by . or end-of-string', () => {
43
+ // `$selfish` would not be a real pattern but we want to ensure the
44
+ // substitution doesn't accidentally rewrite identifier-like names.
45
+ const resolved = resolveSubscription(
46
+ {
47
+ name: 'a',
48
+ pattern: '$self.x.$selfish',
49
+ handler: 'echo',
50
+ },
51
+ 'foo',
52
+ '/p',
53
+ );
54
+ expect(resolved.pattern).toBe('foo.x.$selfish');
55
+ });
56
+
57
+ it('passes through max_attempts and timeout_ms when set', () => {
58
+ const resolved = resolveSubscription(
59
+ {
60
+ name: 'a',
61
+ pattern: 'x',
62
+ handler: 'echo',
63
+ max_attempts: 5,
64
+ timeout_ms: 90000,
65
+ },
66
+ 'foo',
67
+ '/p',
68
+ );
69
+ expect(resolved.maxAttempts).toBe(5);
70
+ expect(resolved.timeoutMs).toBe(90000);
71
+ });
72
+ });
73
+
74
+ describe('register / unregister roundtrip', () => {
75
+ let dir: string;
76
+ let dbPath: string;
77
+
78
+ beforeEach(() => {
79
+ dir = mkdtempSync(join(tmpdir(), 'modsubs-test-'));
80
+ dbPath = join(dir, 'events.db');
81
+ process.env.EVENT_BUS_DB = dbPath;
82
+ });
83
+ afterEach(() => {
84
+ process.env.EVENT_BUS_DB = undefined;
85
+ try {
86
+ rmSync(dir, { recursive: true, force: true });
87
+ } catch {
88
+ /* ignore */
89
+ }
90
+ });
91
+
92
+ it('manifest with no subscriptions is a no-op', () => {
93
+ const result = registerModuleSubscriptions(baseManifest(), '/p');
94
+ expect(result.registered).toBe(0);
95
+ });
96
+
97
+ it('registers each subscription as a row, names scoped to module id', () => {
98
+ const result = registerModuleSubscriptions(
99
+ baseManifest({
100
+ subscriptions: [
101
+ {
102
+ name: 'smoke',
103
+ pattern: 'deploy.completed.$self',
104
+ handler: 'bun ${MODULE_PATH}/x.ts',
105
+ },
106
+ {
107
+ name: 'on-cert-rotated',
108
+ pattern: 'cert.rotated',
109
+ handler: 'echo',
110
+ },
111
+ ],
112
+ }),
113
+ '/var/lib/celilo/modules/lunacycle',
114
+ );
115
+ expect(result.registered).toBe(2);
116
+
117
+ const bus = openBus({ dbPath, events: defineEvents({}) });
118
+ try {
119
+ const rows = bus.db
120
+ .query<{ name: string; pattern: string; handler: string }, []>(
121
+ 'SELECT name, pattern, handler FROM subscribers ORDER BY name',
122
+ )
123
+ .all();
124
+ expect(rows).toEqual([
125
+ {
126
+ name: 'lunacycle.on-cert-rotated',
127
+ pattern: 'cert.rotated',
128
+ handler: 'echo',
129
+ },
130
+ {
131
+ name: 'lunacycle.smoke',
132
+ pattern: 'deploy.completed.lunacycle',
133
+ handler: 'bun /var/lib/celilo/modules/lunacycle/x.ts',
134
+ },
135
+ ]);
136
+ } finally {
137
+ bus.close();
138
+ }
139
+ });
140
+
141
+ it('re-registering the same manifest is idempotent (updates rows in place)', () => {
142
+ const m = baseManifest({
143
+ subscriptions: [{ name: 's', pattern: 'a', handler: 'echo first' }],
144
+ });
145
+ registerModuleSubscriptions(m, '/p');
146
+ // Edit the in-memory manifest and re-register; same name, new handler.
147
+ if (!m.subscriptions) throw new Error('subscriptions missing');
148
+ m.subscriptions[0].handler = 'echo second';
149
+ registerModuleSubscriptions(m, '/p');
150
+
151
+ const bus = openBus({ dbPath, events: defineEvents({}) });
152
+ try {
153
+ const rows = bus.db
154
+ .query<{ count: number }, []>('SELECT COUNT(*) AS count FROM subscribers')
155
+ .get();
156
+ expect(rows?.count).toBe(1);
157
+ const row = bus.db.query<{ handler: string }, []>('SELECT handler FROM subscribers').get();
158
+ expect(row?.handler).toBe('echo second');
159
+ } finally {
160
+ bus.close();
161
+ }
162
+ });
163
+
164
+ it('unregister removes all subscriptions for the module, scoped by name prefix', () => {
165
+ registerModuleSubscriptions(
166
+ baseManifest({
167
+ id: 'lunacycle',
168
+ subscriptions: [
169
+ { name: 'a', pattern: 'x.$self', handler: 'echo' },
170
+ { name: 'b', pattern: 'y.$self', handler: 'echo' },
171
+ ],
172
+ }),
173
+ '/p',
174
+ );
175
+ // Another module's subs should NOT be touched.
176
+ registerModuleSubscriptions(
177
+ baseManifest({
178
+ id: 'authentik',
179
+ subscriptions: [{ name: 'a', pattern: 'z.$self', handler: 'echo' }],
180
+ }),
181
+ '/p',
182
+ );
183
+
184
+ const result = unregisterModuleSubscriptions('lunacycle');
185
+ expect(result.unregistered).toBe(2);
186
+
187
+ const bus = openBus({ dbPath, events: defineEvents({}) });
188
+ try {
189
+ const rows = bus.db
190
+ .query<{ name: string }, []>('SELECT name FROM subscribers ORDER BY name')
191
+ .all();
192
+ expect(rows).toEqual([{ name: 'authentik.a' }]);
193
+ } finally {
194
+ bus.close();
195
+ }
196
+ });
197
+ });