@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.
- package/README.md +126 -359
- package/bin/agent-guild.mjs +29 -3
- package/package.json +1 -1
- package/src/manager/autostart.mjs +104 -20
- package/src/manager/environment-pins.mjs +289 -0
- package/src/manager/environment-probe.mjs +11 -0
- package/src/manager/environment.mjs +320 -15
- package/src/manager/github-views.mjs +34 -2
- package/src/manager/github.mjs +3 -1
- package/src/manager/launch.mjs +93 -1
- package/src/manager/main.mjs +25 -12
- package/src/manager/server.mjs +37 -6
- package/src/manager/systemd-service.mjs +312 -0
- package/web/app.js +163 -9
- package/web/environment.js +239 -34
- package/web/index.html +24 -8
- package/web/styles.css +39 -0
|
@@ -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
|
-
/**
|
|
65
|
-
const
|
|
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
|
|
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
|
|
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 (", \`,
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
|
|
483
|
-
|
|
484
|
-
await
|
|
485
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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.
|