@karmaniverous/jeeves 0.6.0-6 → 0.6.0-7

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/README.md CHANGED
@@ -201,6 +201,13 @@ Pre-defined marker sets: `SOUL_MARKERS`, `AGENTS_MARKERS`, and `LEGACY_TOOLS_MAR
201
201
 
202
202
  - **`getServiceUrl(serviceName, consumerName?)`** — resolves a service URL via: consumer config → core config → default port constants.
203
203
 
204
+ ## Service Management
205
+
206
+ `createServiceManager(descriptor)` backs each component's `service` CLI and `{name}_service` tool (install, uninstall, start, stop, restart, status). It uses NSSM on Windows and a launchd agent on macOS. On Linux it first looks for a system unit named `<service>.service` (for example `/etc/systemd/system/jeeves-watcher.service`, the way jeeves-tools provisions managed instances):
207
+
208
+ - **System unit exists:** `install` changes nothing and reports the unit (`{ existing: true, message }`). `status` reads the system unit (`systemctl is-active`). `start`, `stop` and `restart` run `sudo -n systemctl <verb> <unit>` (no sudo as root), which needs a passwordless sudoers rule for `/usr/bin/systemctl <verb> *`. When sudo refuses, the error names that rule. `uninstall` refuses, because core never removes, reloads or enables a system unit, and never creates a user unit next to one.
209
+ - **No system unit:** core manages a user unit in `~/.config/systemd/user` with `systemctl --user`. First it checks that a user bus exists. When `XDG_RUNTIME_DIR` is unset, or `systemctl --user` cannot connect, the error gives the cause and both fixes: provision a system unit, or run `sudo loginctl enable-linger <user>`. You don't get the raw "Failed to connect to bus" error.
210
+
204
211
  ## Prerequisites
205
212
 
206
213
  - **Node.js >= 22** — the CLI enforces this at startup.
@@ -140,14 +140,14 @@ const DEFAULT_PORTS = {
140
140
  * Core library version, inlined at build time.
141
141
  *
142
142
  * @remarks
143
- * The `0.6.0-5` placeholder is replaced by
143
+ * The `0.6.0-7` placeholder is replaced by
144
144
  * `@rollup/plugin-replace` during the build with the actual version
145
145
  * from `package.json`. This ensures the correct version survives
146
146
  * when consumers bundle core into their own dist (where runtime
147
147
  * `import.meta.url`-based resolution would find the wrong package.json).
148
148
  */
149
149
  /** The core library version from package.json (inlined at build time). */
150
- const CORE_VERSION = '0.6.0-5';
150
+ const CORE_VERSION = '0.6.0-7';
151
151
 
152
152
  /**
153
153
  * Shared internal utility functions.
@@ -215,12 +215,130 @@ async function postJson(url, body) {
215
215
  });
216
216
  }
217
217
 
218
+ /**
219
+ * Linux systemd unit detection and state queries.
220
+ *
221
+ * @remarks
222
+ * A service can be provisioned either as a system unit
223
+ * (`/etc/systemd/system/<name>.service`, as jeeves-tools does on managed
224
+ * instances) or as a user unit (`~/.config/systemd/user`, as
225
+ * `createServiceManager` does elsewhere). An existing system unit always
226
+ * wins: status reads it and core never creates a competing user unit.
227
+ *
228
+ * Every query here is unprivileged. Commands run through an injectable
229
+ * `CommandExec` so the logic can be unit tested without systemd.
230
+ */
231
+ /** Default `CommandExec`: `execSync` with piped stdio and a timeout. */
232
+ const defaultExec = (cmd) => execSync(cmd, {
233
+ encoding: 'utf-8',
234
+ timeout: 30_000,
235
+ stdio: ['pipe', 'pipe', 'pipe'],
236
+ }).trim();
237
+ /**
238
+ * Extract the most useful text from a failed command.
239
+ *
240
+ * @param err - Error thrown by a `CommandExec`.
241
+ * @returns The command's stderr when present, else the error message.
242
+ */
243
+ function execErrorDetail(err) {
244
+ if (typeof err === 'object' && err !== null) {
245
+ if ('stderr' in err) {
246
+ const stderr = String(err.stderr).trim();
247
+ if (stderr)
248
+ return stderr;
249
+ }
250
+ if (err instanceof Error)
251
+ return err.message;
252
+ }
253
+ return String(err);
254
+ }
255
+ /**
256
+ * The systemd unit name for a service.
257
+ *
258
+ * @param serviceName - Service name, e.g. `jeeves-watcher`.
259
+ * @returns The unit name, e.g. `jeeves-watcher.service`.
260
+ */
261
+ function systemdUnitName(serviceName) {
262
+ return `${serviceName}.service`;
263
+ }
264
+ /**
265
+ * LoadStates meaning a system unit exists. A masked unit still counts: an
266
+ * administrator disabled it, and a user unit must not replace it.
267
+ */
268
+ const SYSTEM_UNIT_PRESENT = new Set(['loaded', 'masked']);
269
+ /**
270
+ * Whether a system-level systemd unit exists for the service.
271
+ *
272
+ * @remarks
273
+ * Queries the system manager (no `--user`, no sudo):
274
+ * `systemctl show <unit> --property=LoadState --value` prints `loaded`
275
+ * (or `masked`) for an existing unit and `not-found` otherwise. Any failure (no
276
+ * systemd, no system bus) means no usable system unit.
277
+ *
278
+ * @param serviceName - Service name.
279
+ * @param exec - Command runner.
280
+ * @returns True when a system unit is loaded.
281
+ */
282
+ function hasSystemUnit(serviceName, exec = defaultExec) {
283
+ try {
284
+ const loadState = exec(`systemctl show ${systemdUnitName(serviceName)} --property=LoadState --value`);
285
+ return SYSTEM_UNIT_PRESENT.has(loadState.trim());
286
+ }
287
+ catch {
288
+ return false;
289
+ }
290
+ }
291
+ /**
292
+ * Read a unit's active state and map it to a `ServiceState`.
293
+ *
294
+ * @param scopeFlag - `''` for the system manager, `'--user '` for the user manager.
295
+ * @param unit - Unit name.
296
+ * @param exec - Command runner.
297
+ * @returns `running` when active, else `stopped`.
298
+ */
299
+ function activeState(scopeFlag, unit, exec) {
300
+ try {
301
+ return exec(`systemctl ${scopeFlag}is-active ${unit}`) === 'active'
302
+ ? 'running'
303
+ : 'stopped';
304
+ }
305
+ catch {
306
+ // is-active exits non-zero for inactive/failed units.
307
+ return 'stopped';
308
+ }
309
+ }
310
+ /**
311
+ * Detect the state of a Linux systemd service.
312
+ *
313
+ * @remarks
314
+ * System unit first (`systemctl is-active <unit>`), then the user unit
315
+ * (`systemctl --user is-enabled` / `is-active`).
316
+ *
317
+ * @param serviceName - Service name.
318
+ * @param exec - Command runner.
319
+ * @returns The detected service state.
320
+ */
321
+ function getSystemdServiceState(serviceName, exec = defaultExec) {
322
+ const unit = systemdUnitName(serviceName);
323
+ if (hasSystemUnit(serviceName, exec))
324
+ return activeState('', unit, exec);
325
+ try {
326
+ exec(`systemctl --user is-enabled ${unit}`);
327
+ }
328
+ catch {
329
+ return 'not_installed';
330
+ }
331
+ return activeState('--user ', unit, exec);
332
+ }
333
+
218
334
  /**
219
335
  * Platform-aware service state detection.
220
336
  *
221
337
  * @remarks
222
338
  * Detects whether a system service is installed and running.
223
339
  * Delegates to NSSM (Windows), systemd (Linux), or launchd (macOS).
340
+ * On Linux an existing system unit takes precedence over a user unit
341
+ * (see `getSystemdServiceState`).
224
342
  */
225
343
  /**
226
344
  * Detect the state of a system service by name.
@@ -235,7 +353,7 @@ function getServiceState(serviceName) {
235
353
  case 'darwin':
236
354
  return getServiceStateMacOS(serviceName);
237
355
  default:
238
- return getServiceStateLinux(serviceName);
356
+ return getSystemdServiceState(serviceName);
239
357
  }
240
358
  }
241
359
  /**
@@ -263,36 +381,6 @@ function getServiceStateWindows(serviceName) {
263
381
  return 'not_installed';
264
382
  }
265
383
  }
266
- /**
267
- * Linux: detect via systemd user services.
268
- * - `systemctl --user is-enabled {name}.service` exits non-zero = not installed
269
- * - `systemctl --user is-active {name}.service` returns "active" = running
270
- */
271
- function getServiceStateLinux(serviceName) {
272
- try {
273
- execSync(`systemctl --user is-enabled ${serviceName}.service`, {
274
- encoding: 'utf-8',
275
- timeout: 5000,
276
- stdio: ['pipe', 'pipe', 'pipe'],
277
- });
278
- }
279
- catch {
280
- return 'not_installed';
281
- }
282
- try {
283
- const output = execSync(`systemctl --user is-active ${serviceName}.service`, {
284
- encoding: 'utf-8',
285
- timeout: 5000,
286
- stdio: ['pipe', 'pipe', 'pipe'],
287
- }).trim();
288
- if (output === 'active')
289
- return 'running';
290
- return 'stopped';
291
- }
292
- catch {
293
- return 'stopped';
294
- }
295
- }
296
384
  /**
297
385
  * macOS: detect via launchctl.
298
386
  * - `launchctl list {name}` exits non-zero = not installed
@@ -336,35 +424,12 @@ function isExecError(err) {
336
424
  }
337
425
 
338
426
  /**
339
- * Factory for platform-aware service lifecycle management.
427
+ * Service manager contract and shared option resolution.
340
428
  *
341
429
  * @remarks
342
- * Produces a `ServiceManager` that handles install, uninstall, start,
343
- * stop, restart, and status for system services. Delegates to NSSM
344
- * (Windows), systemd (Linux), or launchd (macOS) based on platform.
430
+ * Shared by the per-platform managers built in `createServiceManager`
431
+ * and `createLinuxManager`.
345
432
  */
346
- /** Exec helper that returns stdout. */
347
- function run(cmd) {
348
- return execSync(cmd, {
349
- encoding: 'utf-8',
350
- timeout: 30_000,
351
- stdio: ['pipe', 'pipe', 'pipe'],
352
- }).trim();
353
- }
354
- /** Exec helper that suppresses errors and returns success boolean. */
355
- function runQuiet(cmd) {
356
- try {
357
- execSync(cmd, {
358
- encoding: 'utf-8',
359
- timeout: 30_000,
360
- stdio: ['pipe', 'pipe', 'pipe'],
361
- });
362
- return true;
363
- }
364
- catch {
365
- return false;
366
- }
367
- }
368
433
  /**
369
434
  * Resolve the effective service name from options and descriptor.
370
435
  *
@@ -388,47 +453,101 @@ function resolveConfigFilePath(descriptor, options) {
388
453
  const configDir = getComponentConfigDir(descriptor.name);
389
454
  return join(configDir, descriptor.configFileName);
390
455
  }
391
- /** Build a Windows NSSM service manager. */
392
- function createWindowsManager(descriptor) {
393
- return {
394
- install(options) {
395
- const svcName = resolveServiceName(descriptor, options);
396
- const cfgPath = resolveConfigFilePath(descriptor, options);
397
- const cmdArgs = descriptor.startCommand(cfgPath);
398
- const appPath = cmdArgs[0];
399
- const appArgs = cmdArgs.slice(1).join(' ');
400
- run(`nssm install ${svcName} ${appPath}`);
401
- if (appArgs) {
402
- run(`nssm set ${svcName} AppParameters ${appArgs}`);
403
- }
404
- run(`nssm set ${svcName} AppStdout ${join(homedir(), `${svcName}.log`)}`);
405
- run(`nssm set ${svcName} AppStderr ${join(homedir(), `${svcName}.log`)}`);
406
- run(`nssm set ${svcName} AppRotateFiles 1`);
407
- run(`nssm set ${svcName} AppRotateBytes 1048576`);
408
- },
409
- uninstall(options) {
410
- const svcName = resolveServiceName(descriptor, options);
411
- runQuiet(`nssm stop ${svcName}`);
412
- run(`nssm remove ${svcName} confirm`);
413
- },
414
- start(options) {
415
- const svcName = resolveServiceName(descriptor, options);
416
- run(`nssm start ${svcName}`);
417
- },
418
- stop(options) {
419
- const svcName = resolveServiceName(descriptor, options);
420
- run(`nssm stop ${svcName}`);
421
- },
422
- restart(options) {
423
- const svcName = resolveServiceName(descriptor, options);
424
- run(`nssm restart ${svcName}`);
425
- },
426
- status(options) {
427
- const svcName = resolveServiceName(descriptor, options);
428
- return getServiceState(svcName);
429
- },
430
- };
456
+ /**
457
+ * The default install result for a freshly installed service.
458
+ *
459
+ * @param svcName - Service name.
460
+ * @returns A non-existing install result.
461
+ */
462
+ function installedResult(svcName) {
463
+ return { existing: false, message: `Service "${svcName}" installed.` };
464
+ }
465
+
466
+ /**
467
+ * Guarded access to the systemd user and system managers.
468
+ *
469
+ * @remarks
470
+ * - User scope: fail early with an actionable message when there is no user
471
+ * bus (typical for `useradd --system` accounts without linger), instead of
472
+ * surfacing systemctl's raw "Failed to connect to bus" error.
473
+ * - System scope: run only `systemctl start|stop|restart <unit>` through
474
+ * non-interactive sudo, which is exactly what the jeeves-tools sudoers rule
475
+ * for the `jeeves` user allows (`/usr/bin/systemctl stop|start|restart|status *`).
476
+ * Core never writes system units, reloads or enables them.
477
+ */
478
+ /** Output patterns sudo prints when a non-interactive command is refused. */
479
+ const SUDO_REFUSED = /password is required|not allowed to execute|may not run sudo|not in the sudoers/i;
480
+ function userLabel(env) {
481
+ return env.USER ?? env.LOGNAME ?? 'this user';
482
+ }
483
+ /**
484
+ * Ensure a systemd user bus is reachable before any `systemctl --user` call.
485
+ *
486
+ * @param unit - Unit being managed (for the message).
487
+ * @param deps - Host dependencies.
488
+ * @throws Error with the cause and remedies when no user bus is available.
489
+ */
490
+ function assertUserBus(unit, deps) {
491
+ const user = userLabel(deps.env);
492
+ const noBus = (reason, cause) => new Error([
493
+ `Cannot manage ${unit} as a systemd user unit: ${reason}.`,
494
+ `No system unit named ${unit} exists either.`,
495
+ 'Fix one of:',
496
+ `(1) have an administrator provision ${unit} as a system unit in /etc/systemd/system; it is then detected and managed with "sudo -n systemctl";`,
497
+ `(2) enable a user manager with "sudo loginctl enable-linger ${user}" and retry from a session where XDG_RUNTIME_DIR is set.`,
498
+ ].join(' '), { cause });
499
+ if (!deps.env.XDG_RUNTIME_DIR && !deps.env.DBUS_SESSION_BUS_ADDRESS) {
500
+ throw noBus(`there is no systemd user bus for "${user}" (XDG_RUNTIME_DIR is not set)`);
501
+ }
502
+ try {
503
+ deps.exec('systemctl --user show-environment');
504
+ }
505
+ catch (err) {
506
+ throw noBus(`the systemd user bus for "${user}" is unreachable (${execErrorDetail(err)})`, err);
507
+ }
508
+ }
509
+ /**
510
+ * Run a lifecycle verb against an existing system unit.
511
+ *
512
+ * @remarks
513
+ * Uses `sudo -n systemctl <verb> <unit>` (plain `systemctl` when root).
514
+ * `-n` makes sudo fail instead of prompting for a password.
515
+ *
516
+ * @param verb - `start`, `stop` or `restart`.
517
+ * @param unit - System unit name.
518
+ * @param deps - Host dependencies.
519
+ * @throws Error naming the missing sudoers rule when sudo refuses.
520
+ */
521
+ function runSystemVerb(verb, unit, deps) {
522
+ const root = deps.isRoot();
523
+ const cmd = root
524
+ ? `systemctl ${verb} ${unit}`
525
+ : `sudo -n systemctl ${verb} ${unit}`;
526
+ try {
527
+ deps.exec(cmd);
528
+ }
529
+ catch (err) {
530
+ const detail = execErrorDetail(err);
531
+ if (!root && SUDO_REFUSED.test(detail)) {
532
+ throw new Error(`${unit} is a system unit and "${cmd}" was refused: passwordless sudo for "/usr/bin/systemctl ${verb} *" is not granted to "${userLabel(deps.env)}" (${detail}). Ask an administrator to run "sudo systemctl ${verb} ${unit}" or to grant that sudoers rule.`, { cause: err });
533
+ }
534
+ throw new Error(`"${cmd}" failed: ${detail}`, { cause: err });
535
+ }
431
536
  }
537
+
538
+ /**
539
+ * Linux systemd service manager.
540
+ *
541
+ * @remarks
542
+ * Two scopes:
543
+ * - **System unit present** (e.g. `/etc/systemd/system/jeeves-watcher.service`
544
+ * provisioned by jeeves-tools): install is a no-op that reports the unit,
545
+ * uninstall refuses, status reads the system unit, and start/stop/restart
546
+ * run `sudo -n systemctl <verb> <unit>`. Core never creates a competing
547
+ * user unit.
548
+ * - **Otherwise**: a user unit in `~/.config/systemd/user` managed with
549
+ * `systemctl --user`, after checking that a user bus exists.
550
+ */
432
551
  /**
433
552
  * Generate a systemd user unit file.
434
553
  *
@@ -437,7 +556,6 @@ function createWindowsManager(descriptor) {
437
556
  * @returns Unit file content.
438
557
  */
439
558
  function buildSystemdUnit(svcName, cmdArgs) {
440
- const execStart = cmdArgs.join(' ');
441
559
  return [
442
560
  '[Unit]',
443
561
  `Description=${svcName}`,
@@ -445,7 +563,7 @@ function buildSystemdUnit(svcName, cmdArgs) {
445
563
  '',
446
564
  '[Service]',
447
565
  'Type=simple',
448
- `ExecStart=${execStart}`,
566
+ `ExecStart=${cmdArgs.join(' ')}`,
449
567
  'Restart=on-failure',
450
568
  'RestartSec=5',
451
569
  '',
@@ -453,42 +571,160 @@ function buildSystemdUnit(svcName, cmdArgs) {
453
571
  'WantedBy=default.target',
454
572
  ].join('\n');
455
573
  }
456
- /** Build a Linux systemd service manager. */
457
- function createLinuxManager(descriptor) {
458
- const unitDir = join(homedir(), '.config', 'systemd', 'user');
574
+ /** Default host dependencies. */
575
+ function defaultDeps() {
576
+ return {
577
+ exec: defaultExec,
578
+ env: process.env,
579
+ isRoot: () => process.getuid?.() === 0,
580
+ };
581
+ }
582
+ /**
583
+ * Build a Linux systemd service manager.
584
+ *
585
+ * @param descriptor - Component descriptor.
586
+ * @param deps - Host dependencies (defaults to the real host).
587
+ * @param unitDir - User unit directory (defaults to `~/.config/systemd/user`).
588
+ * @returns A `ServiceManager` for Linux.
589
+ */
590
+ function createLinuxManager(descriptor, deps = defaultDeps(), unitDir = join(homedir(), '.config', 'systemd', 'user')) {
591
+ const exec = deps.exec;
459
592
  function unitPath(svcName) {
460
- return join(unitDir, `${svcName}.service`);
593
+ return join(unitDir, systemdUnitName(svcName));
594
+ }
595
+ /** Run a lifecycle verb in whichever scope owns the unit. */
596
+ function lifecycle(verb, options) {
597
+ const svcName = resolveServiceName(descriptor, options);
598
+ const unit = systemdUnitName(svcName);
599
+ if (hasSystemUnit(svcName, exec)) {
600
+ runSystemVerb(verb, unit, deps);
601
+ return;
602
+ }
603
+ assertUserBus(unit, deps);
604
+ exec(`systemctl --user ${verb} ${unit}`);
461
605
  }
462
606
  return {
463
607
  install(options) {
464
608
  const svcName = resolveServiceName(descriptor, options);
465
- const cfgPath = resolveConfigFilePath(descriptor, options);
466
- const cmdArgs = descriptor.startCommand(cfgPath);
609
+ const unit = systemdUnitName(svcName);
610
+ if (hasSystemUnit(svcName, exec)) {
611
+ return {
612
+ existing: true,
613
+ message: `Service "${svcName}" is already installed as system unit ${unit}; nothing to do. Manage it with start/stop/restart (sudo -n systemctl).`,
614
+ };
615
+ }
616
+ assertUserBus(unit, deps);
617
+ const cmdArgs = descriptor.startCommand(resolveConfigFilePath(descriptor, options));
467
618
  mkdirSync(unitDir, { recursive: true });
468
619
  writeFileSync(unitPath(svcName), buildSystemdUnit(svcName, cmdArgs));
469
- run('systemctl --user daemon-reload');
470
- run(`systemctl --user enable ${svcName}.service`);
620
+ exec('systemctl --user daemon-reload');
621
+ exec(`systemctl --user enable ${unit}`);
622
+ return installedResult(svcName);
471
623
  },
472
624
  uninstall(options) {
473
625
  const svcName = resolveServiceName(descriptor, options);
474
- runQuiet(`systemctl --user stop ${svcName}.service`);
475
- runQuiet(`systemctl --user disable ${svcName}.service`);
626
+ const unit = systemdUnitName(svcName);
627
+ if (hasSystemUnit(svcName, exec)) {
628
+ throw new Error(`${unit} is a system unit provisioned outside core; core will not remove it. Ask an administrator to disable and delete it.`);
629
+ }
630
+ assertUserBus(unit, deps);
631
+ const quiet = (cmd) => {
632
+ try {
633
+ exec(cmd);
634
+ }
635
+ catch {
636
+ // Best effort: the unit may already be stopped or disabled.
637
+ }
638
+ };
639
+ quiet(`systemctl --user stop ${unit}`);
640
+ quiet(`systemctl --user disable ${unit}`);
476
641
  const path = unitPath(svcName);
477
642
  if (existsSync(path))
478
643
  unlinkSync(path);
479
- runQuiet('systemctl --user daemon-reload');
644
+ quiet('systemctl --user daemon-reload');
480
645
  },
481
646
  start(options) {
647
+ lifecycle('start', options);
648
+ },
649
+ stop(options) {
650
+ lifecycle('stop', options);
651
+ },
652
+ restart(options) {
653
+ lifecycle('restart', options);
654
+ },
655
+ status(options) {
656
+ return getSystemdServiceState(resolveServiceName(descriptor, options), exec);
657
+ },
658
+ };
659
+ }
660
+
661
+ /**
662
+ * Factory for platform-aware service lifecycle management.
663
+ *
664
+ * @remarks
665
+ * Produces a `ServiceManager` that handles install, uninstall, start,
666
+ * stop, restart, and status for system services. Delegates to NSSM
667
+ * (Windows), systemd (Linux), or launchd (macOS) based on platform.
668
+ * On Linux an existing system unit is detected and managed in place
669
+ * (see `createLinuxManager`).
670
+ */
671
+ /** Exec helper that returns stdout. */
672
+ function run(cmd) {
673
+ return execSync(cmd, {
674
+ encoding: 'utf-8',
675
+ timeout: 30_000,
676
+ stdio: ['pipe', 'pipe', 'pipe'],
677
+ }).trim();
678
+ }
679
+ /** Exec helper that suppresses errors and returns success boolean. */
680
+ function runQuiet(cmd) {
681
+ try {
682
+ execSync(cmd, {
683
+ encoding: 'utf-8',
684
+ timeout: 30_000,
685
+ stdio: ['pipe', 'pipe', 'pipe'],
686
+ });
687
+ return true;
688
+ }
689
+ catch {
690
+ return false;
691
+ }
692
+ }
693
+ /** Build a Windows NSSM service manager. */
694
+ function createWindowsManager(descriptor) {
695
+ return {
696
+ install(options) {
482
697
  const svcName = resolveServiceName(descriptor, options);
483
- run(`systemctl --user start ${svcName}.service`);
698
+ const cfgPath = resolveConfigFilePath(descriptor, options);
699
+ const cmdArgs = descriptor.startCommand(cfgPath);
700
+ const appPath = cmdArgs[0];
701
+ const appArgs = cmdArgs.slice(1).join(' ');
702
+ run(`nssm install ${svcName} ${appPath}`);
703
+ if (appArgs) {
704
+ run(`nssm set ${svcName} AppParameters ${appArgs}`);
705
+ }
706
+ run(`nssm set ${svcName} AppStdout ${join(homedir(), `${svcName}.log`)}`);
707
+ run(`nssm set ${svcName} AppStderr ${join(homedir(), `${svcName}.log`)}`);
708
+ run(`nssm set ${svcName} AppRotateFiles 1`);
709
+ run(`nssm set ${svcName} AppRotateBytes 1048576`);
710
+ return installedResult(svcName);
711
+ },
712
+ uninstall(options) {
713
+ const svcName = resolveServiceName(descriptor, options);
714
+ runQuiet(`nssm stop ${svcName}`);
715
+ run(`nssm remove ${svcName} confirm`);
716
+ },
717
+ start(options) {
718
+ const svcName = resolveServiceName(descriptor, options);
719
+ run(`nssm start ${svcName}`);
484
720
  },
485
721
  stop(options) {
486
722
  const svcName = resolveServiceName(descriptor, options);
487
- run(`systemctl --user stop ${svcName}.service`);
723
+ run(`nssm stop ${svcName}`);
488
724
  },
489
725
  restart(options) {
490
726
  const svcName = resolveServiceName(descriptor, options);
491
- run(`systemctl --user restart ${svcName}.service`);
727
+ run(`nssm restart ${svcName}`);
492
728
  },
493
729
  status(options) {
494
730
  const svcName = resolveServiceName(descriptor, options);
@@ -542,6 +778,7 @@ function createMacOSManager(descriptor) {
542
778
  const cmdArgs = descriptor.startCommand(cfgPath);
543
779
  mkdirSync(agentsDir, { recursive: true });
544
780
  writeFileSync(plistPath(svcName), buildLaunchdPlist(svcName, cmdArgs));
781
+ return installedResult(svcName);
545
782
  },
546
783
  uninstall(options) {
547
784
  const svcName = resolveServiceName(descriptor, options);
@@ -848,8 +1085,11 @@ function createServiceCli(descriptor) {
848
1085
  .option('-n, --name <name>', 'Service name', defaultServiceName)
849
1086
  .action((opts) => {
850
1087
  try {
851
- svcManager.install({ name: opts.name, configPath: opts.config });
852
- console.log(`Service "${opts.name}" installed.`);
1088
+ const result = svcManager.install({
1089
+ name: opts.name,
1090
+ configPath: opts.config,
1091
+ });
1092
+ console.log(result.message);
853
1093
  }
854
1094
  catch (err) {
855
1095
  handleCommandError('Install', err);
package/dist/index.d.ts CHANGED
@@ -741,6 +741,8 @@ declare function getBindAddress(componentName?: string): string;
741
741
  * @remarks
742
742
  * Detects whether a system service is installed and running.
743
743
  * Delegates to NSSM (Windows), systemd (Linux), or launchd (macOS).
744
+ * On Linux an existing system unit takes precedence over a user unit
745
+ * (see `getSystemdServiceState`).
744
746
  */
745
747
  /** Service states returned by getServiceState. */
746
748
  type ServiceState = 'not_installed' | 'stopped' | 'running';
@@ -1582,12 +1584,11 @@ declare function saveCache(): void;
1582
1584
  declare function getChannelWorkspace(channelId: string, token: string, options: SlackWorkspaceOptions): Promise<string>;
1583
1585
 
1584
1586
  /**
1585
- * Factory for platform-aware service lifecycle management.
1587
+ * Service manager contract and shared option resolution.
1586
1588
  *
1587
1589
  * @remarks
1588
- * Produces a `ServiceManager` that handles install, uninstall, start,
1589
- * stop, restart, and status for system services. Delegates to NSSM
1590
- * (Windows), systemd (Linux), or launchd (macOS) based on platform.
1590
+ * Shared by the per-platform managers built in `createServiceManager`
1591
+ * and `createLinuxManager`.
1591
1592
  */
1592
1593
 
1593
1594
  /** Options for service manager commands that accept a service name override. */
@@ -1597,10 +1598,20 @@ interface ServiceManagerOptions {
1597
1598
  /** Override config path for install. */
1598
1599
  configPath?: string;
1599
1600
  }
1601
+ /** Outcome of {@link ServiceManager.install}. */
1602
+ interface ServiceInstallResult {
1603
+ /**
1604
+ * True when the service was already provisioned outside core (a Linux
1605
+ * system unit) and install was a no-op.
1606
+ */
1607
+ existing: boolean;
1608
+ /** Human-readable summary of what install did. */
1609
+ message: string;
1610
+ }
1600
1611
  /** Service lifecycle manager produced by the factory. */
1601
1612
  interface ServiceManager {
1602
1613
  /** Install the service with the system service manager. */
1603
- install(options?: ServiceManagerOptions): void;
1614
+ install(options?: ServiceManagerOptions): ServiceInstallResult;
1604
1615
  /** Uninstall the service from the system service manager. */
1605
1616
  uninstall(options?: ServiceManagerOptions): void;
1606
1617
  /** Start the service. */
@@ -1612,6 +1623,18 @@ interface ServiceManager {
1612
1623
  /** Query the service state. */
1613
1624
  status(options?: ServiceManagerOptions): ServiceState;
1614
1625
  }
1626
+
1627
+ /**
1628
+ * Factory for platform-aware service lifecycle management.
1629
+ *
1630
+ * @remarks
1631
+ * Produces a `ServiceManager` that handles install, uninstall, start,
1632
+ * stop, restart, and status for system services. Delegates to NSSM
1633
+ * (Windows), systemd (Linux), or launchd (macOS) based on platform.
1634
+ * On Linux an existing system unit is detected and managed in place
1635
+ * (see `createLinuxManager`).
1636
+ */
1637
+
1615
1638
  /**
1616
1639
  * Create a platform-aware service manager from a component descriptor.
1617
1640
  *
@@ -1646,4 +1669,4 @@ declare function getErrorMessage(err: unknown): string;
1646
1669
  declare function isTransientError(err: unknown): boolean;
1647
1670
 
1648
1671
  export { AGENTS_MARKERS, COMPONENT_CONFIG_PREFIX, CONFIG_FILE, CONVERSATION_HOOK_NAMES, CORE_CONFIG_DIR, CORE_VERSION, DEFAULT_BIND_ADDRESS, DEFAULT_PORTS, LEGACY_TOOLS_MARKERS, META_PORT, PLATFORM_COMPONENTS, RUNNER_PORT, SERVER_PORT, SOUL_MARKERS, STALE_LOCK_MS, VERSION_STAMP_PATTERN, WATCHER_PORT, WORKSPACE_CONFIG_DEFAULTS, WORKSPACE_CONFIG_FILE, WORKSPACE_FILES, analyzeMemory, appendJsonl, atomicWrite, buildEffectiveConfig, checkNodeVersion, connectionFail, coreConfigSchema, createConfigApplyHandler, createConfigQueryHandler, createGoogleAuth, createPluginToolset, createServiceCli, createServiceManager, createStatusHandler, ensureDir, fail, fetchJson, fetchWithTimeout, formatBeginMarker, formatEndMarker, generateJsonSchema, generateWorkspaceJsonSchema, getArg, getBindAddress, getChannelWorkspace, getComponentConfigDir, getComponentConfigPath, getConfigRoot, getCoreConfigDir, getCoreConfigFile, getEffectiveServiceName, getErrorMessage, getPackageRoot, getPackageVersion, getServiceState, getServiceUrl, getWorkspacePath, init, isTransientError, jeevesComponentDescriptorSchema, loadEnvFile, loadWorkspaceConfig, nowIso, ok, onPluginDispose, parseArgs, parseManaged, pluginToolsetOptionsSchema, postJson, promptContextOptionsSchema, readJson, readJsonl, recordRegisteredHooks, registerComponentConfigPath, registerPromptContext, rejectWindowsDrivePath, removeManagedBlock, renderManagedBlock, resetInit, resolveConfigValue, resolveOptionalPluginSetting, resolvePluginSetting, resolveWorkspacePath, run, runScript, runWithRetry, saveCache, sleepAsync, sleepMs, substituteEnvVars, upsertManagedBlock, uuid, validateConversationHooks, validateSkillFrontmatter, withFileLock, workspaceConfigSchema, writeJsonAtomic, writeJsonl };
1649
- export type { AccountConfig, ConfigApplyHandler, ConfigApplyRequest, ConfigApplyResult, ConfigProvenance, ConfigQueryHandler, ConfigQueryResponse, CoreConfig, CreateStatusHandlerOptions, GoogleAuthOptions, HookRegistrationOptions, InitOptions, JeevesComponentDescriptor, ManagedBlockStampOptions, ManagedMarkers, MemoryHygieneOptions, MemoryHygieneResult, ParseManagedResult, PlatformComponent, PluginApi, PluginApiUrlResolver, PluginLifecycleApi, PluginToolsetOptions, PromptBuildContext, PromptBuildEvent, PromptBuildHandler, PromptBuildResult, PromptContextOptions, PromptContextProvider, ResolvedCliConfig, ResolvedValue, RetryOptions, RunOptions, ServiceAccountFileConfig, ServiceManager, ServiceManagerOptions, ServiceState, SkillFrontmatter, SlackWorkspaceOptions, StatusHandler, StatusHandlerResult, StatusResponse, ToolDescriptor, ToolRegistrationOptions, ToolResult, VersionStamp, WorkspaceConfig, WorkspaceOptions };
1672
+ export type { AccountConfig, ConfigApplyHandler, ConfigApplyRequest, ConfigApplyResult, ConfigProvenance, ConfigQueryHandler, ConfigQueryResponse, CoreConfig, CreateStatusHandlerOptions, GoogleAuthOptions, HookRegistrationOptions, InitOptions, JeevesComponentDescriptor, ManagedBlockStampOptions, ManagedMarkers, MemoryHygieneOptions, MemoryHygieneResult, ParseManagedResult, PlatformComponent, PluginApi, PluginApiUrlResolver, PluginLifecycleApi, PluginToolsetOptions, PromptBuildContext, PromptBuildEvent, PromptBuildHandler, PromptBuildResult, PromptContextOptions, PromptContextProvider, ResolvedCliConfig, ResolvedValue, RetryOptions, RunOptions, ServiceAccountFileConfig, ServiceInstallResult, ServiceManager, ServiceManagerOptions, ServiceState, SkillFrontmatter, SlackWorkspaceOptions, StatusHandler, StatusHandlerResult, StatusResponse, ToolDescriptor, ToolRegistrationOptions, ToolResult, VersionStamp, WorkspaceConfig, WorkspaceOptions };
package/dist/index.js CHANGED
@@ -142,14 +142,14 @@ const DEFAULT_PORTS = {
142
142
  * Core library version, inlined at build time.
143
143
  *
144
144
  * @remarks
145
- * The `0.6.0-5` placeholder is replaced by
145
+ * The `0.6.0-7` placeholder is replaced by
146
146
  * `@rollup/plugin-replace` during the build with the actual version
147
147
  * from `package.json`. This ensures the correct version survives
148
148
  * when consumers bundle core into their own dist (where runtime
149
149
  * `import.meta.url`-based resolution would find the wrong package.json).
150
150
  */
151
151
  /** The core library version from package.json (inlined at build time). */
152
- const CORE_VERSION = '0.6.0-5';
152
+ const CORE_VERSION = '0.6.0-7';
153
153
 
154
154
  /**
155
155
  * Workspace and config root initialization.
@@ -1081,12 +1081,130 @@ async function postJson(url, body) {
1081
1081
  });
1082
1082
  }
1083
1083
 
1084
+ /**
1085
+ * Linux systemd unit detection and state queries.
1086
+ *
1087
+ * @remarks
1088
+ * A service can be provisioned either as a system unit
1089
+ * (`/etc/systemd/system/<name>.service`, as jeeves-tools does on managed
1090
+ * instances) or as a user unit (`~/.config/systemd/user`, as
1091
+ * `createServiceManager` does elsewhere). An existing system unit always
1092
+ * wins: status reads it and core never creates a competing user unit.
1093
+ *
1094
+ * Every query here is unprivileged. Commands run through an injectable
1095
+ * `CommandExec` so the logic can be unit tested without systemd.
1096
+ */
1097
+ /** Default `CommandExec`: `execSync` with piped stdio and a timeout. */
1098
+ const defaultExec = (cmd) => execSync(cmd, {
1099
+ encoding: 'utf-8',
1100
+ timeout: 30_000,
1101
+ stdio: ['pipe', 'pipe', 'pipe'],
1102
+ }).trim();
1103
+ /**
1104
+ * Extract the most useful text from a failed command.
1105
+ *
1106
+ * @param err - Error thrown by a `CommandExec`.
1107
+ * @returns The command's stderr when present, else the error message.
1108
+ */
1109
+ function execErrorDetail(err) {
1110
+ if (typeof err === 'object' && err !== null) {
1111
+ if ('stderr' in err) {
1112
+ const stderr = String(err.stderr).trim();
1113
+ if (stderr)
1114
+ return stderr;
1115
+ }
1116
+ if (err instanceof Error)
1117
+ return err.message;
1118
+ }
1119
+ return String(err);
1120
+ }
1121
+ /**
1122
+ * The systemd unit name for a service.
1123
+ *
1124
+ * @param serviceName - Service name, e.g. `jeeves-watcher`.
1125
+ * @returns The unit name, e.g. `jeeves-watcher.service`.
1126
+ */
1127
+ function systemdUnitName(serviceName) {
1128
+ return `${serviceName}.service`;
1129
+ }
1130
+ /**
1131
+ * LoadStates meaning a system unit exists. A masked unit still counts: an
1132
+ * administrator disabled it, and a user unit must not replace it.
1133
+ */
1134
+ const SYSTEM_UNIT_PRESENT = new Set(['loaded', 'masked']);
1135
+ /**
1136
+ * Whether a system-level systemd unit exists for the service.
1137
+ *
1138
+ * @remarks
1139
+ * Queries the system manager (no `--user`, no sudo):
1140
+ * `systemctl show <unit> --property=LoadState --value` prints `loaded`
1141
+ * (or `masked`) for an existing unit and `not-found` otherwise. Any failure (no
1142
+ * systemd, no system bus) means no usable system unit.
1143
+ *
1144
+ * @param serviceName - Service name.
1145
+ * @param exec - Command runner.
1146
+ * @returns True when a system unit is loaded.
1147
+ */
1148
+ function hasSystemUnit(serviceName, exec = defaultExec) {
1149
+ try {
1150
+ const loadState = exec(`systemctl show ${systemdUnitName(serviceName)} --property=LoadState --value`);
1151
+ return SYSTEM_UNIT_PRESENT.has(loadState.trim());
1152
+ }
1153
+ catch {
1154
+ return false;
1155
+ }
1156
+ }
1157
+ /**
1158
+ * Read a unit's active state and map it to a `ServiceState`.
1159
+ *
1160
+ * @param scopeFlag - `''` for the system manager, `'--user '` for the user manager.
1161
+ * @param unit - Unit name.
1162
+ * @param exec - Command runner.
1163
+ * @returns `running` when active, else `stopped`.
1164
+ */
1165
+ function activeState(scopeFlag, unit, exec) {
1166
+ try {
1167
+ return exec(`systemctl ${scopeFlag}is-active ${unit}`) === 'active'
1168
+ ? 'running'
1169
+ : 'stopped';
1170
+ }
1171
+ catch {
1172
+ // is-active exits non-zero for inactive/failed units.
1173
+ return 'stopped';
1174
+ }
1175
+ }
1176
+ /**
1177
+ * Detect the state of a Linux systemd service.
1178
+ *
1179
+ * @remarks
1180
+ * System unit first (`systemctl is-active <unit>`), then the user unit
1181
+ * (`systemctl --user is-enabled` / `is-active`).
1182
+ *
1183
+ * @param serviceName - Service name.
1184
+ * @param exec - Command runner.
1185
+ * @returns The detected service state.
1186
+ */
1187
+ function getSystemdServiceState(serviceName, exec = defaultExec) {
1188
+ const unit = systemdUnitName(serviceName);
1189
+ if (hasSystemUnit(serviceName, exec))
1190
+ return activeState('', unit, exec);
1191
+ try {
1192
+ exec(`systemctl --user is-enabled ${unit}`);
1193
+ }
1194
+ catch {
1195
+ return 'not_installed';
1196
+ }
1197
+ return activeState('--user ', unit, exec);
1198
+ }
1199
+
1084
1200
  /**
1085
1201
  * Platform-aware service state detection.
1086
1202
  *
1087
1203
  * @remarks
1088
1204
  * Detects whether a system service is installed and running.
1089
1205
  * Delegates to NSSM (Windows), systemd (Linux), or launchd (macOS).
1206
+ * On Linux an existing system unit takes precedence over a user unit
1207
+ * (see `getSystemdServiceState`).
1090
1208
  */
1091
1209
  /**
1092
1210
  * Detect the state of a system service by name.
@@ -1101,7 +1219,7 @@ function getServiceState(serviceName) {
1101
1219
  case 'darwin':
1102
1220
  return getServiceStateMacOS(serviceName);
1103
1221
  default:
1104
- return getServiceStateLinux(serviceName);
1222
+ return getSystemdServiceState(serviceName);
1105
1223
  }
1106
1224
  }
1107
1225
  /**
@@ -1129,36 +1247,6 @@ function getServiceStateWindows(serviceName) {
1129
1247
  return 'not_installed';
1130
1248
  }
1131
1249
  }
1132
- /**
1133
- * Linux: detect via systemd user services.
1134
- * - `systemctl --user is-enabled {name}.service` exits non-zero = not installed
1135
- * - `systemctl --user is-active {name}.service` returns "active" = running
1136
- */
1137
- function getServiceStateLinux(serviceName) {
1138
- try {
1139
- execSync(`systemctl --user is-enabled ${serviceName}.service`, {
1140
- encoding: 'utf-8',
1141
- timeout: 5000,
1142
- stdio: ['pipe', 'pipe', 'pipe'],
1143
- });
1144
- }
1145
- catch {
1146
- return 'not_installed';
1147
- }
1148
- try {
1149
- const output = execSync(`systemctl --user is-active ${serviceName}.service`, {
1150
- encoding: 'utf-8',
1151
- timeout: 5000,
1152
- stdio: ['pipe', 'pipe', 'pipe'],
1153
- }).trim();
1154
- if (output === 'active')
1155
- return 'running';
1156
- return 'stopped';
1157
- }
1158
- catch {
1159
- return 'stopped';
1160
- }
1161
- }
1162
1250
  /**
1163
1251
  * macOS: detect via launchctl.
1164
1252
  * - `launchctl list {name}` exits non-zero = not installed
@@ -1202,35 +1290,12 @@ function isExecError(err) {
1202
1290
  }
1203
1291
 
1204
1292
  /**
1205
- * Factory for platform-aware service lifecycle management.
1293
+ * Service manager contract and shared option resolution.
1206
1294
  *
1207
1295
  * @remarks
1208
- * Produces a `ServiceManager` that handles install, uninstall, start,
1209
- * stop, restart, and status for system services. Delegates to NSSM
1210
- * (Windows), systemd (Linux), or launchd (macOS) based on platform.
1296
+ * Shared by the per-platform managers built in `createServiceManager`
1297
+ * and `createLinuxManager`.
1211
1298
  */
1212
- /** Exec helper that returns stdout. */
1213
- function run$1(cmd) {
1214
- return execSync(cmd, {
1215
- encoding: 'utf-8',
1216
- timeout: 30_000,
1217
- stdio: ['pipe', 'pipe', 'pipe'],
1218
- }).trim();
1219
- }
1220
- /** Exec helper that suppresses errors and returns success boolean. */
1221
- function runQuiet(cmd) {
1222
- try {
1223
- execSync(cmd, {
1224
- encoding: 'utf-8',
1225
- timeout: 30_000,
1226
- stdio: ['pipe', 'pipe', 'pipe'],
1227
- });
1228
- return true;
1229
- }
1230
- catch {
1231
- return false;
1232
- }
1233
- }
1234
1299
  /**
1235
1300
  * Resolve the effective service name from options and descriptor.
1236
1301
  *
@@ -1254,47 +1319,101 @@ function resolveConfigFilePath(descriptor, options) {
1254
1319
  const configDir = getComponentConfigDir(descriptor.name);
1255
1320
  return join(configDir, descriptor.configFileName);
1256
1321
  }
1257
- /** Build a Windows NSSM service manager. */
1258
- function createWindowsManager(descriptor) {
1259
- return {
1260
- install(options) {
1261
- const svcName = resolveServiceName(descriptor, options);
1262
- const cfgPath = resolveConfigFilePath(descriptor, options);
1263
- const cmdArgs = descriptor.startCommand(cfgPath);
1264
- const appPath = cmdArgs[0];
1265
- const appArgs = cmdArgs.slice(1).join(' ');
1266
- run$1(`nssm install ${svcName} ${appPath}`);
1267
- if (appArgs) {
1268
- run$1(`nssm set ${svcName} AppParameters ${appArgs}`);
1269
- }
1270
- run$1(`nssm set ${svcName} AppStdout ${join(homedir(), `${svcName}.log`)}`);
1271
- run$1(`nssm set ${svcName} AppStderr ${join(homedir(), `${svcName}.log`)}`);
1272
- run$1(`nssm set ${svcName} AppRotateFiles 1`);
1273
- run$1(`nssm set ${svcName} AppRotateBytes 1048576`);
1274
- },
1275
- uninstall(options) {
1276
- const svcName = resolveServiceName(descriptor, options);
1277
- runQuiet(`nssm stop ${svcName}`);
1278
- run$1(`nssm remove ${svcName} confirm`);
1279
- },
1280
- start(options) {
1281
- const svcName = resolveServiceName(descriptor, options);
1282
- run$1(`nssm start ${svcName}`);
1283
- },
1284
- stop(options) {
1285
- const svcName = resolveServiceName(descriptor, options);
1286
- run$1(`nssm stop ${svcName}`);
1287
- },
1288
- restart(options) {
1289
- const svcName = resolveServiceName(descriptor, options);
1290
- run$1(`nssm restart ${svcName}`);
1291
- },
1292
- status(options) {
1293
- const svcName = resolveServiceName(descriptor, options);
1294
- return getServiceState(svcName);
1295
- },
1296
- };
1322
+ /**
1323
+ * The default install result for a freshly installed service.
1324
+ *
1325
+ * @param svcName - Service name.
1326
+ * @returns A non-existing install result.
1327
+ */
1328
+ function installedResult(svcName) {
1329
+ return { existing: false, message: `Service "${svcName}" installed.` };
1330
+ }
1331
+
1332
+ /**
1333
+ * Guarded access to the systemd user and system managers.
1334
+ *
1335
+ * @remarks
1336
+ * - User scope: fail early with an actionable message when there is no user
1337
+ * bus (typical for `useradd --system` accounts without linger), instead of
1338
+ * surfacing systemctl's raw "Failed to connect to bus" error.
1339
+ * - System scope: run only `systemctl start|stop|restart <unit>` through
1340
+ * non-interactive sudo, which is exactly what the jeeves-tools sudoers rule
1341
+ * for the `jeeves` user allows (`/usr/bin/systemctl stop|start|restart|status *`).
1342
+ * Core never writes system units, reloads or enables them.
1343
+ */
1344
+ /** Output patterns sudo prints when a non-interactive command is refused. */
1345
+ const SUDO_REFUSED = /password is required|not allowed to execute|may not run sudo|not in the sudoers/i;
1346
+ function userLabel(env) {
1347
+ return env.USER ?? env.LOGNAME ?? 'this user';
1348
+ }
1349
+ /**
1350
+ * Ensure a systemd user bus is reachable before any `systemctl --user` call.
1351
+ *
1352
+ * @param unit - Unit being managed (for the message).
1353
+ * @param deps - Host dependencies.
1354
+ * @throws Error with the cause and remedies when no user bus is available.
1355
+ */
1356
+ function assertUserBus(unit, deps) {
1357
+ const user = userLabel(deps.env);
1358
+ const noBus = (reason, cause) => new Error([
1359
+ `Cannot manage ${unit} as a systemd user unit: ${reason}.`,
1360
+ `No system unit named ${unit} exists either.`,
1361
+ 'Fix one of:',
1362
+ `(1) have an administrator provision ${unit} as a system unit in /etc/systemd/system; it is then detected and managed with "sudo -n systemctl";`,
1363
+ `(2) enable a user manager with "sudo loginctl enable-linger ${user}" and retry from a session where XDG_RUNTIME_DIR is set.`,
1364
+ ].join(' '), { cause });
1365
+ if (!deps.env.XDG_RUNTIME_DIR && !deps.env.DBUS_SESSION_BUS_ADDRESS) {
1366
+ throw noBus(`there is no systemd user bus for "${user}" (XDG_RUNTIME_DIR is not set)`);
1367
+ }
1368
+ try {
1369
+ deps.exec('systemctl --user show-environment');
1370
+ }
1371
+ catch (err) {
1372
+ throw noBus(`the systemd user bus for "${user}" is unreachable (${execErrorDetail(err)})`, err);
1373
+ }
1374
+ }
1375
+ /**
1376
+ * Run a lifecycle verb against an existing system unit.
1377
+ *
1378
+ * @remarks
1379
+ * Uses `sudo -n systemctl <verb> <unit>` (plain `systemctl` when root).
1380
+ * `-n` makes sudo fail instead of prompting for a password.
1381
+ *
1382
+ * @param verb - `start`, `stop` or `restart`.
1383
+ * @param unit - System unit name.
1384
+ * @param deps - Host dependencies.
1385
+ * @throws Error naming the missing sudoers rule when sudo refuses.
1386
+ */
1387
+ function runSystemVerb(verb, unit, deps) {
1388
+ const root = deps.isRoot();
1389
+ const cmd = root
1390
+ ? `systemctl ${verb} ${unit}`
1391
+ : `sudo -n systemctl ${verb} ${unit}`;
1392
+ try {
1393
+ deps.exec(cmd);
1394
+ }
1395
+ catch (err) {
1396
+ const detail = execErrorDetail(err);
1397
+ if (!root && SUDO_REFUSED.test(detail)) {
1398
+ throw new Error(`${unit} is a system unit and "${cmd}" was refused: passwordless sudo for "/usr/bin/systemctl ${verb} *" is not granted to "${userLabel(deps.env)}" (${detail}). Ask an administrator to run "sudo systemctl ${verb} ${unit}" or to grant that sudoers rule.`, { cause: err });
1399
+ }
1400
+ throw new Error(`"${cmd}" failed: ${detail}`, { cause: err });
1401
+ }
1297
1402
  }
1403
+
1404
+ /**
1405
+ * Linux systemd service manager.
1406
+ *
1407
+ * @remarks
1408
+ * Two scopes:
1409
+ * - **System unit present** (e.g. `/etc/systemd/system/jeeves-watcher.service`
1410
+ * provisioned by jeeves-tools): install is a no-op that reports the unit,
1411
+ * uninstall refuses, status reads the system unit, and start/stop/restart
1412
+ * run `sudo -n systemctl <verb> <unit>`. Core never creates a competing
1413
+ * user unit.
1414
+ * - **Otherwise**: a user unit in `~/.config/systemd/user` managed with
1415
+ * `systemctl --user`, after checking that a user bus exists.
1416
+ */
1298
1417
  /**
1299
1418
  * Generate a systemd user unit file.
1300
1419
  *
@@ -1303,7 +1422,6 @@ function createWindowsManager(descriptor) {
1303
1422
  * @returns Unit file content.
1304
1423
  */
1305
1424
  function buildSystemdUnit(svcName, cmdArgs) {
1306
- const execStart = cmdArgs.join(' ');
1307
1425
  return [
1308
1426
  '[Unit]',
1309
1427
  `Description=${svcName}`,
@@ -1311,7 +1429,7 @@ function buildSystemdUnit(svcName, cmdArgs) {
1311
1429
  '',
1312
1430
  '[Service]',
1313
1431
  'Type=simple',
1314
- `ExecStart=${execStart}`,
1432
+ `ExecStart=${cmdArgs.join(' ')}`,
1315
1433
  'Restart=on-failure',
1316
1434
  'RestartSec=5',
1317
1435
  '',
@@ -1319,42 +1437,160 @@ function buildSystemdUnit(svcName, cmdArgs) {
1319
1437
  'WantedBy=default.target',
1320
1438
  ].join('\n');
1321
1439
  }
1322
- /** Build a Linux systemd service manager. */
1323
- function createLinuxManager(descriptor) {
1324
- const unitDir = join(homedir(), '.config', 'systemd', 'user');
1440
+ /** Default host dependencies. */
1441
+ function defaultDeps() {
1442
+ return {
1443
+ exec: defaultExec,
1444
+ env: process.env,
1445
+ isRoot: () => process.getuid?.() === 0,
1446
+ };
1447
+ }
1448
+ /**
1449
+ * Build a Linux systemd service manager.
1450
+ *
1451
+ * @param descriptor - Component descriptor.
1452
+ * @param deps - Host dependencies (defaults to the real host).
1453
+ * @param unitDir - User unit directory (defaults to `~/.config/systemd/user`).
1454
+ * @returns A `ServiceManager` for Linux.
1455
+ */
1456
+ function createLinuxManager(descriptor, deps = defaultDeps(), unitDir = join(homedir(), '.config', 'systemd', 'user')) {
1457
+ const exec = deps.exec;
1325
1458
  function unitPath(svcName) {
1326
- return join(unitDir, `${svcName}.service`);
1459
+ return join(unitDir, systemdUnitName(svcName));
1460
+ }
1461
+ /** Run a lifecycle verb in whichever scope owns the unit. */
1462
+ function lifecycle(verb, options) {
1463
+ const svcName = resolveServiceName(descriptor, options);
1464
+ const unit = systemdUnitName(svcName);
1465
+ if (hasSystemUnit(svcName, exec)) {
1466
+ runSystemVerb(verb, unit, deps);
1467
+ return;
1468
+ }
1469
+ assertUserBus(unit, deps);
1470
+ exec(`systemctl --user ${verb} ${unit}`);
1327
1471
  }
1328
1472
  return {
1329
1473
  install(options) {
1330
1474
  const svcName = resolveServiceName(descriptor, options);
1331
- const cfgPath = resolveConfigFilePath(descriptor, options);
1332
- const cmdArgs = descriptor.startCommand(cfgPath);
1475
+ const unit = systemdUnitName(svcName);
1476
+ if (hasSystemUnit(svcName, exec)) {
1477
+ return {
1478
+ existing: true,
1479
+ message: `Service "${svcName}" is already installed as system unit ${unit}; nothing to do. Manage it with start/stop/restart (sudo -n systemctl).`,
1480
+ };
1481
+ }
1482
+ assertUserBus(unit, deps);
1483
+ const cmdArgs = descriptor.startCommand(resolveConfigFilePath(descriptor, options));
1333
1484
  mkdirSync(unitDir, { recursive: true });
1334
1485
  writeFileSync(unitPath(svcName), buildSystemdUnit(svcName, cmdArgs));
1335
- run$1('systemctl --user daemon-reload');
1336
- run$1(`systemctl --user enable ${svcName}.service`);
1486
+ exec('systemctl --user daemon-reload');
1487
+ exec(`systemctl --user enable ${unit}`);
1488
+ return installedResult(svcName);
1337
1489
  },
1338
1490
  uninstall(options) {
1339
1491
  const svcName = resolveServiceName(descriptor, options);
1340
- runQuiet(`systemctl --user stop ${svcName}.service`);
1341
- runQuiet(`systemctl --user disable ${svcName}.service`);
1492
+ const unit = systemdUnitName(svcName);
1493
+ if (hasSystemUnit(svcName, exec)) {
1494
+ throw new Error(`${unit} is a system unit provisioned outside core; core will not remove it. Ask an administrator to disable and delete it.`);
1495
+ }
1496
+ assertUserBus(unit, deps);
1497
+ const quiet = (cmd) => {
1498
+ try {
1499
+ exec(cmd);
1500
+ }
1501
+ catch {
1502
+ // Best effort: the unit may already be stopped or disabled.
1503
+ }
1504
+ };
1505
+ quiet(`systemctl --user stop ${unit}`);
1506
+ quiet(`systemctl --user disable ${unit}`);
1342
1507
  const path = unitPath(svcName);
1343
1508
  if (existsSync(path))
1344
1509
  unlinkSync(path);
1345
- runQuiet('systemctl --user daemon-reload');
1510
+ quiet('systemctl --user daemon-reload');
1511
+ },
1512
+ start(options) {
1513
+ lifecycle('start', options);
1514
+ },
1515
+ stop(options) {
1516
+ lifecycle('stop', options);
1517
+ },
1518
+ restart(options) {
1519
+ lifecycle('restart', options);
1520
+ },
1521
+ status(options) {
1522
+ return getSystemdServiceState(resolveServiceName(descriptor, options), exec);
1523
+ },
1524
+ };
1525
+ }
1526
+
1527
+ /**
1528
+ * Factory for platform-aware service lifecycle management.
1529
+ *
1530
+ * @remarks
1531
+ * Produces a `ServiceManager` that handles install, uninstall, start,
1532
+ * stop, restart, and status for system services. Delegates to NSSM
1533
+ * (Windows), systemd (Linux), or launchd (macOS) based on platform.
1534
+ * On Linux an existing system unit is detected and managed in place
1535
+ * (see `createLinuxManager`).
1536
+ */
1537
+ /** Exec helper that returns stdout. */
1538
+ function run$1(cmd) {
1539
+ return execSync(cmd, {
1540
+ encoding: 'utf-8',
1541
+ timeout: 30_000,
1542
+ stdio: ['pipe', 'pipe', 'pipe'],
1543
+ }).trim();
1544
+ }
1545
+ /** Exec helper that suppresses errors and returns success boolean. */
1546
+ function runQuiet(cmd) {
1547
+ try {
1548
+ execSync(cmd, {
1549
+ encoding: 'utf-8',
1550
+ timeout: 30_000,
1551
+ stdio: ['pipe', 'pipe', 'pipe'],
1552
+ });
1553
+ return true;
1554
+ }
1555
+ catch {
1556
+ return false;
1557
+ }
1558
+ }
1559
+ /** Build a Windows NSSM service manager. */
1560
+ function createWindowsManager(descriptor) {
1561
+ return {
1562
+ install(options) {
1563
+ const svcName = resolveServiceName(descriptor, options);
1564
+ const cfgPath = resolveConfigFilePath(descriptor, options);
1565
+ const cmdArgs = descriptor.startCommand(cfgPath);
1566
+ const appPath = cmdArgs[0];
1567
+ const appArgs = cmdArgs.slice(1).join(' ');
1568
+ run$1(`nssm install ${svcName} ${appPath}`);
1569
+ if (appArgs) {
1570
+ run$1(`nssm set ${svcName} AppParameters ${appArgs}`);
1571
+ }
1572
+ run$1(`nssm set ${svcName} AppStdout ${join(homedir(), `${svcName}.log`)}`);
1573
+ run$1(`nssm set ${svcName} AppStderr ${join(homedir(), `${svcName}.log`)}`);
1574
+ run$1(`nssm set ${svcName} AppRotateFiles 1`);
1575
+ run$1(`nssm set ${svcName} AppRotateBytes 1048576`);
1576
+ return installedResult(svcName);
1577
+ },
1578
+ uninstall(options) {
1579
+ const svcName = resolveServiceName(descriptor, options);
1580
+ runQuiet(`nssm stop ${svcName}`);
1581
+ run$1(`nssm remove ${svcName} confirm`);
1346
1582
  },
1347
1583
  start(options) {
1348
1584
  const svcName = resolveServiceName(descriptor, options);
1349
- run$1(`systemctl --user start ${svcName}.service`);
1585
+ run$1(`nssm start ${svcName}`);
1350
1586
  },
1351
1587
  stop(options) {
1352
1588
  const svcName = resolveServiceName(descriptor, options);
1353
- run$1(`systemctl --user stop ${svcName}.service`);
1589
+ run$1(`nssm stop ${svcName}`);
1354
1590
  },
1355
1591
  restart(options) {
1356
1592
  const svcName = resolveServiceName(descriptor, options);
1357
- run$1(`systemctl --user restart ${svcName}.service`);
1593
+ run$1(`nssm restart ${svcName}`);
1358
1594
  },
1359
1595
  status(options) {
1360
1596
  const svcName = resolveServiceName(descriptor, options);
@@ -1408,6 +1644,7 @@ function createMacOSManager(descriptor) {
1408
1644
  const cmdArgs = descriptor.startCommand(cfgPath);
1409
1645
  mkdirSync(agentsDir, { recursive: true });
1410
1646
  writeFileSync(plistPath(svcName), buildLaunchdPlist(svcName, cmdArgs));
1647
+ return installedResult(svcName);
1411
1648
  },
1412
1649
  uninstall(options) {
1413
1650
  const svcName = resolveServiceName(descriptor, options);
@@ -1624,8 +1861,11 @@ function createServiceCli(descriptor) {
1624
1861
  .option('-n, --name <name>', 'Service name', defaultServiceName)
1625
1862
  .action((opts) => {
1626
1863
  try {
1627
- svcManager.install({ name: opts.name, configPath: opts.config });
1628
- console.log(`Service "${opts.name}" installed.`);
1864
+ const result = svcManager.install({
1865
+ name: opts.name,
1866
+ configPath: opts.config,
1867
+ });
1868
+ console.log(result.message);
1629
1869
  }
1630
1870
  catch (err) {
1631
1871
  handleCommandError('Install', err);
@@ -2595,11 +2835,12 @@ function createPluginToolset(descriptor, options) {
2595
2835
  const state = svcManager.status();
2596
2836
  return Promise.resolve(ok({ service: name, state }));
2597
2837
  }
2838
+ if (action === 'install') {
2839
+ const { existing, message } = svcManager.install();
2840
+ return Promise.resolve(ok({ service: name, action, success: true, existing, message }));
2841
+ }
2598
2842
  // Call the appropriate method
2599
2843
  const methodMap = {
2600
- install: () => {
2601
- svcManager.install();
2602
- },
2603
2844
  uninstall: () => {
2604
2845
  svcManager.uninstall();
2605
2846
  },
package/package.json CHANGED
@@ -96,8 +96,7 @@
96
96
  "after:init": [
97
97
  "npm run lint",
98
98
  "npm run test",
99
- "npm run knip",
100
- "npm run build"
99
+ "npm run knip"
101
100
  ],
102
101
  "after:release": [
103
102
  "git switch -c release/${version}",
@@ -105,6 +104,7 @@
105
104
  "git switch ${branchName}"
106
105
  ],
107
106
  "after:bump": [
107
+ "npm run build",
108
108
  "npx git-cliff -o CHANGELOG.md",
109
109
  "npm run docs",
110
110
  "git add CHANGELOG.md"
@@ -133,7 +133,7 @@
133
133
  },
134
134
  "type": "module",
135
135
  "types": "dist/index.d.ts",
136
- "version": "0.6.0-6",
136
+ "version": "0.6.0-7",
137
137
  "allowScripts": {
138
138
  "lefthook": true
139
139
  }