@celilo/cli 0.2.0 → 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,104 @@
1
+ /**
2
+ * Celilo's own lifecycle events. Emitted on every `module deploy`
3
+ * (started / completed / failed) so subscribers can react — production
4
+ * smoke tests, alerting, dashboards, etc.
5
+ *
6
+ * The event types are dot-segmented with the module id as the last
7
+ * segment: `deploy.started.lunacycle`, `deploy.completed.lunacycle`.
8
+ * This lets subscribers target one module (`deploy.completed.lunacycle`)
9
+ * or fan out (`deploy.completed.*`).
10
+ *
11
+ * Bus errors are best-effort: a sick bus shouldn't break a deploy. Each
12
+ * helper opens-emits-closes; per-event overhead is a few ms.
13
+ */
14
+
15
+ import { defineEvents, openBus } from '@celilo/event-bus';
16
+ import { z } from 'zod';
17
+ import { getEventBusPath } from '../config/paths';
18
+
19
+ const NO_SCHEMAS = defineEvents({});
20
+
21
+ export interface DeployStartedPayload {
22
+ module: string;
23
+ startedAt: number;
24
+ }
25
+
26
+ export interface DeployCompletedPayload {
27
+ module: string;
28
+ startedAt: number;
29
+ durationMs: number;
30
+ }
31
+
32
+ export interface DeployFailedPayload {
33
+ module: string;
34
+ startedAt: number;
35
+ durationMs: number;
36
+ error: string;
37
+ }
38
+
39
+ export interface HealthCheckFailedPayload {
40
+ module: string;
41
+ reason: string;
42
+ }
43
+
44
+ const DeployStartedSchema = z.object({
45
+ module: z.string().min(1),
46
+ startedAt: z.number().int().nonnegative(),
47
+ });
48
+
49
+ const DeployCompletedSchema = z.object({
50
+ module: z.string().min(1),
51
+ startedAt: z.number().int().nonnegative(),
52
+ durationMs: z.number().int().nonnegative(),
53
+ });
54
+
55
+ const DeployFailedSchema = z.object({
56
+ module: z.string().min(1),
57
+ startedAt: z.number().int().nonnegative(),
58
+ durationMs: z.number().int().nonnegative(),
59
+ error: z.string(),
60
+ });
61
+
62
+ const HealthCheckFailedSchema = z.object({
63
+ module: z.string().min(1),
64
+ reason: z.string(),
65
+ });
66
+
67
+ export function emitDeployStarted(payload: DeployStartedPayload): void {
68
+ DeployStartedSchema.parse(payload);
69
+ emitBest(`deploy.started.${payload.module}`, payload);
70
+ }
71
+
72
+ export function emitDeployCompleted(payload: DeployCompletedPayload): void {
73
+ DeployCompletedSchema.parse(payload);
74
+ emitBest(`deploy.completed.${payload.module}`, payload);
75
+ }
76
+
77
+ export function emitDeployFailed(payload: DeployFailedPayload): void {
78
+ DeployFailedSchema.parse(payload);
79
+ emitBest(`deploy.failed.${payload.module}`, payload);
80
+ }
81
+
82
+ export function emitHealthCheckFailed(payload: HealthCheckFailedPayload): void {
83
+ HealthCheckFailedSchema.parse(payload);
84
+ emitBest(`health-check.failed.${payload.module}`, payload);
85
+ }
86
+
87
+ /**
88
+ * Open the bus, emit, close. Errors are caught and logged so a
89
+ * misbehaving bus never wedges the caller. The empty-registry mode
90
+ * skips bus-side payload validation; we validate above with our own
91
+ * schemas before calling.
92
+ */
93
+ function emitBest(type: string, payload: unknown): void {
94
+ let bus: ReturnType<typeof openBus> | undefined;
95
+ try {
96
+ bus = openBus({ dbPath: getEventBusPath(), events: NO_SCHEMAS });
97
+ bus.emitRaw(type, payload);
98
+ } catch (err) {
99
+ const msg = err instanceof Error ? err.message : String(err);
100
+ console.warn(`[celilo] failed to emit ${type}: ${msg}`);
101
+ } finally {
102
+ bus?.close();
103
+ }
104
+ }
@@ -103,7 +103,7 @@ export async function autoDeriveMachineConfig(
103
103
  const derived = resolveMachineDerivation(variable.derive_from, machine);
104
104
  if (derived === null) continue;
105
105
 
106
- log.info(`✓ ${variable.name} = ${derived} (auto-derived from ${machine.hostname})`);
106
+ log.success(`${variable.name} = ${derived} (auto-derived from ${machine.hostname})`);
107
107
 
108
108
  await db
109
109
  .insert(moduleConfigs)
@@ -132,7 +132,7 @@ export async function autoDeriveMachineConfig(
132
132
  }
133
133
 
134
134
  if (followUpValue !== null) {
135
- log.info(`✓ ${followUpKey} = ${followUpValue} (auto-derived from ${machine.hostname})`);
135
+ log.success(`${followUpKey} = ${followUpValue} (auto-derived from ${machine.hostname})`);
136
136
  await db
137
137
  .insert(moduleConfigs)
138
138
  .values({
@@ -534,7 +534,7 @@ export async function interviewForMissingSecrets(
534
534
  ): Promise<InterviewResult> {
535
535
  const configured: string[] = [];
536
536
 
537
- log.info(`Module '${moduleId}' requires secrets. Configuring:`);
537
+ log.message(`Module '${moduleId}' requires secrets. Configuring:`);
538
538
 
539
539
  const masterKey = await getOrCreateMasterKey();
540
540
 
@@ -635,7 +635,7 @@ export async function interviewForMissingSecrets(
635
635
  deriveMethod: metadata.deriveMethod,
636
636
  });
637
637
 
638
- log.info(`🔗 Derived ${variable.name} from ${metadata.deriveFrom}`);
638
+ log.message(`Derived ${variable.name} from ${metadata.deriveFrom}`);
639
639
  } else if (source === 'generated') {
640
640
  // Auto-generate without prompting
641
641
  // Manifest generate field takes priority over schema metadata
@@ -644,7 +644,7 @@ export async function interviewForMissingSecrets(
644
644
 
645
645
  value = generateSecret({ format, length });
646
646
 
647
- log.info(`🔑 Auto-generated ${format} secret: ${variable.name}`);
647
+ log.message(`Auto-generated ${format} secret: ${variable.name}`);
648
648
  } else if (source === 'user_provided') {
649
649
  // Always prompt, required
650
650
  // Check if we're in interactive mode
@@ -667,7 +667,7 @@ export async function interviewForMissingSecrets(
667
667
  },
668
668
  });
669
669
 
670
- log.info(`✓ Saved ${variable.name}`);
670
+ log.success(`Saved ${variable.name}`);
671
671
  } else if (source === 'user_password') {
672
672
  // Password the user must remember — prompt twice to confirm
673
673
  if (!process.stdin.isTTY) {
@@ -706,7 +706,7 @@ export async function interviewForMissingSecrets(
706
706
  };
707
707
  }
708
708
 
709
- log.info(`✓ Saved ${variable.name}`);
709
+ log.success(`Saved ${variable.name}`);
710
710
  } else if (source === 'generated_optional') {
711
711
  // Prompt with auto-generate option
712
712
  const message = variable.description
@@ -725,11 +725,11 @@ export async function interviewForMissingSecrets(
725
725
 
726
726
  value = generateSecret({ format, length });
727
727
 
728
- log.info(`🔑 Auto-generated ${format} secret: ${variable.name}`);
728
+ log.message(`Auto-generated ${format} secret: ${variable.name}`);
729
729
  } else {
730
730
  // Use user-provided value
731
731
  value = userValue;
732
- log.info(`✓ Saved ${variable.name}`);
732
+ log.success(`Saved ${variable.name}`);
733
733
  }
734
734
  } else {
735
735
  return {
@@ -152,7 +152,7 @@ export async function executeAnsible(
152
152
  await writeFile(passwordPath, vaultPassword, { mode: 0o600 });
153
153
 
154
154
  const logPath = join(generatedPath, 'deploy.log');
155
- log.info('Deploying software...');
155
+ log.success('Configuring host (ansible-playbook)');
156
156
 
157
157
  try {
158
158
  const result = await executeBuildWithProgress({
@@ -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=CELILO_EVENT_BUS_PATH=/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('CELILO_EVENT_BUS_PATH=/db2');
145
+ expect(written).toContain('--poll-ms 500');
146
+ expect(written).not.toContain('CELILO_EVENT_BUS_PATH=/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=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
+ }
@@ -117,7 +117,7 @@ export async function runModuleHealthCheck(
117
117
  { debug: false, capabilities: capabilityFunctions, requiredCapabilities },
118
118
  );
119
119
  } else {
120
- const gauge = new FuelGauge(`Checking ${moduleId}`, {
120
+ const gauge = new FuelGauge('Testing app', {
121
121
  skipAnimation: options.noInteractive,
122
122
  });
123
123
  gauge.start();