@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 +41 -10
- package/bin/agent-guild.mjs +29 -3
- package/package.json +1 -1
- package/src/manager/autostart.mjs +128 -32
- package/src/manager/command-resolver.mjs +3 -1
- package/src/manager/environment-pins.mjs +289 -0
- package/src/manager/environment-probe.mjs +80 -19
- package/src/manager/environment.mjs +325 -16
- 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 +129 -8
- package/web/environment.js +239 -34
- package/web/index.html +23 -7
- package/web/styles.css +28 -0
|
@@ -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 },
|
|
@@ -52,8 +53,8 @@ export function passiveExecutable(file, id, { platform = process.platform, env =
|
|
|
52
53
|
if (/\/(?:shims|\.nodejs)\//.test(normalized) || /\/(?:mise|asdf|volta)(?:\.exe)?$/.test(normalized)) {
|
|
53
54
|
return { error: 'A version-manager shim was found. Its runtime cannot be checked without activating the manager.' };
|
|
54
55
|
}
|
|
55
|
-
if (platform === 'win32' &&
|
|
56
|
-
return { error:
|
|
56
|
+
if (platform === 'win32' && windowsAlias(normalized)) {
|
|
57
|
+
return { error: WINDOWS_ALIAS };
|
|
57
58
|
}
|
|
58
59
|
const head = read(real);
|
|
59
60
|
const binary = head[0] === 0x4d && head[1] === 0x5a // PE
|
|
@@ -68,22 +69,43 @@ export function passiveExecutable(file, id, { platform = process.platform, env =
|
|
|
68
69
|
return { error: 'A script launcher was found. Passive detection does not execute unrecognized wrappers.' };
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
const WINDOWS_ALIAS = 'A Windows execution alias was found. A runtime behind this alias has not been verified.';
|
|
73
|
+
|
|
74
|
+
function windowsAlias(file) {
|
|
75
|
+
return /[\\/]windowsapps[\\/]/i.test(file);
|
|
76
|
+
}
|
|
77
|
+
|
|
71
78
|
// The helper has a second, independent overall deadline in Environment. A
|
|
72
79
|
// timeout here resolves immediately, even if a descendant retains stdout.
|
|
73
|
-
|
|
80
|
+
// The kill is recorded for directory cleanup and does not delay this result.
|
|
81
|
+
export function runProbe(file, args, { env, cwd, timeoutMs = TIMEOUT_MS, spawnProcess = spawn, kills = null } = {}) {
|
|
74
82
|
return new Promise((resolve) => {
|
|
75
83
|
let child, timer, size = 0, output = '', settled = false;
|
|
76
84
|
const stop = () => {
|
|
77
85
|
if (!child?.pid) return;
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
86
|
+
const pending = new Promise((done) => {
|
|
87
|
+
let ended = false;
|
|
88
|
+
// Keep cleanup alive after the child is unref'd, even if 'close' never arrives.
|
|
89
|
+
const backup = setTimeout(finishKill, 2000);
|
|
90
|
+
function finishKill() {
|
|
91
|
+
if (ended) return;
|
|
92
|
+
ended = true;
|
|
93
|
+
clearTimeout(backup);
|
|
94
|
+
done();
|
|
95
|
+
}
|
|
96
|
+
// 'close' waits for the process to end. Do not settle when stdio is only destroyed.
|
|
97
|
+
child.once('close', finishKill);
|
|
98
|
+
if (process.platform === 'win32') killWindowsTree(child.pid, finishKill);
|
|
99
|
+
else { try { child.kill('SIGKILL'); } catch { finishKill(); } }
|
|
100
|
+
});
|
|
101
|
+
if (Array.isArray(kills)) kills.push(pending);
|
|
102
|
+
try { child.stdout?.destroy(); child.stderr?.destroy(); child.unref(); } catch { /* the kill is already recorded */ }
|
|
81
103
|
};
|
|
82
104
|
const finish = (result, kill = false) => {
|
|
83
105
|
if (settled) return;
|
|
84
106
|
settled = true;
|
|
85
107
|
clearTimeout(timer);
|
|
86
|
-
if (kill) stop();
|
|
108
|
+
if (kill) try { stop(); } catch { /* the probe result still stands */ }
|
|
87
109
|
resolve({ output, ...result });
|
|
88
110
|
};
|
|
89
111
|
try {
|
|
@@ -108,17 +130,28 @@ const unavailable = {
|
|
|
108
130
|
rust: /no default is configured|toolchain .*not installed|could not choose a version|toolchain .*is not installable/i,
|
|
109
131
|
};
|
|
110
132
|
|
|
111
|
-
export async function scanRuntime(definition, { env, cwd, platform = process.platform, resolve = resolveCommand, inspect = passiveExecutable, run = runProbe } = {}) {
|
|
133
|
+
export async function scanRuntime(definition, { env, cwd, platform = process.platform, resolve = resolveCommand, inspect = passiveExecutable, run = runProbe, kills = null } = {}) {
|
|
112
134
|
const { id, label, command, args, pattern } = definition;
|
|
113
135
|
const base = { id, label, status: 'not_found', version: null, path: null, command, detail: null };
|
|
114
136
|
const one = async (name) => {
|
|
115
|
-
|
|
116
|
-
|
|
137
|
+
// A Windows execution alias must not be inspected or spawned. A later real binary still wins.
|
|
138
|
+
let alias = null;
|
|
139
|
+
const file = resolve(name, env, platform, {
|
|
140
|
+
onSkip(candidate) {
|
|
141
|
+
if (platform !== 'win32' || !windowsAlias(candidate)) return false;
|
|
142
|
+
if (!alias || path.win32.extname(candidate).toLowerCase() === '.exe') alias = candidate;
|
|
143
|
+
return true;
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
if (!file) {
|
|
147
|
+
if (!alias) return { ...base, command: name };
|
|
148
|
+
return { ...base, command: name, path: alias, status: 'unavailable', detail: WINDOWS_ALIAS };
|
|
149
|
+
}
|
|
117
150
|
const row = { ...base, command: name, path: file };
|
|
118
151
|
try {
|
|
119
152
|
const executable = inspect(file, id, { platform, env });
|
|
120
153
|
if (executable.error) return { ...row, status: 'unavailable', detail: executable.error };
|
|
121
|
-
const result = await run(executable.file, args, { env: { ...probeEnv(env), DOTNET_CLI_HOME: cwd, DOTNET_GENERATE_ASPNET_CERTIFICATE: 'false' }, cwd });
|
|
154
|
+
const result = await run(executable.file, args, { env: { ...probeEnv(env), DOTNET_CLI_HOME: cwd, DOTNET_GENERATE_ASPNET_CERTIFICATE: 'false' }, cwd, kills });
|
|
122
155
|
if (result.error) return { ...row, status: 'failed', detail: result.error };
|
|
123
156
|
const version = result.code === 0 ? pattern.exec(result.output)?.[1] : null;
|
|
124
157
|
if (version) return { ...row, status: 'ok', version };
|
|
@@ -139,7 +172,7 @@ export async function scanRuntime(definition, { env, cwd, platform = process.pla
|
|
|
139
172
|
try {
|
|
140
173
|
const executable = inspect(row.path, id, { platform, env });
|
|
141
174
|
if (!executable.error) {
|
|
142
|
-
const result = await run(executable.file, ['--list-runtimes'], { env: { ...probeEnv(env), DOTNET_CLI_HOME: cwd }, cwd });
|
|
175
|
+
const result = await run(executable.file, ['--list-runtimes'], { env: { ...probeEnv(env), DOTNET_CLI_HOME: cwd }, cwd, kills });
|
|
143
176
|
if (result.code === 0) row.runtimes = result.output.split(/\r?\n/).map((line) => line.match(/^(Microsoft\.[\w.]+) (\d+\.\d+\.\d+(?:-[\w.-]+)?) \[/))
|
|
144
177
|
.filter(Boolean).map((match) => ({ name: match[1], version: match[2] }));
|
|
145
178
|
}
|
|
@@ -162,10 +195,41 @@ export function detectTools({ env, platform = process.platform, resolve = resolv
|
|
|
162
195
|
return tools;
|
|
163
196
|
}
|
|
164
197
|
|
|
198
|
+
// Windows cannot remove a process's current working directory, and a probe
|
|
199
|
+
// killed at its deadline can still hold that directory. Cleanup must not
|
|
200
|
+
// decide whether the scan succeeded.
|
|
201
|
+
export async function releaseScanDirectory(cwd, kills = [], {
|
|
202
|
+
chdir = (dir) => process.chdir(dir),
|
|
203
|
+
remove = fs.rmSync,
|
|
204
|
+
tmpdir = os.tmpdir,
|
|
205
|
+
} = {}) {
|
|
206
|
+
await Promise.all(kills).catch(() => {});
|
|
207
|
+
let temp = '';
|
|
208
|
+
try { temp = tmpdir(); } catch { /* removal is skipped below */ }
|
|
209
|
+
try { if (temp) chdir(temp); } catch { /* still try to remove the scan directory */ }
|
|
210
|
+
if (!cwd || !temp) return;
|
|
211
|
+
if (path.dirname(cwd) !== path.resolve(temp) || !path.basename(cwd).startsWith('agent-guild-environment-')) return;
|
|
212
|
+
try {
|
|
213
|
+
remove(cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 40 });
|
|
214
|
+
} catch { /* a leftover lock must not fail the scan */ }
|
|
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
|
+
|
|
165
227
|
if (process.argv[2] === '--scan-environment' && process.send) {
|
|
166
228
|
// A fresh temporary directory contains no project files. Manager env is
|
|
167
229
|
// retained; no selected shell, project or session is inspected or changed.
|
|
168
230
|
let cwd;
|
|
231
|
+
let finished = false;
|
|
232
|
+
const kills = [];
|
|
169
233
|
try {
|
|
170
234
|
cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-guild-environment-'));
|
|
171
235
|
process.chdir(cwd);
|
|
@@ -174,16 +238,13 @@ if (process.argv[2] === '--scan-environment' && process.send) {
|
|
|
174
238
|
// Two batches bound concurrency while giving each runtime its own result.
|
|
175
239
|
for (let i = 0; i < RUNTIMES.length; i += 3) {
|
|
176
240
|
await Promise.all(RUNTIMES.slice(i, i + 3).map(async (definition) => {
|
|
177
|
-
const runtime = await scanRuntime(definition, { env, cwd });
|
|
241
|
+
const runtime = await scanRuntime(definition, { env, cwd, kills });
|
|
178
242
|
process.send({ runtime });
|
|
179
243
|
}));
|
|
180
244
|
}
|
|
245
|
+
finished = true;
|
|
181
246
|
} finally {
|
|
182
|
-
|
|
183
|
-
process.chdir(os.tmpdir());
|
|
184
|
-
if (cwd && path.dirname(cwd) === path.resolve(os.tmpdir()) && path.basename(cwd).startsWith('agent-guild-environment-')) {
|
|
185
|
-
fs.rmSync(cwd, { recursive: true, force: true });
|
|
186
|
-
}
|
|
247
|
+
await releaseScanDirectory(cwd, kills);
|
|
187
248
|
}
|
|
188
|
-
process.send({ done: true });
|
|
249
|
+
if (finished) process.send({ done: true });
|
|
189
250
|
}
|