@oddessentials/agent-guild 0.33.2 → 0.35.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.
@@ -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
@@ -35,6 +39,7 @@ 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
 
@@ -318,6 +323,9 @@ async function clearSignIns(dataDir) {
318
323
  * @param {string|null} [opts.unavailable] a reason autostart cannot be offered, which turns it off here
319
324
  * @param {(args: string[]) => Promise<{ status: number, stdout: string, stderr: string }>} [opts.reg] reg.exe, replaceable in tests
320
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
321
329
  */
322
330
  export function createAutostart({
323
331
  platform = process.platform,
@@ -333,6 +341,10 @@ export function createAutostart({
333
341
  reg = runCommand(path.win32.join(env.SystemRoot || env.SYSTEMROOT || env.WINDIR || 'C:\\Windows', 'System32', 'reg.exe')),
334
342
  uid = process.getuid?.(),
335
343
  launchctl = runCommand('/bin/launchctl'),
344
+ user = userName(uid),
345
+ systemd = systemdRunner({ uid, env }),
346
+ lingerDir,
347
+ pid = process.pid,
336
348
  }) {
337
349
  const entryExecPath = platform === 'win32' ? execPath : stableExecPath(execPath);
338
350
  const attempt = path.join(dataDir, SIGN_IN_ATTEMPT);
@@ -340,6 +352,9 @@ export function createAutostart({
340
352
  let note = null;
341
353
  let unusable = null;
342
354
  let refreshError = null;
355
+ /** The systemd user service, on a Linux that can offer it. */
356
+ let boot = null;
357
+ let bootRefreshError = null;
343
358
  let queue = Promise.resolve();
344
359
  /** One change or check at a time, so two clicks cannot interleave their writes. */
345
360
  const serial = (fn) => {
@@ -424,13 +439,15 @@ export function createAutostart({
424
439
  await fs.promises.rm(launcher, { force: true });
425
440
  },
426
441
  };
427
- } else if (platform === 'linux' && !(env.WSL_DISTRO_NAME || /microsoft|wsl/i.test(release))) {
442
+ } else if (bootSupported({ platform, env, release })) {
428
443
  const file = path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'autostart', 'agent-guild.desktop');
429
444
  const launcher = path.join(dataDir, LINUX_LAUNCHER);
430
- 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));
431
447
  if (unsafe) {
432
- 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}`;
433
449
  } else {
450
+ boot = service;
434
451
  const desktop = env.XDG_CURRENT_DESKTOP || env.WAYLAND_DISPLAY || env.DISPLAY;
435
452
  note = desktop ? LINUX_NOTE : `${LINUX_NOTE} ${LINUX_NO_DESKTOP}`;
436
453
  target = {
@@ -455,10 +472,25 @@ export function createAutostart({
455
472
  : unavailable || unusable || (platform === 'linux' ? 'Not available in WSL. Start Agent Guild from Windows to launch it at sign-in.'
456
473
  : 'Not available on this operating system.');
457
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
+
458
489
  const describe = async () => {
459
490
  if (!target) return { available: false, enabled: false, reason };
460
491
  try {
461
492
  const enabled = await target.enabled();
493
+ const linux = boot ? await describeBoot() : null;
462
494
  return {
463
495
  available: true,
464
496
  enabled,
@@ -467,38 +499,90 @@ export function createAutostart({
467
499
  // An unreadable record never keeps the setting from being turned off.
468
500
  lastRun: enabled ? await lastSignIn(dataDir).catch(() => null) : null,
469
501
  log,
502
+ ...(linux && { mode: modeOf(enabled, linux.enabled), boot: linux }),
470
503
  };
471
504
  } catch (err) {
472
505
  return { available: false, enabled: false, reason: `Could not read the startup setting: ${err.message}` };
473
506
  }
474
507
  };
475
508
 
476
- return {
477
- describe: () => serial(describe),
478
- /** Turns autostart on or off and resolves to the new description. */
479
- set: (enabled) => serial(async () => {
480
- 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' });
481
521
  try {
482
- // A fresh start says it has not run yet rather than report an earlier time it was on.
483
- if (!enabled || !(await target.enabled().catch(() => false))) await clearSignIns(dataDir);
484
- await (enabled ? target.enable() : target.disable());
485
- refreshError = null;
486
- const state = await describe();
487
- if (!state.available || state.enabled !== enabled) throw failure(state.reason || 'Could not verify the startup setting.');
488
- 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.');
489
526
  } catch (err) {
490
- 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;
491
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';
492
565
  }),
493
- /** 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. */
494
569
  refresh: () => serial(async () => {
570
+ let first = null;
495
571
  try {
496
572
  if (target && await target.enabled()) await target.write();
497
573
  refreshError = null;
498
574
  } catch (err) {
499
575
  refreshError = `Could not update the sign-in entry: ${err.message}`;
500
- 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;
501
584
  }
585
+ if (first) throw first;
502
586
  }),
503
587
  };
504
588
  }
@@ -0,0 +1,289 @@
1
+ // Project pin files only. A pin is what a file asks for, never proof that a
2
+ // runtime is installed or which binary a shell would execute.
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+
7
+ export const PIN_LIMIT = 64 * 1024;
8
+ export const PIN_TEXT_MAX = 128;
9
+
10
+ export const PIN_NAMES = [
11
+ '.nvmrc', '.node-version', '.python-version', 'package.json', 'pyproject.toml',
12
+ 'rust-toolchain.toml', 'rust-toolchain', 'go.mod', 'global.json', '.tool-versions',
13
+ ];
14
+
15
+ const TOOL_VERSIONS = {
16
+ node: ['tool-versions-node', 'Node.js'],
17
+ nodejs: ['tool-versions-node', 'Node.js'],
18
+ python: ['tool-versions-python', 'Python'],
19
+ go: ['tool-versions-go', 'Go'],
20
+ golang: ['tool-versions-go', 'Go'],
21
+ rust: ['tool-versions-rust', 'Rust'],
22
+ dotnet: ['tool-versions-dotnet', '.NET SDK'],
23
+ 'dotnet-sdk': ['tool-versions-dotnet', '.NET SDK'],
24
+ };
25
+
26
+ function pin(id, label, source, status, version = null, detail = null) {
27
+ return { id, label, source, version, status, detail };
28
+ }
29
+
30
+ function configured(id, label, source, version) {
31
+ const text = typeof version === 'string' ? version.trim() : '';
32
+ if (!text) return pin(id, label, source, 'invalid', null, 'The pin is empty.');
33
+ if (text.length > PIN_TEXT_MAX) return pin(id, label, source, 'invalid', null, 'The pin is longer than 128 characters.');
34
+ return pin(id, label, source, 'configured', text);
35
+ }
36
+
37
+ function stripBom(text) {
38
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
39
+ }
40
+
41
+ function versionLine(text) {
42
+ for (const raw of stripBom(text).split(/\r?\n/)) {
43
+ const line = raw.trim();
44
+ if (!line || line.startsWith('#')) continue;
45
+ return line;
46
+ }
47
+ return null;
48
+ }
49
+
50
+ function textPin(id, label, source, text) {
51
+ const line = versionLine(text);
52
+ if (line === null) return [pin(id, label, source, 'invalid', null, 'The pin file has no version.')];
53
+ return [configured(id, label, source, line)];
54
+ }
55
+
56
+ function packagePins(text) {
57
+ let data;
58
+ try { data = JSON.parse(stripBom(text)); } catch {
59
+ return [pin('package-json', 'package.json', 'package.json', 'invalid', null, 'package.json could not be read as JSON.')];
60
+ }
61
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
62
+ return [pin('package-json', 'package.json', 'package.json', 'invalid', null, 'package.json could not be read as JSON.')];
63
+ }
64
+ const pins = [];
65
+ if (data.engines !== undefined) {
66
+ if (!data.engines || typeof data.engines !== 'object' || Array.isArray(data.engines)) {
67
+ pins.push(pin('engines', 'package.json', 'package.json', 'invalid', null, 'engines is not an object.'));
68
+ } else {
69
+ for (const [key, id, label] of [['node', 'engines-node', 'Node.js'], ['npm', 'engines-npm', 'npm']]) {
70
+ if (data.engines[key] === undefined) continue;
71
+ if (typeof data.engines[key] !== 'string') pins.push(pin(id, label, 'package.json', 'invalid', null, `engines.${key} is not a string.`));
72
+ else pins.push(configured(id, label, 'package.json', data.engines[key]));
73
+ }
74
+ }
75
+ }
76
+ if (data.packageManager !== undefined) {
77
+ if (typeof data.packageManager !== 'string') pins.push(pin('package-manager', 'packageManager', 'package.json', 'invalid', null, 'packageManager is not a string.'));
78
+ else {
79
+ const name = data.packageManager.split('@')[0].trim();
80
+ pins.push(configured('package-manager', name || 'packageManager', 'package.json', data.packageManager));
81
+ }
82
+ }
83
+ return pins;
84
+ }
85
+
86
+ function pythonRequires(text) {
87
+ const lines = stripBom(text).split(/\r?\n/).map((line) => line.trim()).filter((line) => line && !line.startsWith('#'));
88
+ const keys = lines.filter((line) => /^requires-python\s*=/.test(line));
89
+ if (!keys.length) return [];
90
+ if (keys.length !== 1) return [pin('requires-python', 'Python', 'pyproject.toml', 'invalid', null, 'requires-python is not a single-line string.')];
91
+ const match = /^requires-python\s*=\s*(["'])([^"']+)\1\s*(?:#.*)?$/.exec(keys[0]);
92
+ if (!match) return [pin('requires-python', 'Python', 'pyproject.toml', 'invalid', null, 'requires-python is not a single-line string.')];
93
+ return [configured('requires-python', 'Python', 'pyproject.toml', match[2])];
94
+ }
95
+
96
+ function rustToml(text) {
97
+ let section = '';
98
+ let channel = null;
99
+ let saw = false;
100
+ let bad = false;
101
+ for (const raw of stripBom(text).split(/\r?\n/)) {
102
+ const line = raw.trim();
103
+ if (!line || line.startsWith('#')) continue;
104
+ const heading = /^\[([^\]]+)\]$/.exec(line);
105
+ if (heading) {
106
+ section = heading[1].trim();
107
+ if (section === 'toolchain') saw = true;
108
+ continue;
109
+ }
110
+ if (section !== 'toolchain' || !/^channel\s*=/.test(line)) continue;
111
+ const match = /^channel\s*=\s*(["'])([^"']+)\1\s*(?:#.*)?$/.exec(line);
112
+ if (!match || channel !== null) bad = true;
113
+ else channel = match[2];
114
+ }
115
+ if (bad || (saw && channel === null)) return [pin('rust-toolchain', 'Rust', 'rust-toolchain.toml', 'invalid', null, 'rust-toolchain channel is not a single-line string.')];
116
+ return channel === null ? [] : [configured('rust-toolchain', 'Rust', 'rust-toolchain.toml', channel)];
117
+ }
118
+
119
+ function rustFile(text) {
120
+ const line = versionLine(text);
121
+ if (line === null) return [pin('rust-toolchain-file', 'Rust', 'rust-toolchain', 'invalid', null, 'The pin file has no version.')];
122
+ if (line.startsWith('[')) return [pin('rust-toolchain-file', 'Rust', 'rust-toolchain', 'invalid', null, 'rust-toolchain is not a single version line.')];
123
+ return [configured('rust-toolchain-file', 'Rust', 'rust-toolchain', line)];
124
+ }
125
+
126
+ function goMod(text) {
127
+ let language = null;
128
+ let toolchain = null;
129
+ let bad = false;
130
+ for (const raw of stripBom(text).split(/\r?\n/)) {
131
+ const line = raw.trim();
132
+ if (!line || line.startsWith('//')) continue;
133
+ if (/^go\s/.test(line)) {
134
+ const match = /^go\s+(\S+)$/.exec(line);
135
+ if (!match || language !== null) bad = true;
136
+ else language = match[1];
137
+ } else if (/^toolchain\s/.test(line)) {
138
+ const match = /^toolchain\s+(\S+)$/.exec(line);
139
+ if (!match || toolchain !== null) bad = true;
140
+ else toolchain = match[1];
141
+ }
142
+ }
143
+ if (bad) return [pin('go-mod', 'Go', 'go.mod', 'invalid', null, 'go.mod has a version line that could not be read.')];
144
+ const pins = [];
145
+ if (language) pins.push(configured('go-language', 'Go language', 'go.mod', language));
146
+ if (toolchain) pins.push(configured('go-toolchain', 'Go toolchain', 'go.mod', toolchain));
147
+ return pins;
148
+ }
149
+
150
+ function globalJson(text) {
151
+ let data;
152
+ try { data = JSON.parse(stripBom(text)); } catch {
153
+ return [pin('global-json', '.NET SDK', 'global.json', 'invalid', null, 'global.json could not be read as JSON.')];
154
+ }
155
+ if (!data || typeof data !== 'object' || Array.isArray(data) || data.sdk === undefined) return [];
156
+ if (!data.sdk || typeof data.sdk !== 'object' || Array.isArray(data.sdk) || typeof data.sdk.version !== 'string') {
157
+ return [pin('global-json', '.NET SDK', 'global.json', 'invalid', null, 'sdk.version is not a string.')];
158
+ }
159
+ return [configured('global-json', '.NET SDK', 'global.json', data.sdk.version)];
160
+ }
161
+
162
+ function toolVersions(text) {
163
+ const pins = [];
164
+ const used = new Set();
165
+ let bad = false;
166
+ for (const raw of stripBom(text).split(/\r?\n/)) {
167
+ const line = raw.trim();
168
+ if (!line || line.startsWith('#')) continue;
169
+ const gap = line.search(/\s/);
170
+ const name = (gap === -1 ? line : line.slice(0, gap)).toLowerCase();
171
+ const known = TOOL_VERSIONS[name];
172
+ if (!known) continue;
173
+ const version = gap === -1 ? '' : line.slice(gap).trim();
174
+ if (!version) bad = true;
175
+ else {
176
+ let id = known[0];
177
+ let n = 2;
178
+ while (used.has(id)) id = `${known[0]}-${n++}`;
179
+ used.add(id);
180
+ pins.push(configured(id, known[1], '.tool-versions', version));
181
+ }
182
+ }
183
+ if (bad) return [pin('tool-versions', '.tool-versions', '.tool-versions', 'invalid', null, '.tool-versions has a line that could not be read.')];
184
+ return pins;
185
+ }
186
+
187
+ const PARSERS = {
188
+ '.nvmrc': (text) => textPin('nvmrc', 'Node.js', '.nvmrc', text),
189
+ '.node-version': (text) => textPin('node-version', 'Node.js', '.node-version', text),
190
+ '.python-version': (text) => textPin('python-version', 'Python', '.python-version', text),
191
+ 'package.json': packagePins,
192
+ 'pyproject.toml': pythonRequires,
193
+ 'rust-toolchain.toml': rustToml,
194
+ 'rust-toolchain': rustFile,
195
+ 'go.mod': goMod,
196
+ 'global.json': globalJson,
197
+ '.tool-versions': toolVersions,
198
+ };
199
+
200
+ function inside(file, root) {
201
+ const child = path.resolve(file);
202
+ const base = path.resolve(root);
203
+ const left = process.platform === 'win32' ? child.toLowerCase() : child;
204
+ const right = process.platform === 'win32' ? base.toLowerCase() : base;
205
+ return left.startsWith(right.endsWith(path.sep) ? right : right + path.sep);
206
+ }
207
+
208
+ const FILE_LABELS = {
209
+ '.nvmrc': 'Node.js', '.node-version': 'Node.js', '.python-version': 'Python', 'package.json': 'package.json',
210
+ 'pyproject.toml': 'Python', 'rust-toolchain.toml': 'Rust', 'rust-toolchain': 'Rust', 'go.mod': 'Go',
211
+ 'global.json': '.NET SDK', '.tool-versions': '.tool-versions',
212
+ };
213
+
214
+ function readPin(file, realRoot) {
215
+ const name = path.basename(file);
216
+ const label = FILE_LABELS[name] || name;
217
+ let listed;
218
+ try { listed = fs.lstatSync(file); } catch (err) {
219
+ if (err.code === 'ENOENT') return { identity: { name, state: 'absent' } };
220
+ return { identity: { name, state: 'rejected' }, failure: pin(name, label, name, 'unreadable', null, 'The pin file could not be read.') };
221
+ }
222
+ let real = file;
223
+ if (listed.isSymbolicLink()) {
224
+ try { real = fs.realpathSync(file); } catch {
225
+ return { identity: { name, state: 'rejected' }, failure: pin(name, label, name, 'unreadable', null, 'The pin file could not be read.') };
226
+ }
227
+ if (!inside(real, realRoot)) {
228
+ return { identity: { name, state: 'rejected' }, failure: pin(name, label, name, 'unreadable', null, 'The pin is outside the project folder.') };
229
+ }
230
+ }
231
+ let stat;
232
+ try { stat = fs.statSync(real); } catch {
233
+ return { identity: { name, state: 'rejected' }, failure: pin(name, label, name, 'unreadable', null, 'The pin file could not be read.') };
234
+ }
235
+ if (!stat.isFile()) {
236
+ return { identity: { name, state: 'rejected' }, failure: pin(name, label, name, 'unreadable', null, 'The pin is not a file.') };
237
+ }
238
+ if (stat.size > PIN_LIMIT) {
239
+ return { identity: { name, state: 'file', mtimeMs: stat.mtimeMs, size: stat.size }, failure: pin(name, label, name, 'unreadable', null, 'The pin file is larger than 64 KiB.') };
240
+ }
241
+ return { identity: { name, state: 'file', mtimeMs: stat.mtimeMs, size: stat.size }, file: real };
242
+ }
243
+
244
+ /** Pins and file identity for one project folder. Does not change the process working directory. */
245
+ export function scanPins(cwd, { identityOnly = false } = {}) {
246
+ if (typeof cwd !== 'string' || !path.isAbsolute(cwd)) return { error: 'Could not read the project pins.' };
247
+ let root;
248
+ try { root = fs.statSync(cwd); } catch { return { error: 'Could not read the project pins.' }; }
249
+ if (!root.isDirectory()) return { error: 'Could not read the project pins.' };
250
+ let realRoot;
251
+ try { realRoot = fs.realpathSync(cwd); } catch { return { error: 'Could not read the project pins.' }; }
252
+ const identity = [];
253
+ const pins = [];
254
+ for (const name of PIN_NAMES) {
255
+ const read = readPin(path.join(cwd, name), realRoot);
256
+ identity.push(read.identity);
257
+ if (identityOnly) continue;
258
+ if (read.failure) pins.push(read.failure);
259
+ else if (read.file) {
260
+ let text;
261
+ try { text = fs.readFileSync(read.file); } catch {
262
+ pins.push(pin(name, FILE_LABELS[name] || name, name, 'unreadable', null, 'The pin file could not be read.'));
263
+ continue;
264
+ }
265
+ if (text.length > PIN_LIMIT) {
266
+ pins.push(pin(name, FILE_LABELS[name] || name, name, 'unreadable', null, 'The pin file is larger than 64 KiB.'));
267
+ continue;
268
+ }
269
+ pins.push(...PARSERS[name](text.toString('utf8')));
270
+ }
271
+ }
272
+ return { identity: JSON.stringify(identity), ...(identityOnly ? {} : { pins }), cwd: process.cwd() };
273
+ }
274
+
275
+ /** A working folder for a project check. Empty input is refused and does not become the home directory. */
276
+ export function resolveProjectCwd(cwd, { homedir = os.homedir, stat = fs.statSync } = {}) {
277
+ if (typeof cwd !== 'string' || !cwd.trim()) {
278
+ throw Object.assign(new Error('A working folder is required.'), { status: 400, code: 'cwd_required' });
279
+ }
280
+ let dir = cwd.trim();
281
+ if (dir === '~' || dir.startsWith('~/') || dir.startsWith('~\\')) dir = path.join(homedir(), dir.slice(1));
282
+ dir = path.resolve(dir);
283
+ let info;
284
+ try { info = stat(dir); } catch {
285
+ throw Object.assign(new Error(`working directory does not exist: ${dir}`), { status: 400, code: 'bad_cwd' });
286
+ }
287
+ if (!info.isDirectory()) throw Object.assign(new Error(`working directory is not a folder: ${dir}`), { status: 400, code: 'bad_cwd' });
288
+ return dir;
289
+ }
@@ -5,6 +5,7 @@ import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { spawn } from 'node:child_process';
7
7
  import { resolveCommand, killWindowsTree } from './command-resolver.mjs';
8
+ import { scanPins } from './environment-pins.mjs';
8
9
 
9
10
  export const RUNTIMES = [
10
11
  { id: 'node', label: 'Node.js', command: 'node', args: ['--version'], pattern: /^v(\d+\.\d+\.\d+(?:-[\w.-]+)?)\s*$/m },
@@ -213,6 +214,16 @@ export async function releaseScanDirectory(cwd, kills = [], {
213
214
  } catch { /* a leftover lock must not fail the scan */ }
214
215
  }
215
216
 
217
+ if ((process.argv[2] === '--scan-pins' || process.argv[2] === '--pin-identity') && process.send) {
218
+ // Read the named folder by absolute path. Never chdir into it: a project
219
+ // directory can activate tools on entry, and this check must not.
220
+ const started = process.cwd();
221
+ const result = scanPins(process.argv[3], { identityOnly: process.argv[2] === '--pin-identity' });
222
+ process.send({ ...result, cwd: process.cwd() });
223
+ if (process.cwd() !== started) process.chdir(started);
224
+ process.send({ done: true });
225
+ }
226
+
216
227
  if (process.argv[2] === '--scan-environment' && process.send) {
217
228
  // A fresh temporary directory contains no project files. Manager env is
218
229
  // retained; no selected shell, project or session is inspected or changed.