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

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.
Files changed (101) hide show
  1. package/drizzle/0009_dns_registrations.sql +13 -0
  2. package/drizzle/0010_dns_internal_records.sql +12 -0
  3. package/drizzle/0011_backups_name.sql +1 -0
  4. package/drizzle/meta/_journal.json +22 -1
  5. package/package.json +3 -3
  6. package/src/ansible/inventory.test.ts +10 -10
  7. package/src/ansible/validation.test.ts +25 -15
  8. package/src/api-clients/proxmox.test.ts +211 -1
  9. package/src/api-clients/proxmox.ts +399 -8
  10. package/src/cli/command-registry.ts +83 -6
  11. package/src/cli/commands/backup-delete.ts +10 -7
  12. package/src/cli/commands/backup-import.ts +11 -8
  13. package/src/cli/commands/backup-restore.ts +11 -8
  14. package/src/cli/commands/dns.ts +57 -0
  15. package/src/cli/commands/events.test.ts +4 -4
  16. package/src/cli/commands/events.ts +89 -24
  17. package/src/cli/commands/machine-add.ts +178 -163
  18. package/src/cli/commands/machine-remove.ts +10 -7
  19. package/src/cli/commands/module-config.test.ts +78 -0
  20. package/src/cli/commands/module-config.ts +18 -3
  21. package/src/cli/commands/module-import.ts +9 -5
  22. package/src/cli/commands/module-publish.ts +24 -0
  23. package/src/cli/commands/module-remove.ts +20 -9
  24. package/src/cli/commands/module-status.ts +15 -0
  25. package/src/cli/commands/module-upgrade.test.ts +37 -0
  26. package/src/cli/commands/module-upgrade.ts +26 -6
  27. package/src/cli/commands/proxmox-node-list.ts +101 -0
  28. package/src/cli/commands/proxmox-template-selection.ts +16 -15
  29. package/src/cli/commands/proxmox-vm-template-build.ts +171 -0
  30. package/src/cli/commands/publish/alpha.test.ts +26 -0
  31. package/src/cli/commands/publish/alpha.ts +23 -0
  32. package/src/cli/commands/publish/types.ts +7 -2
  33. package/src/cli/commands/publish/workspace.ts +11 -1
  34. package/src/cli/commands/restore.ts +29 -0
  35. package/src/cli/commands/service-add-digitalocean.ts +120 -109
  36. package/src/cli/commands/service-add-proxmox.ts +283 -209
  37. package/src/cli/commands/service-reconfigure.test.ts +115 -0
  38. package/src/cli/commands/service-reconfigure.ts +252 -129
  39. package/src/cli/commands/service-remove.ts +19 -13
  40. package/src/cli/commands/service-verify.ts +9 -10
  41. package/src/cli/commands/storage-add-local.ts +120 -107
  42. package/src/cli/commands/storage-add-s3.ts +145 -131
  43. package/src/cli/commands/storage-remove.ts +11 -8
  44. package/src/cli/commands/system-doctor.ts +135 -40
  45. package/src/cli/commands/system-init.ts +119 -128
  46. package/src/cli/commands/system-migrate.test.ts +40 -0
  47. package/src/cli/commands/system-migrate.ts +65 -0
  48. package/src/cli/completion.ts +23 -0
  49. package/src/cli/index.ts +91 -7
  50. package/src/cli/service-credential.ts +54 -0
  51. package/src/config/paths.test.ts +61 -48
  52. package/src/db/client.ts +15 -146
  53. package/src/db/migrate.ts +14 -6
  54. package/src/db/schema-introspection.ts +88 -0
  55. package/src/db/schema.ts +74 -0
  56. package/src/hooks/capability-loader-firewall.test.ts +3 -3
  57. package/src/hooks/capability-loader.ts +43 -2
  58. package/src/hooks/run-named-hook.ts +28 -2
  59. package/src/hooks/types.ts +2 -1
  60. package/src/infrastructure/property-extractor.test.ts +15 -0
  61. package/src/infrastructure/property-extractor.ts +12 -0
  62. package/src/manifest/contracts/v1.ts +16 -0
  63. package/src/manifest/schema.ts +17 -0
  64. package/src/manifest/validate.test.ts +53 -0
  65. package/src/services/bus-interview.test.ts +2 -2
  66. package/src/services/bus-interview.ts +232 -0
  67. package/src/services/bus-secret-flow.test.ts +2 -2
  68. package/src/services/celilo-mgmt-hooks.test.ts +3 -2
  69. package/src/services/deploy-preflight.ts +25 -0
  70. package/src/services/deploy-validation.test.ts +54 -4
  71. package/src/services/deploy-validation.ts +27 -36
  72. package/src/services/dns-internal-records.test.ts +126 -0
  73. package/src/services/dns-internal-records.ts +119 -0
  74. package/src/services/dns-provider-backfill.test.ts +2 -2
  75. package/src/services/dns-provider-backfill.ts +14 -2
  76. package/src/services/dns-registrations.test.ts +120 -0
  77. package/src/services/dns-registrations.ts +108 -0
  78. package/src/services/events-daemon.test.ts +59 -0
  79. package/src/services/events-daemon.ts +191 -57
  80. package/src/services/fleet-checks.test.ts +508 -0
  81. package/src/services/fleet-checks.ts +678 -0
  82. package/src/services/module-build.test.ts +43 -38
  83. package/src/services/module-config.ts +12 -0
  84. package/src/services/module-deploy.ts +7 -6
  85. package/src/services/module-subscriptions.test.ts +88 -0
  86. package/src/services/module-subscriptions.ts +50 -1
  87. package/src/services/module-validator/bundled-deps.test.ts +55 -0
  88. package/src/services/module-validator/bundled-deps.ts +115 -0
  89. package/src/services/module-validator/capability-versions.test.ts +1 -1
  90. package/src/services/placement-reconcile.test.ts +86 -0
  91. package/src/services/placement-reconcile.ts +108 -0
  92. package/src/services/programmatic-responder.ts +34 -0
  93. package/src/services/terminal-responder.ts +113 -0
  94. package/src/templates/generator.test.ts +92 -12
  95. package/src/templates/generator.ts +165 -80
  96. package/src/test-utils/fixtures.test.ts +1 -1
  97. package/src/test-utils/integration-guard.ts +33 -0
  98. package/src/types/infrastructure.ts +6 -0
  99. package/src/variables/computed/computed-integration.test.ts +3 -3
  100. package/src/variables/computed/computed.test.ts +5 -5
  101. package/src/variables/declarative-derivation.test.ts +6 -6
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Internal split-horizon DNS A-record ledger (designs/CELILO_DOCTOR_FLEET_DRIFT.md
3
+ * Phase 4, ISS-0094 / ISS-0111).
4
+ *
5
+ * The dns_internal capability (technitium/knot) registers records straight
6
+ * into the resolver's own DB, leaving celilo no offline record of what it
7
+ * asked to be served. This ledger — the internal-DNS sibling of
8
+ * `dns_registrations` — records every successful `registerRecord({type:'A'})`
9
+ * so `celilo system doctor` can assert, without a live resolver probe, that
10
+ * service hostnames resolve to the firewall natIp (LAN-reachable) rather than
11
+ * a zone-side container IP a LAN device can't route to.
12
+ *
13
+ * Row lifecycle is FK cascade — records die with their provider or consumer.
14
+ */
15
+
16
+ import type { DnsInternalCapability, DnsRecordRequest } from '@celilo/capabilities';
17
+ import { and, eq } from 'drizzle-orm';
18
+ import type { DbClient } from '../db/client';
19
+ import { dnsInternalRecords } from '../db/schema';
20
+
21
+ export interface DnsInternalRecordRow {
22
+ host: string;
23
+ ip: string;
24
+ providerModuleId: string;
25
+ consumerModuleId: string;
26
+ registeredAt: Date;
27
+ }
28
+
29
+ export function listDnsInternalRecords(
30
+ db: DbClient,
31
+ options: { providerModuleId?: string } = {},
32
+ ): DnsInternalRecordRow[] {
33
+ const query = db
34
+ .select({
35
+ host: dnsInternalRecords.host,
36
+ ip: dnsInternalRecords.ip,
37
+ providerModuleId: dnsInternalRecords.providerModuleId,
38
+ consumerModuleId: dnsInternalRecords.consumerModuleId,
39
+ registeredAt: dnsInternalRecords.registeredAt,
40
+ })
41
+ .from(dnsInternalRecords);
42
+ return options.providerModuleId
43
+ ? query.where(eq(dnsInternalRecords.providerModuleId, options.providerModuleId)).all()
44
+ : query.all();
45
+ }
46
+
47
+ export function recordDnsInternalRecord(
48
+ db: DbClient,
49
+ record: { providerModuleId: string; consumerModuleId: string; host: string; ip: string },
50
+ ): void {
51
+ db.insert(dnsInternalRecords)
52
+ .values({
53
+ providerModuleId: record.providerModuleId,
54
+ consumerModuleId: record.consumerModuleId,
55
+ host: record.host,
56
+ ip: record.ip,
57
+ registeredAt: new Date(),
58
+ })
59
+ .onConflictDoUpdate({
60
+ target: [dnsInternalRecords.providerModuleId, dnsInternalRecords.host],
61
+ set: {
62
+ consumerModuleId: record.consumerModuleId,
63
+ ip: record.ip,
64
+ registeredAt: new Date(),
65
+ },
66
+ })
67
+ .run();
68
+ }
69
+
70
+ export function removeDnsInternalRecord(
71
+ db: DbClient,
72
+ record: { providerModuleId: string; host: string },
73
+ ): void {
74
+ db.delete(dnsInternalRecords)
75
+ .where(
76
+ and(
77
+ eq(dnsInternalRecords.providerModuleId, record.providerModuleId),
78
+ eq(dnsInternalRecords.host, record.host),
79
+ ),
80
+ )
81
+ .run();
82
+ }
83
+
84
+ /**
85
+ * Wrap a dns_internal interface so A-record register/delete calls are
86
+ * mirrored into the ledger. Only A records are tracked — they're the
87
+ * host→IP mapping the natIp drift check reasons about; CNAMEs/others pass
88
+ * through unrecorded. A throw from the underlying call propagates before
89
+ * any ledger write, so only a confirmed register/delete earns a row change.
90
+ */
91
+ export function withDnsInternalLedger(
92
+ iface: DnsInternalCapability,
93
+ ctx: { db: DbClient; providerModuleId: string; consumerModuleId: string },
94
+ ): DnsInternalCapability {
95
+ const isA = (request: DnsRecordRequest) => request.type.toUpperCase() === 'A';
96
+ return {
97
+ ...iface,
98
+ async registerRecord(request: DnsRecordRequest): Promise<void> {
99
+ await iface.registerRecord(request);
100
+ if (isA(request)) {
101
+ recordDnsInternalRecord(ctx.db, {
102
+ providerModuleId: ctx.providerModuleId,
103
+ consumerModuleId: ctx.consumerModuleId,
104
+ host: request.host,
105
+ ip: request.value,
106
+ });
107
+ }
108
+ },
109
+ async deleteRecord(request: DnsRecordRequest): Promise<void> {
110
+ await iface.deleteRecord(request);
111
+ if (isA(request)) {
112
+ removeDnsInternalRecord(ctx.db, {
113
+ providerModuleId: ctx.providerModuleId,
114
+ host: request.host,
115
+ });
116
+ }
117
+ },
118
+ };
119
+ }
@@ -105,14 +105,14 @@ describe('backfillWebRouteDns (ISS-0029)', () => {
105
105
  seedFirewall('100.64.0.1');
106
106
  seedRoute('apt.celilo.computer', '/');
107
107
  seedRoute('apt.celilo.computer', '/-/publish'); // same host, different path → deduped
108
- seedRoute('registry.lunacycle.net', '/');
108
+ seedRoute('registry.example.net', '/');
109
109
 
110
110
  const calls: DnsRecordRequest[] = [];
111
111
  await backfillWebRouteDns('technitium', getDb(), silentLogger, stubLoader(calls));
112
112
 
113
113
  expect(calls.map((c) => c.host).sort()).toEqual([
114
114
  'apt.celilo.computer',
115
- 'registry.lunacycle.net',
115
+ 'registry.example.net',
116
116
  ]);
117
117
  expect(calls.every((c) => c.value === '100.64.0.1' && c.type === 'A')).toBe(true);
118
118
  });
@@ -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.example.net',
54
+ ip: '198.51.100.7',
55
+ });
56
+ recordDnsRegistration(db, {
57
+ providerModuleId: 'namecheap',
58
+ consumerModuleId: 'caddy',
59
+ fqdn: 'www.example.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.example.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.example.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.example.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.example.net', ip: '198.51.100.7' });
110
+ await wrapped.registerHost({ fqdn: 'fail.example.net', ip: '198.51.100.7' });
111
+ await wrapped.registerHost({ fqdn: 'auto.example.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.example.net', 'www.example.net']);
117
+ const auto = rows.find((r) => r.fqdn === 'auto.example.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