@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.
- package/package.json +11 -4
- package/src/cli/command-registry.ts +114 -0
- package/src/cli/commands/events.test.ts +156 -0
- package/src/cli/commands/events.ts +356 -0
- package/src/cli/commands/module-remove.ts +14 -0
- package/src/cli/index.ts +103 -0
- package/src/cli/prompts.ts +1 -1
- package/src/config/paths.ts +19 -0
- package/src/hooks/define-hook.test.ts +12 -0
- package/src/hooks/executor.ts +9 -0
- package/src/hooks/logger.ts +38 -0
- package/src/hooks/types.ts +12 -1
- package/src/manifest/schema.ts +34 -0
- package/src/manifest/validate.test.ts +143 -0
- package/src/manifest/validate.ts +9 -3
- package/src/module/import.ts +15 -0
- package/src/services/celilo-events.test.ts +98 -0
- package/src/services/celilo-events.ts +104 -0
- package/src/services/config-interview.ts +9 -9
- package/src/services/deploy-ansible.ts +1 -1
- package/src/services/events-daemon.test.ts +184 -0
- package/src/services/events-daemon.ts +244 -0
- package/src/services/health-runner.ts +1 -1
- package/src/services/module-deploy.ts +62 -22
- package/src/services/module-subscriptions.test.ts +197 -0
- package/src/services/module-subscriptions.ts +120 -0
- package/src/templates/generator.ts +3 -6
- package/src/variables/declarative-derivation.test.ts +93 -8
- package/src/variables/declarative-derivation.ts +15 -0
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
728
|
+
log.message(`Auto-generated ${format} secret: ${variable.name}`);
|
|
729
729
|
} else {
|
|
730
730
|
// Use user-provided value
|
|
731
731
|
value = userValue;
|
|
732
|
-
log.
|
|
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.
|
|
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(
|
|
120
|
+
const gauge = new FuelGauge('Testing app', {
|
|
121
121
|
skipAnimation: options.noInteractive,
|
|
122
122
|
});
|
|
123
123
|
gauge.start();
|