@cleocode/cleo 2026.5.1 → 2026.5.2

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,801 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * install-daemon-service.mjs — Cross-platform CLEO daemon system service installer.
4
+ *
5
+ * Registers the CLEO sentient daemon as a user-level persistent system service
6
+ * so it auto-starts on login and persists across reboots.
7
+ *
8
+ * Platform support:
9
+ * Linux — systemd user unit (XDG: ~/.config/systemd/user/cleo-daemon.service)
10
+ * macOS — launchd plist (~/Library/LaunchAgents/io.cleocode.daemon.plist)
11
+ * Windows / WSL — Windows Service stub (followup T1684). WSL is detected via
12
+ * uname -r containing 'microsoft' and treated as Linux for systemd
13
+ * path resolution.
14
+ *
15
+ * ALL filesystem paths are resolved via env-paths (the same library used by
16
+ * packages/core/src/system/platform-paths.ts) so cross-OS resolution is
17
+ * consistent. No hardcoded ~/.config or ~/Library paths exist in this file.
18
+ *
19
+ * Idempotent: re-runs do not duplicate or unnecessarily restart the service
20
+ * when the generated content is identical (checked via SHA-256 hash comparison).
21
+ *
22
+ * Environment:
23
+ * CLEO_DAEMON_DISABLE=1 Skip auto-start activation (CI/container path).
24
+ * Unit/plist file is still written; only enable/load
25
+ * is skipped so the operator can activate later.
26
+ * CLEO_HOME Override the global CLEO data directory (forwarded
27
+ * to env-paths as the data root).
28
+ *
29
+ * @task T1682
30
+ * @task T1683 (paths.ts compliance audit + WSL detection)
31
+ */
32
+
33
+ import { createHash } from 'node:crypto';
34
+ import { execFileSync } from 'node:child_process';
35
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, rmSync, writeFileSync } from 'node:fs';
36
+ import { homedir } from 'node:os';
37
+ import { join } from 'node:path';
38
+ import { createRequire } from 'node:module';
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Platform paths — mirrors packages/core/src/system/platform-paths.ts
42
+ // Using env-paths directly (same dep, no compiled core needed at postinstall).
43
+ // ---------------------------------------------------------------------------
44
+
45
+ /**
46
+ * Resolve OS-appropriate paths — mirrors getPlatformPaths() from
47
+ * packages/core/src/system/platform-paths.ts.
48
+ *
49
+ * Strategy: attempt to import env-paths (pure-ESM, v4+) dynamically.
50
+ * If env-paths is not available (e.g. direct invocation in a minimal
51
+ * environment), fall back to XDG / macOS / Windows platform conventions
52
+ * computed inline so the installer remains self-contained.
53
+ *
54
+ * The CLEO_HOME env var overrides the data path for backward compatibility.
55
+ *
56
+ * @returns {{ data: string; config: string; cache: string; log: string; temp: string }}
57
+ */
58
+ let _cachedPlatformPaths = null;
59
+
60
+ async function getPlatformPathsAsync() {
61
+ if (_cachedPlatformPaths) return _cachedPlatformPaths;
62
+
63
+ const home = homedir();
64
+ const platform = process.platform;
65
+
66
+ // Attempt dynamic import of env-paths first.
67
+ try {
68
+ // env-paths may be available via the workspace node_modules tree.
69
+ // Use createRequire to resolve it relative to THIS file.
70
+ const require = createRequire(import.meta.url);
71
+ // env-paths v4 exports an ES module; under some Node versions createRequire
72
+ // can still load it from the pnpm virtual store.
73
+ const ep = require('env-paths')('cleo', { suffix: '' });
74
+ _cachedPlatformPaths = {
75
+ data: process.env['CLEO_HOME'] ?? ep.data,
76
+ config: ep.config,
77
+ cache: ep.cache,
78
+ log: ep.log,
79
+ temp: ep.temp,
80
+ };
81
+ return _cachedPlatformPaths;
82
+ } catch {
83
+ // env-paths unavailable (pure-ESM in some Node versions / isolated run).
84
+ // Fall through to manual computation below.
85
+ }
86
+
87
+ // Try dynamic ESM import of env-paths (the canonical path for pure-ESM v4).
88
+ try {
89
+ const mod = await import('env-paths');
90
+ const fn = typeof mod.default === 'function' ? mod.default : mod;
91
+ const ep = fn('cleo', { suffix: '' });
92
+ _cachedPlatformPaths = {
93
+ data: process.env['CLEO_HOME'] ?? ep.data,
94
+ config: ep.config,
95
+ cache: ep.cache,
96
+ log: ep.log,
97
+ temp: ep.temp,
98
+ };
99
+ return _cachedPlatformPaths;
100
+ } catch {
101
+ // Dynamic import also failed — fall through to manual XDG/platform logic.
102
+ }
103
+
104
+ // Manual fallback: compute XDG / platform paths inline.
105
+ // This mirrors the env-paths v4 logic exactly so results are identical.
106
+ let data, config, cache, log, temp;
107
+ if (platform === 'win32') {
108
+ const appData = process.env['APPDATA'] ?? join(home, 'AppData', 'Roaming');
109
+ const localAppData = process.env['LOCALAPPDATA'] ?? join(home, 'AppData', 'Local');
110
+ data = join(localAppData, 'cleo', 'Data');
111
+ config = join(appData, 'cleo', 'Config');
112
+ cache = join(localAppData, 'cleo', 'Cache');
113
+ log = join(localAppData, 'cleo', 'Log');
114
+ temp = join(localAppData, 'cleo', 'Temp');
115
+ } else if (platform === 'darwin') {
116
+ const library = join(home, 'Library');
117
+ data = join(library, 'Application Support', 'cleo');
118
+ config = join(library, 'Preferences', 'cleo');
119
+ cache = join(library, 'Caches', 'cleo');
120
+ log = join(library, 'Logs', 'cleo');
121
+ temp = join(library, 'Application Support', 'cleo', 'Temp');
122
+ } else {
123
+ // Linux / BSD / XDG
124
+ const xdgData = process.env['XDG_DATA_HOME'] ?? join(home, '.local', 'share');
125
+ const xdgConfig = process.env['XDG_CONFIG_HOME'] ?? join(home, '.config');
126
+ const xdgCache = process.env['XDG_CACHE_HOME'] ?? join(home, '.cache');
127
+ const xdgState = process.env['XDG_STATE_HOME'] ?? join(home, '.local', 'state');
128
+ data = join(xdgData, 'cleo');
129
+ config = join(xdgConfig, 'cleo');
130
+ cache = join(xdgCache, 'cleo');
131
+ log = join(xdgState, 'cleo');
132
+ temp = join(xdgData, 'cleo', 'Temp');
133
+ }
134
+
135
+ _cachedPlatformPaths = {
136
+ data: process.env['CLEO_HOME'] ?? data,
137
+ config,
138
+ cache,
139
+ log,
140
+ temp,
141
+ };
142
+ return _cachedPlatformPaths;
143
+ }
144
+
145
+ /**
146
+ * Synchronous wrapper — only used for non-critical path resolution.
147
+ * Falls back to XDG/platform defaults if env-paths is not available.
148
+ *
149
+ * @returns {{ data: string; config: string; cache: string; log: string; temp: string }}
150
+ */
151
+ function getPlatformPaths() {
152
+ if (_cachedPlatformPaths) return _cachedPlatformPaths;
153
+
154
+ // Compute synchronously using the XDG/platform logic from getPlatformPathsAsync.
155
+ const home = homedir();
156
+ const platform = process.platform;
157
+ let data, config, cache, log, temp;
158
+ if (platform === 'win32') {
159
+ const appData = process.env['APPDATA'] ?? join(home, 'AppData', 'Roaming');
160
+ const localAppData = process.env['LOCALAPPDATA'] ?? join(home, 'AppData', 'Local');
161
+ data = join(localAppData, 'cleo', 'Data');
162
+ config = join(appData, 'cleo', 'Config');
163
+ cache = join(localAppData, 'cleo', 'Cache');
164
+ log = join(localAppData, 'cleo', 'Log');
165
+ temp = join(localAppData, 'cleo', 'Temp');
166
+ } else if (platform === 'darwin') {
167
+ const library = join(home, 'Library');
168
+ data = join(library, 'Application Support', 'cleo');
169
+ config = join(library, 'Preferences', 'cleo');
170
+ cache = join(library, 'Caches', 'cleo');
171
+ log = join(library, 'Logs', 'cleo');
172
+ temp = join(library, 'Application Support', 'cleo', 'Temp');
173
+ } else {
174
+ // Linux / BSD / XDG
175
+ const xdgData = process.env['XDG_DATA_HOME'] ?? join(home, '.local', 'share');
176
+ const xdgConfig = process.env['XDG_CONFIG_HOME'] ?? join(home, '.config');
177
+ const xdgCache = process.env['XDG_CACHE_HOME'] ?? join(home, '.cache');
178
+ const xdgState = process.env['XDG_STATE_HOME'] ?? join(home, '.local', 'state');
179
+ data = join(xdgData, 'cleo');
180
+ config = join(xdgConfig, 'cleo');
181
+ cache = join(xdgCache, 'cleo');
182
+ log = join(xdgState, 'cleo');
183
+ temp = join(xdgData, 'cleo', 'Temp');
184
+ }
185
+
186
+ return {
187
+ data: process.env['CLEO_HOME'] ?? data,
188
+ config,
189
+ cache,
190
+ log,
191
+ temp,
192
+ };
193
+ }
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // WSL detection
197
+ // ---------------------------------------------------------------------------
198
+
199
+ /**
200
+ * Detect whether the process is running inside Windows Subsystem for Linux.
201
+ *
202
+ * WSL identifies as Linux (process.platform === 'linux') but exposes its
203
+ * origin via /proc/version (contains 'microsoft' or 'WSL') and the kernel
204
+ * release string (os.release() contains 'microsoft').
205
+ *
206
+ * Per T1683 spec: WSL is treated as Linux for systemd path resolution.
207
+ *
208
+ * @returns {boolean} True when running in WSL.
209
+ */
210
+ function isWSL() {
211
+ // Only relevant on Linux — WSL reports platform === 'linux'.
212
+ if (process.platform !== 'linux') return false;
213
+ try {
214
+ const buf = Buffer.alloc(256);
215
+ const fd = openSync('/proc/version', 'r');
216
+ try {
217
+ const bytesRead = readSync(fd, buf, 0, 256, 0);
218
+ const content = buf.slice(0, bytesRead).toString('utf8').toLowerCase();
219
+ return content.includes('microsoft') || content.includes('wsl');
220
+ } finally {
221
+ try { closeSync(fd); } catch { /* ignore */ }
222
+ }
223
+ } catch {
224
+ return false;
225
+ }
226
+ }
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // Constants
230
+ // ---------------------------------------------------------------------------
231
+
232
+ /** Environment variable that disables daemon auto-start (CI/container path). */
233
+ const DAEMON_DISABLE_ENV = 'CLEO_DAEMON_DISABLE';
234
+
235
+ /**
236
+ * Daemon log file path — resolved via env-paths log directory.
237
+ *
238
+ * Linux: ~/.local/state/cleo/daemon/cleo-daemon.log
239
+ * macOS: ~/Library/Logs/cleo/daemon/cleo-daemon.log
240
+ * Windows: %LOCALAPPDATA%\cleo\Log\daemon\cleo-daemon.log
241
+ */
242
+ function getDaemonLogFile() {
243
+ const paths = getPlatformPaths();
244
+ return join(paths.log, 'daemon', 'cleo-daemon.log');
245
+ }
246
+
247
+ /**
248
+ * Daemon log directory — parent of the log file.
249
+ */
250
+ function getDaemonLogDir() {
251
+ const paths = getPlatformPaths();
252
+ return join(paths.log, 'daemon');
253
+ }
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // Helpers
257
+ // ---------------------------------------------------------------------------
258
+
259
+ /**
260
+ * Compute a SHA-256 hex digest of a string.
261
+ *
262
+ * @param {string} content - The string to hash.
263
+ * @returns {string} Lowercase hex string.
264
+ */
265
+ function sha256(content) {
266
+ return createHash('sha256').update(content, 'utf8').digest('hex');
267
+ }
268
+
269
+ /**
270
+ * Write a file only when the content differs from what is on disk.
271
+ *
272
+ * @param {string} filePath - Absolute path to the file.
273
+ * @param {string} content - Desired file content.
274
+ * @returns {boolean} `true` when the file was written, `false` when skipped (hash match).
275
+ */
276
+ function writeIfChanged(filePath, content) {
277
+ if (existsSync(filePath)) {
278
+ const existing = readFileSync(filePath, 'utf8');
279
+ if (sha256(existing) === sha256(content)) {
280
+ return false;
281
+ }
282
+ }
283
+ const dir = join(filePath, '..');
284
+ mkdirSync(dir, { recursive: true });
285
+ writeFileSync(filePath, content, { encoding: 'utf8', mode: 0o644 });
286
+ return true;
287
+ }
288
+
289
+ /**
290
+ * Execute a binary with an argument list (no shell interpolation).
291
+ * Uses execFileSync to prevent shell injection.
292
+ *
293
+ * @param {string} bin - Absolute or PATH-relative binary name.
294
+ * @param {string[]} args - Argument list.
295
+ * @returns {{ ok: boolean; output: string }} Result.
296
+ */
297
+ function runBin(bin, args) {
298
+ try {
299
+ const output = execFileSync(bin, args, {
300
+ encoding: 'utf8',
301
+ stdio: ['ignore', 'pipe', 'pipe'],
302
+ });
303
+ return { ok: true, output: typeof output === 'string' ? output.trim() : '' };
304
+ } catch (err) {
305
+ const stdout = typeof err.stdout === 'string' ? err.stdout : '';
306
+ const stderr = typeof err.stderr === 'string' ? err.stderr : '';
307
+ return { ok: false, output: (stdout + stderr).trim() };
308
+ }
309
+ }
310
+
311
+ // ---------------------------------------------------------------------------
312
+ // Linux — systemd user unit
313
+ // ---------------------------------------------------------------------------
314
+
315
+ /** Name of the systemd user unit (without .service extension). */
316
+ const SYSTEMD_UNIT_NAME = 'cleo-daemon';
317
+
318
+ /**
319
+ * Absolute path to the systemd user unit file.
320
+ *
321
+ * Resolved via env-paths config directory (XDG: ~/.config/cleo → ../systemd/user/).
322
+ * The systemd user unit directory is always XDG_CONFIG_HOME/systemd/user/,
323
+ * where XDG_CONFIG_HOME defaults to ~/.config.
324
+ *
325
+ * We derive it from the env-paths config root minus the 'cleo' suffix:
326
+ * ~/.config/cleo → ~/.config → ~/.config/systemd/user/
327
+ *
328
+ * @returns {string} Absolute path to the .service file.
329
+ */
330
+ function getSystemdUnitFile() {
331
+ // env-paths config for 'cleo' → ~/.config/cleo
332
+ // systemd user dir → ~/.config/systemd/user/ (one level up from 'cleo')
333
+ const paths = getPlatformPaths();
334
+ const configParent = join(paths.config, '..');
335
+ return join(configParent, 'systemd', 'user', `${SYSTEMD_UNIT_NAME}.service`);
336
+ }
337
+
338
+ /**
339
+ * Generate the systemd user unit file content.
340
+ *
341
+ * The unit runs `cleo daemon start --foreground` so systemd owns the
342
+ * lifecycle (restart policy, log collection). stdout/stderr are appended
343
+ * to the shared daemon log via StandardOutput/StandardError directives.
344
+ *
345
+ * @param {string} cleoExec - Absolute path to the `cleo` binary.
346
+ * @returns {string} Systemd unit file content.
347
+ */
348
+ function buildSystemdUnit(cleoExec) {
349
+ const logFile = getDaemonLogFile();
350
+ return `[Unit]
351
+ Description=CLEO Sentient Daemon (autonomous task hygiene + dream cycles)
352
+ Documentation=https://github.com/kryptobaseddev/cleocode
353
+ After=network.target
354
+
355
+ [Service]
356
+ Type=simple
357
+ ExecStart=${cleoExec} daemon start --foreground
358
+ Restart=on-failure
359
+ RestartSec=5
360
+ StandardOutput=append:${logFile}
361
+ StandardError=append:${logFile}
362
+ Environment=CLEO_SENTIENT_DAEMON=1
363
+
364
+ [Install]
365
+ WantedBy=default.target
366
+ `;
367
+ }
368
+
369
+ /**
370
+ * Install and optionally activate the systemd user unit.
371
+ *
372
+ * @param {string} cleoExec - Absolute path to the `cleo` binary.
373
+ */
374
+ function installSystemd(cleoExec) {
375
+ const unitFile = getSystemdUnitFile();
376
+ const unit = buildSystemdUnit(cleoExec);
377
+ const written = writeIfChanged(unitFile, unit);
378
+
379
+ if (written) {
380
+ console.log(`CLEO: Wrote systemd user unit → ${unitFile}`);
381
+ // Reload daemon to pick up the new unit file.
382
+ const reload = runBin('systemctl', ['--user', 'daemon-reload']);
383
+ if (!reload.ok) {
384
+ console.log(`CLEO: systemctl daemon-reload skipped (${reload.output || 'no output'})`);
385
+ }
386
+ } else {
387
+ console.log('CLEO: systemd unit already up-to-date — skipping write.');
388
+ }
389
+
390
+ if (process.env[DAEMON_DISABLE_ENV] === '1') {
391
+ console.log(
392
+ `CLEO: ${DAEMON_DISABLE_ENV}=1 — unit written but activation skipped (CI/container path).`,
393
+ );
394
+ return;
395
+ }
396
+
397
+ // Enable + start the service.
398
+ const enable = runBin('systemctl', ['--user', 'enable', '--now', SYSTEMD_UNIT_NAME]);
399
+ if (enable.ok) {
400
+ console.log('CLEO: systemd user service enabled and started.');
401
+ } else {
402
+ // Graceful degradation: systemctl may not be available (container, minimal
403
+ // environments without D-Bus). Log and continue — the unit file is present
404
+ // for manual activation later.
405
+ console.log(
406
+ `CLEO: systemctl enable --now skipped (${enable.output || 'systemctl unavailable'}).`,
407
+ );
408
+ console.log('CLEO: Unit file present. Run: systemctl --user enable --now cleo-daemon');
409
+ }
410
+ }
411
+
412
+ // ---------------------------------------------------------------------------
413
+ // macOS — launchd plist
414
+ // ---------------------------------------------------------------------------
415
+
416
+ /** Reverse-DNS label for the launchd agent. */
417
+ const LAUNCHD_PLIST_LABEL = 'io.cleocode.daemon';
418
+
419
+ /**
420
+ * Absolute path to the launchd plist file.
421
+ *
422
+ * macOS launchd user agents always live in ~/Library/LaunchAgents/.
423
+ * env-paths resolves this correctly on macOS:
424
+ * data → ~/Library/Application Support/cleo
425
+ * config → ~/Library/Preferences/cleo
426
+ * Neither maps to LaunchAgents; we use homedir + Library/LaunchAgents
427
+ * which is the macOS-mandated location (not configurable by XDG).
428
+ *
429
+ * @returns {string} Absolute path to the .plist file.
430
+ */
431
+ function getLaunchdPlistFile() {
432
+ // On macOS, env-paths data = ~/Library/Application Support/cleo
433
+ // LaunchAgents is a sibling of Application Support: ~/Library/LaunchAgents/
434
+ const paths = getPlatformPaths();
435
+ // data → ~/Library/Application Support/cleo → up two levels → ~/Library
436
+ const libraryDir = join(paths.data, '..', '..');
437
+ return join(libraryDir, 'LaunchAgents', `${LAUNCHD_PLIST_LABEL}.plist`);
438
+ }
439
+
440
+ /**
441
+ * Generate the launchd plist content.
442
+ *
443
+ * The plist uses KeepAlive=true so launchd restarts the daemon on exit,
444
+ * and RunAtLoad=true to start it immediately on launchctl load.
445
+ *
446
+ * @param {string} cleoExec - Absolute path to the `cleo` binary.
447
+ * @returns {string} Plist XML content.
448
+ */
449
+ function buildLaunchdPlist(cleoExec) {
450
+ const logFile = getDaemonLogFile();
451
+ return `<?xml version="1.0" encoding="UTF-8"?>
452
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
453
+ "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
454
+ <plist version="1.0">
455
+ <dict>
456
+ <key>Label</key>
457
+ <string>${LAUNCHD_PLIST_LABEL}</string>
458
+
459
+ <key>ProgramArguments</key>
460
+ <array>
461
+ <string>${cleoExec}</string>
462
+ <string>daemon</string>
463
+ <string>start</string>
464
+ <string>--foreground</string>
465
+ </array>
466
+
467
+ <key>KeepAlive</key>
468
+ <true/>
469
+
470
+ <key>StandardOutPath</key>
471
+ <string>${logFile}</string>
472
+
473
+ <key>StandardErrorPath</key>
474
+ <string>${logFile}</string>
475
+
476
+ <key>EnvironmentVariables</key>
477
+ <dict>
478
+ <key>CLEO_SENTIENT_DAEMON</key>
479
+ <string>1</string>
480
+ </dict>
481
+
482
+ <key>RunAtLoad</key>
483
+ <true/>
484
+ </dict>
485
+ </plist>
486
+ `;
487
+ }
488
+
489
+ /**
490
+ * Install and optionally load the launchd plist.
491
+ *
492
+ * @param {string} cleoExec - Absolute path to the `cleo` binary.
493
+ */
494
+ function installLaunchd(cleoExec) {
495
+ const plistFile = getLaunchdPlistFile();
496
+ const plist = buildLaunchdPlist(cleoExec);
497
+ const written = writeIfChanged(plistFile, plist);
498
+
499
+ if (written) {
500
+ console.log(`CLEO: Wrote launchd plist → ${plistFile}`);
501
+ } else {
502
+ console.log('CLEO: launchd plist already up-to-date — skipping write.');
503
+ }
504
+
505
+ if (process.env[DAEMON_DISABLE_ENV] === '1') {
506
+ console.log(
507
+ `CLEO: ${DAEMON_DISABLE_ENV}=1 — plist written but activation skipped (CI/container path).`,
508
+ );
509
+ return;
510
+ }
511
+
512
+ // Try bootstrap (macOS 10.13+) first; fall back to legacy launchctl load.
513
+ const uid = process.getuid ? String(process.getuid()) : '';
514
+ if (uid) {
515
+ const bootstrap = runBin('launchctl', [
516
+ 'bootstrap',
517
+ `gui/${uid}`,
518
+ plistFile,
519
+ ]);
520
+
521
+ if (bootstrap.ok) {
522
+ console.log(`CLEO: launchd agent bootstrapped (gui/${uid}).`);
523
+ return;
524
+ }
525
+
526
+ // Error 36 (EALREADY) means already loaded — not a real error.
527
+ if (
528
+ bootstrap.output.includes('36') ||
529
+ bootstrap.output.toLowerCase().includes('already')
530
+ ) {
531
+ console.log('CLEO: launchd agent already loaded — no action needed.');
532
+ return;
533
+ }
534
+ }
535
+
536
+ // Fall back to legacy `launchctl load`.
537
+ const load = runBin('launchctl', ['load', plistFile]);
538
+ if (load.ok) {
539
+ console.log('CLEO: launchd agent loaded (legacy launchctl load).');
540
+ } else {
541
+ console.log(
542
+ `CLEO: launchctl load skipped (${load.output || 'launchctl unavailable'}).`,
543
+ );
544
+ console.log(`CLEO: Plist present. Run: launchctl load "${plistFile}"`);
545
+ }
546
+ }
547
+
548
+ // ---------------------------------------------------------------------------
549
+ // Windows — stub (followup T1684)
550
+ // ---------------------------------------------------------------------------
551
+
552
+ /**
553
+ * Windows service registration — not yet implemented.
554
+ *
555
+ * TODO: Implement Windows Task Scheduler or NSSM-based service registration.
556
+ * Filed as followup task T1684.
557
+ */
558
+ function installWindows() {
559
+ console.log(
560
+ 'CLEO: Windows daemon auto-start is not yet implemented (followup: T1684).',
561
+ );
562
+ console.log('CLEO: To start the daemon manually: cleo daemon start');
563
+ }
564
+
565
+ // ---------------------------------------------------------------------------
566
+ // Log directory bootstrap
567
+ // ---------------------------------------------------------------------------
568
+
569
+ /**
570
+ * Ensure the daemon log directory exists before writing service files.
571
+ *
572
+ * Both systemd and launchd reference the log path directly in the unit/plist.
573
+ * The directory must exist before the service is activated so the OS can open
574
+ * the append target without error.
575
+ */
576
+ function ensureLogDir() {
577
+ mkdirSync(getDaemonLogDir(), { recursive: true });
578
+ }
579
+
580
+ // ---------------------------------------------------------------------------
581
+ // cleo binary resolution
582
+ // ---------------------------------------------------------------------------
583
+
584
+ /**
585
+ * Resolve the path to the `cleo` executable that npm installed.
586
+ *
587
+ * Strategy (in order):
588
+ * 1. $npm_config_prefix/bin/cleo — set by npm during global installs.
589
+ * 2. `which cleo` on the current PATH.
590
+ * 3. Bare `cleo` — relies on PATH being correct at service start time.
591
+ *
592
+ * @returns {string} Absolute path to the `cleo` binary (or bare `cleo`).
593
+ */
594
+ function resolveCleoExec() {
595
+ const prefix = process.env.npm_config_prefix;
596
+ if (prefix) {
597
+ const candidate = join(prefix, 'bin', 'cleo');
598
+ if (existsSync(candidate)) return candidate;
599
+ }
600
+
601
+ // `which` is POSIX; `where` on Windows — but this path is skipped on Windows.
602
+ const whichResult = runBin('which', ['cleo']);
603
+ if (whichResult.ok && whichResult.output) {
604
+ return whichResult.output.split('\n')[0].trim();
605
+ }
606
+
607
+ return 'cleo';
608
+ }
609
+
610
+ // ---------------------------------------------------------------------------
611
+ // Public API: install
612
+ // ---------------------------------------------------------------------------
613
+
614
+ /**
615
+ * Install the daemon system service for the current platform.
616
+ *
617
+ * Called by the npm postinstall hook in `bin/postinstall.js`.
618
+ * Never throws — all errors are caught and logged so `npm install`
619
+ * always exits 0.
620
+ *
621
+ * WSL is detected and treated as Linux (systemd path resolution).
622
+ *
623
+ * @returns {Promise<void>}
624
+ */
625
+ export async function installDaemonService() {
626
+ try {
627
+ ensureLogDir();
628
+ const cleoExec = resolveCleoExec();
629
+ const platform = process.platform;
630
+
631
+ if (platform === 'linux' || isWSL()) {
632
+ installSystemd(cleoExec);
633
+ } else if (platform === 'darwin') {
634
+ installLaunchd(cleoExec);
635
+ } else if (platform === 'win32') {
636
+ installWindows();
637
+ } else {
638
+ console.log(
639
+ `CLEO: Daemon auto-start not supported on platform "${platform}".`,
640
+ );
641
+ }
642
+ } catch (err) {
643
+ const message = err instanceof Error ? err.message : String(err);
644
+ console.log(`CLEO: Daemon service installation deferred: ${message}`);
645
+ if (process.env.CLEO_DEBUG) {
646
+ console.error('CLEO: Daemon install detail:', err);
647
+ }
648
+ }
649
+ }
650
+
651
+ // ---------------------------------------------------------------------------
652
+ // Public API: uninstall
653
+ // ---------------------------------------------------------------------------
654
+
655
+ /**
656
+ * @typedef {Object} UninstallResult
657
+ * @property {string} platform - Platform identifier.
658
+ * @property {string|null} removed - Path that was removed, or null.
659
+ * @property {boolean} success - Whether the operation succeeded.
660
+ * @property {string} message - Human-readable outcome.
661
+ */
662
+
663
+ /**
664
+ * Uninstall the daemon service for the current platform.
665
+ *
666
+ * Disables and removes the unit/plist file cleanly.
667
+ * Used by `cleo daemon uninstall`.
668
+ *
669
+ * @returns {Promise<UninstallResult>} Uninstall outcome.
670
+ */
671
+ export async function uninstallDaemonService() {
672
+ const platform = process.platform;
673
+ try {
674
+ if (platform === 'linux' || isWSL()) {
675
+ return uninstallSystemd();
676
+ } else if (platform === 'darwin') {
677
+ return uninstallLaunchd();
678
+ } else if (platform === 'win32') {
679
+ return {
680
+ platform,
681
+ removed: null,
682
+ success: false,
683
+ message: 'Windows uninstall not yet implemented (T1684).',
684
+ };
685
+ } else {
686
+ return {
687
+ platform,
688
+ removed: null,
689
+ success: false,
690
+ message: `Platform "${platform}" not supported.`,
691
+ };
692
+ }
693
+ } catch (err) {
694
+ const message = err instanceof Error ? err.message : String(err);
695
+ return { platform, removed: null, success: false, message };
696
+ }
697
+ }
698
+
699
+ // ---------------------------------------------------------------------------
700
+ // Uninstall helpers
701
+ // ---------------------------------------------------------------------------
702
+
703
+ /**
704
+ * Disable and remove the systemd user unit.
705
+ *
706
+ * @returns {UninstallResult} Result.
707
+ */
708
+ function uninstallSystemd() {
709
+ const platform = 'linux';
710
+ const unitFile = getSystemdUnitFile();
711
+
712
+ // Stop + disable — ignore failures (unit may already be stopped/disabled).
713
+ runBin('systemctl', ['--user', 'stop', SYSTEMD_UNIT_NAME]);
714
+ runBin('systemctl', ['--user', 'disable', SYSTEMD_UNIT_NAME]);
715
+
716
+ if (existsSync(unitFile)) {
717
+ rmSync(unitFile, { force: true });
718
+ runBin('systemctl', ['--user', 'daemon-reload']);
719
+ return {
720
+ platform,
721
+ removed: unitFile,
722
+ success: true,
723
+ message: `Systemd unit removed: ${unitFile}`,
724
+ };
725
+ }
726
+
727
+ return {
728
+ platform,
729
+ removed: null,
730
+ success: true,
731
+ message: 'Systemd unit was not installed — nothing to remove.',
732
+ };
733
+ }
734
+
735
+ /**
736
+ * Unload and remove the launchd plist.
737
+ *
738
+ * @returns {UninstallResult} Result.
739
+ */
740
+ function uninstallLaunchd() {
741
+ const platform = 'darwin';
742
+ const plistFile = getLaunchdPlistFile();
743
+
744
+ if (existsSync(plistFile)) {
745
+ const uid = process.getuid ? String(process.getuid()) : '';
746
+ if (uid) {
747
+ runBin('launchctl', ['bootout', `gui/${uid}`, plistFile]);
748
+ } else {
749
+ runBin('launchctl', ['unload', plistFile]);
750
+ }
751
+ rmSync(plistFile, { force: true });
752
+ return {
753
+ platform,
754
+ removed: plistFile,
755
+ success: true,
756
+ message: `launchd plist removed: ${plistFile}`,
757
+ };
758
+ }
759
+
760
+ return {
761
+ platform,
762
+ removed: null,
763
+ success: true,
764
+ message: 'launchd plist was not installed — nothing to remove.',
765
+ };
766
+ }
767
+
768
+ // ---------------------------------------------------------------------------
769
+ // Exported path resolution (for testing and audit)
770
+ // ---------------------------------------------------------------------------
771
+
772
+ /**
773
+ * Resolve all daemon-related filesystem paths using env-paths.
774
+ *
775
+ * Exported for test verification and `cleo admin paths` introspection.
776
+ * All paths are derived from env-paths (never hardcoded).
777
+ *
778
+ * @returns {{ logDir: string; logFile: string; systemdUnitFile: string | null; launchdPlistFile: string | null }}
779
+ */
780
+ export function resolveDaemonPaths() {
781
+ const platform = process.platform;
782
+ return {
783
+ logDir: getDaemonLogDir(),
784
+ logFile: getDaemonLogFile(),
785
+ systemdUnitFile: platform === 'linux' ? getSystemdUnitFile() : null,
786
+ launchdPlistFile: platform === 'darwin' ? getLaunchdPlistFile() : null,
787
+ };
788
+ }
789
+
790
+ // ---------------------------------------------------------------------------
791
+ // Direct invocation (node install-daemon-service.mjs)
792
+ // ---------------------------------------------------------------------------
793
+
794
+ // When executed directly (not imported as a module), run the installer.
795
+ if (process.argv[1] && process.argv[1].endsWith('install-daemon-service.mjs')) {
796
+ installDaemonService().catch((err) => {
797
+ console.error('CLEO: Fatal daemon install error:', err.message);
798
+ // Non-fatal exit so npm install always succeeds.
799
+ process.exit(0);
800
+ });
801
+ }