@oddessentials/agent-guild 0.35.0 → 0.36.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 +5 -3
- package/bin/agent-guild.mjs +9 -1
- package/package.json +1 -1
- 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
package/README.md
CHANGED
|
@@ -39,10 +39,12 @@ terminal.
|
|
|
39
39
|
| Node.js | 22 or newer |
|
|
40
40
|
| Windows | 10 version 1809 or later, x64 or arm64. No WSL needed. |
|
|
41
41
|
| macOS | 11 or later, Intel or Apple silicon |
|
|
42
|
-
| Linux | x64 or arm64, with a desktop or over SSH |
|
|
42
|
+
| Linux | x64 or arm64 with glibc 2.28 or later (for example Debian 10, Ubuntu 20.04 or RHEL 8), with a desktop or over SSH |
|
|
43
43
|
|
|
44
|
-
No compiler is needed
|
|
45
|
-
|
|
44
|
+
No compiler is needed, except on Linux without glibc, such as Alpine:
|
|
45
|
+
`agent-guild` prints the commands that build its terminal library there. A
|
|
46
|
+
missing tool shows **Install** on its card; Antigravity CLI, which is not on
|
|
47
|
+
npm, shows its install command instead.
|
|
46
48
|
|
|
47
49
|
## Setup
|
|
48
50
|
|
package/bin/agent-guild.mjs
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
} from '../src/manager/config.mjs';
|
|
22
22
|
import { defaultBoot, serviceFor, spawnManager, startService } from '../src/manager/launch.mjs';
|
|
23
23
|
import { recordSignIn } from '../src/manager/autostart.mjs';
|
|
24
|
+
import { ptyProblem } from '../src/manager/pty.mjs';
|
|
24
25
|
import { EXIT_PORT_IN_USE, describeStartup, startupState, startupSummary, unitPort } from '../src/manager/systemd-service.mjs';
|
|
25
26
|
|
|
26
27
|
function usage() {
|
|
@@ -106,6 +107,10 @@ async function ensureManager({ port = null } = {}) {
|
|
|
106
107
|
const running = await health(knownUrl);
|
|
107
108
|
if (running) return { url: knownUrl, started: false, version: running.version };
|
|
108
109
|
|
|
110
|
+
// Say why no manager can run here rather than start one that fails.
|
|
111
|
+
const problem = ptyProblem();
|
|
112
|
+
if (problem) throw new Error(problem);
|
|
113
|
+
|
|
109
114
|
// While the boot service is on for this port, systemd is the one that starts a manager.
|
|
110
115
|
const { boot, note } = await serviceFor(listenPort);
|
|
111
116
|
if (boot) return startService({ boot, port: listenPort, url: expectedUrl, health });
|
|
@@ -161,7 +166,10 @@ async function requestShutdown(url, { restart = false } = {}) {
|
|
|
161
166
|
headers: { Authorization: `Bearer ${loadOrCreateToken()}`, 'Content-Type': 'application/json' },
|
|
162
167
|
body: JSON.stringify(restart ? { force: true, restart: true } : { force: true }),
|
|
163
168
|
});
|
|
164
|
-
if (!res.ok)
|
|
169
|
+
if (!res.ok) {
|
|
170
|
+
const { error } = await res.json().catch(() => ({}));
|
|
171
|
+
throw new Error(`${restart ? 'restart' : 'stop'} failed: ${error?.message ?? `HTTP ${res.status}`}`);
|
|
172
|
+
}
|
|
165
173
|
const body = await res.json().catch(() => ({}));
|
|
166
174
|
const running = body.running ?? 0;
|
|
167
175
|
if (running > 0) console.log(`Ending ${running} running session(s).`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oddessentials/agent-guild",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.36.0",
|
|
4
4
|
"description": "Launch and watch AI coding-assistant terminal sessions from one local web page. A bundled session manager owns the terminals so the page can close and reconnect.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -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
|
+
}
|
|
@@ -6,10 +6,10 @@ import { createHash } from 'node:crypto';
|
|
|
6
6
|
import { EventEmitter } from 'node:events';
|
|
7
7
|
import { fork } from 'node:child_process';
|
|
8
8
|
import { killWindowsTree } from './command-resolver.mjs';
|
|
9
|
-
import { RUNTIMES } from './environment-probe.mjs';
|
|
9
|
+
import { RUNTIMES, DESIGN_TOOLS } from './environment-probe.mjs';
|
|
10
10
|
import { resolveProjectCwd } from './environment-pins.mjs';
|
|
11
11
|
|
|
12
|
-
export const ENVIRONMENT_TIMEOUT_MS =
|
|
12
|
+
export const ENVIRONMENT_TIMEOUT_MS = 15000;
|
|
13
13
|
export const LAUNCH_DETAIL = 'Launch PATH, profiles not applied. The selected shell is not consulted.';
|
|
14
14
|
export const SESSION_DETAIL = 'Spawn PATH, before the shell startup files.';
|
|
15
15
|
export const MULTIPLEXER_DETAIL = 'This session is tmux or herdr. Its environment is not the spawn record.';
|
|
@@ -20,7 +20,9 @@ const PATH_ENV = [
|
|
|
20
20
|
'PATH', 'PATHEXT', 'SYSTEMROOT', 'HOME', 'USERPROFILE', 'HOMEDRIVE', 'HOMEPATH', 'XDG_DATA_HOME', 'XDG_CONFIG_HOME',
|
|
21
21
|
'NVM_DIR', 'VOLTA_HOME', 'NODENV_ROOT', 'NODENV_VERSION', 'PYENV_ROOT', 'PYENV_VERSION',
|
|
22
22
|
'ASDF_DIR', 'ASDF_DATA_DIR', 'MISE_DATA_DIR', 'MISE_CONFIG_DIR', 'RUSTUP_HOME', 'RUSTUP_TOOLCHAIN', 'CARGO_HOME',
|
|
23
|
-
'GOROOT', 'GOTOOLCHAIN', 'DOTNET_ROOT', 'R_HOME',
|
|
23
|
+
'GOROOT', 'GOTOOLCHAIN', 'DOTNET_ROOT', 'R_HOME', 'DOCKER_HOST', 'DOCKER_CONTEXT', 'DOCKER_CONFIG',
|
|
24
|
+
// Install folders searched for design tools that are not on PATH.
|
|
25
|
+
'LOCALAPPDATA', 'PROGRAMFILES', 'PROGRAMW6432',
|
|
24
26
|
];
|
|
25
27
|
|
|
26
28
|
function hostName() {
|
|
@@ -35,8 +37,41 @@ function cloneRow(row) {
|
|
|
35
37
|
};
|
|
36
38
|
}
|
|
37
39
|
|
|
38
|
-
function
|
|
39
|
-
return
|
|
40
|
+
function pendingRows(list) {
|
|
41
|
+
return list.map(({ id, label }) => ({ id, label, status: 'pending', version: null, path: null }));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const PENDING_DOCKER = { status: 'pending' };
|
|
45
|
+
|
|
46
|
+
function scanRun(extra = {}) {
|
|
47
|
+
return { child: null, timer: null, rows: new Map(), designs: new Map(), tools: [], docker: null, system: undefined, withSystem: false, ...extra };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Records one helper message. Returns true when the helper reports it is done.
|
|
51
|
+
function takeMessage(run, message) {
|
|
52
|
+
if (message?.runtime && RUNTIMES.some((runtime) => runtime.id === message.runtime.id)) run.rows.set(message.runtime.id, message.runtime);
|
|
53
|
+
if (message?.design && DESIGN_TOOLS.some((tool) => tool.id === message.design.id)) run.designs.set(message.design.id, message.design);
|
|
54
|
+
if (Array.isArray(message?.tools)) run.tools = message.tools;
|
|
55
|
+
if (message?.docker && typeof message.docker === 'object') run.docker = message.docker;
|
|
56
|
+
if (run.withSystem && message && 'system' in message) run.system = message.system ?? null;
|
|
57
|
+
return Boolean(message?.done);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function scanComplete(run) {
|
|
61
|
+
return run.rows.size === RUNTIMES.length && run.designs.size === DESIGN_TOOLS.length
|
|
62
|
+
&& Boolean(run.docker) && (!run.withSystem || run.system !== undefined);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Every row has a fresh result; an unfinished check is a failure, never a stale success.
|
|
66
|
+
function scanRows(run, error) {
|
|
67
|
+
const failed = ({ id, label }) => ({ id, label, status: 'failed', version: null, path: null, detail: error || 'The environment check did not finish.' });
|
|
68
|
+
return {
|
|
69
|
+
runtimes: RUNTIMES.map((row) => run.rows.get(row.id) ?? failed(row)),
|
|
70
|
+
designTools: DESIGN_TOOLS.map((row) => run.designs.get(row.id) ?? failed(row)),
|
|
71
|
+
docker: run.docker ?? { status: 'failed', detail: error || 'The environment check did not finish.' },
|
|
72
|
+
tools: run.tools,
|
|
73
|
+
...(run.withSystem ? { system: run.system ?? null } : {}),
|
|
74
|
+
};
|
|
40
75
|
}
|
|
41
76
|
|
|
42
77
|
export function probePathEnv(env) {
|
|
@@ -76,7 +111,10 @@ export class Environment extends EventEmitter {
|
|
|
76
111
|
return {
|
|
77
112
|
scope: 'manager', host: this.host, platform: process.platform, revision: 0, refreshing: false, checkedAt: null, error: null,
|
|
78
113
|
managerNode: { version: process.versions.node, path: process.execPath },
|
|
79
|
-
runtimes:
|
|
114
|
+
runtimes: pendingRows(RUNTIMES),
|
|
115
|
+
designTools: pendingRows(DESIGN_TOOLS),
|
|
116
|
+
docker: PENDING_DOCKER,
|
|
117
|
+
system: null,
|
|
80
118
|
tools: [],
|
|
81
119
|
};
|
|
82
120
|
}
|
|
@@ -93,7 +131,7 @@ export class Environment extends EventEmitter {
|
|
|
93
131
|
}
|
|
94
132
|
return publishLaunch ? this.launch : this.snapshot();
|
|
95
133
|
}
|
|
96
|
-
const run = {
|
|
134
|
+
const run = scanRun({ publishLaunch, withSystem: true });
|
|
97
135
|
this.run = run;
|
|
98
136
|
this.value = { ...this.value, revision: this.value.revision + 1, refreshing: true, error: null };
|
|
99
137
|
if (publishLaunch) this.armLaunch();
|
|
@@ -104,10 +142,7 @@ export class Environment extends EventEmitter {
|
|
|
104
142
|
this.stopWorker(run.child);
|
|
105
143
|
this.value = {
|
|
106
144
|
...this.value, revision: this.value.revision + 1, refreshing: false, checkedAt: new Date().toISOString(), error,
|
|
107
|
-
|
|
108
|
-
id, label, status: 'failed', version: null, path: null, detail: error || 'The environment check did not finish.',
|
|
109
|
-
}),
|
|
110
|
-
tools: run.tools,
|
|
145
|
+
...scanRows(run, error),
|
|
111
146
|
};
|
|
112
147
|
if (!this.closed) this.emit('updated', this.snapshot());
|
|
113
148
|
if (run.publishLaunch) this.storeLaunch();
|
|
@@ -116,21 +151,18 @@ export class Environment extends EventEmitter {
|
|
|
116
151
|
// A preload in NODE_OPTIONS must not execute inside a passive scan.
|
|
117
152
|
const env = { ...this.env };
|
|
118
153
|
for (const key of Object.keys(env)) if (key.toUpperCase() === 'NODE_OPTIONS') delete env[key];
|
|
119
|
-
run.child = this.forkWorker(new URL('./environment-probe.mjs', import.meta.url), ['--scan-environment'], {
|
|
154
|
+
run.child = this.forkWorker(new URL('./environment-probe.mjs', import.meta.url), ['--scan-environment', '--system'], {
|
|
120
155
|
env, execArgv: [], stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
|
|
121
156
|
windowsHide: true, detached: process.platform !== 'win32',
|
|
122
157
|
});
|
|
123
158
|
run.child.on('message', (message) => {
|
|
124
|
-
if (this.run
|
|
125
|
-
if (message?.runtime && RUNTIMES.some((runtime) => runtime.id === message.runtime.id)) run.rows.set(message.runtime.id, message.runtime);
|
|
126
|
-
if (Array.isArray(message?.tools)) run.tools = message.tools;
|
|
127
|
-
if (message?.done) finish();
|
|
159
|
+
if (this.run === run && takeMessage(run, message)) finish();
|
|
128
160
|
});
|
|
129
161
|
run.child.once('error', () => finish('Could not start the environment check.'));
|
|
130
|
-
// Let messages already queued win. Exit is a failed check only when a
|
|
162
|
+
// Let messages already queued win. Exit is a failed check only when a row never reported.
|
|
131
163
|
run.child.once('exit', () => setImmediate(() => {
|
|
132
164
|
if (this.run !== run) return;
|
|
133
|
-
finish(run
|
|
165
|
+
finish(scanComplete(run) ? null : 'The environment check stopped before finishing.');
|
|
134
166
|
}));
|
|
135
167
|
run.timer = setTimeout(() => finish('The environment check timed out.'), this.timeoutMs);
|
|
136
168
|
run.timer.unref();
|
|
@@ -146,6 +178,8 @@ export class Environment extends EventEmitter {
|
|
|
146
178
|
revision: (previous?.revision ?? 0) + 1, refreshing: true, checkedAt: previous?.checkedAt ?? null, error: null,
|
|
147
179
|
detail: LAUNCH_DETAIL,
|
|
148
180
|
runtimes: (previous?.runtimes ?? this.value.runtimes).map(cloneRow),
|
|
181
|
+
designTools: (previous?.designTools ?? this.value.designTools).map(cloneRow),
|
|
182
|
+
docker: { ...(previous?.docker ?? this.value.docker) },
|
|
149
183
|
tools: (previous?.tools ?? this.value.tools).map((tool) => ({ ...tool })),
|
|
150
184
|
};
|
|
151
185
|
if (!this.closed) this.emit('updated', this.launch);
|
|
@@ -157,6 +191,8 @@ export class Environment extends EventEmitter {
|
|
|
157
191
|
revision: (this.launch?.revision ?? 0) + 1, refreshing: false, checkedAt: this.value.checkedAt, error: this.value.error,
|
|
158
192
|
detail: LAUNCH_DETAIL,
|
|
159
193
|
runtimes: this.value.runtimes.map(cloneRow),
|
|
194
|
+
designTools: this.value.designTools.map(cloneRow),
|
|
195
|
+
docker: { ...this.value.docker },
|
|
160
196
|
tools: this.value.tools.map((tool) => ({ ...tool })),
|
|
161
197
|
};
|
|
162
198
|
if (!this.closed) this.emit('updated', this.launch);
|
|
@@ -287,7 +323,7 @@ export class Environment extends EventEmitter {
|
|
|
287
323
|
return {
|
|
288
324
|
scope: 'session', host: this.host, platform: process.platform, sessionId: id, spawnCwd: found.spawnCwd,
|
|
289
325
|
availability: 'ok', detail: SESSION_DETAIL, revision: 0, refreshing: false, checkedAt: null, error: null,
|
|
290
|
-
runtimes:
|
|
326
|
+
runtimes: pendingRows(RUNTIMES), designTools: pendingRows(DESIGN_TOOLS), docker: PENDING_DOCKER, tools: [],
|
|
291
327
|
};
|
|
292
328
|
}
|
|
293
329
|
|
|
@@ -299,7 +335,7 @@ export class Environment extends EventEmitter {
|
|
|
299
335
|
const value = {
|
|
300
336
|
scope: 'session', host: this.host, platform: process.platform, sessionId: id, spawnCwd: found.spawnCwd,
|
|
301
337
|
availability: 'unavailable', detail: MULTIPLEXER_DETAIL, revision: 1, refreshing: false,
|
|
302
|
-
checkedAt: new Date().toISOString(), error: null, runtimes: [], tools: [],
|
|
338
|
+
checkedAt: new Date().toISOString(), error: null, runtimes: [], designTools: [], docker: null, tools: [],
|
|
303
339
|
};
|
|
304
340
|
this.sessions.set(key, { value, run: null });
|
|
305
341
|
return value;
|
|
@@ -307,7 +343,7 @@ export class Environment extends EventEmitter {
|
|
|
307
343
|
|
|
308
344
|
startSession(entry, found) {
|
|
309
345
|
if (this.closed || entry.run) return;
|
|
310
|
-
const run =
|
|
346
|
+
const run = scanRun();
|
|
311
347
|
entry.run = run;
|
|
312
348
|
entry.value = { ...entry.value, revision: entry.value.revision + 1, refreshing: true, error: null };
|
|
313
349
|
const finish = (error = null) => {
|
|
@@ -317,10 +353,7 @@ export class Environment extends EventEmitter {
|
|
|
317
353
|
this.stopWorker(run.child);
|
|
318
354
|
entry.value = {
|
|
319
355
|
...entry.value, revision: entry.value.revision + 1, refreshing: false, checkedAt: new Date().toISOString(), error,
|
|
320
|
-
|
|
321
|
-
id, label, status: 'failed', version: null, path: null, detail: error || 'The environment check did not finish.',
|
|
322
|
-
}),
|
|
323
|
-
tools: run.tools,
|
|
356
|
+
...scanRows(run, error),
|
|
324
357
|
};
|
|
325
358
|
if (!this.closed) this.emit('updated', entry.value);
|
|
326
359
|
};
|
|
@@ -332,15 +365,12 @@ export class Environment extends EventEmitter {
|
|
|
332
365
|
windowsHide: true, detached: process.platform !== 'win32',
|
|
333
366
|
});
|
|
334
367
|
run.child.on('message', (message) => {
|
|
335
|
-
if (entry.run
|
|
336
|
-
if (message?.runtime && RUNTIMES.some((runtime) => runtime.id === message.runtime.id)) run.rows.set(message.runtime.id, message.runtime);
|
|
337
|
-
if (Array.isArray(message?.tools)) run.tools = message.tools;
|
|
338
|
-
if (message?.done) finish();
|
|
368
|
+
if (entry.run === run && takeMessage(run, message)) finish();
|
|
339
369
|
});
|
|
340
370
|
run.child.once('error', () => finish('Could not start the environment check.'));
|
|
341
371
|
run.child.once('exit', () => setImmediate(() => {
|
|
342
372
|
if (entry.run !== run) return;
|
|
343
|
-
finish(run
|
|
373
|
+
finish(scanComplete(run) ? null : 'The environment check stopped before finishing.');
|
|
344
374
|
}));
|
|
345
375
|
run.timer = setTimeout(() => finish('The environment check timed out.'), this.timeoutMs);
|
|
346
376
|
run.timer.unref();
|
package/src/manager/main.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import { NewsFeed } from './news.mjs';
|
|
|
14
14
|
import { Changelog } from './changelog.mjs';
|
|
15
15
|
import { GitHub } from './github.mjs';
|
|
16
16
|
import { createManagerServer } from './server.mjs';
|
|
17
|
+
import { loadPty } from './pty.mjs';
|
|
17
18
|
import { createNotesStore } from './notes.mjs';
|
|
18
19
|
import { RemoteAccess, loadRemoteAccess } from './remote-access.mjs';
|
|
19
20
|
import { SelfUpdate } from './self-update.mjs';
|
|
@@ -48,7 +49,9 @@ const VERSION_REFRESH_MS = 60 * 60 * 1000;
|
|
|
48
49
|
* `supervised`: started by the Linux boot service, so systemd starts the next manager after a restart.
|
|
49
50
|
*/
|
|
50
51
|
export async function startManager({ port = resolvePort(), host = DEFAULT_HOST, sessionDefaults, version = VERSION, packageFile = PACKAGE_FILE, github: githubOptions = {}, remoteAccess: remoteOptions = {}, autostart = null, boot, supervised = false } = {}) {
|
|
51
|
-
// Validate before creating files, processes or timers, so a typo
|
|
52
|
+
// Validate before creating files, processes or timers, so a typo, or a
|
|
53
|
+
// system node-pty cannot run on, fails startup cleanly.
|
|
54
|
+
loadPty();
|
|
52
55
|
const remoteSettings = loadRemoteAccess(paths.remoteAccess);
|
|
53
56
|
ensureDataDir();
|
|
54
57
|
const token = loadOrCreateToken();
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// node-pty, the library that runs each session's terminal. Its Linux builds
|
|
2
|
+
// in the package need glibc 2.28 or later: on an older glibc they do not
|
|
3
|
+
// load, and on musl (Alpine) the x64 one loads, then crashes the manager when
|
|
4
|
+
// the first session starts. So the C library is checked before node-pty is
|
|
5
|
+
// loaded, unless node-pty was compiled on this computer, which it then loads
|
|
6
|
+
// in place of the builds it comes with.
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { createRequire } from 'node:module';
|
|
11
|
+
|
|
12
|
+
const require = createRequire(import.meta.url);
|
|
13
|
+
|
|
14
|
+
/** node-pty's folder. Looked up when needed, so commands that never run a terminal do not depend on it. */
|
|
15
|
+
export function ptyDir() {
|
|
16
|
+
return path.dirname(require.resolve('node-pty/package.json'));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let glibc;
|
|
20
|
+
|
|
21
|
+
/** The glibc version this process runs on, such as '2.39'; null on another C library, such as musl. */
|
|
22
|
+
export function glibcVersion() {
|
|
23
|
+
if (glibc !== undefined) return glibc;
|
|
24
|
+
// Node.js reports it only on glibc, as npm reads it for a package's `libc`.
|
|
25
|
+
// Leaving out network details keeps the report from looking up host names.
|
|
26
|
+
const { report } = process;
|
|
27
|
+
const excludeNetwork = report.excludeNetwork;
|
|
28
|
+
report.excludeNetwork = true;
|
|
29
|
+
try {
|
|
30
|
+
glibc = report.getReport().header.glibcVersionRuntime ?? null;
|
|
31
|
+
} finally {
|
|
32
|
+
report.excludeNetwork = excludeNetwork;
|
|
33
|
+
}
|
|
34
|
+
return glibc;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** True when node-pty's own builds run here: other systems than Linux, or glibc 2.28 or later. */
|
|
38
|
+
function bundledBuildsRun({ platform = process.platform, glibc: version } = {}) {
|
|
39
|
+
if (platform !== 'linux') return true;
|
|
40
|
+
if (version === undefined) version = glibcVersion();
|
|
41
|
+
if (!version) return false;
|
|
42
|
+
const [major, minor] = version.split('.').map(Number);
|
|
43
|
+
return major > 2 || (major === 2 && minor >= 28);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Whether node-pty in `dir` was compiled on this computer; node-pty loads such a build before the ones it comes with. */
|
|
47
|
+
export function ptyBuiltHere(dir = ptyDir()) {
|
|
48
|
+
return ['Release', 'Debug'].some((build) => fs.existsSync(path.join(dir, 'build', build, 'pty.node')));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const shellWord = (word) => (/^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replaceAll('\'', '\'\\\'\'')}'`);
|
|
52
|
+
|
|
53
|
+
/** The command that compiles node-pty in `dir` for this computer. */
|
|
54
|
+
export function ptyBuildCommand(dir = ptyDir()) {
|
|
55
|
+
return `cd ${shellWord(dir)} && npx --yes node-gyp rebuild`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* On a computer that has to compile node-pty itself: the command that does,
|
|
60
|
+
* and whether the files on disk hold such a build. Null where node-pty's own
|
|
61
|
+
* builds run. `platform`, `glibc` and `dir` stand in for this computer's in tests.
|
|
62
|
+
*/
|
|
63
|
+
export function ptyBuild({ platform, glibc: version, dir } = {}) {
|
|
64
|
+
if (bundledBuildsRun({ platform, glibc: version })) return null;
|
|
65
|
+
dir ??= ptyDir();
|
|
66
|
+
return { command: ptyBuildCommand(dir), built: ptyBuiltHere(dir) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Why node-pty cannot run here, as a message for the user, or null when it
|
|
71
|
+
* can. Takes the same stand-ins as ptyBuild().
|
|
72
|
+
*/
|
|
73
|
+
export function ptyProblem(opts = {}) {
|
|
74
|
+
const build = ptyBuild(opts);
|
|
75
|
+
if (!build || build.built) return null;
|
|
76
|
+
const version = opts.glibc === undefined ? glibcVersion() : opts.glibc;
|
|
77
|
+
if (version) {
|
|
78
|
+
return `Agent Guild needs glibc 2.28 or later on Linux, for example Debian 10, Ubuntu 20.04 or RHEL 8. This system has glibc ${version}.`;
|
|
79
|
+
}
|
|
80
|
+
return [
|
|
81
|
+
'Agent Guild\'s terminal library, node-pty, comes built for Linux with glibc 2.28 or later, and this system uses another C library, such as musl on Alpine.',
|
|
82
|
+
'To build it here, install Python 3, make, g++ and the Linux headers (on Alpine: apk add python3 make g++ linux-headers), then run this, and again after each Agent Guild upgrade:',
|
|
83
|
+
` ${build.command}`,
|
|
84
|
+
].join('\n');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Why a manager started from the files on disk could not run a terminal, or
|
|
89
|
+
* null. A manager running where node-pty's own builds do not means node-pty
|
|
90
|
+
* was compiled here, so the message is how to compile it again, on any C
|
|
91
|
+
* library. Takes the same stand-ins as ptyBuild().
|
|
92
|
+
*/
|
|
93
|
+
export function ptyRestartProblem(opts = {}) {
|
|
94
|
+
const build = ptyBuild(opts);
|
|
95
|
+
if (!build || build.built) return null;
|
|
96
|
+
return [
|
|
97
|
+
'Agent Guild\'s terminal library, node-pty, is built on this computer, and the files on disk no longer hold that build, as after an upgrade. Build it again, then restart:',
|
|
98
|
+
` ${build.command}`,
|
|
99
|
+
'The manager was not restarted, and its sessions keep running.',
|
|
100
|
+
].join('\n');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let pty = null;
|
|
104
|
+
|
|
105
|
+
/** node-pty, loaded on first use. Throws with ptyProblem()'s message where it cannot run. */
|
|
106
|
+
export function loadPty() {
|
|
107
|
+
if (pty) return pty;
|
|
108
|
+
const problem = ptyProblem();
|
|
109
|
+
if (problem) throw new Error(problem);
|
|
110
|
+
pty = require('node-pty');
|
|
111
|
+
return pty;
|
|
112
|
+
}
|
|
@@ -7,6 +7,7 @@ import { EventEmitter } from 'node:events';
|
|
|
7
7
|
import { compareVersions, fetchManifest } from './versions.mjs';
|
|
8
8
|
import { formatCommand } from './install-channels.mjs';
|
|
9
9
|
import { buildSpawnSpec } from './command-resolver.mjs';
|
|
10
|
+
import { ptyBuild } from './pty.mjs';
|
|
10
11
|
|
|
11
12
|
const CHECK_TTL_MS = 60 * 60 * 1000;
|
|
12
13
|
const FAILED_CHECK_TTL_MS = 5 * 60 * 1000;
|
|
@@ -39,13 +40,15 @@ export class SelfUpdate extends EventEmitter {
|
|
|
39
40
|
* @param {string} opts.version the running version
|
|
40
41
|
* @param {string|null} [opts.packageFile] the package.json the manager runs from; read again after an install
|
|
41
42
|
* @param {import('./providers.mjs').ProviderRegistry} opts.registry for the registry URL, npm, and fetch
|
|
43
|
+
* @param {() => ({ command: string, built: boolean }|null)} [opts.ptyBuild] node-pty's build on this computer; a stand-in in tests
|
|
42
44
|
*/
|
|
43
|
-
constructor({ pkg, version, packageFile = null, registry }) {
|
|
45
|
+
constructor({ pkg, version, packageFile = null, registry, ptyBuild: readPtyBuild = ptyBuild }) {
|
|
44
46
|
super();
|
|
45
47
|
this.pkg = pkg;
|
|
46
48
|
this.version = version;
|
|
47
49
|
this.packageFile = packageFile;
|
|
48
50
|
this.registry = registry;
|
|
51
|
+
this.readPtyBuild = readPtyBuild;
|
|
49
52
|
this.latest = null;
|
|
50
53
|
this.error = null;
|
|
51
54
|
this.checkedAt = 0;
|
|
@@ -139,7 +142,7 @@ export class SelfUpdate extends EventEmitter {
|
|
|
139
142
|
if (this.installing) {
|
|
140
143
|
return {
|
|
141
144
|
version: this.version, latestVersion: this.latest, available: false, command: null, guidance: null,
|
|
142
|
-
pendingVersion: null, installing: true, lastInstall: null,
|
|
145
|
+
pendingVersion: null, installing: true, lastInstall: null, ptyBuild: null,
|
|
143
146
|
};
|
|
144
147
|
}
|
|
145
148
|
const installed = this.installedVersion();
|
|
@@ -155,6 +158,8 @@ export class SelfUpdate extends EventEmitter {
|
|
|
155
158
|
pendingVersion: this.pendingVersion(installed),
|
|
156
159
|
installing: false,
|
|
157
160
|
lastInstall: this.lastInstall,
|
|
161
|
+
// Where node-pty has to be compiled here, an upgrade replaces that build.
|
|
162
|
+
ptyBuild: this.readPtyBuild(),
|
|
158
163
|
};
|
|
159
164
|
}
|
|
160
165
|
|
package/src/manager/server.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import { createFolderBrowser } from './folder-browser.mjs';
|
|
|
14
14
|
import { normalizeAccess } from './access-policy.mjs';
|
|
15
15
|
import { NOTES_BODY_LIMIT, createNotesStore } from './notes.mjs';
|
|
16
16
|
import { MODES as AUTOSTART_MODES } from './autostart.mjs';
|
|
17
|
+
import { ptyRestartProblem } from './pty.mjs';
|
|
17
18
|
|
|
18
19
|
const require = createRequire(import.meta.url);
|
|
19
20
|
const API = '/api/v1';
|
|
@@ -154,6 +155,8 @@ export function createManagerServer({
|
|
|
154
155
|
launcher = null,
|
|
155
156
|
/** @type {(opts: { restart: boolean }) => void} */
|
|
156
157
|
onShutdownRequest = () => {},
|
|
158
|
+
/** Why a manager started from the files on disk could not run a terminal, or null; a stand-in in tests. */
|
|
159
|
+
nextManagerProblem = ptyRestartProblem,
|
|
157
160
|
}) {
|
|
158
161
|
let access = normalizeAccess({ hosts: extraHosts, origins: extraOrigins });
|
|
159
162
|
let policyVersion = 0;
|
|
@@ -429,6 +432,11 @@ export function createManagerServer({
|
|
|
429
432
|
// so a client must say `force` while any of those is running. The same
|
|
430
433
|
// guard serves every front end.
|
|
431
434
|
const body = await readJsonBody(req);
|
|
435
|
+
// The next manager runs the files on disk. Where node-pty has to be
|
|
436
|
+
// built on this computer and an upgrade replaced that build, it would
|
|
437
|
+
// not start, so this one keeps running and says what to do first.
|
|
438
|
+
const problem = body.restart === true ? nextManagerProblem() : null;
|
|
439
|
+
if (problem) throw new HttpError(409, problem, 'pty_unavailable');
|
|
432
440
|
const running = manager.runningCount();
|
|
433
441
|
if (running > 0 && body.force !== true) {
|
|
434
442
|
const err = new HttpError(409, `${running} session(s) are running; stopping the manager ends them`, 'sessions_running');
|
package/src/manager/session.mjs
CHANGED
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
|
|
5
5
|
import { EventEmitter } from 'node:events';
|
|
6
6
|
import crypto from 'node:crypto';
|
|
7
|
-
import pty from 'node-pty';
|
|
8
7
|
import headless from '@xterm/headless';
|
|
9
8
|
import serializeAddon from '@xterm/addon-serialize';
|
|
10
9
|
import { killWindowsTree } from './command-resolver.mjs';
|
|
10
|
+
import { loadPty } from './pty.mjs';
|
|
11
11
|
|
|
12
12
|
const { Terminal } = headless;
|
|
13
13
|
const { SerializeAddon } = serializeAddon;
|
|
@@ -175,7 +175,7 @@ export class Session extends EventEmitter {
|
|
|
175
175
|
}
|
|
176
176
|
|
|
177
177
|
_start(spawnSpec, cwd = this.cwd) {
|
|
178
|
-
const proc =
|
|
178
|
+
const proc = loadPty().spawn(spawnSpec.file, spawnSpec.args, {
|
|
179
179
|
name: 'xterm-256color',
|
|
180
180
|
cols: this.cols,
|
|
181
181
|
rows: this.rows,
|
package/web/app.js
CHANGED
|
@@ -879,26 +879,58 @@ function moveDockTab(e) {
|
|
|
879
879
|
|
|
880
880
|
// ---- upgrading the manager ------------------------------------------------
|
|
881
881
|
|
|
882
|
+
/**
|
|
883
|
+
* On a computer that compiles node-pty, the terminal library, itself: the
|
|
884
|
+
* build an installed upgrade replaced, which the manager will not restart
|
|
885
|
+
* without. Null otherwise.
|
|
886
|
+
*/
|
|
887
|
+
function ptyRebuild(u = state.upgrade) {
|
|
888
|
+
const build = u?.ptyBuild;
|
|
889
|
+
return u?.pendingVersion && build && !build.built ? build : null;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* While an upgrade waits for node-pty to be compiled again, read the build
|
|
894
|
+
* once more when the user comes back to the page, from the terminal where
|
|
895
|
+
* they may have run the command. The manager sends no event for it.
|
|
896
|
+
*/
|
|
897
|
+
async function recheckPtyBuild() {
|
|
898
|
+
if (!state.connected || !ptyRebuild()) return;
|
|
899
|
+
try {
|
|
900
|
+
setUpgrade((await api('GET', '/info')).upgrade);
|
|
901
|
+
} catch { /* the next hello brings it */ }
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/** A message about compiling node-pty here, with a button that copies the command. */
|
|
905
|
+
function ptyBuildToast(message, build) {
|
|
906
|
+
toast(message, 20000, { label: 'Copy command', run: () => copyText(build.command, 'the command') });
|
|
907
|
+
}
|
|
908
|
+
|
|
882
909
|
function renderUpgrade() {
|
|
883
910
|
const u = state.upgrade;
|
|
884
911
|
const button = $('upgrade');
|
|
885
912
|
const note = $('upgrade-note');
|
|
886
913
|
const offer = Boolean(state.connected && u?.available && u.command);
|
|
914
|
+
const build = u?.ptyBuild;
|
|
887
915
|
button.hidden = !offer;
|
|
888
916
|
if (offer) {
|
|
889
917
|
button.textContent = `Upgrade to ${u.latestVersion}`;
|
|
890
|
-
button.title = `Run "${u.command}" in a session. Sessions keep running; the new version is used once the manager is restarted
|
|
918
|
+
button.title = `Run "${u.command}" in a session. Sessions keep running; the new version is used once the manager is restarted.` +
|
|
919
|
+
(build ? ` This computer builds node-pty, the terminal library, itself: after the upgrade, run "${build.command}" in a terminal before restarting.` : '');
|
|
891
920
|
}
|
|
892
921
|
// A newer version on disk is used by the next manager, so the restart
|
|
893
922
|
// button becomes the way to pick it up.
|
|
894
923
|
const restart = $('restart-manager');
|
|
895
924
|
const pending = u?.pendingVersion;
|
|
925
|
+
const rebuild = ptyRebuild(u);
|
|
896
926
|
restart.classList.toggle('pending', Boolean(pending));
|
|
897
927
|
$('manager').classList.toggle('pending', Boolean(pending) && state.restartable);
|
|
898
928
|
restart.textContent = pending ? `Restart to use v${pending}` : 'Restart manager';
|
|
899
|
-
restart.title =
|
|
900
|
-
? `Agent Guild ${pending} is installed,
|
|
901
|
-
:
|
|
929
|
+
restart.title = rebuild
|
|
930
|
+
? `Agent Guild ${pending} is installed, and node-pty, the terminal library, has to be built for this computer again before the manager can restart. Run "${rebuild.command}" in a terminal first.`
|
|
931
|
+
: pending
|
|
932
|
+
? `Agent Guild ${pending} is installed, but this manager is still ${u.version}. Restarting ends every session and starts the new version; this page reconnects by itself.`
|
|
933
|
+
: 'Stop the session manager and start it again. This ends every session; this page reconnects by itself.';
|
|
902
934
|
const panelUpgrade = $('changelog-upgrade');
|
|
903
935
|
panelUpgrade.hidden = button.hidden;
|
|
904
936
|
panelUpgrade.textContent = button.textContent;
|
|
@@ -914,6 +946,9 @@ function renderUpgrade() {
|
|
|
914
946
|
if (u?.installing) {
|
|
915
947
|
text = `Upgrading${u.latestVersion ? ` to v${u.latestVersion}` : ''}…`;
|
|
916
948
|
title = 'npm is running in a session. Keep the manager running until it finishes.';
|
|
949
|
+
} else if (rebuild) {
|
|
950
|
+
text = `v${pending} installed · build node-pty before restarting`;
|
|
951
|
+
title = `This computer builds node-pty, the terminal library, itself, and the upgrade replaced that build. Run "${rebuild.command}" in a terminal, then restart.`;
|
|
917
952
|
} else if (pending && !state.restartable) {
|
|
918
953
|
text = `v${pending} installed · run "agent-guild restart" to use it`;
|
|
919
954
|
title = `Agent Guild ${pending} is installed, but this manager is still ${u.version} and cannot restart itself. Run "agent-guild restart" in a terminal when your sessions are done; this page reconnects by itself.`;
|
|
@@ -926,6 +961,9 @@ function renderUpgrade() {
|
|
|
926
961
|
} else if (u?.available && !u.command) {
|
|
927
962
|
text = `v${u.latestVersion} available`;
|
|
928
963
|
title = u.guidance || '';
|
|
964
|
+
} else if (offer && build) {
|
|
965
|
+
text = 'Build node-pty again after upgrading';
|
|
966
|
+
title = `This computer builds node-pty, the terminal library, itself, and an upgrade replaces that build. After upgrading, run "${build.command}" in a terminal before restarting.`;
|
|
929
967
|
}
|
|
930
968
|
note.hidden = !state.connected || !text;
|
|
931
969
|
note.textContent = text;
|
|
@@ -940,7 +978,10 @@ function setUpgrade(upgrade, baseline = false) {
|
|
|
940
978
|
renderVersion();
|
|
941
979
|
if ($('changelog').open) renderChangelog();
|
|
942
980
|
const pending = state.upgrade?.pendingVersion;
|
|
943
|
-
|
|
981
|
+
const rebuild = ptyRebuild();
|
|
982
|
+
if (pending && pending !== before?.pendingVersion && rebuild) {
|
|
983
|
+
ptyBuildToast(`Agent Guild ${pending} is installed. Before restarting, build node-pty for this computer again: run "${rebuild.command}" in a terminal.`, rebuild);
|
|
984
|
+
} else if (pending && pending !== before?.pendingVersion) {
|
|
944
985
|
toast(state.restartable
|
|
945
986
|
? `Agent Guild ${pending} is installed. Use "Restart to use v${pending}" in the top bar when your sessions are done.`
|
|
946
987
|
: `Agent Guild ${pending} is installed. Run "agent-guild restart" in a terminal when your sessions are done.`, 10000);
|
|
@@ -5634,6 +5675,10 @@ async function stopManager({ force = false, restart = false } = {}) {
|
|
|
5634
5675
|
}
|
|
5635
5676
|
return;
|
|
5636
5677
|
}
|
|
5678
|
+
const build = state.upgrade?.ptyBuild;
|
|
5679
|
+
if (err.code === 'pty_unavailable' && build) {
|
|
5680
|
+
return ptyBuildToast(`The manager was not restarted, and its sessions keep running. First build node-pty, the terminal library, for this computer: run "${build.command}" in a terminal.`, build);
|
|
5681
|
+
}
|
|
5637
5682
|
toast(err.message, 8000);
|
|
5638
5683
|
} finally {
|
|
5639
5684
|
for (const button of buttons) button.disabled = false;
|
|
@@ -6123,10 +6168,12 @@ document.addEventListener('visibilitychange', () => {
|
|
|
6123
6168
|
flushNotes();
|
|
6124
6169
|
}
|
|
6125
6170
|
if (document.visibilityState === 'visible' && state.connected && Date.now() - newsLoadedAt > 60000) loadNews();
|
|
6171
|
+
if (document.visibilityState === 'visible') recheckPtyBuild();
|
|
6126
6172
|
if (document.visibilityState === 'visible' && dockShows('github') && githubShownView() === 'actions') loadView('actions');
|
|
6127
6173
|
else scheduleRuns();
|
|
6128
6174
|
if (branchesVisible() && !viewData('branches')?.error) loadBranches({ resume: true });
|
|
6129
6175
|
});
|
|
6176
|
+
addEventListener('focus', recheckPtyBuild);
|
|
6130
6177
|
addEventListener('pagehide', () => {
|
|
6131
6178
|
flushNotes();
|
|
6132
6179
|
state.pageAway = true;
|
package/web/environment.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
const STATES = { pending: 'Not checked', not_found: 'Not found', unavailable: 'Runtime unavailable', failed: 'Probe failed' };
|
|
2
|
+
const DESIGN_STATES = { pending: 'Not checked', not_found: 'Not found', on_path: 'On PATH', not_on_path: 'Not on PATH', failed: 'Probe failed' };
|
|
3
|
+
const DOCKER_STATES = { pending: 'Not checked', running: 'Running', stopped: 'Not running', denied: 'No access', remote: 'Not checked', not_found: 'Not found', failed: 'Probe failed' };
|
|
2
4
|
const PIN_STATES = { configured: 'Configured', unreadable: 'Unreadable', invalid: 'Invalid' };
|
|
3
5
|
const SCOPES = [
|
|
4
6
|
['manager', 'Manager', 'Manager environment'],
|
|
@@ -9,6 +11,37 @@ const SCOPES = [
|
|
|
9
11
|
|
|
10
12
|
export function runtimeValue(row) { return row.status === 'ok' ? row.version : STATES[row.status] || 'Probe failed'; }
|
|
11
13
|
|
|
14
|
+
export function designValue(row) {
|
|
15
|
+
const state = DESIGN_STATES[row.status] || DESIGN_STATES.failed;
|
|
16
|
+
if (!row.version) return state;
|
|
17
|
+
return row.status === 'not_on_path' ? `${row.version} · Not on PATH` : row.version;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function formatMemory(bytes) { return `${(bytes / 1024 ** 3).toFixed(1)} GB`; }
|
|
21
|
+
|
|
22
|
+
// Label, value and detail lines for each System row.
|
|
23
|
+
export function systemRows(system) {
|
|
24
|
+
const rows = [];
|
|
25
|
+
const wsl = system.wsl;
|
|
26
|
+
rows.push({ label: 'OS', value: system.os, details: [wsl && `WSL ${wsl.version}${wsl.distro ? ` · ${wsl.distro}` : ''}`, system.osDetail] });
|
|
27
|
+
rows.push({ label: 'Architecture', value: system.hostArch ? `${system.arch} · emulated on ${system.hostArch}` : system.arch, details: [] });
|
|
28
|
+
rows.push({ label: 'CPU', value: `${system.cpu.threads} threads`, details: [system.cpu.model] });
|
|
29
|
+
rows.push({ label: 'Memory', value: formatMemory(system.memory), details: [wsl && 'Memory of the WSL virtual machine, set in .wslconfig. Windows can have more.'] });
|
|
30
|
+
if (wsl) rows.push({ label: 'Windows interop', value: wsl.interop ? 'On' : 'Off', details: [!wsl.interop && 'Agents cannot run Windows programs from this distribution.'] });
|
|
31
|
+
if (Array.isArray(system.wslDistributions)) {
|
|
32
|
+
const list = system.wslDistributions;
|
|
33
|
+
rows.push({
|
|
34
|
+
label: 'WSL', value: !list.length ? 'None installed' : list.length === 1 ? '1 distribution' : `${list.length} distributions`,
|
|
35
|
+
details: list.map((item) => `${item.name}${item.version ? ` · WSL ${item.version}` : ''}${item.default ? ' · default' : ''}`),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return rows.map((row) => ({ ...row, details: row.details.filter(Boolean) }));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function dockerValue(docker) {
|
|
42
|
+
return docker.status === 'running' && docker.version ? docker.version : DOCKER_STATES[docker.status] || DOCKER_STATES.failed;
|
|
43
|
+
}
|
|
44
|
+
|
|
12
45
|
export function createEnvironmentUI({
|
|
13
46
|
api, onAuthError, isAuthError = () => false, document = globalThis.document,
|
|
14
47
|
workingFolder = () => '', sessions = () => [],
|
|
@@ -36,11 +69,16 @@ export function createEnvironmentUI({
|
|
|
36
69
|
: !manager ? 'Not checked' : !found.length ? 'No runtime versions verified' : 'Manager environment';
|
|
37
70
|
}
|
|
38
71
|
|
|
39
|
-
function
|
|
72
|
+
function rowHead(label, value) {
|
|
40
73
|
const node = element('section', undefined, 'environment-row');
|
|
41
74
|
const head = element('div', undefined, 'environment-row-head');
|
|
42
|
-
head.append(element('strong',
|
|
75
|
+
head.append(element('strong', label), element('span', value));
|
|
43
76
|
node.append(head);
|
|
77
|
+
return node;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function rowElement(row) {
|
|
81
|
+
const node = rowHead(row.label, runtimeValue(row));
|
|
44
82
|
if (row.command) node.append(element('p', `Command: ${row.command}`, 'environment-detail'));
|
|
45
83
|
if (row.path) node.append(element('code', row.path, 'environment-path'));
|
|
46
84
|
const detail = row.detail || (row.status === 'not_found' ? 'No executable was found on the manager’s PATH.' : '');
|
|
@@ -54,11 +92,37 @@ export function createEnvironmentUI({
|
|
|
54
92
|
return node;
|
|
55
93
|
}
|
|
56
94
|
|
|
95
|
+
function designElement(row) {
|
|
96
|
+
const node = rowHead(row.label, designValue(row));
|
|
97
|
+
if (row.command) node.append(element('p', `Command: ${row.command}`, 'environment-detail'));
|
|
98
|
+
if (row.path) node.append(element('code', row.path, 'environment-path'));
|
|
99
|
+
if (row.status === 'not_on_path') node.append(element('p', 'Installed outside PATH. Agents can run it once its folder is on PATH.', 'environment-detail'));
|
|
100
|
+
if (row.detail) node.append(element('p', row.detail, 'environment-detail'));
|
|
101
|
+
return node;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function systemElement(row) {
|
|
105
|
+
const node = rowHead(row.label, row.value);
|
|
106
|
+
for (const detail of row.details) node.append(element('p', detail, 'environment-detail'));
|
|
107
|
+
return node;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function dockerElement(docker) {
|
|
111
|
+
const node = rowHead('Docker Engine', dockerValue(docker));
|
|
112
|
+
if (docker.platform) node.append(element('p', docker.platform, 'environment-detail'));
|
|
113
|
+
if (docker.os) node.append(element('p', `${docker.os}${docker.arch ? `/${docker.arch}` : ''}${docker.wsl2 ? ' · runs in WSL 2' : ''}`, 'environment-detail'));
|
|
114
|
+
if (docker.context) node.append(element('p', `Context: ${docker.context}`, 'environment-detail'));
|
|
115
|
+
if (docker.endpoint) node.append(element('code', docker.endpoint, 'environment-path'));
|
|
116
|
+
if (docker.detail) node.append(element('p', docker.detail, 'environment-detail'));
|
|
117
|
+
if (docker.status !== 'pending') {
|
|
118
|
+
node.append(element('p', docker.cli ? 'Command: docker' : 'The docker command is not on PATH.', 'environment-detail'));
|
|
119
|
+
if (docker.cli) node.append(element('code', docker.cli, 'environment-path'));
|
|
120
|
+
}
|
|
121
|
+
return node;
|
|
122
|
+
}
|
|
123
|
+
|
|
57
124
|
function pinElement(row) {
|
|
58
|
-
const node =
|
|
59
|
-
const head = element('div', undefined, 'environment-row-head');
|
|
60
|
-
head.append(element('strong', row.label), element('span', PIN_STATES[row.status] || 'Invalid'));
|
|
61
|
-
node.append(head);
|
|
125
|
+
const node = rowHead(row.label, PIN_STATES[row.status] || 'Invalid');
|
|
62
126
|
node.append(element('p', row.source, 'environment-detail'));
|
|
63
127
|
if (row.version) node.append(element('code', row.version, 'environment-path'));
|
|
64
128
|
if (row.detail) node.append(element('p', row.detail, 'environment-detail'));
|
|
@@ -143,6 +207,18 @@ export function createEnvironmentUI({
|
|
|
143
207
|
$('environment-tools-note').hidden = !showTools;
|
|
144
208
|
$('environment-tools').hidden = !showTools;
|
|
145
209
|
$('environment-tools-empty').hidden = !showTools;
|
|
210
|
+
$('environment-runtimes-heading').textContent = scope === 'project' ? 'Version pins' : 'Runtimes';
|
|
211
|
+
$('environment-runtimes-heading').hidden = scope === 'session' && data?.availability === 'unavailable';
|
|
212
|
+
for (const id of ['environment-design-heading', 'environment-design-note', 'environment-design']) $(id).hidden = !showTools;
|
|
213
|
+
$('environment-design').replaceChildren(...(showTools ? data?.designTools || [] : []).map(designElement));
|
|
214
|
+
const docker = showTools && data?.docker ? data.docker : null;
|
|
215
|
+
$('environment-docker-heading').hidden = !docker;
|
|
216
|
+
$('environment-docker').hidden = !docker;
|
|
217
|
+
$('environment-docker').replaceChildren(...(docker ? [dockerElement(docker)] : []));
|
|
218
|
+
const system = scope === 'manager' && data?.system ? data.system : null;
|
|
219
|
+
$('environment-system-heading').hidden = !system;
|
|
220
|
+
$('environment-system').hidden = !system;
|
|
221
|
+
$('environment-system').replaceChildren(...(system ? systemRows(system).map(systemElement) : []));
|
|
146
222
|
if (scope === 'project' && data && folder === projectFolder) {
|
|
147
223
|
$('environment-runtimes').hidden = true;
|
|
148
224
|
$('environment-runtimes').replaceChildren();
|
|
@@ -157,8 +233,8 @@ export function createEnvironmentUI({
|
|
|
157
233
|
}
|
|
158
234
|
if (showTools) {
|
|
159
235
|
$('environment-tools').replaceChildren(...tools.map((tool) => {
|
|
160
|
-
const node =
|
|
161
|
-
node.append(element('
|
|
236
|
+
const node = rowHead(tool.label, 'Detected');
|
|
237
|
+
node.append(element('code', tool.path, 'environment-path'));
|
|
162
238
|
return node;
|
|
163
239
|
}));
|
|
164
240
|
$('environment-tools-empty').hidden = Boolean(tools.length);
|
package/web/index.html
CHANGED
|
@@ -400,12 +400,20 @@
|
|
|
400
400
|
<select id="environment-session" hidden></select>
|
|
401
401
|
<p id="environment-status" class="environment-detail" role="status"></p>
|
|
402
402
|
<button id="environment-refresh" class="btn" type="button">Refresh</button>
|
|
403
|
+
<h3 id="environment-system-heading" hidden>System</h3>
|
|
404
|
+
<div id="environment-system" hidden></div>
|
|
405
|
+
<h3 id="environment-runtimes-heading">Runtimes</h3>
|
|
403
406
|
<div id="environment-runtimes"></div>
|
|
404
407
|
<div id="environment-pins" hidden></div>
|
|
405
408
|
<h3 id="environment-tools-heading">Detected tools</h3>
|
|
406
409
|
<p id="environment-tools-note" class="environment-detail">Presence only. Activation and the runtimes these tools manage are not checked.</p>
|
|
407
410
|
<div id="environment-tools"></div>
|
|
408
411
|
<p id="environment-tools-empty" class="environment-detail"></p>
|
|
412
|
+
<h3 id="environment-design-heading">Design tools</h3>
|
|
413
|
+
<p id="environment-design-note" class="environment-detail">Agents can run a tool only when it is on PATH. A version is read only from the tool's own program.</p>
|
|
414
|
+
<div id="environment-design"></div>
|
|
415
|
+
<h3 id="environment-docker-heading">Docker</h3>
|
|
416
|
+
<div id="environment-docker"></div>
|
|
409
417
|
<p id="environment-manager-node" class="environment-detail"></p>
|
|
410
418
|
</div>
|
|
411
419
|
</dialog>
|