@cleocode/cleo 2026.5.0 → 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.
- package/bin/postinstall.js +23 -0
- package/dist/cli/commands/complete.d.ts +9 -0
- package/dist/cli/commands/complete.d.ts.map +1 -1
- package/dist/cli/index.js +263 -36
- package/dist/cli/index.js.map +3 -3
- package/dist/dispatch/domains/tasks.d.ts.map +1 -1
- package/package.json +9 -9
- package/scripts/install-daemon-service.mjs +801 -0
|
@@ -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
|
+
}
|