@oddessentials/agent-guild 0.35.0 → 0.37.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 +7 -3
- package/bin/agent-guild.mjs +24 -1
- package/package.json +2 -1
- package/src/manager/doctor.mjs +406 -0
- package/src/manager/environment-probe.mjs +252 -13
- package/src/manager/environment-system.mjs +205 -0
- package/src/manager/environment.mjs +60 -30
- package/src/manager/main.mjs +4 -1
- package/src/manager/pty.mjs +112 -0
- package/src/manager/self-update.mjs +7 -2
- package/src/manager/server.mjs +8 -0
- package/src/manager/session.mjs +2 -2
- package/web/app.js +52 -5
- package/web/environment.js +84 -8
- package/web/index.html +8 -0
|
@@ -6,6 +6,7 @@ import path from 'node:path';
|
|
|
6
6
|
import { spawn } from 'node:child_process';
|
|
7
7
|
import { resolveCommand, killWindowsTree } from './command-resolver.mjs';
|
|
8
8
|
import { scanPins } from './environment-pins.mjs';
|
|
9
|
+
import { systemInfo, dockerInfo, isWsl, wslInteropEnabled } from './environment-system.mjs';
|
|
9
10
|
|
|
10
11
|
export const RUNTIMES = [
|
|
11
12
|
{ id: 'node', label: 'Node.js', command: 'node', args: ['--version'], pattern: /^v(\d+\.\d+\.\d+(?:-[\w.-]+)?)\s*$/m },
|
|
@@ -57,10 +58,7 @@ export function passiveExecutable(file, id, { platform = process.platform, env =
|
|
|
57
58
|
return { error: WINDOWS_ALIAS };
|
|
58
59
|
}
|
|
59
60
|
const head = read(real);
|
|
60
|
-
|
|
61
|
-
|| head[0] === 0x7f && head.subarray(1, 4).toString() === 'ELF'
|
|
62
|
-
|| ['feedface', 'feedfacf', 'cefaedfe', 'cffaedfe', 'cafebabe', 'bebafeca', 'cafebabf', 'bfbafeca'].includes(head.subarray(0, 4).toString('hex'));
|
|
63
|
-
if (binary) {
|
|
61
|
+
if (nativeProgram(head)) {
|
|
64
62
|
// rustup dispatches using argv[0]; preserve its rustc proxy's name.
|
|
65
63
|
return { file: id === 'rust' && /\/rustup(?:\.exe)?$/.test(normalized) ? file : real };
|
|
66
64
|
}
|
|
@@ -71,6 +69,12 @@ export function passiveExecutable(file, id, { platform = process.platform, env =
|
|
|
71
69
|
|
|
72
70
|
const WINDOWS_ALIAS = 'A Windows execution alias was found. A runtime behind this alias has not been verified.';
|
|
73
71
|
|
|
72
|
+
function nativeProgram(head) {
|
|
73
|
+
return head[0] === 0x4d && head[1] === 0x5a // PE
|
|
74
|
+
|| head[0] === 0x7f && head.subarray(1, 4).toString() === 'ELF'
|
|
75
|
+
|| ['feedface', 'feedfacf', 'cefaedfe', 'cffaedfe', 'cafebabe', 'bebafeca', 'cafebabf', 'bfbafeca'].includes(head.subarray(0, 4).toString('hex'));
|
|
76
|
+
}
|
|
77
|
+
|
|
74
78
|
function windowsAlias(file) {
|
|
75
79
|
return /[\\/]windowsapps[\\/]/i.test(file);
|
|
76
80
|
}
|
|
@@ -78,7 +82,7 @@ function windowsAlias(file) {
|
|
|
78
82
|
// The helper has a second, independent overall deadline in Environment. A
|
|
79
83
|
// timeout here resolves immediately, even if a descendant retains stdout.
|
|
80
84
|
// 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 } = {}) {
|
|
85
|
+
export function runProbe(file, args, { env, cwd, timeoutMs = TIMEOUT_MS, limit = LIMIT, spawnProcess = spawn, kills = null } = {}) {
|
|
82
86
|
return new Promise((resolve) => {
|
|
83
87
|
let child, timer, size = 0, output = '', settled = false;
|
|
84
88
|
const stop = () => {
|
|
@@ -113,7 +117,7 @@ export function runProbe(file, args, { env, cwd, timeoutMs = TIMEOUT_MS, spawnPr
|
|
|
113
117
|
timer = setTimeout(() => finish({ error: 'Version check timed out.' }, true), timeoutMs);
|
|
114
118
|
for (const stream of [child.stdout, child.stderr]) stream.on('data', (chunk) => {
|
|
115
119
|
size += chunk.length;
|
|
116
|
-
if (size >
|
|
120
|
+
if (size > limit) return finish({ error: 'Version check produced too much output.' }, true);
|
|
117
121
|
output += chunk.toString();
|
|
118
122
|
});
|
|
119
123
|
child.on('error', () => finish({ error: 'Could not run the resolved executable.' }, true));
|
|
@@ -195,6 +199,229 @@ export function detectTools({ env, platform = process.platform, resolve = resolv
|
|
|
195
199
|
return tools;
|
|
196
200
|
}
|
|
197
201
|
|
|
202
|
+
// Design tools an agent can drive from a terminal. A version is read only from
|
|
203
|
+
// a native program whose real name is the tool's own: a snap, a Flatpak
|
|
204
|
+
// launcher or another wrapper is reported by presence. Windows `convert` is a
|
|
205
|
+
// disk utility, so ImageMagick is only `magick` there.
|
|
206
|
+
export const DESIGN_TOOLS = [
|
|
207
|
+
{
|
|
208
|
+
id: 'blender', label: 'Blender', args: ['--version', '--factory-startup'],
|
|
209
|
+
commands: () => ['blender'],
|
|
210
|
+
pattern: /^Blender (\d+\.\d+(?:\.\d+)?)/m, program: /^blender(?:\.exe)?$/i, installed: /^Blender\b/i,
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
id: 'ffmpeg', label: 'FFmpeg', args: ['-version'],
|
|
214
|
+
commands: () => ['ffmpeg'],
|
|
215
|
+
pattern: /^ffmpeg version n?(\d+(?:\.\d+)+|N-\d+)/m, program: /^ffmpeg(?:\.exe)?$/i, installed: /^FFmpeg\b/i,
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
// GIMP's window program on Windows does not print its version; the console build does.
|
|
219
|
+
id: 'gimp', label: 'GIMP', args: ['--version'],
|
|
220
|
+
commands: (platform) => platform === 'win32' ? ['gimp-console'] : ['gimp-console', 'gimp'],
|
|
221
|
+
pattern: /version (\d+\.\d+\.\d+)/, installed: /^GIMP\b/i,
|
|
222
|
+
program: (platform) => platform === 'win32' ? /^gimp-console(?:-[\d.]+)?\.exe$/i : /^gimp(?:-console)?(?:-[\d.]+)?$/i,
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
id: 'inkscape', label: 'Inkscape', args: ['--version'],
|
|
226
|
+
commands: () => ['inkscape'],
|
|
227
|
+
pattern: /^Inkscape (\d+\.\d+(?:\.\d+)?)/m, program: /^inkscape(?:\.com|\.exe)?$/i, installed: /^Inkscape\b/i,
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
id: 'imagemagick', label: 'ImageMagick', args: ['-version'],
|
|
231
|
+
commands: (platform) => platform === 'win32' ? ['magick'] : ['magick', 'convert'],
|
|
232
|
+
pattern: /^Version: ImageMagick (\d+\.\d+\.\d+(?:-\d+)?)/m, program: /^(?:magick|convert)(?:-im\d[\w.]*)?(?:\.exe)?$/i, installed: /^ImageMagick\b/i,
|
|
233
|
+
},
|
|
234
|
+
];
|
|
235
|
+
const DESIGN_TIMEOUT_MS = 3000;
|
|
236
|
+
const REGISTRY_LIMIT = 1024 * 1024;
|
|
237
|
+
const UNINSTALL_KEYS = [
|
|
238
|
+
'HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
|
|
239
|
+
'HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
|
|
240
|
+
'HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
|
|
241
|
+
];
|
|
242
|
+
// Paths inside an install folder, Windows. A RegExp segment matches a directory entry.
|
|
243
|
+
const WINDOWS_FILES = {
|
|
244
|
+
blender: [['blender.exe']],
|
|
245
|
+
ffmpeg: [['bin', 'ffmpeg.exe'], ['ffmpeg.exe'], [/^ffmpeg-/i, 'bin', 'ffmpeg.exe']],
|
|
246
|
+
gimp: [['bin', 'gimp-console.exe'], ['bin', /^gimp-console-[\d.]+\.exe$/i]],
|
|
247
|
+
inkscape: [['bin', 'inkscape.com'], ['bin', 'inkscape.exe']],
|
|
248
|
+
imagemagick: [['magick.exe']],
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
// Usual install locations, used only when PATH has no match. Each entry is an
|
|
252
|
+
// absolute root followed by segments below it.
|
|
253
|
+
export function designLocations(id, { platform = process.platform, env = process.env, registry = [] } = {}) {
|
|
254
|
+
if (platform === 'win32') {
|
|
255
|
+
const programFiles = [value(env, 'ProgramFiles'), value(env, 'ProgramW6432'), 'C:\\Program Files'].filter(Boolean);
|
|
256
|
+
const profile = value(env, 'USERPROFILE');
|
|
257
|
+
const local = value(env, 'LOCALAPPDATA') || (profile && path.win32.join(profile, 'AppData', 'Local'));
|
|
258
|
+
const folders = {
|
|
259
|
+
blender: programFiles.map((root) => [root, 'Blender Foundation', /^Blender/i]),
|
|
260
|
+
ffmpeg: [],
|
|
261
|
+
gimp: [...programFiles.map((root) => [root, /^GIMP/i]), ...(local ? [[local, 'Programs', /^GIMP/i]] : [])],
|
|
262
|
+
inkscape: programFiles.map((root) => [root, 'Inkscape']),
|
|
263
|
+
imagemagick: programFiles.map((root) => [root, /^ImageMagick/i]),
|
|
264
|
+
}[id];
|
|
265
|
+
return [...registry.map((dir) => [dir]), ...folders].flatMap((folder) => WINDOWS_FILES[id].map((file) => [...folder, ...file]));
|
|
266
|
+
}
|
|
267
|
+
const home = value(env, 'HOME') || os.homedir();
|
|
268
|
+
const bins = platform === 'darwin'
|
|
269
|
+
? ['/opt/homebrew/bin', '/usr/local/bin', '/opt/local/bin']
|
|
270
|
+
: ['/usr/local/bin', '/usr/bin', '/snap/bin', '/home/linuxbrew/.linuxbrew/bin'];
|
|
271
|
+
const flatpaks = ['/var/lib/flatpak/exports/bin', path.posix.join(home, '.local/share/flatpak/exports/bin')];
|
|
272
|
+
const apps = ['/Applications', path.posix.join(home, 'Applications')];
|
|
273
|
+
const inBins = (names) => bins.flatMap((dir) => names.map((name) => [dir, name]));
|
|
274
|
+
const flatpak = (app) => platform === 'linux' ? flatpaks.map((dir) => [dir, app]) : [];
|
|
275
|
+
const bundle = (app, file) => platform === 'darwin' ? apps.map((dir) => [dir, app, 'Contents', 'MacOS', file]) : [];
|
|
276
|
+
return {
|
|
277
|
+
blender: [...bundle(/^Blender.*\.app$/i, 'Blender'), ...inBins(['blender']), ...flatpak('org.blender.Blender')],
|
|
278
|
+
ffmpeg: inBins(['ffmpeg']),
|
|
279
|
+
gimp: [...bundle(/^GIMP.*\.app$/i, /^gimp(?:-[\d.]+)?$/i), ...inBins(['gimp-console', 'gimp']), ...flatpak('org.gimp.GIMP')],
|
|
280
|
+
inkscape: [...bundle(/^Inkscape.*\.app$/i, 'inkscape'), ...inBins(['inkscape']), ...flatpak('org.inkscape.Inkscape')],
|
|
281
|
+
imagemagick: inBins(platform === 'darwin' ? ['magick'] : ['magick', 'convert']),
|
|
282
|
+
}[id];
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// The first existing file among the locations, in order. Directory listings are sorted.
|
|
286
|
+
export function findInstalled(locations, { platform = process.platform, readdir = fs.readdirSync, isFile = (file) => isExecutable(file, platform) } = {}) {
|
|
287
|
+
const join = platform === 'win32' ? path.win32.join : path.posix.join;
|
|
288
|
+
for (const [root, ...segments] of locations) {
|
|
289
|
+
let paths = [root];
|
|
290
|
+
for (const segment of segments) {
|
|
291
|
+
paths = paths.flatMap((dir) => {
|
|
292
|
+
if (typeof segment === 'string') return [join(dir, segment)];
|
|
293
|
+
try { return readdir(dir).filter((name) => segment.test(name)).sort().map((name) => join(dir, name)); } catch { return []; }
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
const hit = paths.find((file) => isFile(file));
|
|
297
|
+
if (hit) return hit;
|
|
298
|
+
}
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function isExecutable(file, platform) {
|
|
303
|
+
try {
|
|
304
|
+
if (!fs.statSync(file).isFile()) return false;
|
|
305
|
+
if (platform !== 'win32') fs.accessSync(file, fs.constants.X_OK);
|
|
306
|
+
return true;
|
|
307
|
+
} catch { return false; }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Install folders from Windows uninstall entries whose name is a design tool.
|
|
311
|
+
// Each query is bounded; a failed query adds nothing.
|
|
312
|
+
export async function windowsInstallFolders({ env, cwd, run = runProbe, kills = null } = {}) {
|
|
313
|
+
const reg = path.win32.join(value(env, 'SystemRoot') || 'C:\\Windows', 'System32', 'reg.exe');
|
|
314
|
+
const query = (args) => run(reg, ['query', ...args], { env, cwd, kills, timeoutMs: DESIGN_TIMEOUT_MS, limit: REGISTRY_LIMIT })
|
|
315
|
+
.then((result) => result.code === 0 ? result.output : '');
|
|
316
|
+
const entries = (output, name) => {
|
|
317
|
+
const found = [];
|
|
318
|
+
let key = null;
|
|
319
|
+
for (const line of output.split(/\r?\n/)) {
|
|
320
|
+
if (/^HKEY_/.test(line)) key = line.trim();
|
|
321
|
+
const match = line.match(new RegExp(`^\\s+${name}\\s+REG_(?:EXPAND_)?SZ\\s+(.+?)\\s*$`));
|
|
322
|
+
if (key && match) found.push({ key, value: match[1] });
|
|
323
|
+
}
|
|
324
|
+
return found;
|
|
325
|
+
};
|
|
326
|
+
const named = (await Promise.all(UNINSTALL_KEYS.map((root) => query([root, '/s', '/v', 'DisplayName']))))
|
|
327
|
+
.flatMap((output) => entries(output, 'DisplayName'))
|
|
328
|
+
.map(({ key, value: name }) => ({ key, tool: DESIGN_TOOLS.find((tool) => tool.installed.test(name)) }))
|
|
329
|
+
.filter(({ tool }) => tool);
|
|
330
|
+
const folders = Object.fromEntries(DESIGN_TOOLS.map(({ id }) => [id, []]));
|
|
331
|
+
await Promise.all(named.map(async ({ key, tool }) => {
|
|
332
|
+
const folder = entries(await query([key, '/v', 'InstallLocation']), 'InstallLocation')[0]?.value.replace(/^"(.*)"$/, '$1');
|
|
333
|
+
if (folder && path.win32.isAbsolute(folder)) folders[tool.id].push(folder);
|
|
334
|
+
}));
|
|
335
|
+
return folders;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// A Homebrew cask's command wrapper is exactly `exec "<program in the app>" "$@"`.
|
|
339
|
+
// Only that exact form is followed; any other script stays unchecked.
|
|
340
|
+
const HOMEBREW_WRAPPER = /\/Caskroom\/[^/]+\/[^/]+\/\.homebrew-command-wrappers\/[^/]+$/;
|
|
341
|
+
|
|
342
|
+
function homebrewTarget(head) {
|
|
343
|
+
return head.toString('utf8').match(/^#!\/bin\/(?:ba)?sh\nexec "(\/[^"$`\\\n]+)"\s+"\$@"\n?$/)?.[1] ?? null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Why a found program's version is not read, or the program to run.
|
|
347
|
+
export function designProgram(file, tool, { platform = process.platform, realpath = fs.realpathSync, read = readHead } = {}) {
|
|
348
|
+
let real = realpath(file);
|
|
349
|
+
const normalized = real.replaceAll('\\', '/');
|
|
350
|
+
if (/\/flatpak\/exports\/bin\//.test(file.replaceAll('\\', '/'))) return { reason: 'A Flatpak app. Its version is not checked.' };
|
|
351
|
+
if (/(?:^|\/)snap$/.test(normalized)) return { reason: 'A snap. Its version is not checked.' };
|
|
352
|
+
const head = read(real);
|
|
353
|
+
if (!nativeProgram(head)) {
|
|
354
|
+
const target = platform === 'darwin' && HOMEBREW_WRAPPER.test(normalized) ? homebrewTarget(head) : null;
|
|
355
|
+
if (!target) return { reason: 'A launcher script. Its version is not checked.' };
|
|
356
|
+
real = realpath(target);
|
|
357
|
+
if (!nativeProgram(read(real))) return { reason: 'A launcher script. Its version is not checked.' };
|
|
358
|
+
}
|
|
359
|
+
const name = path.posix.basename(real.replaceAll('\\', '/'));
|
|
360
|
+
const program = typeof tool.program === 'function' ? tool.program(platform) : tool.program;
|
|
361
|
+
if (!program.test(name)) return { reason: `This runs ${name}, so its version is not checked.` };
|
|
362
|
+
return { file: real };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// GTK programs write a D-Bus keyring and profile folders even for --version.
|
|
366
|
+
// Point every home and profile location at the scan's own temporary folder.
|
|
367
|
+
export function designEnv(env, cwd) {
|
|
368
|
+
const out = probeEnv(env);
|
|
369
|
+
const overrides = { DBUS_SESSION_BUS_ADDRESS: 'disabled:' };
|
|
370
|
+
for (const key of ['HOME', 'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_CACHE_HOME', 'XDG_STATE_HOME', 'INKSCAPE_PROFILE_DIR', 'GIMP2_DIRECTORY', 'GIMP3_DIRECTORY']) overrides[key] = cwd;
|
|
371
|
+
for (const key of Object.keys(out)) if (Object.hasOwn(overrides, key.toUpperCase())) delete out[key];
|
|
372
|
+
return { ...out, ...overrides };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export async function scanDesignTool(tool, {
|
|
376
|
+
env, cwd, platform = process.platform, resolve = resolveCommand, installed = async () => null,
|
|
377
|
+
inspect = designProgram, run = runProbe, kills = null, wslInterop = false,
|
|
378
|
+
} = {}) {
|
|
379
|
+
const { id, label, args, pattern } = tool;
|
|
380
|
+
const base = { id, label, status: 'not_found', version: null, path: null, command: null, detail: null };
|
|
381
|
+
let command = null, file = null;
|
|
382
|
+
for (const name of tool.commands(platform)) {
|
|
383
|
+
file = resolve(name, env, platform, { onSkip: (candidate) => platform === 'win32' && windowsAlias(candidate) });
|
|
384
|
+
if (file) { command = name; break; }
|
|
385
|
+
}
|
|
386
|
+
// WSL puts the Windows PATH on its own. Agents there run a Windows program by its .exe name.
|
|
387
|
+
if (!file && wslInterop) {
|
|
388
|
+
for (const name of tool.commands('win32').map((name) => `${name}.exe`)) {
|
|
389
|
+
file = resolve(name, env, platform);
|
|
390
|
+
if (file) return { ...base, status: 'on_path', path: file, command: name, detail: 'A Windows program, run through WSL. Its version is not checked.' };
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
const onPath = Boolean(file);
|
|
394
|
+
if (!file) file = await installed(id);
|
|
395
|
+
if (!file) return { ...base, detail: 'Not on PATH or in the usual install locations.' };
|
|
396
|
+
const row = { ...base, status: onPath ? 'on_path' : 'not_on_path', path: file, command };
|
|
397
|
+
try {
|
|
398
|
+
const program = inspect(file, tool, { platform });
|
|
399
|
+
if (program.reason) return { ...row, detail: program.reason };
|
|
400
|
+
const result = await run(program.file, args, { env: designEnv(env, cwd), cwd, kills, timeoutMs: DESIGN_TIMEOUT_MS });
|
|
401
|
+
const version = !result.error && result.code === 0 ? pattern.exec(result.output)?.[1] : null;
|
|
402
|
+
if (version) return { ...row, version };
|
|
403
|
+
return { ...row, detail: result.error === 'Version check timed out.' ? 'The version check timed out.'
|
|
404
|
+
: result.error ? 'Could not read its version.' : 'Its version response was not recognized.' };
|
|
405
|
+
} catch { return { ...row, detail: 'Could not read its version.' }; }
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export async function scanDesignTools({ env, cwd, platform = process.platform, resolve = resolveCommand, kills = null, send = () => {} } = {}) {
|
|
409
|
+
let folders = null;
|
|
410
|
+
// Read the registry at most once, and only when some tool is missing from PATH.
|
|
411
|
+
const registry = () => folders ??= platform === 'win32'
|
|
412
|
+
? windowsInstallFolders({ env, cwd, kills }).catch(() => ({}))
|
|
413
|
+
: Promise.resolve({});
|
|
414
|
+
const installed = async (id) => findInstalled(designLocations(id, { platform, env, registry: (await registry())[id] || [] }), { platform });
|
|
415
|
+
const wslInterop = platform === 'linux' && isWsl({ env }) && wslInteropEnabled();
|
|
416
|
+
// Bounded concurrency: cold starts of large applications compete for the disk.
|
|
417
|
+
const queue = [...DESIGN_TOOLS];
|
|
418
|
+
await Promise.all([0, 1, 2].map(async () => {
|
|
419
|
+
for (let tool = queue.shift(); tool; tool = queue.shift()) {
|
|
420
|
+
send(await scanDesignTool(tool, { env, cwd, platform, resolve, installed, kills, wslInterop }));
|
|
421
|
+
}
|
|
422
|
+
}));
|
|
423
|
+
}
|
|
424
|
+
|
|
198
425
|
// Windows cannot remove a process's current working directory, and a probe
|
|
199
426
|
// killed at its deadline can still hold that directory. Cleanup must not
|
|
200
427
|
// decide whether the scan succeeded.
|
|
@@ -235,13 +462,25 @@ if (process.argv[2] === '--scan-environment' && process.send) {
|
|
|
235
462
|
process.chdir(cwd);
|
|
236
463
|
const env = { ...process.env };
|
|
237
464
|
process.send({ tools: detectTools({ env }) });
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
465
|
+
await Promise.all([
|
|
466
|
+
(async () => {
|
|
467
|
+
// Two batches bound concurrency while giving each runtime its own result.
|
|
468
|
+
for (let i = 0; i < RUNTIMES.length; i += 3) {
|
|
469
|
+
await Promise.all(RUNTIMES.slice(i, i + 3).map(async (definition) => {
|
|
470
|
+
const runtime = await scanRuntime(definition, { env, cwd, kills });
|
|
471
|
+
process.send({ runtime });
|
|
472
|
+
}));
|
|
473
|
+
}
|
|
474
|
+
})(),
|
|
475
|
+
scanDesignTools({ env, cwd, kills, send: (design) => process.send({ design }) }),
|
|
476
|
+
dockerInfo({ env, resolve: resolveCommand })
|
|
477
|
+
.catch(() => ({ status: 'failed', detail: 'The Docker check did not finish.' }))
|
|
478
|
+
.then((docker) => process.send({ docker })),
|
|
479
|
+
// System facts are the same for every scope, so only the manager asks.
|
|
480
|
+
process.argv.includes('--system') && systemInfo({ env, cwd, run: runProbe, kills })
|
|
481
|
+
.catch(() => null)
|
|
482
|
+
.then((system) => process.send({ system })),
|
|
483
|
+
]);
|
|
245
484
|
finished = true;
|
|
246
485
|
} finally {
|
|
247
486
|
await releaseScanDirectory(cwd, kills);
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// System facts and the Docker engine, for the environment helper. Reads only:
|
|
2
|
+
// os, a few small files, one registry query on Windows, and one GET /version
|
|
3
|
+
// on a local Docker socket or pipe. No docker process is started and a remote
|
|
4
|
+
// engine is never contacted. Where systemd starts the engine on demand
|
|
5
|
+
// (docker.socket, podman.socket), opening its socket starts it.
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import http from 'node:http';
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
|
|
12
|
+
const DOCKER_TIMEOUT_MS = 1500;
|
|
13
|
+
const DOCKER_LIMIT = 256 * 1024;
|
|
14
|
+
const REGISTRY_TIMEOUT_MS = 3000;
|
|
15
|
+
const LXSS = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss';
|
|
16
|
+
// Linux's system sockets belong to the docker group; a rootless or Podman socket does not.
|
|
17
|
+
const GROUP_SOCKETS = ['/var/run/docker.sock', '/run/docker.sock'];
|
|
18
|
+
|
|
19
|
+
function value(env, name) {
|
|
20
|
+
return env[Object.keys(env).find((key) => key.toUpperCase() === name.toUpperCase())];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readText(file, read) {
|
|
24
|
+
try { return read(file, 'utf8'); } catch { return null; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isWsl({ env = process.env, release = os.release() } = {}) {
|
|
28
|
+
return Boolean(value(env, 'WSL_DISTRO_NAME')) || /microsoft|wsl/i.test(release);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// WSL 1 kernels end in "-Microsoft"; WSL 2 kernels name WSL2. Newer WSL registers interop as WSLInterop-late.
|
|
32
|
+
export function wslInteropEnabled({ read = fs.readFileSync } = {}) {
|
|
33
|
+
return ['WSLInterop', 'WSLInterop-late'].some((name) => /^enabled/.test(readText(`/proc/sys/fs/binfmt_misc/${name}`, read) || ''));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function normalArch(name) {
|
|
37
|
+
const lower = String(name || '').toLowerCase();
|
|
38
|
+
if (['x86_64', 'amd64', 'x64'].includes(lower)) return 'x64';
|
|
39
|
+
if (['aarch64', 'arm64', 'aarch64_be'].includes(lower)) return 'arm64';
|
|
40
|
+
return lower || null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function linuxName(read) {
|
|
44
|
+
for (const file of ['/etc/os-release', '/usr/lib/os-release']) {
|
|
45
|
+
const text = readText(file, read);
|
|
46
|
+
const match = text?.match(/^PRETTY_NAME=(["']?)(.+)\1\s*$/m);
|
|
47
|
+
if (match) return match[2];
|
|
48
|
+
}
|
|
49
|
+
return 'Linux';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function macName(read) {
|
|
53
|
+
const text = readText('/System/Library/CoreServices/SystemVersion.plist', read);
|
|
54
|
+
const version = text?.match(/<key>ProductVersion<\/key>\s*<string>([^<]+)<\/string>/)?.[1];
|
|
55
|
+
return version ? `macOS ${version}` : 'macOS';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Windows lists WSL distributions under the user's Lxss key. Reading it does not start WSL.
|
|
59
|
+
export async function wslDistributions({ env, cwd, run, kills = null } = {}) {
|
|
60
|
+
const reg = path.win32.join(value(env, 'SystemRoot') || 'C:\\Windows', 'System32', 'reg.exe');
|
|
61
|
+
const result = await run(reg, ['query', LXSS, '/s'], { env, cwd, kills, timeoutMs: REGISTRY_TIMEOUT_MS });
|
|
62
|
+
if (result.error) return null;
|
|
63
|
+
if (result.code !== 0) return /unable to find/i.test(result.output) ? [] : null;
|
|
64
|
+
const fallback = result.output.match(/^\s+DefaultDistribution\s+REG_SZ\s+(\S+)/m)?.[1]?.toLowerCase();
|
|
65
|
+
const distros = new Map();
|
|
66
|
+
let key = null;
|
|
67
|
+
for (const line of result.output.split(/\r?\n/)) {
|
|
68
|
+
if (/^HKEY_/.test(line)) key = line.trim().split('\\').pop().toLowerCase();
|
|
69
|
+
const name = line.match(/^\s+DistributionName\s+REG_SZ\s+(.+?)\s*$/)?.[1];
|
|
70
|
+
const version = line.match(/^\s+Version\s+REG_DWORD\s+0x([0-9a-f]+)/i)?.[1];
|
|
71
|
+
if (!key || !key.startsWith('{')) continue;
|
|
72
|
+
const entry = distros.get(key) || { name: null, version: null, default: key === fallback };
|
|
73
|
+
if (name) entry.name = name;
|
|
74
|
+
if (version) entry.version = parseInt(version, 16);
|
|
75
|
+
distros.set(key, entry);
|
|
76
|
+
}
|
|
77
|
+
return [...distros.values()].filter((entry) => entry.name)
|
|
78
|
+
.sort((a, b) => Number(b.default) - Number(a.default) || a.name.localeCompare(b.name));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function systemInfo({
|
|
82
|
+
env = process.env, cwd, platform = process.platform, run, kills = null,
|
|
83
|
+
read = fs.readFileSync, release = os.release(), version = os.version(),
|
|
84
|
+
arch = process.arch, machine = os.machine(), cpus = os.cpus(), threads = os.availableParallelism(), memory = os.totalmem(),
|
|
85
|
+
} = {}) {
|
|
86
|
+
// Node says 'unknown' for a CPU it cannot name, such as many arm64 cores on Linux.
|
|
87
|
+
const model = cpus[0]?.model?.trim();
|
|
88
|
+
const info = {
|
|
89
|
+
os: null, osDetail: null, arch: normalArch(arch), hostArch: null,
|
|
90
|
+
cpu: { model: model && model !== 'unknown' ? model : null, threads }, memory, wsl: null, wslDistributions: null,
|
|
91
|
+
};
|
|
92
|
+
// Linux names the kernel's architecture, which can differ from Node's
|
|
93
|
+
// without emulation (a 32-bit build on a 64-bit kernel), so only Windows
|
|
94
|
+
// and macOS name a host architecture.
|
|
95
|
+
const host = platform === 'win32' || platform === 'darwin' ? normalArch(machine) : null;
|
|
96
|
+
if (host && host !== info.arch) info.hostArch = host;
|
|
97
|
+
if (platform === 'win32') {
|
|
98
|
+
info.os = version || 'Windows';
|
|
99
|
+
info.osDetail = `Version ${release}`;
|
|
100
|
+
try { info.wslDistributions = await wslDistributions({ env, cwd, run, kills }); } catch { /* the row is left out */ }
|
|
101
|
+
} else if (platform === 'darwin') {
|
|
102
|
+
info.os = macName(read);
|
|
103
|
+
info.osDetail = `Darwin ${release}`;
|
|
104
|
+
} else {
|
|
105
|
+
info.os = linuxName(read);
|
|
106
|
+
info.osDetail = `Kernel ${release}`;
|
|
107
|
+
if (isWsl({ env, release })) {
|
|
108
|
+
info.wsl = { version: /WSL2/i.test(release) ? 2 : 1, distro: value(env, 'WSL_DISTRO_NAME') || null, interop: wslInteropEnabled({ read }) };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return info;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Which engine the docker command would use: DOCKER_HOST, then DOCKER_CONTEXT,
|
|
115
|
+
// then the config's current context, then the platform default.
|
|
116
|
+
export function dockerEndpoint({ env = process.env, platform = process.platform, read = fs.readFileSync } = {}) {
|
|
117
|
+
const host = value(env, 'DOCKER_HOST');
|
|
118
|
+
if (host) return { endpoint: host, context: null, source: 'DOCKER_HOST' };
|
|
119
|
+
const home = platform === 'win32' ? value(env, 'USERPROFILE') || os.homedir() : value(env, 'HOME') || os.homedir();
|
|
120
|
+
const join = platform === 'win32' ? path.win32.join : path.posix.join;
|
|
121
|
+
const configDir = value(env, 'DOCKER_CONFIG') || join(home, '.docker');
|
|
122
|
+
let context = value(env, 'DOCKER_CONTEXT');
|
|
123
|
+
let source = 'DOCKER_CONTEXT';
|
|
124
|
+
if (!context) {
|
|
125
|
+
try { context = JSON.parse(read(join(configDir, 'config.json'), 'utf8')).currentContext; } catch { /* no config */ }
|
|
126
|
+
source = 'config';
|
|
127
|
+
}
|
|
128
|
+
if (!context || context === 'default') {
|
|
129
|
+
return { endpoint: platform === 'win32' ? 'npipe:////./pipe/docker_engine' : 'unix:///var/run/docker.sock', context: 'default', source: 'default' };
|
|
130
|
+
}
|
|
131
|
+
const id = createHash('sha256').update(context).digest('hex');
|
|
132
|
+
try {
|
|
133
|
+
const meta = JSON.parse(read(join(configDir, 'contexts', 'meta', id, 'meta.json'), 'utf8'));
|
|
134
|
+
const endpoint = meta?.Endpoints?.docker?.Host;
|
|
135
|
+
if (typeof endpoint === 'string' && endpoint) return { endpoint, context, source };
|
|
136
|
+
} catch { /* reported below */ }
|
|
137
|
+
return { endpoint: null, context, source, error: `Docker context ${context} was not found.` };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// A local socket or named pipe, or null for an engine reached over the network.
|
|
141
|
+
export function localSocket(endpoint) {
|
|
142
|
+
if (/^unix:\/\//i.test(endpoint)) return endpoint.replace(/^unix:\/\//i, '') || null;
|
|
143
|
+
if (/^npipe:/i.test(endpoint)) {
|
|
144
|
+
const name = endpoint.replace(/^npipe:/i, '').replace(/^[/\\]+\.?[/\\]+pipe[/\\]+/i, '');
|
|
145
|
+
return name && !/[/\\]/.test(name) ? `\\\\.\\pipe\\${name}` : null;
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function requestVersion(socketPath, { timeoutMs = DOCKER_TIMEOUT_MS, request = http.request } = {}) {
|
|
151
|
+
return new Promise((resolve) => {
|
|
152
|
+
let settled = false;
|
|
153
|
+
const finish = (result) => { if (!settled) { settled = true; resolve(result); } };
|
|
154
|
+
try {
|
|
155
|
+
const req = request({ socketPath, path: '/version', method: 'GET', headers: { Host: 'docker' }, timeout: timeoutMs }, (res) => {
|
|
156
|
+
let body = '';
|
|
157
|
+
res.setEncoding('utf8');
|
|
158
|
+
res.on('data', (chunk) => {
|
|
159
|
+
body += chunk;
|
|
160
|
+
if (body.length > DOCKER_LIMIT) { req.destroy(); finish({ error: 'too_large' }); }
|
|
161
|
+
});
|
|
162
|
+
res.on('end', () => finish({ status: res.statusCode, body }));
|
|
163
|
+
res.on('error', () => finish({ error: 'read' }));
|
|
164
|
+
});
|
|
165
|
+
req.on('timeout', () => { req.destroy(); finish({ error: 'timeout' }); });
|
|
166
|
+
req.on('error', (err) => finish({ error: err.code || 'error' }));
|
|
167
|
+
req.end();
|
|
168
|
+
} catch (err) { finish({ error: err.code || 'error' }); }
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function dockerInfo({ env = process.env, platform = process.platform, read = fs.readFileSync, resolve, query = requestVersion } = {}) {
|
|
173
|
+
const cli = resolve('docker', env, platform);
|
|
174
|
+
const target = dockerEndpoint({ env, platform, read });
|
|
175
|
+
const row = {
|
|
176
|
+
status: 'failed', version: null, platform: null, os: null, arch: null, wsl2: false,
|
|
177
|
+
context: target.context, endpoint: target.endpoint, cli, detail: null,
|
|
178
|
+
};
|
|
179
|
+
if (target.error) return { ...row, detail: target.error };
|
|
180
|
+
const socket = localSocket(target.endpoint);
|
|
181
|
+
if (!socket) return { ...row, status: 'remote', detail: 'This engine is reached over the network, so it is not checked.' };
|
|
182
|
+
const result = await query(socket);
|
|
183
|
+
if (result.error === 'EACCES' || result.error === 'EPERM') {
|
|
184
|
+
return { ...row, status: 'denied', detail: platform === 'win32'
|
|
185
|
+
? 'The engine is running, but this user cannot open its pipe.'
|
|
186
|
+
: `The engine socket exists, but this user cannot open it.${platform === 'linux' && GROUP_SOCKETS.includes(socket) ? ' Adding the user to the docker group allows it.' : ''}` };
|
|
187
|
+
}
|
|
188
|
+
if (['ENOENT', 'ECONNREFUSED', 'ENOTSOCK'].includes(result.error)) {
|
|
189
|
+
return cli
|
|
190
|
+
? { ...row, status: 'stopped', detail: 'The Docker engine is not running.' }
|
|
191
|
+
: { ...row, status: 'not_found', detail: 'No docker command on PATH and no engine answered.' };
|
|
192
|
+
}
|
|
193
|
+
if (result.error === 'timeout') return { ...row, detail: 'The engine did not answer in time.' };
|
|
194
|
+
if (result.error || result.status !== 200) return { ...row, detail: 'The engine did not give a usable answer.' };
|
|
195
|
+
let data;
|
|
196
|
+
try { data = JSON.parse(result.body); } catch { return { ...row, detail: 'The engine did not give a usable answer.' }; }
|
|
197
|
+
const version = typeof data?.Version === 'string' ? data.Version : null;
|
|
198
|
+
if (!version) return { ...row, detail: 'The engine did not give a usable answer.' };
|
|
199
|
+
const text = (item) => typeof item === 'string' && item ? item.slice(0, 200) : null;
|
|
200
|
+
return {
|
|
201
|
+
...row, status: 'running', version,
|
|
202
|
+
platform: text(data.Platform?.Name), os: text(data.Os), arch: text(data.Arch),
|
|
203
|
+
wsl2: data.Os === 'linux' && /microsoft-standard-WSL2/i.test(data.KernelVersion || ''),
|
|
204
|
+
};
|
|
205
|
+
}
|