@karmaniverous/jeeves 0.3.1 → 0.4.1

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