@oddessentials/agent-guild 0.33.1 → 0.34.0

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
@@ -87,12 +87,16 @@ shell. Pick the default chip to clear a saved choice and follow the default
87
87
  again. The choice applies to new sessions.
88
88
 
89
89
  **See the manager environment.** The Shell card summarizes detected Node.js,
90
- Python, Go, .NET SDK, R and Rust versions. **Environment details** shows the
91
- resolved paths, detection results, .NET runtimes and detected nvm/NVM for
92
- Windows, vfox, uv and pnpm launchers. Tools are listed by presence; their
93
- activation is not inferred. This describes the computer running Agent Guild
94
- and its manager environment, including when viewed remotely. Selected shells,
95
- projects and existing sessions may use different versions.
90
+ Python, Go, .NET SDK, R and Rust versions. **Environment details** opens on
91
+ the manager result: resolved paths, detection results, .NET runtimes and
92
+ detected nvm/NVM for Windows, vfox, uv and pnpm launchers. Tools are listed
93
+ by presence; their activation is not inferred. The same dialog can show three
94
+ other facts, each refreshed on its own: pin files in the working folder
95
+ (configured text, not an installed version), the PATH a Guild session was
96
+ given when it started, and the PATH a new process receives before the shell
97
+ runs its startup files. tmux and herdr sessions have no spawn record to show.
98
+ The card itself stays the manager summary. This describes the computer running
99
+ Agent Guild, including when viewed remotely.
96
100
 
97
101
  Python always uses `python` when it is found on the manager's PATH; otherwise
98
102
  it uses `python3`. A broken `python` remains the primary result, with a
@@ -105,8 +109,10 @@ Discovery runs on first viewing and again on **Refresh**, in an isolated
105
109
  helper with a neutral temporary working directory. It does not load shell
106
110
  profiles, activate tools, download runtimes or modify installations. Unknown
107
111
  script/shim launchers and Windows execution aliases are left unverified.
108
- Refresh checks the manager's current environment; it does not reload its
109
- PATH from a shell. Existing results remain visible during refresh. Go reports
112
+ Refresh checks the fact selected in the dialog and leaves the others as they
113
+ are. The manager fact reads the manager's current environment and does not
114
+ reload its PATH from a shell. Existing results for that fact remain visible
115
+ during refresh. Go reports
110
116
  the local bundled toolchain with automatic switching disabled, and the Node
111
117
  version running Agent Guild is also shown in details.
112
118
 
@@ -233,6 +239,23 @@ does not start even while **Launch at sign-in** is checked. While it is on,
233
239
  the setting says when it last ran at sign-in and whether the session manager
234
240
  started then.
235
241
 
242
+ On Linux the setting is one choice: **Only when I start it**, **When I sign
243
+ in** (the desktop entry above) or **When the computer starts**. The last
244
+ runs the session manager as a systemd user service, `agent-guild.service`,
245
+ so it is running before you connect to a machine with no desktop, and
246
+ systemd starts it again if it fails. Only one of the two starters is on at
247
+ a time, and changing the choice never stops the running session manager: one
248
+ started by hand hands over to systemd at its next restart. While the service
249
+ is on, `agent-guild open` and `agent-guild restart` start the session manager
250
+ through systemd, `agent-guild stop` stops it until the next start, and tmux
251
+ cards survive restarts as they do without it. To start before anyone signs
252
+ in, systemd needs lingering, which only an administrator can always turn
253
+ on; the setting shows the command, `sudo loginctl enable-linger <user>`,
254
+ until it is on. It also shows what systemd reports, such as a port another
255
+ session manager was using, and for a failure the command that shows its
256
+ log, `journalctl --user -u agent-guild.service`. `agent-guild status` prints
257
+ the same state, read from systemd when no session manager answers.
258
+
236
259
  The browser tab icon adds a rotating green ring whenever any session card says
237
260
  **Working**, including Shell and task cards. It mirrors the cards' terminal
238
261
  activity, not assistant completion. The ring remains visible with reduced
@@ -255,7 +278,7 @@ There is no added polling while connected and no additional monitoring process.
255
278
  | Command | What it does |
256
279
  | --- | --- |
257
280
  | `agent-guild` or `agent-guild open` | Start the manager if needed and open the page. `--no-browser` prints the URL instead. |
258
- | `agent-guild status` | Show whether the manager is running and list its sessions. |
281
+ | `agent-guild status` | Show whether the manager is running, how it starts, and list its sessions. |
259
282
  | `agent-guild stop` | Stop the manager, ending every session. |
260
283
  | `agent-guild restart` | Stop the manager and start it again on the version installed on disk, ending every session. |
261
284
  | `agent-guild url` | Print the page URL with its access token. |
@@ -337,12 +360,20 @@ Any other tool or script can report agents and its model too. See
337
360
  been uninstalled or moved removes itself at the next sign-in. With nvm,
338
361
  the package lives in the Node.js version's folder, so uninstalling that
339
362
  version counts as uninstalling Agent Guild; a Homebrew Node.js upgrade
340
- does not. On macOS and Linux what the entry runs at sign-in, including a
363
+ or a Snap Node.js refresh does not. On macOS and Linux what the entry runs at sign-in, including a
341
364
  missing Node.js, is appended to `manager.log`.
342
365
  The entry saves the manager's listening port, including the assigned port
343
366
  when started with `AGENT_GUILD_PORT=0`. Enabled entries are updated when
344
367
  the manager starts. A macOS entry disabled through `launchctl` stays off
345
368
  until you explicitly turn **Launch at sign-in** back on.
369
+ * **When the computer starts**, on Linux, writes
370
+ `~/.config/systemd/user/agent-guild.service` and enables it, which links
371
+ it from `default.target.wants`; the unit runs `boot.sh` from the data
372
+ folder, which runs `agent-guild start` in the foreground. Choosing another
373
+ option disables and removes both. A unit whose Agent Guild package has
374
+ been uninstalled or moved disables and removes itself at the next start.
375
+ Lingering, which lets it start before anyone signs in, is a system
376
+ setting that Agent Guild reads but never changes.
346
377
 
347
378
  The manager makes these outbound requests, and none of them carry your code
348
379
  or prompts:
@@ -19,8 +19,9 @@ import {
19
19
  readRuntimeFile,
20
20
  resolvePort,
21
21
  } from '../src/manager/config.mjs';
22
- import { spawnManager } from '../src/manager/launch.mjs';
22
+ import { defaultBoot, serviceFor, spawnManager, startService } from '../src/manager/launch.mjs';
23
23
  import { recordSignIn } from '../src/manager/autostart.mjs';
24
+ import { EXIT_PORT_IN_USE, describeStartup, startupState, startupSummary, unitPort } from '../src/manager/systemd-service.mjs';
24
25
 
25
26
  function usage() {
26
27
  console.log(`Usage: agent-guild [command] [--no-browser]
@@ -105,6 +106,11 @@ async function ensureManager({ port = null } = {}) {
105
106
  const running = await health(knownUrl);
106
107
  if (running) return { url: knownUrl, started: false, version: running.version };
107
108
 
109
+ // While the boot service is on for this port, systemd is the one that starts a manager.
110
+ const { boot, note } = await serviceFor(listenPort);
111
+ if (boot) return startService({ boot, port: listenPort, url: expectedUrl, health });
112
+ if (note) console.log(note);
113
+
108
114
  const child = spawnManager({ env: { ...process.env, AGENT_GUILD_PORT: String(listenPort) } });
109
115
 
110
116
  let exited = false;
@@ -223,15 +229,26 @@ async function cmdStatus() {
223
229
  const h = await health(url);
224
230
  if (!h) {
225
231
  console.log('Session manager is not running.');
232
+ // With no manager to ask, the boot service is read from systemd directly.
233
+ const boot = defaultBoot();
234
+ const read = boot && await boot.read().catch(() => null);
235
+ if (read?.enabled) {
236
+ const port = unitPort(await boot.text().catch(() => null));
237
+ console.log(describeStartup(startupState(read.show, { port }), { linger: read.linger, user: boot.user }));
238
+ }
226
239
  process.exitCode = 3;
227
240
  return;
228
241
  }
229
- const res = await fetch(`${url}/api/v1/sessions`, { headers: { Authorization: `Bearer ${loadOrCreateToken()}` } });
242
+ const headers = { Authorization: `Bearer ${loadOrCreateToken()}` };
243
+ const res = await fetch(`${url}/api/v1/sessions`, { headers });
230
244
  const { sessions = [] } = res.ok ? await res.json() : {};
231
245
  const running = sessions.filter((s) => s.status === 'running').length;
232
246
  console.log(`Session manager ${h.version} running at ${url} (pid ${h.pid}).`);
233
247
  const notice = versionNotice(h.version);
234
248
  if (notice) console.log(notice);
249
+ const startup = await fetch(`${url}/api/v1/autostart`, { headers }).then((r) => (r.ok ? r.json() : {}), () => ({}));
250
+ const summary = startupSummary(startup.autostart);
251
+ if (summary) console.log(summary);
235
252
  console.log(`${sessions.length} session(s), ${running} running.`);
236
253
  for (const s of sessions) {
237
254
  const model = s.model ? ` [${s.model.displayName || s.model.name}]` : '';
@@ -255,10 +272,19 @@ async function main() {
255
272
  switch (command) {
256
273
  case 'open': return cmdOpen({ browser: !flags.has('--no-browser'), signIn: flags.has('--sign-in') });
257
274
  case 'start': {
275
+ // `--service`: run by the Linux boot service, which systemd supervises.
276
+ const supervised = flags.has('--service');
258
277
  await import('../src/manager/main.mjs').then(async (m) => {
259
278
  process.on('uncaughtException', (err) => console.error('[manager] unexpected error:', err));
260
279
  process.on('unhandledRejection', (err) => console.error('[manager] unhandled rejection:', err));
261
- const { shutdown } = await m.startManager();
280
+ const { shutdown } = await m.startManager({ supervised }).catch((err) => {
281
+ // Another manager has the port: an exit systemd does not retry, and the page reports.
282
+ if (supervised && err.code === 'EADDRINUSE') {
283
+ console.error(`agent-guild: ${err.message}`);
284
+ process.exit(EXIT_PORT_IN_USE);
285
+ }
286
+ throw err;
287
+ });
262
288
  console.log(`Open: ${pageUrl(readRuntimeFile()?.url ?? baseUrl(), loadOrCreateToken())}`);
263
289
  for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
264
290
  process.on(sig, () => shutdown(sig).then(() => process.exit(0)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oddessentials/agent-guild",
3
- "version": "0.33.1",
3
+ "version": "0.34.0",
4
4
  "description": "Launch and watch AI coding-assistant terminal sessions from one local web page. A bundled session manager owns the terminals so the page can close and reconnect.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -14,6 +14,10 @@
14
14
  // runs a script in the data folder, as on macOS: desktops read Exec lines
15
15
  // by different rules (systemd's autostart generator keeps a backslash that
16
16
  // GLib removes), so the line holds only paths that read the same in all.
17
+ // Linux also offers a systemd user service that starts the manager when
18
+ // the computer starts (systemd-service.mjs). One starter at a time: the
19
+ // setting is a mode, off, sign-in or boot, and choosing one removes the
20
+ // other, so a sign-in never races the service for the port.
17
21
  //
18
22
  // Each launcher touches a file in the data folder before it starts Node.js,
19
23
  // and `open --sign-in` records whether a manager then answered, so the page
@@ -27,14 +31,15 @@
27
31
  // an entry whose package script is gone removes itself at sign-in instead
28
32
  // of failing there at every sign-in after. A missing Node.js alone keeps
29
33
  // the entry: the next manager to start points it at its own. Homebrew's
30
- // Node.js is named by its opt link, which an upgrade keeps, rather than by
31
- // the versioned Cellar folder the upgrade deletes.
34
+ // and Snap's Node.js are named by the link an upgrade keeps (opt, current)
35
+ // rather than by the versioned folder the upgrade deletes.
32
36
 
33
37
  import fs from 'node:fs';
34
38
  import os from 'node:os';
35
39
  import path from 'node:path';
36
40
  import { execFile } from 'node:child_process';
37
41
  import { DEFAULT_PORT } from './config.mjs';
42
+ import { BOOT_NOTE, UNIT_UNSAFE, bootSupported, createBootService, journalCommand, lingerCommand, startupState, systemdRunner, unitPort, userName } from './systemd-service.mjs';
38
43
 
39
44
  export const ARGS = ['open', '--no-browser', '--sign-in'];
40
45
  export const WINDOWS_RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
@@ -61,8 +66,8 @@ export const MAC_NOTE = 'Starts the session manager in the background when you s
61
66
  export const LINUX_NOTE = 'Starts the session manager in the background when you sign in to a desktop session on this computer. The page does not open. A computer with no desktop session, such as a server reached over SSH, does not start it. Desktop startup settings list it as Agent Guild.';
62
67
  /** Added to LINUX_NOTE when the manager itself was not started from a desktop session. */
63
68
  export const LINUX_NO_DESKTOP = 'This manager was not started from a desktop session, so this computer may not have one.';
64
- /** Characters that systemd's autostart generator and GLib read differently in an Exec line. */
65
- const EXEC_UNSAFE = /["`$\\]/;
69
+ /** Linux startup modes. */
70
+ export const MODES = ['off', 'sign-in', 'boot'];
66
71
 
67
72
  const failure = (message) => Object.assign(new Error(message), { status: 500, code: 'autostart_failed' });
68
73
 
@@ -138,19 +143,31 @@ export function posixCommand({ launcher, ...paths }) {
138
143
  }
139
144
 
140
145
  /**
141
- * Homebrew runs Node.js from `<prefix>/Cellar/<formula>/<version>`, which
142
- * the next upgrade deletes. `<prefix>/opt/<formula>` follows the formula
143
- * across upgrades, so it names Node.js instead when it leads to the same file.
146
+ * Versioned Node.js folders that an upgrade deletes, each with the link that
147
+ * follows the package across upgrades. Homebrew runs Node.js from
148
+ * `<prefix>/Cellar/<formula>/<version>`, kept as `<prefix>/opt/<formula>`.
149
+ * A snap runs it from `<snaps>/<snap>/<revision>`, where `<snaps>` is
150
+ * `/snap` or `/var/lib/snapd/snap`, kept as `<snaps>/<snap>/current`; snapd
151
+ * removes old revisions after a refresh.
144
152
  */
153
+ const VERSIONED_NODE = [
154
+ [/^(.+)\/Cellar\/([^/]+)\/[^/]+\/bin\/node$/, (prefix, formula) => `${prefix}/opt/${formula}/bin/node`],
155
+ [/^(.*\/snap)\/([^/]+)\/x?\d+\/bin\/node$/, (snaps, snap) => `${snaps}/${snap}/current/bin/node`],
156
+ ];
157
+
158
+ /** Names Node.js by the link that survives upgrades, when that link leads to the same file. */
145
159
  export function stableExecPath(execPath, realpath = fs.realpathSync) {
146
- const match = execPath.match(/^(.+)\/Cellar\/([^/]+)\/[^/]+\/bin\/node$/);
147
- if (!match) return execPath;
148
- const opt = `${match[1]}/opt/${match[2]}/bin/node`;
149
- try {
150
- return realpath(opt) === realpath(execPath) ? opt : execPath;
151
- } catch {
152
- return execPath;
160
+ for (const [pattern, link] of VERSIONED_NODE) {
161
+ const match = execPath.match(pattern);
162
+ if (!match) continue;
163
+ const stable = link(...match.slice(1));
164
+ try {
165
+ return realpath(stable) === realpath(execPath) ? stable : execPath;
166
+ } catch {
167
+ return execPath;
168
+ }
153
169
  }
170
+ return execPath;
154
171
  }
155
172
 
156
173
  const xml = (value) => value.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
@@ -306,6 +323,9 @@ async function clearSignIns(dataDir) {
306
323
  * @param {string|null} [opts.unavailable] a reason autostart cannot be offered, which turns it off here
307
324
  * @param {(args: string[]) => Promise<{ status: number, stdout: string, stderr: string }>} [opts.reg] reg.exe, replaceable in tests
308
325
  * @param {(args: string[]) => Promise<{ status: number, stdout: string, stderr: string }>} [opts.launchctl] launchctl, replaceable in tests
326
+ * @param {(command: string, args: string[]) => Promise<object>} [opts.systemd] systemctl and journalctl on Linux, replaceable in tests
327
+ * @param {string} [opts.lingerDir] where systemd records lingering users, replaceable in tests
328
+ * @param {number} [opts.pid] this manager, which the service state compares with systemd's
309
329
  */
310
330
  export function createAutostart({
311
331
  platform = process.platform,
@@ -321,6 +341,10 @@ export function createAutostart({
321
341
  reg = runCommand(path.win32.join(env.SystemRoot || env.SYSTEMROOT || env.WINDIR || 'C:\\Windows', 'System32', 'reg.exe')),
322
342
  uid = process.getuid?.(),
323
343
  launchctl = runCommand('/bin/launchctl'),
344
+ user = userName(uid),
345
+ systemd = systemdRunner({ uid, env }),
346
+ lingerDir,
347
+ pid = process.pid,
324
348
  }) {
325
349
  const entryExecPath = platform === 'win32' ? execPath : stableExecPath(execPath);
326
350
  const attempt = path.join(dataDir, SIGN_IN_ATTEMPT);
@@ -328,6 +352,9 @@ export function createAutostart({
328
352
  let note = null;
329
353
  let unusable = null;
330
354
  let refreshError = null;
355
+ /** The systemd user service, on a Linux that can offer it. */
356
+ let boot = null;
357
+ let bootRefreshError = null;
331
358
  let queue = Promise.resolve();
332
359
  /** One change or check at a time, so two clicks cannot interleave their writes. */
333
360
  const serial = (fn) => {
@@ -412,13 +439,15 @@ export function createAutostart({
412
439
  await fs.promises.rm(launcher, { force: true });
413
440
  },
414
441
  };
415
- } else if (platform === 'linux' && !(env.WSL_DISTRO_NAME || /microsoft|wsl/i.test(release))) {
442
+ } else if (bootSupported({ platform, env, release })) {
416
443
  const file = path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'autostart', 'agent-guild.desktop');
417
444
  const launcher = path.join(dataDir, LINUX_LAUNCHER);
418
- const unsafe = [entryExecPath, script, file, launcher, log, attempt].find((p) => EXEC_UNSAFE.test(p));
445
+ const service = createBootService({ env, home, user, uid, dataDir, run: systemd, ...(lingerDir && { lingerDir }) });
446
+ const unsafe = [entryExecPath, script, file, launcher, log, attempt, ...service.paths].find((p) => UNIT_UNSAFE.test(p));
419
447
  if (unsafe) {
420
- unusable = `Not available because this path holds a character (", \`, $ or \\) that desktops read differently in a startup entry: ${unsafe}`;
448
+ unusable = `Not available because this path holds a character (", \`, $, \\ or a line break) that desktops and systemd read differently in a startup entry: ${unsafe}`;
421
449
  } else {
450
+ boot = service;
422
451
  const desktop = env.XDG_CURRENT_DESKTOP || env.WAYLAND_DISPLAY || env.DISPLAY;
423
452
  note = desktop ? LINUX_NOTE : `${LINUX_NOTE} ${LINUX_NO_DESKTOP}`;
424
453
  target = {
@@ -443,10 +472,25 @@ export function createAutostart({
443
472
  : unavailable || unusable || (platform === 'linux' ? 'Not available in WSL. Start Agent Guild from Windows to launch it at sign-in.'
444
473
  : 'Not available on this operating system.');
445
474
 
475
+ const FAILED = new Set(['failed', 'port-in-use']);
476
+ /** The service's side of a Linux description: its state, and the commands that are the next step. */
477
+ const describeBoot = async () => {
478
+ const read = await boot.read();
479
+ const base = { note: BOOT_NOTE, user, linger: read.linger };
480
+ if (!read.reachable) return { ...base, available: false, enabled: false, reason: read.reason, state: null, commands: [] };
481
+ const state = startupState(read.show, { pid, port: unitPort(await boot.text()) });
482
+ const commands = [];
483
+ if (read.enabled && FAILED.has(state.kind)) commands.push(journalCommand());
484
+ if (read.enabled && !read.linger) commands.push(lingerCommand(user));
485
+ return { ...base, available: true, enabled: read.enabled, reason: read.enabled ? bootRefreshError : null, state, commands };
486
+ };
487
+ const modeOf = (signIn, bootOn) => (signIn && bootOn ? 'both' : bootOn ? 'boot' : signIn ? 'sign-in' : 'off');
488
+
446
489
  const describe = async () => {
447
490
  if (!target) return { available: false, enabled: false, reason };
448
491
  try {
449
492
  const enabled = await target.enabled();
493
+ const linux = boot ? await describeBoot() : null;
450
494
  return {
451
495
  available: true,
452
496
  enabled,
@@ -455,38 +499,90 @@ export function createAutostart({
455
499
  // An unreadable record never keeps the setting from being turned off.
456
500
  lastRun: enabled ? await lastSignIn(dataDir).catch(() => null) : null,
457
501
  log,
502
+ ...(linux && { mode: modeOf(enabled, linux.enabled), boot: linux }),
458
503
  };
459
504
  } catch (err) {
460
505
  return { available: false, enabled: false, reason: `Could not read the startup setting: ${err.message}` };
461
506
  }
462
507
  };
463
508
 
464
- return {
465
- describe: () => serial(describe),
466
- /** Turns autostart on or off and resolves to the new description. */
467
- set: (enabled) => serial(async () => {
468
- if (!target) throw Object.assign(new Error(reason), { status: 409, code: 'autostart_unavailable' });
509
+ /**
510
+ * Makes `mode` the one starter. The new one is added and confirmed before
511
+ * the old one goes, and nothing here starts or stops a manager: a service
512
+ * takes over at the manager's next restart or the computer's next start.
513
+ */
514
+ const apply = async (mode) => {
515
+ const signedIn = await target.enabled().catch(() => false);
516
+ // A fresh start says it has not run yet rather than report an earlier time it was on.
517
+ if (mode !== 'sign-in' || !signedIn) await clearSignIns(dataDir);
518
+ if (mode === 'boot') {
519
+ const read = await boot.read();
520
+ if (!read.reachable) throw Object.assign(new Error(read.reason), { status: 409, code: 'autostart_unavailable' });
469
521
  try {
470
- // A fresh start says it has not run yet rather than report an earlier time it was on.
471
- if (!enabled || !(await target.enabled().catch(() => false))) await clearSignIns(dataDir);
472
- await (enabled ? target.enable() : target.disable());
473
- refreshError = null;
474
- const state = await describe();
475
- if (!state.available || state.enabled !== enabled) throw failure(state.reason || 'Could not verify the startup setting.');
476
- return state;
522
+ await boot.write(command());
523
+ await boot.reload();
524
+ await boot.enable();
525
+ if (!(await boot.read()).enabled) throw failure('systemd did not enable the unit.');
477
526
  } catch (err) {
478
- throw err.code === 'autostart_failed' ? err : failure(`Could not change the startup setting: ${err.message}`);
527
+ // Leave no half-made unit behind; the sign-in entry, if any, is still in place.
528
+ if (!read.enabled) await boot.remove().catch(() => {});
529
+ throw err;
479
530
  }
531
+ await target.disable();
532
+ return;
533
+ }
534
+ await (mode === 'sign-in' ? target.enable() : target.disable());
535
+ if (boot) await boot.remove();
536
+ };
537
+
538
+ /** Applies the mode `pick` resolves to, inside the queue, and checks the result against what the OS reports. */
539
+ const change = (pick) => serial(async () => {
540
+ if (!target) throw Object.assign(new Error(reason), { status: 409, code: 'autostart_unavailable' });
541
+ const mode = await pick();
542
+ if (mode === 'boot' && !boot) throw Object.assign(new Error('Starting when the computer starts is offered only on Linux with systemd.'), { status: 409, code: 'autostart_unavailable' });
543
+ try {
544
+ await apply(mode);
545
+ refreshError = null;
546
+ bootRefreshError = null;
547
+ const state = await describe();
548
+ const done = boot ? state.mode === mode : state.enabled === (mode === 'sign-in');
549
+ if (!state.available || !done) throw failure(state.reason || 'Could not verify the startup setting.');
550
+ return state;
551
+ } catch (err) {
552
+ throw err.code === 'autostart_failed' || err.code === 'autostart_unavailable' ? err : failure(`Could not change the startup setting: ${err.message}`);
553
+ }
554
+ });
555
+
556
+ return {
557
+ describe: () => serial(describe),
558
+ /**
559
+ * Turns the sign-in entry on or off and resolves to the new description.
560
+ * On Linux, on means the sign-in mode, and off keeps a service that is on.
561
+ */
562
+ set: (enabled) => change(async () => {
563
+ if (enabled) return 'sign-in';
564
+ return boot && ['boot', 'both'].includes((await describe()).mode) ? 'boot' : 'off';
480
565
  }),
481
- /** Points an entry that is on at this manager's Node.js, package and port. */
566
+ /** Linux: makes `mode` (off, sign-in or boot) the one way the manager starts. Elsewhere boot is unavailable. */
567
+ setMode: (mode) => change(async () => mode),
568
+ /** Points what is on at this manager's Node.js, package and port. */
482
569
  refresh: () => serial(async () => {
570
+ let first = null;
483
571
  try {
484
572
  if (target && await target.enabled()) await target.write();
485
573
  refreshError = null;
486
574
  } catch (err) {
487
575
  refreshError = `Could not update the sign-in entry: ${err.message}`;
488
- throw err;
576
+ first = err;
577
+ }
578
+ try {
579
+ if (boot && (await boot.read()).enabled && await boot.write(command())) await boot.reload();
580
+ bootRefreshError = null;
581
+ } catch (err) {
582
+ bootRefreshError = `Could not update the systemd unit: ${err.message}`;
583
+ first ??= err;
489
584
  }
585
+ if (first) throw first;
490
586
  }),
491
587
  };
492
588
  }
@@ -55,13 +55,15 @@ function candidateGroups(command, env, platform) {
55
55
  * Resolve `command` to an absolute path, or return null when it is not
56
56
  * installed. Commands that already contain a path separator are checked as-is.
57
57
  * `isExecutable` is injectable so tests can simulate another platform.
58
+ * `onSkip` drops a candidate before that check, without touching the filesystem.
58
59
  */
59
60
  export function resolveCommand(command, env = process.env, platform = process.platform, {
60
61
  isExecutable = (file) => isExecutableFile(file, platform),
62
+ onSkip = () => false,
61
63
  } = {}) {
62
64
  if (!command) return null;
63
65
  for (const group of candidateGroups(command, env, platform)) {
64
- const hit = group.find((c) => isExecutable(c));
66
+ const hit = group.find((candidate) => !onSkip(candidate) && isExecutable(candidate));
65
67
  if (hit) return hit;
66
68
  }
67
69
  return null;