@karmaniverous/jeeves 0.4.0 → 0.4.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,929 @@
1
+ #!/usr/bin/env node
2
+ import { execSync, spawn } from 'node:child_process';
3
+ import { existsSync, unlinkSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import * as commander from 'commander';
6
+ import { z } from 'zod';
7
+ import { homedir } from 'node:os';
8
+
9
+ function getDefaultExportFromCjs (x) {
10
+ return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x;
11
+ }
12
+
13
+ function getAugmentedNamespace(n) {
14
+ if (Object.prototype.hasOwnProperty.call(n, '__esModule')) return n;
15
+ var f = n.default;
16
+ if (typeof f == "function") {
17
+ var a = function a () {
18
+ var isInstance = false;
19
+ try {
20
+ isInstance = this instanceof a;
21
+ } catch {}
22
+ if (isInstance) {
23
+ return Reflect.construct(f, arguments, this.constructor);
24
+ }
25
+ return f.apply(this, arguments);
26
+ };
27
+ a.prototype = f.prototype;
28
+ } else a = {};
29
+ Object.defineProperty(a, '__esModule', {value: true});
30
+ Object.keys(n).forEach(function (k) {
31
+ var d = Object.getOwnPropertyDescriptor(n, k);
32
+ Object.defineProperty(a, k, d.get ? d : {
33
+ enumerable: true,
34
+ get: function () {
35
+ return n[k];
36
+ }
37
+ });
38
+ });
39
+ return a;
40
+ }
41
+
42
+ var extraTypings = {exports: {}};
43
+
44
+ var require$$0 = /*@__PURE__*/getAugmentedNamespace(commander);
45
+
46
+ var hasRequiredExtraTypings;
47
+
48
+ function requireExtraTypings () {
49
+ if (hasRequiredExtraTypings) return extraTypings.exports;
50
+ hasRequiredExtraTypings = 1;
51
+ (function (module, exports$1) {
52
+ const commander = require$$0;
53
+
54
+ exports$1 = module.exports = {};
55
+
56
+ // Return a different global program than commander,
57
+ // and don't also return it as default export.
58
+ exports$1.program = new commander.Command();
59
+
60
+ /**
61
+ * Expose classes. The FooT versions are just types, so return Commander original implementations!
62
+ */
63
+
64
+ exports$1.Argument = commander.Argument;
65
+ exports$1.Command = commander.Command;
66
+ exports$1.CommanderError = commander.CommanderError;
67
+ exports$1.Help = commander.Help;
68
+ exports$1.InvalidArgumentError = commander.InvalidArgumentError;
69
+ exports$1.InvalidOptionArgumentError = commander.InvalidArgumentError; // Deprecated
70
+ exports$1.Option = commander.Option;
71
+
72
+ exports$1.createCommand = (name) => new commander.Command(name);
73
+ exports$1.createOption = (flags, description) =>
74
+ new commander.Option(flags, description);
75
+ exports$1.createArgument = (name, description) =>
76
+ new commander.Argument(name, description);
77
+ } (extraTypings, extraTypings.exports));
78
+ return extraTypings.exports;
79
+ }
80
+
81
+ var extraTypingsExports = requireExtraTypings();
82
+ var extraTypingsCommander = /*@__PURE__*/getDefaultExportFromCjs(extraTypingsExports);
83
+
84
+ // wrapper to provide named exports for ESM.
85
+ const {
86
+ program,
87
+ createCommand,
88
+ createArgument,
89
+ createOption,
90
+ CommanderError,
91
+ InvalidArgumentError,
92
+ InvalidOptionArgumentError, // deprecated old name
93
+ Command,
94
+ Argument,
95
+ Option,
96
+ Help,
97
+ } = extraTypingsCommander;
98
+
99
+ /**
100
+ * Zod schema for the Jeeves component descriptor.
101
+ *
102
+ * @remarks
103
+ * The descriptor replaces the v0.4.0 `JeevesComponent` interface with a
104
+ * Zod-first approach. The TypeScript type is inferred via `z.infer<>`.
105
+ * Validates at parse time: prime interval, callable functions.
106
+ */
107
+ /**
108
+ * Check whether a number is prime.
109
+ *
110
+ * @param n - Number to check.
111
+ * @returns `true` if n is prime.
112
+ */
113
+ function isPrime(n) {
114
+ if (n < 2)
115
+ return false;
116
+ if (n === 2)
117
+ return true;
118
+ if (n % 2 === 0)
119
+ return false;
120
+ for (let i = 3; i * i <= n; i += 2) {
121
+ if (n % i === 0)
122
+ return false;
123
+ }
124
+ return true;
125
+ }
126
+ /**
127
+ * Zod schema for the Jeeves component descriptor.
128
+ *
129
+ * @remarks
130
+ * Single source of truth for what a component must provide.
131
+ * Factories consume this descriptor to produce CLI commands,
132
+ * plugin tools, and HTTP handlers.
133
+ */
134
+ z.object({
135
+ /** Component name (e.g., 'watcher', 'runner', 'server', 'meta'). */
136
+ name: z.string().min(1, 'name must be a non-empty string'),
137
+ /** Component version (from package.json). */
138
+ version: z.string().min(1, 'version must be a non-empty string'),
139
+ /** npm package name for the service. */
140
+ servicePackage: z.string().min(1),
141
+ /** npm package name for the plugin. */
142
+ pluginPackage: z.string().min(1),
143
+ /** System service name. Defaults to `jeeves-${name}` when not provided. */
144
+ serviceName: z.string().min(1).optional(),
145
+ /** Default port for the service's HTTP API. */
146
+ defaultPort: z.number().int().positive(),
147
+ /** Zod schema for validating config files. */
148
+ configSchema: z.custom((val) => val !== null &&
149
+ typeof val === 'object' &&
150
+ typeof val.parse === 'function', { message: 'configSchema must be a Zod schema' }),
151
+ /** Config file name (e.g., 'jeeves-watcher.config.json'). */
152
+ configFileName: z.string().min(1),
153
+ /** Returns a default config object for `init`. */
154
+ initTemplate: z.function().returns(z.record(z.unknown())),
155
+ /**
156
+ * Service-side callback after config apply. Receives the merged,
157
+ * validated config (not the raw patch). Optional — if omitted,
158
+ * write-only (service picks up changes on restart).
159
+ */
160
+ onConfigApply: z
161
+ .function()
162
+ .args(z.record(z.unknown()))
163
+ .returns(z.promise(z.void()))
164
+ .optional(),
165
+ /**
166
+ * Custom merge function for config apply. Receives the existing config
167
+ * and the patch, returns the merged result. Optional — if omitted,
168
+ * the default deep-merge (object-recursive, array-replacing) is used.
169
+ *
170
+ * Use this to implement domain-specific merge strategies such as
171
+ * name-based array merging for inference rules.
172
+ */
173
+ customMerge: z
174
+ .function()
175
+ .args(z.record(z.unknown()), z.record(z.unknown()))
176
+ .returns(z.record(z.unknown()))
177
+ .optional(),
178
+ /**
179
+ * Returns command + args for launching the service process.
180
+ * Consumed by `start` CLI command and `service install`.
181
+ */
182
+ startCommand: z.function().args(z.string()).returns(z.array(z.string())),
183
+ /** TOOLS.md section name (e.g., 'Watcher'). */
184
+ sectionId: z.string().min(1, 'sectionId must be a non-empty string'),
185
+ /** Refresh interval in seconds (must be a prime number). */
186
+ refreshIntervalSeconds: z.number().int().positive().refine(isPrime, {
187
+ message: 'refreshIntervalSeconds must be a prime number',
188
+ }),
189
+ /** Produce the component's TOOLS.md section content. */
190
+ generateToolsContent: z.function().returns(z.string()),
191
+ /** Component dependencies for HEARTBEAT alert suppression. */
192
+ dependencies: z
193
+ .object({
194
+ hard: z.array(z.string()),
195
+ soft: z.array(z.string()),
196
+ })
197
+ .optional(),
198
+ /** Extension point: add custom CLI commands to the service CLI. */
199
+ customCliCommands: z
200
+ .function()
201
+ .args(z.custom())
202
+ .returns(z.void())
203
+ .optional(),
204
+ /** Extension point: return additional plugin tool descriptors. */
205
+ customPluginTools: z
206
+ .function()
207
+ .args(z.custom())
208
+ .returns(z.array(z.unknown()))
209
+ .optional(),
210
+ });
211
+ /**
212
+ * Derive the effective service name from a descriptor.
213
+ *
214
+ * @param descriptor - The component descriptor.
215
+ * @returns The service name (explicit or derived from `jeeves-{name}`).
216
+ */
217
+ function getEffectiveServiceName(descriptor) {
218
+ return descriptor.serviceName ?? `jeeves-${descriptor.name}`;
219
+ }
220
+
221
+ /**
222
+ * Workspace and config root initialization.
223
+ *
224
+ * @remarks
225
+ * `init()` must be called once before any other core library functions.
226
+ * It caches `workspacePath` and `configRoot` at module level.
227
+ * Core derives all namespaced paths from these values:
228
+ * - `{configRoot}/jeeves-core/` for core config
229
+ * - `{configRoot}/jeeves-{name}/` for each component
230
+ */
231
+ /**
232
+ * Derive the component config directory from the component name.
233
+ *
234
+ * @param componentName - The component name (e.g., 'watcher', 'runner').
235
+ * @returns Absolute path to the component's config directory.
236
+ * @throws Error if `init()` has not been called.
237
+ */
238
+ function getComponentConfigDir(componentName) {
239
+ throw new Error('jeeves-core: init() must be called first');
240
+ }
241
+
242
+ /**
243
+ * HTTP helpers for the OpenClaw plugin SDK.
244
+ *
245
+ * @remarks
246
+ * Thin wrappers around `fetch` that throw on non-OK responses
247
+ * and handle JSON serialisation/deserialisation.
248
+ */
249
+ /**
250
+ * Fetch a URL with an automatic abort timeout.
251
+ *
252
+ * @param url - URL to fetch.
253
+ * @param timeoutMs - Timeout in milliseconds before aborting.
254
+ * @param init - Optional `fetch` init options.
255
+ * @returns The fetch Response object.
256
+ */
257
+ /**
258
+ * Fetch JSON from a URL, throwing on non-OK responses.
259
+ *
260
+ * @param url - URL to fetch.
261
+ * @param init - Optional `fetch` init options.
262
+ * @returns Parsed JSON response body.
263
+ * @throws Error with `HTTP {status}: {body}` message on non-OK responses.
264
+ */
265
+ async function fetchJson(url, init) {
266
+ const res = await fetch(url, init);
267
+ if (!res.ok) {
268
+ throw new Error('HTTP ' + String(res.status) + ': ' + (await res.text()));
269
+ }
270
+ return res.json();
271
+ }
272
+ /**
273
+ * POST JSON to a URL and return parsed response.
274
+ *
275
+ * @param url - URL to POST to.
276
+ * @param body - Request body (will be JSON-stringified).
277
+ * @returns Parsed JSON response body.
278
+ */
279
+ async function postJson(url, body) {
280
+ return fetchJson(url, {
281
+ method: 'POST',
282
+ headers: { 'Content-Type': 'application/json' },
283
+ body: JSON.stringify(body),
284
+ });
285
+ }
286
+
287
+ /**
288
+ * Platform-aware service state detection.
289
+ *
290
+ * @remarks
291
+ * Detects whether a system service is installed and running.
292
+ * Delegates to NSSM (Windows), systemd (Linux), or launchd (macOS).
293
+ */
294
+ /**
295
+ * Detect the state of a system service by name.
296
+ *
297
+ * @param serviceName - The service name (e.g., 'jeeves-runner').
298
+ * @returns The detected service state.
299
+ */
300
+ function getServiceState(serviceName) {
301
+ switch (process.platform) {
302
+ case 'win32':
303
+ return getServiceStateWindows(serviceName);
304
+ case 'darwin':
305
+ return getServiceStateMacOS(serviceName);
306
+ default:
307
+ return getServiceStateLinux(serviceName);
308
+ }
309
+ }
310
+ /**
311
+ * Windows: detect via NSSM.
312
+ * - Exit code 3 = service does not exist
313
+ * - "SERVICE_RUNNING" in output = running
314
+ * - Other output = stopped/paused
315
+ */
316
+ function getServiceStateWindows(serviceName) {
317
+ try {
318
+ const output = execSync(`nssm status ${serviceName}`, {
319
+ encoding: 'utf-8',
320
+ timeout: 5000,
321
+ stdio: ['pipe', 'pipe', 'pipe'],
322
+ }).trim();
323
+ if (output.includes('SERVICE_RUNNING'))
324
+ return 'running';
325
+ return 'stopped';
326
+ }
327
+ catch (err) {
328
+ // NSSM exits with code 3 when the service doesn't exist
329
+ if (isExecError(err) && err.status === 3)
330
+ return 'not_installed';
331
+ // Any other error (nssm not found, timeout, etc.) — treat as not installed
332
+ return 'not_installed';
333
+ }
334
+ }
335
+ /**
336
+ * Linux: detect via systemd user services.
337
+ * - `systemctl --user is-enabled {name}.service` exits non-zero = not installed
338
+ * - `systemctl --user is-active {name}.service` returns "active" = running
339
+ */
340
+ function getServiceStateLinux(serviceName) {
341
+ try {
342
+ execSync(`systemctl --user is-enabled ${serviceName}.service`, {
343
+ encoding: 'utf-8',
344
+ timeout: 5000,
345
+ stdio: ['pipe', 'pipe', 'pipe'],
346
+ });
347
+ }
348
+ catch {
349
+ return 'not_installed';
350
+ }
351
+ try {
352
+ const output = execSync(`systemctl --user is-active ${serviceName}.service`, {
353
+ encoding: 'utf-8',
354
+ timeout: 5000,
355
+ stdio: ['pipe', 'pipe', 'pipe'],
356
+ }).trim();
357
+ if (output === 'active')
358
+ return 'running';
359
+ return 'stopped';
360
+ }
361
+ catch {
362
+ return 'stopped';
363
+ }
364
+ }
365
+ /**
366
+ * macOS: detect via launchctl.
367
+ * - `launchctl list {name}` exits non-zero = not installed
368
+ * - PID column is `-` or `0` = stopped
369
+ */
370
+ function getServiceStateMacOS(serviceName) {
371
+ try {
372
+ const output = execSync(`launchctl list ${serviceName}`, {
373
+ encoding: 'utf-8',
374
+ timeout: 5000,
375
+ stdio: ['pipe', 'pipe', 'pipe'],
376
+ }).trim();
377
+ // launchctl list output formats:
378
+ // 1. Table row: "PID\tStatus\tLabel" (e.g., "1234\t0\tcom.jeeves.runner")
379
+ // 2. Plist-style: '"PID" = 1234;'
380
+ // 3. Single service: first token is the PID or "-"
381
+ // Try table format: first token is PID
382
+ const tableMatch = /^(\d+|-)\s/m.exec(output);
383
+ if (tableMatch) {
384
+ const pid = tableMatch[1];
385
+ return pid !== '-' && Number(pid) > 0 ? 'running' : 'stopped';
386
+ }
387
+ // Try plist-style: "PID" = <number>;
388
+ const plistMatch = /"PID"\s*=\s*(\d+)/m.exec(output);
389
+ if (plistMatch) {
390
+ return Number(plistMatch[1]) > 0 ? 'running' : 'stopped';
391
+ }
392
+ // If we got output but can't parse it, assume stopped (service exists but state unclear)
393
+ return 'stopped';
394
+ }
395
+ catch {
396
+ return 'not_installed';
397
+ }
398
+ }
399
+ /** Type guard for execSync errors with a status code. */
400
+ function isExecError(err) {
401
+ return (typeof err === 'object' &&
402
+ err !== null &&
403
+ 'status' in err &&
404
+ typeof err.status === 'number');
405
+ }
406
+
407
+ /**
408
+ * Factory for platform-aware service lifecycle management.
409
+ *
410
+ * @remarks
411
+ * Produces a `ServiceManager` that handles install, uninstall, start,
412
+ * stop, restart, and status for system services. Delegates to NSSM
413
+ * (Windows), systemd (Linux), or launchd (macOS) based on platform.
414
+ */
415
+ /** Exec helper that returns stdout. */
416
+ function run(cmd) {
417
+ return execSync(cmd, {
418
+ encoding: 'utf-8',
419
+ timeout: 30_000,
420
+ stdio: ['pipe', 'pipe', 'pipe'],
421
+ }).trim();
422
+ }
423
+ /** Exec helper that suppresses errors and returns success boolean. */
424
+ function runQuiet(cmd) {
425
+ try {
426
+ execSync(cmd, {
427
+ encoding: 'utf-8',
428
+ timeout: 30_000,
429
+ stdio: ['pipe', 'pipe', 'pipe'],
430
+ });
431
+ return true;
432
+ }
433
+ catch {
434
+ return false;
435
+ }
436
+ }
437
+ /**
438
+ * Resolve the effective service name from options and descriptor.
439
+ *
440
+ * @param descriptor - Component descriptor.
441
+ * @param options - Optional overrides.
442
+ * @returns The service name to use.
443
+ */
444
+ function resolveServiceName(descriptor, options) {
445
+ return options?.name ?? getEffectiveServiceName(descriptor);
446
+ }
447
+ /**
448
+ * Resolve the config path for install.
449
+ *
450
+ * @param descriptor - Component descriptor.
451
+ * @param options - Optional overrides.
452
+ * @returns Absolute config file path.
453
+ */
454
+ function resolveConfigFilePath(descriptor, options) {
455
+ if (options?.configPath)
456
+ return options.configPath;
457
+ const configDir = getComponentConfigDir(descriptor.name);
458
+ return join(configDir, descriptor.configFileName);
459
+ }
460
+ /** Build a Windows NSSM service manager. */
461
+ function createWindowsManager(descriptor) {
462
+ return {
463
+ install(options) {
464
+ const svcName = resolveServiceName(descriptor, options);
465
+ const cfgPath = resolveConfigFilePath(descriptor, options);
466
+ const cmdArgs = descriptor.startCommand(cfgPath);
467
+ const appPath = cmdArgs[0];
468
+ const appArgs = cmdArgs.slice(1).join(' ');
469
+ run(`nssm install ${svcName} ${appPath}`);
470
+ if (appArgs) {
471
+ run(`nssm set ${svcName} AppParameters ${appArgs}`);
472
+ }
473
+ run(`nssm set ${svcName} AppStdout ${join(homedir(), `${svcName}.log`)}`);
474
+ run(`nssm set ${svcName} AppStderr ${join(homedir(), `${svcName}.log`)}`);
475
+ run(`nssm set ${svcName} AppRotateFiles 1`);
476
+ run(`nssm set ${svcName} AppRotateBytes 1048576`);
477
+ },
478
+ uninstall(options) {
479
+ const svcName = resolveServiceName(descriptor, options);
480
+ runQuiet(`nssm stop ${svcName}`);
481
+ run(`nssm remove ${svcName} confirm`);
482
+ },
483
+ start(options) {
484
+ const svcName = resolveServiceName(descriptor, options);
485
+ run(`nssm start ${svcName}`);
486
+ },
487
+ stop(options) {
488
+ const svcName = resolveServiceName(descriptor, options);
489
+ run(`nssm stop ${svcName}`);
490
+ },
491
+ restart(options) {
492
+ const svcName = resolveServiceName(descriptor, options);
493
+ run(`nssm restart ${svcName}`);
494
+ },
495
+ status(options) {
496
+ const svcName = resolveServiceName(descriptor, options);
497
+ return getServiceState(svcName);
498
+ },
499
+ };
500
+ }
501
+ /**
502
+ * Generate a systemd user unit file.
503
+ *
504
+ * @param svcName - Service name.
505
+ * @param cmdArgs - Command + args array.
506
+ * @returns Unit file content.
507
+ */
508
+ function buildSystemdUnit(svcName, cmdArgs) {
509
+ const execStart = cmdArgs.join(' ');
510
+ return [
511
+ '[Unit]',
512
+ `Description=${svcName}`,
513
+ 'After=network.target',
514
+ '',
515
+ '[Service]',
516
+ 'Type=simple',
517
+ `ExecStart=${execStart}`,
518
+ 'Restart=on-failure',
519
+ 'RestartSec=5',
520
+ '',
521
+ '[Install]',
522
+ 'WantedBy=default.target',
523
+ ].join('\n');
524
+ }
525
+ /** Build a Linux systemd service manager. */
526
+ function createLinuxManager(descriptor) {
527
+ const unitDir = join(homedir(), '.config', 'systemd', 'user');
528
+ function unitPath(svcName) {
529
+ return join(unitDir, `${svcName}.service`);
530
+ }
531
+ return {
532
+ install(options) {
533
+ const svcName = resolveServiceName(descriptor, options);
534
+ const cfgPath = resolveConfigFilePath(descriptor, options);
535
+ const cmdArgs = descriptor.startCommand(cfgPath);
536
+ mkdirSync(unitDir, { recursive: true });
537
+ writeFileSync(unitPath(svcName), buildSystemdUnit(svcName, cmdArgs));
538
+ run('systemctl --user daemon-reload');
539
+ run(`systemctl --user enable ${svcName}.service`);
540
+ },
541
+ uninstall(options) {
542
+ const svcName = resolveServiceName(descriptor, options);
543
+ runQuiet(`systemctl --user stop ${svcName}.service`);
544
+ runQuiet(`systemctl --user disable ${svcName}.service`);
545
+ const path = unitPath(svcName);
546
+ if (existsSync(path))
547
+ unlinkSync(path);
548
+ runQuiet('systemctl --user daemon-reload');
549
+ },
550
+ start(options) {
551
+ const svcName = resolveServiceName(descriptor, options);
552
+ run(`systemctl --user start ${svcName}.service`);
553
+ },
554
+ stop(options) {
555
+ const svcName = resolveServiceName(descriptor, options);
556
+ run(`systemctl --user stop ${svcName}.service`);
557
+ },
558
+ restart(options) {
559
+ const svcName = resolveServiceName(descriptor, options);
560
+ run(`systemctl --user restart ${svcName}.service`);
561
+ },
562
+ status(options) {
563
+ const svcName = resolveServiceName(descriptor, options);
564
+ return getServiceState(svcName);
565
+ },
566
+ };
567
+ }
568
+ /**
569
+ * Generate a macOS launchd plist.
570
+ *
571
+ * @param svcName - Service label.
572
+ * @param cmdArgs - Command + args array.
573
+ * @returns Plist XML content.
574
+ */
575
+ function buildLaunchdPlist(svcName, cmdArgs) {
576
+ const argsXml = cmdArgs.map((a) => ` <string>${a}</string>`).join('\n');
577
+ return [
578
+ '<?xml version="1.0" encoding="UTF-8"?>',
579
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"',
580
+ ' "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
581
+ '<plist version="1.0">',
582
+ '<dict>',
583
+ ' <key>Label</key>',
584
+ ` <string>${svcName}</string>`,
585
+ ' <key>ProgramArguments</key>',
586
+ ' <array>',
587
+ argsXml,
588
+ ' </array>',
589
+ ' <key>RunAtLoad</key>',
590
+ ' <true/>',
591
+ ' <key>KeepAlive</key>',
592
+ ' <true/>',
593
+ ' <key>StandardOutPath</key>',
594
+ ` <string>${join(homedir(), 'Library', 'Logs', `${svcName}.log`)}</string>`,
595
+ ' <key>StandardErrorPath</key>',
596
+ ` <string>${join(homedir(), 'Library', 'Logs', `${svcName}.log`)}</string>`,
597
+ '</dict>',
598
+ '</plist>',
599
+ ].join('\n');
600
+ }
601
+ /** Build a macOS launchd service manager. */
602
+ function createMacOSManager(descriptor) {
603
+ const agentsDir = join(homedir(), 'Library', 'LaunchAgents');
604
+ function plistPath(svcName) {
605
+ return join(agentsDir, `${svcName}.plist`);
606
+ }
607
+ return {
608
+ install(options) {
609
+ const svcName = resolveServiceName(descriptor, options);
610
+ const cfgPath = resolveConfigFilePath(descriptor, options);
611
+ const cmdArgs = descriptor.startCommand(cfgPath);
612
+ mkdirSync(agentsDir, { recursive: true });
613
+ writeFileSync(plistPath(svcName), buildLaunchdPlist(svcName, cmdArgs));
614
+ },
615
+ uninstall(options) {
616
+ const svcName = resolveServiceName(descriptor, options);
617
+ runQuiet(`launchctl unload ${plistPath(svcName)}`);
618
+ const path = plistPath(svcName);
619
+ if (existsSync(path))
620
+ unlinkSync(path);
621
+ },
622
+ start(options) {
623
+ const svcName = resolveServiceName(descriptor, options);
624
+ run(`launchctl load ${plistPath(svcName)}`);
625
+ },
626
+ stop(options) {
627
+ const svcName = resolveServiceName(descriptor, options);
628
+ run(`launchctl unload ${plistPath(svcName)}`);
629
+ },
630
+ restart(options) {
631
+ const svcName = resolveServiceName(descriptor, options);
632
+ runQuiet(`launchctl unload ${plistPath(svcName)}`);
633
+ run(`launchctl load ${plistPath(svcName)}`);
634
+ },
635
+ status(options) {
636
+ const svcName = resolveServiceName(descriptor, options);
637
+ return getServiceState(svcName);
638
+ },
639
+ };
640
+ }
641
+ /**
642
+ * Create a platform-aware service manager from a component descriptor.
643
+ *
644
+ * @remarks
645
+ * Detects the current platform and returns a `ServiceManager` that
646
+ * delegates to NSSM (Windows), systemd (Linux), or launchd (macOS).
647
+ *
648
+ * @param descriptor - The component descriptor.
649
+ * @returns A `ServiceManager` for the current platform.
650
+ */
651
+ function createServiceManager(descriptor) {
652
+ switch (process.platform) {
653
+ case 'win32':
654
+ return createWindowsManager(descriptor);
655
+ case 'darwin':
656
+ return createMacOSManager(descriptor);
657
+ default:
658
+ return createLinuxManager(descriptor);
659
+ }
660
+ }
661
+
662
+ /**
663
+ * Factory for the standard Jeeves service CLI.
664
+ *
665
+ * @remarks
666
+ * Produces a Commander program with all standard commands from
667
+ * a component descriptor. Components add domain-specific commands
668
+ * via `descriptor.customCliCommands`.
669
+ */
670
+ /**
671
+ * Create a standard service CLI program from a component descriptor.
672
+ *
673
+ * @remarks
674
+ * Standard commands:
675
+ * - `start -c <path>` - Launch the service process (foreground)
676
+ * - `status [-p port]` - Probe service health
677
+ * - `config [jsonpath] [-p port]` - Query running config
678
+ * - `config validate -c <path>` - Validate a config file
679
+ * - `config apply [-p port] [--file path] [--replace]` - Apply config patch
680
+ * - `init [-o path]` - Generate default config
681
+ * - `service install` - Install system service
682
+ * - `service uninstall` - Uninstall system service
683
+ * - `service start` - Start system service
684
+ * - `service stop` - Stop system service
685
+ * - `service restart` - Restart system service
686
+ * - `service status` - Query system service state
687
+ *
688
+ * @param descriptor - The component descriptor.
689
+ * @returns A Commander program ready for custom commands and `.parse()`.
690
+ */
691
+ function createServiceCli(descriptor) {
692
+ const defaultServiceName = getEffectiveServiceName(descriptor);
693
+ const program = new Command()
694
+ .name(`jeeves-${descriptor.name}`)
695
+ .description(`Jeeves ${descriptor.name} service CLI`)
696
+ .version(descriptor.version)
697
+ .enablePositionalOptions()
698
+ .passThroughOptions();
699
+ // --- start ---
700
+ program
701
+ .command('start')
702
+ .description('Launch the service process (foreground)')
703
+ .requiredOption('-c, --config <path>', 'Config file path')
704
+ .action((opts) => {
705
+ const cmdArgs = descriptor.startCommand(opts.config);
706
+ const proc = spawn(cmdArgs[0], cmdArgs.slice(1), {
707
+ stdio: 'inherit',
708
+ });
709
+ proc.on('exit', (code) => {
710
+ process.exit(code ?? 1);
711
+ });
712
+ });
713
+ // --- status ---
714
+ program
715
+ .command('status')
716
+ .description('Probe service health and version')
717
+ .option('-p, --port <port>', 'Service port', String(descriptor.defaultPort))
718
+ .action(async (opts) => {
719
+ const url = `http://127.0.0.1:${opts.port}`;
720
+ try {
721
+ const result = await fetchJson(`${url}/status`);
722
+ console.log(JSON.stringify(result, null, 2));
723
+ }
724
+ catch (err) {
725
+ const msg = err instanceof Error ? err.message : String(err);
726
+ console.error(`Service unreachable: ${msg}`);
727
+ process.exitCode = 1;
728
+ }
729
+ });
730
+ // --- config ---
731
+ const configCmd = program
732
+ .command('config')
733
+ .description('Query or manage service configuration');
734
+ configCmd
735
+ .command('query')
736
+ .description('Query running service config via JSONPath')
737
+ .argument('[jsonpath]', 'JSONPath expression')
738
+ .option('-p, --port <port>', 'Service port', String(descriptor.defaultPort))
739
+ .action(async (jsonpath, opts) => {
740
+ const url = `http://127.0.0.1:${opts.port}`;
741
+ const qs = jsonpath ? `?path=${encodeURIComponent(jsonpath)}` : '';
742
+ try {
743
+ const result = await fetchJson(`${url}/config${qs}`);
744
+ console.log(JSON.stringify(result, null, 2));
745
+ }
746
+ catch (err) {
747
+ const msg = err instanceof Error ? err.message : String(err);
748
+ console.error(`Config query failed: ${msg}`);
749
+ process.exitCode = 1;
750
+ }
751
+ });
752
+ configCmd
753
+ .command('validate')
754
+ .description('Validate a config file against the schema')
755
+ .requiredOption('-c, --config <path>', 'Config file path')
756
+ .action((opts) => {
757
+ try {
758
+ const raw = readFileSync(opts.config, 'utf-8');
759
+ const parsed = JSON.parse(raw);
760
+ descriptor.configSchema.parse(parsed);
761
+ console.log('Config is valid.');
762
+ }
763
+ catch (err) {
764
+ const msg = err instanceof Error ? err.message : String(err);
765
+ console.error(`Validation failed: ${msg}`);
766
+ process.exitCode = 1;
767
+ }
768
+ });
769
+ configCmd
770
+ .command('apply')
771
+ .description('Apply a config patch to the running service')
772
+ .option('-p, --port <port>', 'Service port', String(descriptor.defaultPort))
773
+ .option('-f, --file <path>', 'Config patch file (JSON)')
774
+ .option('--replace', 'Replace entire config instead of merging')
775
+ .action(async (opts) => {
776
+ const url = `http://127.0.0.1:${opts.port}`;
777
+ let patch = {};
778
+ if (opts.file) {
779
+ const raw = readFileSync(opts.file, 'utf-8');
780
+ patch = JSON.parse(raw);
781
+ }
782
+ else {
783
+ // Read from stdin
784
+ const chunks = [];
785
+ for await (const chunk of process.stdin) {
786
+ chunks.push(chunk);
787
+ }
788
+ const input = Buffer.concat(chunks).toString('utf-8').trim();
789
+ if (input) {
790
+ patch = JSON.parse(input);
791
+ }
792
+ }
793
+ const qs = opts.replace ? '?replace=true' : '';
794
+ try {
795
+ const result = await postJson(`${url}/config/apply${qs}`, patch);
796
+ console.log(JSON.stringify(result, null, 2));
797
+ }
798
+ catch (err) {
799
+ const msg = err instanceof Error ? err.message : String(err);
800
+ console.error(`Config apply failed: ${msg}`);
801
+ process.exitCode = 1;
802
+ }
803
+ });
804
+ // --- init ---
805
+ program
806
+ .command('init')
807
+ .description('Generate default config file')
808
+ .option('-o, --output <path>', 'Output directory')
809
+ .action((opts) => {
810
+ const outputDir = opts.output ?? getComponentConfigDir(descriptor.name);
811
+ mkdirSync(outputDir, { recursive: true });
812
+ const configPath = join(outputDir, descriptor.configFileName);
813
+ if (existsSync(configPath)) {
814
+ console.log(`Config already exists at ${configPath}`);
815
+ return;
816
+ }
817
+ const template = descriptor.initTemplate();
818
+ writeFileSync(configPath, JSON.stringify(template, null, 2) + '\n');
819
+ console.log(`Config written to ${configPath}`);
820
+ });
821
+ // --- service ---
822
+ const serviceCmd = program
823
+ .command('service')
824
+ .description('System service management');
825
+ const svcManager = createServiceManager(descriptor);
826
+ serviceCmd
827
+ .command('install')
828
+ .description('Install as a system service')
829
+ .option('-c, --config <path>', 'Config file path')
830
+ .option('-n, --name <name>', 'Service name', defaultServiceName)
831
+ .action((opts) => {
832
+ try {
833
+ svcManager.install({ name: opts.name, configPath: opts.config });
834
+ console.log(`Service "${opts.name}" installed.`);
835
+ }
836
+ catch (err) {
837
+ const msg = err instanceof Error ? err.message : String(err);
838
+ console.error(`Install failed: ${msg}`);
839
+ process.exitCode = 1;
840
+ }
841
+ });
842
+ serviceCmd
843
+ .command('uninstall')
844
+ .description('Uninstall the system service')
845
+ .option('-n, --name <name>', 'Service name', defaultServiceName)
846
+ .action((opts) => {
847
+ try {
848
+ svcManager.uninstall({ name: opts.name });
849
+ console.log(`Service "${opts.name}" uninstalled.`);
850
+ }
851
+ catch (err) {
852
+ const msg = err instanceof Error ? err.message : String(err);
853
+ console.error(`Uninstall failed: ${msg}`);
854
+ process.exitCode = 1;
855
+ }
856
+ });
857
+ serviceCmd
858
+ .command('start')
859
+ .description('Start the system service')
860
+ .option('-n, --name <name>', 'Service name', defaultServiceName)
861
+ .action((opts) => {
862
+ try {
863
+ svcManager.start({ name: opts.name });
864
+ console.log(`Service "${opts.name}" started.`);
865
+ }
866
+ catch (err) {
867
+ const msg = err instanceof Error ? err.message : String(err);
868
+ console.error(`Start failed: ${msg}`);
869
+ process.exitCode = 1;
870
+ }
871
+ });
872
+ serviceCmd
873
+ .command('stop')
874
+ .description('Stop the system service')
875
+ .option('-n, --name <name>', 'Service name', defaultServiceName)
876
+ .action((opts) => {
877
+ try {
878
+ svcManager.stop({ name: opts.name });
879
+ console.log(`Service "${opts.name}" stopped.`);
880
+ }
881
+ catch (err) {
882
+ const msg = err instanceof Error ? err.message : String(err);
883
+ console.error(`Stop failed: ${msg}`);
884
+ process.exitCode = 1;
885
+ }
886
+ });
887
+ serviceCmd
888
+ .command('restart')
889
+ .description('Restart the system service')
890
+ .option('-n, --name <name>', 'Service name', defaultServiceName)
891
+ .action((opts) => {
892
+ try {
893
+ svcManager.restart({ name: opts.name });
894
+ console.log(`Service "${opts.name}" restarted.`);
895
+ }
896
+ catch (err) {
897
+ const msg = err instanceof Error ? err.message : String(err);
898
+ console.error(`Restart failed: ${msg}`);
899
+ process.exitCode = 1;
900
+ }
901
+ });
902
+ serviceCmd
903
+ .command('status')
904
+ .description('Query system service state')
905
+ .option('-n, --name <name>', 'Service name', defaultServiceName)
906
+ .action((opts) => {
907
+ try {
908
+ const state = svcManager.status({ name: opts.name });
909
+ console.log(`Service "${opts.name}": ${state}`);
910
+ }
911
+ catch (err) {
912
+ const msg = err instanceof Error ? err.message : String(err);
913
+ console.error(`Status failed: ${msg}`);
914
+ process.exitCode = 1;
915
+ }
916
+ });
917
+ // Apply custom CLI commands if provided
918
+ if (descriptor.customCliCommands) {
919
+ // Cast required: @commander-js/extra-typings Command has generic type
920
+ // parameters that don't align with the descriptor's base Command type.
921
+ // The descriptor can't know the parent Command's exact generic parameters
922
+ // at definition time. The cast is safe — customCliCommands only adds
923
+ // subcommands and doesn't depend on the parent's generic state.
924
+ descriptor.customCliCommands(program);
925
+ }
926
+ return program;
927
+ }
928
+
929
+ export { createServiceCli };