@oddessentials/agent-guild 0.33.2 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -9
- package/bin/agent-guild.mjs +29 -3
- package/package.json +1 -1
- package/src/manager/autostart.mjs +104 -20
- package/src/manager/environment-pins.mjs +289 -0
- package/src/manager/environment-probe.mjs +11 -0
- package/src/manager/environment.mjs +320 -15
- package/src/manager/github-views.mjs +34 -2
- package/src/manager/github.mjs +3 -1
- package/src/manager/launch.mjs +93 -1
- package/src/manager/main.mjs +25 -12
- package/src/manager/server.mjs +37 -6
- package/src/manager/systemd-service.mjs +312 -0
- package/web/app.js +129 -8
- package/web/environment.js +239 -34
- package/web/index.html +23 -7
- package/web/styles.css +28 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// Project pin files only. A pin is what a file asks for, never proof that a
|
|
2
|
+
// runtime is installed or which binary a shell would execute.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
export const PIN_LIMIT = 64 * 1024;
|
|
8
|
+
export const PIN_TEXT_MAX = 128;
|
|
9
|
+
|
|
10
|
+
export const PIN_NAMES = [
|
|
11
|
+
'.nvmrc', '.node-version', '.python-version', 'package.json', 'pyproject.toml',
|
|
12
|
+
'rust-toolchain.toml', 'rust-toolchain', 'go.mod', 'global.json', '.tool-versions',
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const TOOL_VERSIONS = {
|
|
16
|
+
node: ['tool-versions-node', 'Node.js'],
|
|
17
|
+
nodejs: ['tool-versions-node', 'Node.js'],
|
|
18
|
+
python: ['tool-versions-python', 'Python'],
|
|
19
|
+
go: ['tool-versions-go', 'Go'],
|
|
20
|
+
golang: ['tool-versions-go', 'Go'],
|
|
21
|
+
rust: ['tool-versions-rust', 'Rust'],
|
|
22
|
+
dotnet: ['tool-versions-dotnet', '.NET SDK'],
|
|
23
|
+
'dotnet-sdk': ['tool-versions-dotnet', '.NET SDK'],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function pin(id, label, source, status, version = null, detail = null) {
|
|
27
|
+
return { id, label, source, version, status, detail };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function configured(id, label, source, version) {
|
|
31
|
+
const text = typeof version === 'string' ? version.trim() : '';
|
|
32
|
+
if (!text) return pin(id, label, source, 'invalid', null, 'The pin is empty.');
|
|
33
|
+
if (text.length > PIN_TEXT_MAX) return pin(id, label, source, 'invalid', null, 'The pin is longer than 128 characters.');
|
|
34
|
+
return pin(id, label, source, 'configured', text);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function stripBom(text) {
|
|
38
|
+
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function versionLine(text) {
|
|
42
|
+
for (const raw of stripBom(text).split(/\r?\n/)) {
|
|
43
|
+
const line = raw.trim();
|
|
44
|
+
if (!line || line.startsWith('#')) continue;
|
|
45
|
+
return line;
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function textPin(id, label, source, text) {
|
|
51
|
+
const line = versionLine(text);
|
|
52
|
+
if (line === null) return [pin(id, label, source, 'invalid', null, 'The pin file has no version.')];
|
|
53
|
+
return [configured(id, label, source, line)];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function packagePins(text) {
|
|
57
|
+
let data;
|
|
58
|
+
try { data = JSON.parse(stripBom(text)); } catch {
|
|
59
|
+
return [pin('package-json', 'package.json', 'package.json', 'invalid', null, 'package.json could not be read as JSON.')];
|
|
60
|
+
}
|
|
61
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
62
|
+
return [pin('package-json', 'package.json', 'package.json', 'invalid', null, 'package.json could not be read as JSON.')];
|
|
63
|
+
}
|
|
64
|
+
const pins = [];
|
|
65
|
+
if (data.engines !== undefined) {
|
|
66
|
+
if (!data.engines || typeof data.engines !== 'object' || Array.isArray(data.engines)) {
|
|
67
|
+
pins.push(pin('engines', 'package.json', 'package.json', 'invalid', null, 'engines is not an object.'));
|
|
68
|
+
} else {
|
|
69
|
+
for (const [key, id, label] of [['node', 'engines-node', 'Node.js'], ['npm', 'engines-npm', 'npm']]) {
|
|
70
|
+
if (data.engines[key] === undefined) continue;
|
|
71
|
+
if (typeof data.engines[key] !== 'string') pins.push(pin(id, label, 'package.json', 'invalid', null, `engines.${key} is not a string.`));
|
|
72
|
+
else pins.push(configured(id, label, 'package.json', data.engines[key]));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (data.packageManager !== undefined) {
|
|
77
|
+
if (typeof data.packageManager !== 'string') pins.push(pin('package-manager', 'packageManager', 'package.json', 'invalid', null, 'packageManager is not a string.'));
|
|
78
|
+
else {
|
|
79
|
+
const name = data.packageManager.split('@')[0].trim();
|
|
80
|
+
pins.push(configured('package-manager', name || 'packageManager', 'package.json', data.packageManager));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return pins;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function pythonRequires(text) {
|
|
87
|
+
const lines = stripBom(text).split(/\r?\n/).map((line) => line.trim()).filter((line) => line && !line.startsWith('#'));
|
|
88
|
+
const keys = lines.filter((line) => /^requires-python\s*=/.test(line));
|
|
89
|
+
if (!keys.length) return [];
|
|
90
|
+
if (keys.length !== 1) return [pin('requires-python', 'Python', 'pyproject.toml', 'invalid', null, 'requires-python is not a single-line string.')];
|
|
91
|
+
const match = /^requires-python\s*=\s*(["'])([^"']+)\1\s*(?:#.*)?$/.exec(keys[0]);
|
|
92
|
+
if (!match) return [pin('requires-python', 'Python', 'pyproject.toml', 'invalid', null, 'requires-python is not a single-line string.')];
|
|
93
|
+
return [configured('requires-python', 'Python', 'pyproject.toml', match[2])];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function rustToml(text) {
|
|
97
|
+
let section = '';
|
|
98
|
+
let channel = null;
|
|
99
|
+
let saw = false;
|
|
100
|
+
let bad = false;
|
|
101
|
+
for (const raw of stripBom(text).split(/\r?\n/)) {
|
|
102
|
+
const line = raw.trim();
|
|
103
|
+
if (!line || line.startsWith('#')) continue;
|
|
104
|
+
const heading = /^\[([^\]]+)\]$/.exec(line);
|
|
105
|
+
if (heading) {
|
|
106
|
+
section = heading[1].trim();
|
|
107
|
+
if (section === 'toolchain') saw = true;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (section !== 'toolchain' || !/^channel\s*=/.test(line)) continue;
|
|
111
|
+
const match = /^channel\s*=\s*(["'])([^"']+)\1\s*(?:#.*)?$/.exec(line);
|
|
112
|
+
if (!match || channel !== null) bad = true;
|
|
113
|
+
else channel = match[2];
|
|
114
|
+
}
|
|
115
|
+
if (bad || (saw && channel === null)) return [pin('rust-toolchain', 'Rust', 'rust-toolchain.toml', 'invalid', null, 'rust-toolchain channel is not a single-line string.')];
|
|
116
|
+
return channel === null ? [] : [configured('rust-toolchain', 'Rust', 'rust-toolchain.toml', channel)];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function rustFile(text) {
|
|
120
|
+
const line = versionLine(text);
|
|
121
|
+
if (line === null) return [pin('rust-toolchain-file', 'Rust', 'rust-toolchain', 'invalid', null, 'The pin file has no version.')];
|
|
122
|
+
if (line.startsWith('[')) return [pin('rust-toolchain-file', 'Rust', 'rust-toolchain', 'invalid', null, 'rust-toolchain is not a single version line.')];
|
|
123
|
+
return [configured('rust-toolchain-file', 'Rust', 'rust-toolchain', line)];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function goMod(text) {
|
|
127
|
+
let language = null;
|
|
128
|
+
let toolchain = null;
|
|
129
|
+
let bad = false;
|
|
130
|
+
for (const raw of stripBom(text).split(/\r?\n/)) {
|
|
131
|
+
const line = raw.trim();
|
|
132
|
+
if (!line || line.startsWith('//')) continue;
|
|
133
|
+
if (/^go\s/.test(line)) {
|
|
134
|
+
const match = /^go\s+(\S+)$/.exec(line);
|
|
135
|
+
if (!match || language !== null) bad = true;
|
|
136
|
+
else language = match[1];
|
|
137
|
+
} else if (/^toolchain\s/.test(line)) {
|
|
138
|
+
const match = /^toolchain\s+(\S+)$/.exec(line);
|
|
139
|
+
if (!match || toolchain !== null) bad = true;
|
|
140
|
+
else toolchain = match[1];
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (bad) return [pin('go-mod', 'Go', 'go.mod', 'invalid', null, 'go.mod has a version line that could not be read.')];
|
|
144
|
+
const pins = [];
|
|
145
|
+
if (language) pins.push(configured('go-language', 'Go language', 'go.mod', language));
|
|
146
|
+
if (toolchain) pins.push(configured('go-toolchain', 'Go toolchain', 'go.mod', toolchain));
|
|
147
|
+
return pins;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function globalJson(text) {
|
|
151
|
+
let data;
|
|
152
|
+
try { data = JSON.parse(stripBom(text)); } catch {
|
|
153
|
+
return [pin('global-json', '.NET SDK', 'global.json', 'invalid', null, 'global.json could not be read as JSON.')];
|
|
154
|
+
}
|
|
155
|
+
if (!data || typeof data !== 'object' || Array.isArray(data) || data.sdk === undefined) return [];
|
|
156
|
+
if (!data.sdk || typeof data.sdk !== 'object' || Array.isArray(data.sdk) || typeof data.sdk.version !== 'string') {
|
|
157
|
+
return [pin('global-json', '.NET SDK', 'global.json', 'invalid', null, 'sdk.version is not a string.')];
|
|
158
|
+
}
|
|
159
|
+
return [configured('global-json', '.NET SDK', 'global.json', data.sdk.version)];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function toolVersions(text) {
|
|
163
|
+
const pins = [];
|
|
164
|
+
const used = new Set();
|
|
165
|
+
let bad = false;
|
|
166
|
+
for (const raw of stripBom(text).split(/\r?\n/)) {
|
|
167
|
+
const line = raw.trim();
|
|
168
|
+
if (!line || line.startsWith('#')) continue;
|
|
169
|
+
const gap = line.search(/\s/);
|
|
170
|
+
const name = (gap === -1 ? line : line.slice(0, gap)).toLowerCase();
|
|
171
|
+
const known = TOOL_VERSIONS[name];
|
|
172
|
+
if (!known) continue;
|
|
173
|
+
const version = gap === -1 ? '' : line.slice(gap).trim();
|
|
174
|
+
if (!version) bad = true;
|
|
175
|
+
else {
|
|
176
|
+
let id = known[0];
|
|
177
|
+
let n = 2;
|
|
178
|
+
while (used.has(id)) id = `${known[0]}-${n++}`;
|
|
179
|
+
used.add(id);
|
|
180
|
+
pins.push(configured(id, known[1], '.tool-versions', version));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (bad) return [pin('tool-versions', '.tool-versions', '.tool-versions', 'invalid', null, '.tool-versions has a line that could not be read.')];
|
|
184
|
+
return pins;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const PARSERS = {
|
|
188
|
+
'.nvmrc': (text) => textPin('nvmrc', 'Node.js', '.nvmrc', text),
|
|
189
|
+
'.node-version': (text) => textPin('node-version', 'Node.js', '.node-version', text),
|
|
190
|
+
'.python-version': (text) => textPin('python-version', 'Python', '.python-version', text),
|
|
191
|
+
'package.json': packagePins,
|
|
192
|
+
'pyproject.toml': pythonRequires,
|
|
193
|
+
'rust-toolchain.toml': rustToml,
|
|
194
|
+
'rust-toolchain': rustFile,
|
|
195
|
+
'go.mod': goMod,
|
|
196
|
+
'global.json': globalJson,
|
|
197
|
+
'.tool-versions': toolVersions,
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
function inside(file, root) {
|
|
201
|
+
const child = path.resolve(file);
|
|
202
|
+
const base = path.resolve(root);
|
|
203
|
+
const left = process.platform === 'win32' ? child.toLowerCase() : child;
|
|
204
|
+
const right = process.platform === 'win32' ? base.toLowerCase() : base;
|
|
205
|
+
return left.startsWith(right.endsWith(path.sep) ? right : right + path.sep);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const FILE_LABELS = {
|
|
209
|
+
'.nvmrc': 'Node.js', '.node-version': 'Node.js', '.python-version': 'Python', 'package.json': 'package.json',
|
|
210
|
+
'pyproject.toml': 'Python', 'rust-toolchain.toml': 'Rust', 'rust-toolchain': 'Rust', 'go.mod': 'Go',
|
|
211
|
+
'global.json': '.NET SDK', '.tool-versions': '.tool-versions',
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
function readPin(file, realRoot) {
|
|
215
|
+
const name = path.basename(file);
|
|
216
|
+
const label = FILE_LABELS[name] || name;
|
|
217
|
+
let listed;
|
|
218
|
+
try { listed = fs.lstatSync(file); } catch (err) {
|
|
219
|
+
if (err.code === 'ENOENT') return { identity: { name, state: 'absent' } };
|
|
220
|
+
return { identity: { name, state: 'rejected' }, failure: pin(name, label, name, 'unreadable', null, 'The pin file could not be read.') };
|
|
221
|
+
}
|
|
222
|
+
let real = file;
|
|
223
|
+
if (listed.isSymbolicLink()) {
|
|
224
|
+
try { real = fs.realpathSync(file); } catch {
|
|
225
|
+
return { identity: { name, state: 'rejected' }, failure: pin(name, label, name, 'unreadable', null, 'The pin file could not be read.') };
|
|
226
|
+
}
|
|
227
|
+
if (!inside(real, realRoot)) {
|
|
228
|
+
return { identity: { name, state: 'rejected' }, failure: pin(name, label, name, 'unreadable', null, 'The pin is outside the project folder.') };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
let stat;
|
|
232
|
+
try { stat = fs.statSync(real); } catch {
|
|
233
|
+
return { identity: { name, state: 'rejected' }, failure: pin(name, label, name, 'unreadable', null, 'The pin file could not be read.') };
|
|
234
|
+
}
|
|
235
|
+
if (!stat.isFile()) {
|
|
236
|
+
return { identity: { name, state: 'rejected' }, failure: pin(name, label, name, 'unreadable', null, 'The pin is not a file.') };
|
|
237
|
+
}
|
|
238
|
+
if (stat.size > PIN_LIMIT) {
|
|
239
|
+
return { identity: { name, state: 'file', mtimeMs: stat.mtimeMs, size: stat.size }, failure: pin(name, label, name, 'unreadable', null, 'The pin file is larger than 64 KiB.') };
|
|
240
|
+
}
|
|
241
|
+
return { identity: { name, state: 'file', mtimeMs: stat.mtimeMs, size: stat.size }, file: real };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Pins and file identity for one project folder. Does not change the process working directory. */
|
|
245
|
+
export function scanPins(cwd, { identityOnly = false } = {}) {
|
|
246
|
+
if (typeof cwd !== 'string' || !path.isAbsolute(cwd)) return { error: 'Could not read the project pins.' };
|
|
247
|
+
let root;
|
|
248
|
+
try { root = fs.statSync(cwd); } catch { return { error: 'Could not read the project pins.' }; }
|
|
249
|
+
if (!root.isDirectory()) return { error: 'Could not read the project pins.' };
|
|
250
|
+
let realRoot;
|
|
251
|
+
try { realRoot = fs.realpathSync(cwd); } catch { return { error: 'Could not read the project pins.' }; }
|
|
252
|
+
const identity = [];
|
|
253
|
+
const pins = [];
|
|
254
|
+
for (const name of PIN_NAMES) {
|
|
255
|
+
const read = readPin(path.join(cwd, name), realRoot);
|
|
256
|
+
identity.push(read.identity);
|
|
257
|
+
if (identityOnly) continue;
|
|
258
|
+
if (read.failure) pins.push(read.failure);
|
|
259
|
+
else if (read.file) {
|
|
260
|
+
let text;
|
|
261
|
+
try { text = fs.readFileSync(read.file); } catch {
|
|
262
|
+
pins.push(pin(name, FILE_LABELS[name] || name, name, 'unreadable', null, 'The pin file could not be read.'));
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (text.length > PIN_LIMIT) {
|
|
266
|
+
pins.push(pin(name, FILE_LABELS[name] || name, name, 'unreadable', null, 'The pin file is larger than 64 KiB.'));
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
pins.push(...PARSERS[name](text.toString('utf8')));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return { identity: JSON.stringify(identity), ...(identityOnly ? {} : { pins }), cwd: process.cwd() };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** A working folder for a project check. Empty input is refused and does not become the home directory. */
|
|
276
|
+
export function resolveProjectCwd(cwd, { homedir = os.homedir, stat = fs.statSync } = {}) {
|
|
277
|
+
if (typeof cwd !== 'string' || !cwd.trim()) {
|
|
278
|
+
throw Object.assign(new Error('A working folder is required.'), { status: 400, code: 'cwd_required' });
|
|
279
|
+
}
|
|
280
|
+
let dir = cwd.trim();
|
|
281
|
+
if (dir === '~' || dir.startsWith('~/') || dir.startsWith('~\\')) dir = path.join(homedir(), dir.slice(1));
|
|
282
|
+
dir = path.resolve(dir);
|
|
283
|
+
let info;
|
|
284
|
+
try { info = stat(dir); } catch {
|
|
285
|
+
throw Object.assign(new Error(`working directory does not exist: ${dir}`), { status: 400, code: 'bad_cwd' });
|
|
286
|
+
}
|
|
287
|
+
if (!info.isDirectory()) throw Object.assign(new Error(`working directory is not a folder: ${dir}`), { status: 400, code: 'bad_cwd' });
|
|
288
|
+
return dir;
|
|
289
|
+
}
|
|
@@ -5,6 +5,7 @@ import os from 'node:os';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { spawn } from 'node:child_process';
|
|
7
7
|
import { resolveCommand, killWindowsTree } from './command-resolver.mjs';
|
|
8
|
+
import { scanPins } from './environment-pins.mjs';
|
|
8
9
|
|
|
9
10
|
export const RUNTIMES = [
|
|
10
11
|
{ id: 'node', label: 'Node.js', command: 'node', args: ['--version'], pattern: /^v(\d+\.\d+\.\d+(?:-[\w.-]+)?)\s*$/m },
|
|
@@ -213,6 +214,16 @@ export async function releaseScanDirectory(cwd, kills = [], {
|
|
|
213
214
|
} catch { /* a leftover lock must not fail the scan */ }
|
|
214
215
|
}
|
|
215
216
|
|
|
217
|
+
if ((process.argv[2] === '--scan-pins' || process.argv[2] === '--pin-identity') && process.send) {
|
|
218
|
+
// Read the named folder by absolute path. Never chdir into it: a project
|
|
219
|
+
// directory can activate tools on entry, and this check must not.
|
|
220
|
+
const started = process.cwd();
|
|
221
|
+
const result = scanPins(process.argv[3], { identityOnly: process.argv[2] === '--pin-identity' });
|
|
222
|
+
process.send({ ...result, cwd: process.cwd() });
|
|
223
|
+
if (process.cwd() !== started) process.chdir(started);
|
|
224
|
+
process.send({ done: true });
|
|
225
|
+
}
|
|
226
|
+
|
|
216
227
|
if (process.argv[2] === '--scan-environment' && process.send) {
|
|
217
228
|
// A fresh temporary directory contains no project files. Manager env is
|
|
218
229
|
// retained; no selected shell, project or session is inspected or changed.
|