@celilo/cli 0.5.0-alpha.1 → 0.5.0-alpha.3

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.
@@ -19,6 +19,7 @@ import {
19
19
  } from '@celilo/capabilities';
20
20
  import type {
21
21
  DnsInternalCapability,
22
+ DnsRegistrarCapability,
22
23
  HookLogger,
23
24
  RouteOps,
24
25
  RouteReadView,
@@ -30,6 +31,7 @@ import { decryptSecret } from '../secrets/encryption';
30
31
  import { getOrCreateMasterKey } from '../secrets/master-key';
31
32
  import { emitWebRoutesChangedAndWait } from '../services/celilo-events';
32
33
  import { getModuleSystems } from '../services/deployed-systems';
34
+ import { withDnsRegistrationLedger } from '../services/dns-registrations';
33
35
  import { loadHookConfigMap } from './load-hook-config';
34
36
 
35
37
  /**
@@ -194,6 +196,20 @@ export async function loadCapabilityFunctions(
194
196
  const providerConfig = await loadModuleConfig(capability.moduleId, db);
195
197
  const providerSecrets = await loadModuleSecrets(capability.moduleId, masterKey, db);
196
198
 
199
+ // dns_registrar interfaces get the registration-ledger wrapper:
200
+ // every successful registerHost is recorded in dns_registrations so
201
+ // the provider's refresh_registrations hook can re-assert it later
202
+ // (designs/DISPATCHER_DAEMON_AND_TIMER_EVENTS.md B2). The loader is
203
+ // the one layer that knows both provider and consumer.
204
+ const withLedgerIfDnsRegistrar = (iface: unknown): unknown =>
205
+ capName === 'dns_registrar'
206
+ ? withDnsRegistrationLedger(iface as DnsRegistrarCapability, {
207
+ db,
208
+ providerModuleId: capability.moduleId,
209
+ consumerModuleId: consumingModuleId,
210
+ })
211
+ : iface;
212
+
197
213
  try {
198
214
  // Dynamically import the capability module. Try the default export
199
215
  // first (the HOOK_API_V2 Phase 8 pattern), fall back to the legacy
@@ -217,7 +233,7 @@ export async function loadCapabilityFunctions(
217
233
  systems: getModuleSystems(capability.moduleId, db),
218
234
  logger,
219
235
  });
220
- result[capName] = capabilityInterface;
236
+ result[capName] = withLedgerIfDnsRegistrar(capabilityInterface);
221
237
  debugLog(`${capName}: loaded via defineCapabilityFunction`);
222
238
  continue;
223
239
  }
@@ -233,7 +249,9 @@ export async function loadCapabilityFunctions(
233
249
  );
234
250
 
235
251
  if (capabilityInterface) {
236
- result[capName] = wrapWithLogging(capabilityInterface as object, logger, capName);
252
+ result[capName] = withLedgerIfDnsRegistrar(
253
+ wrapWithLogging(capabilityInterface as object, logger, capName),
254
+ );
237
255
  debugLog(`${capName}: loaded via legacy factory`);
238
256
  }
239
257
  } catch (error) {
@@ -388,6 +406,16 @@ export async function loadCapabilityFunctions(
388
406
  `public_web reconcile for ${consumingModuleId}: ${reconcile.failed} delivery(ies) failed — caddy could not apply the route, so the hostname is not served.`,
389
407
  );
390
408
  }
409
+ // ISS-0087: a route WAS registered and the dispatcher IS alive, yet ZERO
410
+ // providers reconciled it (no subscriber consumed routes_changed). That
411
+ // registers a route nobody applied and would otherwise report success.
412
+ // `events === 0` means no routes changed (benign); `events > 0 &&
413
+ // succeeded === 0` means the route changed but nobody served it — a failure.
414
+ if (reconcile.events > 0 && reconcile.succeeded === 0) {
415
+ throw new Error(
416
+ `public_web route for ${consumingModuleId} changed (${reconcile.events} event(s)) but NO provider reconciled it — caddy has no reconcile_routes subscription on the bus, so the route is persisted yet never served (no site block, no cert). Ensure a public_web provider (caddy) is deployed and subscribed.`,
417
+ );
418
+ }
391
419
  },
392
420
  });
393
421
  debugLog(`public_web: loaded via framework implementation for ${consumingModuleId}`);
@@ -19,6 +19,10 @@ import type { ModuleManifest } from '../manifest/schema';
19
19
  import { decryptSecret } from '../secrets/encryption';
20
20
  import { getOrCreateMasterKey } from '../secrets/master-key';
21
21
  import { getModuleSystems } from '../services/deployed-systems';
22
+ import {
23
+ listDnsRegistrations,
24
+ stampDnsRegistrationsRefreshed,
25
+ } from '../services/dns-registrations';
22
26
  import { loadCapabilityFunctions } from './capability-loader';
23
27
  import { invokeHook } from './executor';
24
28
  import { loadHookConfigMap } from './load-hook-config';
@@ -125,12 +129,26 @@ export async function runNamedHook(
125
129
  : [];
126
130
  const capabilityFunctions = await loadCapabilityFunctions(moduleId, db, logger);
127
131
 
128
- return invokeHook(
132
+ // refresh_registrations is fed by the framework: module scripts can't
133
+ // read the celilo DB, so the dns_registrations ledger rows for THIS
134
+ // provider are injected as the contract's `registrations` input
135
+ // (designs/DISPATCHER_DAEMON_AND_TIMER_EVENTS.md B3). Authoritative —
136
+ // overrides any caller-supplied value.
137
+ let inputs = options.inputs ?? {};
138
+ if (hookName === 'refresh_registrations') {
139
+ const registrations = listDnsRegistrations(db, { providerModuleId: moduleId }).map((r) => ({
140
+ fqdn: r.fqdn,
141
+ ip: r.ip,
142
+ }));
143
+ inputs = { ...inputs, registrations };
144
+ }
145
+
146
+ const result = await invokeHook(
129
147
  module.sourcePath,
130
148
  hookName,
131
149
  manifest.celilo_contract,
132
150
  hookDef,
133
- options.inputs ?? {},
151
+ inputs,
134
152
  configMap,
135
153
  secretMap,
136
154
  logger,
@@ -141,4 +159,12 @@ export async function runNamedHook(
141
159
  systems: getModuleSystems(moduleId, db),
142
160
  },
143
161
  );
162
+
163
+ // A fully successful refresh stamps the ledger so `celilo dns
164
+ // registrations` shows when each provider last re-asserted.
165
+ if (hookName === 'refresh_registrations' && result.success) {
166
+ stampDnsRegistrationsRefreshed(db, moduleId);
167
+ }
168
+
169
+ return result;
144
170
  }
@@ -119,7 +119,8 @@ export type HookName =
119
119
  | 'on_backup'
120
120
  | 'on_backup_analyze'
121
121
  | 'on_restore'
122
- | 'on_system_event';
122
+ | 'on_system_event'
123
+ | 'refresh_registrations';
123
124
 
124
125
  /**
125
126
  * Hook manifest section - maps hook names to definitions
@@ -178,6 +178,22 @@ export const V1_HOOKS: ContractHooks = {
178
178
  inputs: {},
179
179
  outputs: {},
180
180
  },
181
+ /**
182
+ * Periodic re-assertion of a dns_registrar provider's registered
183
+ * records (designs/DISPATCHER_DAEMON_AND_TIMER_EVENTS.md B3). The
184
+ * framework populates `registrations` from the dns_registrations
185
+ * ledger (module scripts cannot read the celilo DB); the hook
186
+ * re-sends each {fqdn, ip} to the underlying DNS API and fails —
187
+ * loudly — if ANY record cannot be re-asserted. Typically driven by
188
+ * a `timer.tick.15m` subscription.
189
+ */
190
+ refresh_registrations: {
191
+ inputs: {
192
+ /** Array<{ fqdn: string; ip: string | null }>, framework-injected. */
193
+ registrations: { required: true },
194
+ },
195
+ outputs: {},
196
+ },
181
197
  /**
182
198
  * Build-bus upstream publish hook. The executor passes the
183
199
  * PublishEvent fields as env vars (CELILO_EVENT_PAYLOAD,
@@ -523,6 +523,16 @@ export const ModuleManifestSchema = z
523
523
  * [[v2/PUBLIC_WEB_PROVIDER_RECONCILE.md]].
524
524
  */
525
525
  reconcile_routes: LifecycleHookSchema.optional(),
526
+ /**
527
+ * Periodic re-assertion of a dns_registrar provider's registered
528
+ * records. The framework injects the provider's dns_registrations
529
+ * ledger rows as the `registrations` input; the hook re-sends each
530
+ * one to the underlying DNS API and fails loudly if any cannot be
531
+ * re-asserted. Providers subscribe it to a `timer.tick.*` event.
532
+ * Part of the dns_registrar capability contract — see
533
+ * designs/DISPATCHER_DAEMON_AND_TIMER_EVENTS.md (B3).
534
+ */
535
+ refresh_registrations: LifecycleHookSchema.optional(),
526
536
  /**
527
537
  * Build-bus upstream publish hooks. Array (a module can react
528
538
  * to multiple upstream packages with different actions). See
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  import type { DnsInternalCapability, HookLogger } from '@celilo/capabilities';
18
- import { and, eq } from 'drizzle-orm';
18
+ import { and, eq, inArray, or } from 'drizzle-orm';
19
19
  import type { DbClient } from '../db/client';
20
20
  import { capabilities as capabilitiesTable, modules, webRoutes } from '../db/schema';
21
21
  import { loadCapabilityFunctions, resolveFirewallNatIp } from '../hooks/capability-loader';
@@ -54,7 +54,19 @@ export async function backfillProviderDns(
54
54
  ): Promise<void> {
55
55
  logger.info(`dns_internal provider '${moduleId}' deployed — backfilling DNS for all systems`);
56
56
 
57
- const deployed = db.select().from(modules).all();
57
+ // ISS-0009: register only DEPLOYED systems, never IMPORTED-but-undeployed
58
+ // modules (those have a hostname/target_ip configured but nothing serving at
59
+ // that address — registering an A record for them is a phantom record). A
60
+ // deployed system is INSTALLED or VERIFIED. The provider itself is already
61
+ // INSTALLED by the time backfill runs (module-deploy transitions state before
62
+ // calling this), but we union its id explicitly so the provider's own
63
+ // self-record is registered even if that transition order ever changes —
64
+ // critical in daemonless contexts where no dispatcher delivers system.created.
65
+ const deployed = db
66
+ .select()
67
+ .from(modules)
68
+ .where(or(inArray(modules.state, ['INSTALLED', 'VERIFIED']), eq(modules.id, moduleId)))
69
+ .all();
58
70
  const failures: string[] = [];
59
71
 
60
72
  // One register per deployed system across all modules — a module with N
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Unit tests for the DNS registration ledger
3
+ * (designs/DISPATCHER_DAEMON_AND_TIMER_EVENTS.md B2): upsert semantics,
4
+ * the registerHost recording wrapper (success-only), refresh stamping,
5
+ * and FK-cascade cleanup when a module is removed.
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
9
+ import { mkdtempSync, rmSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import type { DnsRegistrarCapability } from '@celilo/capabilities';
13
+ import { eq } from 'drizzle-orm';
14
+ import { type DbClient, getDb } from '../db/client';
15
+ import { modules } from '../db/schema';
16
+ import {
17
+ listDnsRegistrations,
18
+ recordDnsRegistration,
19
+ stampDnsRegistrationsRefreshed,
20
+ withDnsRegistrationLedger,
21
+ } from './dns-registrations';
22
+
23
+ describe('dns_registrations ledger', () => {
24
+ let tempDir: string;
25
+ let db: DbClient;
26
+
27
+ beforeEach(() => {
28
+ tempDir = mkdtempSync(join(tmpdir(), 'celilo-dnsreg-'));
29
+ process.env.CELILO_DB_PATH = join(tempDir, 'test.db');
30
+ db = getDb();
31
+ for (const id of ['namecheap', 'caddy']) {
32
+ db.insert(modules)
33
+ .values({
34
+ id,
35
+ name: id,
36
+ sourcePath: join(tempDir, id),
37
+ version: '1.0.0',
38
+ manifestData: { celilo_contract: '1.0', id, name: id, version: '1.0.0' },
39
+ })
40
+ .run();
41
+ }
42
+ });
43
+
44
+ afterEach(() => {
45
+ rmSync(tempDir, { recursive: true, force: true });
46
+ process.env.CELILO_DB_PATH = undefined;
47
+ });
48
+
49
+ test('record + list roundtrip; upsert replaces ip and consumer', () => {
50
+ recordDnsRegistration(db, {
51
+ providerModuleId: 'namecheap',
52
+ consumerModuleId: 'caddy',
53
+ fqdn: 'www.lunacycle.net',
54
+ ip: '198.51.100.7',
55
+ });
56
+ recordDnsRegistration(db, {
57
+ providerModuleId: 'namecheap',
58
+ consumerModuleId: 'caddy',
59
+ fqdn: 'www.lunacycle.net',
60
+ ip: '198.51.100.8',
61
+ });
62
+
63
+ const rows = listDnsRegistrations(db, { providerModuleId: 'namecheap' });
64
+ expect(rows.length).toBe(1);
65
+ expect(rows[0].fqdn).toBe('www.lunacycle.net');
66
+ expect(rows[0].ip).toBe('198.51.100.8');
67
+ expect(rows[0].consumerModuleId).toBe('caddy');
68
+ expect(rows[0].refreshedAt).toBeNull();
69
+ });
70
+
71
+ test('stampDnsRegistrationsRefreshed sets refreshedAt for the provider', () => {
72
+ recordDnsRegistration(db, {
73
+ providerModuleId: 'namecheap',
74
+ consumerModuleId: 'caddy',
75
+ fqdn: 'git.lunacycle.net',
76
+ ip: null,
77
+ });
78
+ stampDnsRegistrationsRefreshed(db, 'namecheap');
79
+ const [row] = listDnsRegistrations(db, { providerModuleId: 'namecheap' });
80
+ expect(row.refreshedAt).not.toBeNull();
81
+ });
82
+
83
+ test('rows die with the consumer module (FK cascade)', () => {
84
+ recordDnsRegistration(db, {
85
+ providerModuleId: 'namecheap',
86
+ consumerModuleId: 'caddy',
87
+ fqdn: 'www.lunacycle.net',
88
+ ip: '198.51.100.7',
89
+ });
90
+ db.delete(modules).where(eq(modules.id, 'caddy')).run();
91
+ expect(listDnsRegistrations(db).length).toBe(0);
92
+ });
93
+
94
+ test('withDnsRegistrationLedger records successes only', async () => {
95
+ const calls: string[] = [];
96
+ const fake: DnsRegistrarCapability = {
97
+ async registerHost(request) {
98
+ calls.push(request.fqdn);
99
+ const success = !request.fqdn.startsWith('fail.');
100
+ return { success, outputs: {}, duration: 1 };
101
+ },
102
+ };
103
+ const wrapped = withDnsRegistrationLedger(fake, {
104
+ db,
105
+ providerModuleId: 'namecheap',
106
+ consumerModuleId: 'caddy',
107
+ });
108
+
109
+ await wrapped.registerHost({ fqdn: 'www.lunacycle.net', ip: '198.51.100.7' });
110
+ await wrapped.registerHost({ fqdn: 'fail.lunacycle.net', ip: '198.51.100.7' });
111
+ await wrapped.registerHost({ fqdn: 'auto.lunacycle.net' });
112
+
113
+ expect(calls.length).toBe(3);
114
+ const rows = listDnsRegistrations(db);
115
+ const fqdns = rows.map((r) => r.fqdn).sort();
116
+ expect(fqdns).toEqual(['auto.lunacycle.net', 'www.lunacycle.net']);
117
+ const auto = rows.find((r) => r.fqdn === 'auto.lunacycle.net');
118
+ expect(auto?.ip).toBeNull();
119
+ });
120
+ });
@@ -0,0 +1,108 @@
1
+ /**
2
+ * DNS registration ledger operations
3
+ * (designs/DISPATCHER_DAEMON_AND_TIMER_EVENTS.md B2/B3).
4
+ *
5
+ * The capability loader records every successful
6
+ * dns_registrar.registerHost here; the run-hook path reads the ledger
7
+ * back to feed a provider's `refresh_registrations` hook, and `celilo
8
+ * dns registrations` lists it for the operator. Row lifecycle is FK
9
+ * cascade — registrations die with their provider or consumer module.
10
+ */
11
+
12
+ import type { DnsRegistrarCapability, HookResult } from '@celilo/capabilities';
13
+ import { eq } from 'drizzle-orm';
14
+ import type { DbClient } from '../db/client';
15
+ import { dnsRegistrations } from '../db/schema';
16
+
17
+ export interface DnsRegistrationRow {
18
+ fqdn: string;
19
+ /** null = the provider auto-detected the request's source IP. */
20
+ ip: string | null;
21
+ providerModuleId: string;
22
+ consumerModuleId: string;
23
+ registeredAt: Date;
24
+ refreshedAt: Date | null;
25
+ }
26
+
27
+ export function listDnsRegistrations(
28
+ db: DbClient,
29
+ options: { providerModuleId?: string } = {},
30
+ ): DnsRegistrationRow[] {
31
+ const query = db
32
+ .select({
33
+ fqdn: dnsRegistrations.fqdn,
34
+ ip: dnsRegistrations.ip,
35
+ providerModuleId: dnsRegistrations.providerModuleId,
36
+ consumerModuleId: dnsRegistrations.consumerModuleId,
37
+ registeredAt: dnsRegistrations.registeredAt,
38
+ refreshedAt: dnsRegistrations.refreshedAt,
39
+ })
40
+ .from(dnsRegistrations);
41
+ const rows = options.providerModuleId
42
+ ? query.where(eq(dnsRegistrations.providerModuleId, options.providerModuleId)).all()
43
+ : query.all();
44
+ return rows;
45
+ }
46
+
47
+ export function recordDnsRegistration(
48
+ db: DbClient,
49
+ registration: {
50
+ providerModuleId: string;
51
+ consumerModuleId: string;
52
+ fqdn: string;
53
+ ip: string | null;
54
+ },
55
+ ): void {
56
+ db.insert(dnsRegistrations)
57
+ .values({
58
+ providerModuleId: registration.providerModuleId,
59
+ consumerModuleId: registration.consumerModuleId,
60
+ fqdn: registration.fqdn,
61
+ ip: registration.ip,
62
+ registeredAt: new Date(),
63
+ })
64
+ .onConflictDoUpdate({
65
+ target: [dnsRegistrations.providerModuleId, dnsRegistrations.fqdn],
66
+ set: {
67
+ consumerModuleId: registration.consumerModuleId,
68
+ ip: registration.ip,
69
+ registeredAt: new Date(),
70
+ },
71
+ })
72
+ .run();
73
+ }
74
+
75
+ /** Stamp every row of a provider as refreshed (successful refresh hook). */
76
+ export function stampDnsRegistrationsRefreshed(db: DbClient, providerModuleId: string): void {
77
+ db.update(dnsRegistrations)
78
+ .set({ refreshedAt: new Date() })
79
+ .where(eq(dnsRegistrations.providerModuleId, providerModuleId))
80
+ .run();
81
+ }
82
+
83
+ /**
84
+ * Wrap a dns_registrar interface so successful registerHost calls are
85
+ * recorded in the ledger. Failures and MissingProviderInputError
86
+ * interview throws pass through untouched — only a confirmed
87
+ * registration earns a row.
88
+ */
89
+ export function withDnsRegistrationLedger(
90
+ registrar: DnsRegistrarCapability,
91
+ ctx: { db: DbClient; providerModuleId: string; consumerModuleId: string },
92
+ ): DnsRegistrarCapability {
93
+ return {
94
+ ...registrar,
95
+ async registerHost(request): Promise<HookResult> {
96
+ const result = await registrar.registerHost(request);
97
+ if (result.success && request.fqdn) {
98
+ recordDnsRegistration(ctx.db, {
99
+ providerModuleId: ctx.providerModuleId,
100
+ consumerModuleId: ctx.consumerModuleId,
101
+ fqdn: request.fqdn,
102
+ ip: request.ip ?? null,
103
+ });
104
+ }
105
+ return result;
106
+ },
107
+ };
108
+ }
@@ -8,6 +8,7 @@ import {
8
8
  readInstalledUnit,
9
9
  renderLaunchdPlist,
10
10
  renderSystemdUnit,
11
+ resolveRunAsUser,
11
12
  uninstallDaemon,
12
13
  } from './events-daemon';
13
14
 
@@ -19,6 +20,7 @@ describe('renderSystemdUnit', () => {
19
20
  pollMs: 1000,
20
21
  concurrency: 4,
21
22
  home: '/home/op',
23
+ scope: 'user',
22
24
  });
23
25
  expect(out).toContain(
24
26
  'ExecStart=/usr/local/bin/celilo events run --poll-ms 1000 --concurrency 4',
@@ -26,6 +28,7 @@ describe('renderSystemdUnit', () => {
26
28
  expect(out).toContain('Environment=EVENT_BUS_DB=/var/lib/celilo/events.db');
27
29
  expect(out).toContain('Restart=on-failure');
28
30
  expect(out).toContain('WantedBy=default.target');
31
+ expect(out).not.toContain('User=');
29
32
  });
30
33
 
31
34
  it('honors --poll-ms and --concurrency overrides', () => {
@@ -35,9 +38,26 @@ describe('renderSystemdUnit', () => {
35
38
  pollMs: 250,
36
39
  concurrency: 8,
37
40
  home: '/h',
41
+ scope: 'user',
38
42
  });
39
43
  expect(out).toContain('--poll-ms 250 --concurrency 8');
40
44
  });
45
+
46
+ it('system scope sets explicit User= and multi-user.target', () => {
47
+ const out = renderSystemdUnit({
48
+ celiloPath: '/usr/local/bin/celilo',
49
+ busDbPath: '/var/celilo/events.db',
50
+ pollMs: 1000,
51
+ concurrency: 4,
52
+ home: '/root',
53
+ scope: 'system',
54
+ runAsUser: 'celilo',
55
+ });
56
+ expect(out).toContain('User=celilo');
57
+ expect(out).toContain('WantedBy=multi-user.target');
58
+ expect(out).toContain('journalctl -u celilo-events.service');
59
+ expect(out).not.toContain('journalctl --user');
60
+ });
41
61
  });
42
62
 
43
63
  describe('renderLaunchdPlist', () => {
@@ -48,6 +68,7 @@ describe('renderLaunchdPlist', () => {
48
68
  pollMs: 1000,
49
69
  concurrency: 4,
50
70
  home: '/Users/op',
71
+ scope: 'user',
51
72
  });
52
73
  expect(out).toContain('<string>/Users/op/Library/Logs/celilo-events.out.log</string>');
53
74
  expect(out).toContain('<string>/Users/op/Library/Logs/celilo-events.err.log</string>');
@@ -55,6 +76,24 @@ describe('renderLaunchdPlist', () => {
55
76
  expect(out).toContain('<string>com.celilo.events</string>');
56
77
  expect(out).toContain('<key>RunAtLoad</key>');
57
78
  expect(out).toContain('<key>KeepAlive</key>');
79
+ expect(out).not.toContain('<key>UserName</key>');
80
+ });
81
+
82
+ it('system scope sets UserName and logs under /Library/Logs', () => {
83
+ const out = renderLaunchdPlist({
84
+ celiloPath: '/c',
85
+ busDbPath: '/db',
86
+ pollMs: 1000,
87
+ concurrency: 4,
88
+ home: '/Users/op',
89
+ scope: 'system',
90
+ runAsUser: 'celilo',
91
+ });
92
+ expect(out).toContain('<key>UserName</key>');
93
+ expect(out).toContain('<string>celilo</string>');
94
+ expect(out).toContain('<string>/Library/Logs/celilo-events.out.log</string>');
95
+ expect(out).toContain('<string>/Library/Logs/celilo-events.err.log</string>');
96
+ expect(out).not.toContain('/Users/op/Library/Logs');
58
97
  });
59
98
  });
60
99
 
@@ -69,6 +108,24 @@ describe('getDaemonUnitPath', () => {
69
108
  '/Users/op/Library/LaunchAgents/com.celilo.events.plist',
70
109
  );
71
110
  });
111
+ it('returns the system paths for system scope', () => {
112
+ expect(getDaemonUnitPath('linux', '/home/op', 'system')).toBe(
113
+ '/etc/systemd/system/celilo-events.service',
114
+ );
115
+ expect(getDaemonUnitPath('darwin', '/Users/op', 'system')).toBe(
116
+ '/Library/LaunchDaemons/com.celilo.events.plist',
117
+ );
118
+ });
119
+ });
120
+
121
+ describe('resolveRunAsUser', () => {
122
+ it('honors an explicit override', () => {
123
+ expect(resolveRunAsUser('/var/celilo/events.db', 'celilo')).toBe('celilo');
124
+ });
125
+ it('falls back to the current user when the state dir is missing', () => {
126
+ const me = resolveRunAsUser('/no/such/dir/events.db');
127
+ expect(me.length).toBeGreaterThan(0);
128
+ });
72
129
  });
73
130
 
74
131
  describe('installDaemon / uninstallDaemon roundtrip', () => {
@@ -99,6 +156,8 @@ describe('installDaemon / uninstallDaemon roundtrip', () => {
99
156
  busDbPath: '/var/lib/celilo/events.db',
100
157
  });
101
158
  expect(existsSync(installed.unitPath)).toBe(true);
159
+ expect(installed.scope).toBe('user');
160
+ expect(installed.runAsUser).toBeUndefined();
102
161
  expect(installed.unitPath).toBe(join(home, '.config/systemd/user/celilo-events.service'));
103
162
  expect(installed.nextSteps[0]).toContain('systemctl --user daemon-reload');
104
163