@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.
@@ -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=CELILO_EVENT_BUS_PATH=${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>CELILO_EVENT_BUS_PATH</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.CELILO_EVENT_BUS_PATH = dbPath;
82
+ });
83
+ afterEach(() => {
84
+ process.env.CELILO_EVENT_BUS_PATH = 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
+ });
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Wire a module's manifest `subscriptions:` block into the SQLite event
3
+ * bus. Called from `module/import.ts` on install and from
4
+ * `cli/commands/module-remove.ts` on remove.
5
+ *
6
+ * Substitutions performed at subscribe time:
7
+ * - `$self` in `pattern` → the module's id
8
+ * - `${MODULE_PATH}` in `handler` → the module's installed targetPath
9
+ *
10
+ * The bus subscriber's name is namespaced as `<module-id>.<sub-name>`
11
+ * so two modules can declare a subscription named `smoke` without
12
+ * colliding.
13
+ */
14
+
15
+ import { defineEvents, openBus } from '@celilo/event-bus';
16
+ import { getEventBusPath } from '../config/paths';
17
+ import type { ModuleManifest, ModuleSubscription } from '../manifest/schema';
18
+
19
+ /**
20
+ * The bus is opened by the celilo CLI without an event registry — the
21
+ * CLI doesn't know the schemas of every module's events. The bus's
22
+ * empty-registry path skips payload validation, leaving that to the
23
+ * linked handlers (which open the bus *with* their own registry).
24
+ */
25
+ const NO_SCHEMAS = defineEvents({});
26
+
27
+ /**
28
+ * Resolve the per-module substitutions on a single subscription. Pure
29
+ * function: takes a parsed manifest entry, returns the bus-shaped
30
+ * subscribe options.
31
+ */
32
+ export function resolveSubscription(
33
+ sub: ModuleSubscription,
34
+ moduleId: string,
35
+ modulePath: string,
36
+ ): {
37
+ name: string;
38
+ pattern: string;
39
+ handler: string;
40
+ maxAttempts?: number;
41
+ timeoutMs?: number;
42
+ registeredBy: string;
43
+ } {
44
+ return {
45
+ name: scopedName(moduleId, sub.name),
46
+ pattern: substituteSelf(sub.pattern, moduleId),
47
+ handler: substituteModulePath(sub.handler, modulePath),
48
+ maxAttempts: sub.max_attempts,
49
+ timeoutMs: sub.timeout_ms,
50
+ registeredBy: moduleId,
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Register all of a module's subscriptions on the bus. Idempotent —
56
+ * re-running with the same manifest updates existing rows in place.
57
+ *
58
+ * If the module's manifest declares no subscriptions, this is a
59
+ * cheap no-op (the bus DB isn't even touched).
60
+ */
61
+ export function registerModuleSubscriptions(
62
+ manifest: ModuleManifest,
63
+ modulePath: string,
64
+ ): { registered: number } {
65
+ const subs = manifest.subscriptions ?? [];
66
+ if (subs.length === 0) return { registered: 0 };
67
+
68
+ const bus = openBus({ dbPath: getEventBusPath(), events: NO_SCHEMAS });
69
+ try {
70
+ for (const sub of subs) {
71
+ const resolved = resolveSubscription(sub, manifest.id, modulePath);
72
+ bus.subscribe(resolved);
73
+ }
74
+ return { registered: subs.length };
75
+ } finally {
76
+ bus.close();
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Tear down every bus subscription that belongs to this module. Looks
82
+ * up rows by name prefix `<module-id>.` rather than rereading the
83
+ * old manifest, so a stale subscription left behind by a manifest
84
+ * change still gets cleaned up.
85
+ *
86
+ * Best-effort: if the bus DB doesn't exist (never opened), returns 0.
87
+ */
88
+ export function unregisterModuleSubscriptions(moduleId: string): {
89
+ unregistered: number;
90
+ } {
91
+ const bus = openBus({ dbPath: getEventBusPath(), events: NO_SCHEMAS });
92
+ try {
93
+ const likePattern = `${moduleId}.%`;
94
+ const rows = bus.db
95
+ .query<{ name: string }, [string]>('SELECT name FROM subscribers WHERE name LIKE ?')
96
+ .all(likePattern);
97
+ for (const row of rows) {
98
+ bus.unsubscribe(row.name);
99
+ }
100
+ return { unregistered: rows.length };
101
+ } finally {
102
+ bus.close();
103
+ }
104
+ }
105
+
106
+ function scopedName(moduleId: string, subName: string): string {
107
+ return `${moduleId}.${subName}`;
108
+ }
109
+
110
+ function substituteSelf(pattern: string, moduleId: string): string {
111
+ // `$self` matches when followed by a dot or end-of-string, so a
112
+ // pattern like `deploy.$self.foo` substitutes correctly without
113
+ // confusing `$selfish` if anyone wrote that. (No real reason they
114
+ // would, but be precise.)
115
+ return pattern.replace(/\$self(?=\.|$)/g, moduleId);
116
+ }
117
+
118
+ function substituteModulePath(handler: string, modulePath: string): string {
119
+ return handler.replace(/\$\{MODULE_PATH\}/g, modulePath);
120
+ }