@yeaft/webchat-agent 1.0.300 → 1.0.301
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/context.js +1 -0
- package/index.js +9 -0
- package/local-runtime/version.json +1 -1
- package/package.json +1 -1
- package/yeaft/cli-session-runner.js +3 -2
- package/yeaft/cli.js +20 -1
- package/yeaft/engine.js +8 -1
- package/yeaft/managed-cli.js +618 -0
- package/yeaft/session.js +7 -0
- package/yeaft/sub-agent/runner.js +1 -0
- package/yeaft/tools/disk-usage.js +243 -0
- package/yeaft/tools/glob.js +84 -28
- package/yeaft/tools/grep.js +616 -152
- package/yeaft/tools/index.js +2 -0
- package/yeaft/tools/process-runner.js +211 -0
- package/yeaft/tools/search-paths.js +108 -0
- package/yeaft/tools/types.js +1 -0
- package/yeaft/web-bridge.js +3 -0
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import {
|
|
4
|
+
accessSync,
|
|
5
|
+
chmodSync,
|
|
6
|
+
constants,
|
|
7
|
+
existsSync,
|
|
8
|
+
lstatSync,
|
|
9
|
+
mkdirSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
realpathSync,
|
|
12
|
+
renameSync,
|
|
13
|
+
rmSync,
|
|
14
|
+
statSync,
|
|
15
|
+
unlinkSync,
|
|
16
|
+
writeFileSync,
|
|
17
|
+
} from 'node:fs';
|
|
18
|
+
import { homedir } from 'node:os';
|
|
19
|
+
import { basename, delimiter, dirname, join, resolve } from 'node:path';
|
|
20
|
+
import { gunzipSync, inflateRawSync } from 'node:zlib';
|
|
21
|
+
|
|
22
|
+
const DEFAULT_ROOT = join(homedir(), '.yeaft');
|
|
23
|
+
const DOWNLOAD_TIMEOUT_MS = 30_000;
|
|
24
|
+
const MAX_ARCHIVE_BYTES = 25 * 1024 * 1024;
|
|
25
|
+
const MAX_BINARY_BYTES = 20 * 1024 * 1024;
|
|
26
|
+
const FAILURE_COOLDOWN_MS = 60 * 60 * 1000;
|
|
27
|
+
const LOCK_STALE_MS = 2 * 60 * 1000;
|
|
28
|
+
const LOCK_WAIT_MS = 15_000;
|
|
29
|
+
const STATE_FILE = 'managed-cli.json';
|
|
30
|
+
const installFlights = new Map();
|
|
31
|
+
|
|
32
|
+
const TOOL_SPECS = Object.freeze({
|
|
33
|
+
rg: {
|
|
34
|
+
version: '15.2.0',
|
|
35
|
+
repository: 'BurntSushi/ripgrep',
|
|
36
|
+
tag: '15.2.0',
|
|
37
|
+
assets: {
|
|
38
|
+
'linux-x64': ['ripgrep-15.2.0-x86_64-unknown-linux-musl.tar.gz', '33e15bcf1624b25cdd2a55813a47a2f95dbe126268203e76aa6a585d1e7b149c'],
|
|
39
|
+
'linux-arm64': ['ripgrep-15.2.0-aarch64-unknown-linux-musl.tar.gz', '800b1e7206afe799dfb5a6901f23147cfaabe0e52210538100f61e86e1740915'],
|
|
40
|
+
'darwin-x64': ['ripgrep-15.2.0-x86_64-apple-darwin.tar.gz', 'af7825fcc69a2afc7a7aea55fc9af90e26421d8f20fe59df32e233c0b8a231c1'],
|
|
41
|
+
'darwin-arm64': ['ripgrep-15.2.0-aarch64-apple-darwin.tar.gz', '3750b2e93f37e0c692657da574d7019a101c0084da05a790c83fd335bad973e4'],
|
|
42
|
+
'win32-x64': ['ripgrep-15.2.0-x86_64-pc-windows-msvc.zip', '71b2fef860abe467217a538ff31de02f5258807c0129f771846f87bd029aafc5'],
|
|
43
|
+
'win32-arm64': ['ripgrep-15.2.0-aarch64-pc-windows-msvc.zip', 'e4abca10c3a64ebea742667dd7009449d49403db5460dd6873e389fa2945360f'],
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
fd: {
|
|
47
|
+
version: '10.3.0',
|
|
48
|
+
repository: 'sharkdp/fd',
|
|
49
|
+
tag: 'v10.3.0',
|
|
50
|
+
aliases: ['fdfind'],
|
|
51
|
+
assets: {
|
|
52
|
+
'linux-x64': ['fd-v10.3.0-x86_64-unknown-linux-musl.tar.gz', '2b6bfaae8c48f12050813c2ffe1884c61ea26e750d803df9c9114550a314cd14'],
|
|
53
|
+
'linux-arm64': ['fd-v10.3.0-aarch64-unknown-linux-musl.tar.gz', '996b9b1366433b211cb3bbedba91c9dbce2431842144d925428ead0adf32020b'],
|
|
54
|
+
'darwin-x64': ['fd-v10.3.0-x86_64-apple-darwin.tar.gz', '50d30f13fe3d5914b14c4fff5abcbd4d0cdab4b855970a6956f4f006c17117a3'],
|
|
55
|
+
'darwin-arm64': ['fd-v10.3.0-aarch64-apple-darwin.tar.gz', '0570263812089120bc2a5d84f9e65cd0c25e4a4d724c80075c357239c74ae904'],
|
|
56
|
+
'win32-x64': ['fd-v10.3.0-x86_64-pc-windows-msvc.zip', '318aa2a6fa664325933e81fda60d523fff29444129e91ebf0726b5b3bcd8b059'],
|
|
57
|
+
'win32-arm64': ['fd-v10.3.0-aarch64-pc-windows-msvc.zip', 'bf9b1e31bcac71c1e95d49c56f0d872f525b95d03854e94b1d4dd6786f825cc5'],
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
dust: {
|
|
61
|
+
version: '1.2.4',
|
|
62
|
+
repository: 'bootandy/dust',
|
|
63
|
+
tag: 'v1.2.4',
|
|
64
|
+
assets: {
|
|
65
|
+
'linux-x64': ['dust-v1.2.4-x86_64-unknown-linux-musl.tar.gz', '4e313f9f854017e58a2ada4c0d1774677b8cf53d63ab55a991d5871d5f504452'],
|
|
66
|
+
'linux-arm64': ['dust-v1.2.4-aarch64-unknown-linux-musl.tar.gz', 'e09b0d24b5da0fa06aecf1561849c13ae41ef055c1ce7077e35e9a46744b16af'],
|
|
67
|
+
'darwin-x64': ['dust-v1.2.4-x86_64-apple-darwin.tar.gz', 'bf84d3ff7f58e325d3eb5bb7696df6b22ef1e01fec80c2d8f7c9d3e611be66f4'],
|
|
68
|
+
'win32-x64': ['dust-v1.2.4-x86_64-pc-windows-msvc.zip', 'eb08d642f016787bb9fc918a4dc5f34665463657fddf83a40f2441cbf020fb4c'],
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
function executableName(name, platform = process.platform) {
|
|
74
|
+
return platform === 'win32' ? `${name}.exe` : name;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function managedCliBinDir(yeaftDir = DEFAULT_ROOT) {
|
|
78
|
+
return join(resolve(yeaftDir), 'bin');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function prependManagedCliBinToPath(yeaftDir = DEFAULT_ROOT, env = process.env, platform = process.platform) {
|
|
82
|
+
const binDir = managedCliBinDir(yeaftDir);
|
|
83
|
+
const current = typeof env.PATH === 'string'
|
|
84
|
+
? env.PATH
|
|
85
|
+
: (typeof env.Path === 'string' ? env.Path : '');
|
|
86
|
+
const parts = current.split(delimiter).filter(Boolean);
|
|
87
|
+
const normalized = platform === 'win32' ? binDir.toLowerCase() : binDir;
|
|
88
|
+
if (!parts.some(part => (platform === 'win32' ? part.toLowerCase() : part) === normalized)) {
|
|
89
|
+
const nextPath = [binDir, ...parts].join(delimiter);
|
|
90
|
+
env.PATH = nextPath;
|
|
91
|
+
if (platform === 'win32' && Object.hasOwn(env, 'Path')) env.Path = nextPath;
|
|
92
|
+
}
|
|
93
|
+
return binDir;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function canExecute(path, platform) {
|
|
97
|
+
try {
|
|
98
|
+
accessSync(path, platform === 'win32' ? constants.F_OK : constants.X_OK);
|
|
99
|
+
return statSync(path).isFile();
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function pathCandidates(name, { env, platform }) {
|
|
106
|
+
const names = platform === 'win32'
|
|
107
|
+
? [name.endsWith('.exe') ? name : `${name}.exe`, name]
|
|
108
|
+
: [name];
|
|
109
|
+
const pathEntries = String(env.PATH || env.Path || '').split(delimiter).filter(Boolean);
|
|
110
|
+
const candidates = [];
|
|
111
|
+
for (const directory of pathEntries) {
|
|
112
|
+
for (const candidate of names) candidates.push(join(directory, candidate));
|
|
113
|
+
}
|
|
114
|
+
return candidates;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function hashFile(path) {
|
|
118
|
+
return createHash('sha256').update(readFileSync(path)).digest('hex');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function samePath(left, right, platform) {
|
|
122
|
+
let normalizedLeft;
|
|
123
|
+
let normalizedRight;
|
|
124
|
+
try { normalizedLeft = realpathSync(left); } catch { normalizedLeft = resolve(left); }
|
|
125
|
+
try { normalizedRight = realpathSync(right); } catch { normalizedRight = resolve(right); }
|
|
126
|
+
return platform === 'win32'
|
|
127
|
+
? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
|
|
128
|
+
: normalizedLeft === normalizedRight;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function sameFileIdentity(left, right) {
|
|
132
|
+
try {
|
|
133
|
+
const leftStat = statSync(left, { bigint: true });
|
|
134
|
+
const rightStat = statSync(right, { bigint: true });
|
|
135
|
+
return leftStat.isFile()
|
|
136
|
+
&& rightStat.isFile()
|
|
137
|
+
&& leftStat.dev === rightStat.dev
|
|
138
|
+
&& leftStat.ino === rightStat.ino;
|
|
139
|
+
} catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function inspectManagedBinary(name, { yeaftDir, platform, arch }) {
|
|
145
|
+
const asset = selectAsset(name, platform, arch);
|
|
146
|
+
const path = join(managedCliBinDir(yeaftDir), executableName(name, platform));
|
|
147
|
+
if (!asset || !canExecute(path, platform)) return { path, exists: false, valid: false };
|
|
148
|
+
const installation = readState(yeaftDir).installations?.[name];
|
|
149
|
+
if (installation?.version !== asset.version
|
|
150
|
+
|| installation?.platform !== platform
|
|
151
|
+
|| installation?.arch !== arch
|
|
152
|
+
|| installation?.assetFileName !== asset.fileName
|
|
153
|
+
|| installation?.archiveSha256 !== asset.sha256
|
|
154
|
+
|| typeof installation?.binarySha256 !== 'string') {
|
|
155
|
+
return { path, exists: true, valid: false };
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
return {
|
|
159
|
+
path,
|
|
160
|
+
exists: true,
|
|
161
|
+
valid: hashFile(path) === installation.binarySha256,
|
|
162
|
+
};
|
|
163
|
+
} catch {
|
|
164
|
+
return { path, exists: true, valid: false };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function resolveExternalCommand(name, { yeaftDir, env, platform }) {
|
|
169
|
+
const spec = TOOL_SPECS[name];
|
|
170
|
+
const managedBinDir = managedCliBinDir(yeaftDir);
|
|
171
|
+
const managedPath = join(managedBinDir, executableName(name, platform));
|
|
172
|
+
for (const commandName of [name, ...(spec.aliases || [])]) {
|
|
173
|
+
for (const candidate of pathCandidates(commandName, { env, platform })) {
|
|
174
|
+
if (samePath(dirname(candidate), managedBinDir, platform)
|
|
175
|
+
|| samePath(candidate, managedPath, platform)
|
|
176
|
+
|| sameFileIdentity(candidate, managedPath)) continue;
|
|
177
|
+
if (canExecute(candidate, platform)) return candidate;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function resolveManagedCliCommand(name, options = {}) {
|
|
184
|
+
const spec = TOOL_SPECS[name];
|
|
185
|
+
if (!spec) return null;
|
|
186
|
+
const platform = options.platform || process.platform;
|
|
187
|
+
const env = options.env || process.env;
|
|
188
|
+
const arch = options.arch || process.arch;
|
|
189
|
+
const yeaftDir = resolve(options.yeaftDir || DEFAULT_ROOT);
|
|
190
|
+
const managed = inspectManagedBinary(name, { yeaftDir, platform, arch });
|
|
191
|
+
if (managed.valid) return managed.path;
|
|
192
|
+
return resolveExternalCommand(name, { yeaftDir, env, platform });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function selectAsset(name, platform, arch) {
|
|
196
|
+
const spec = TOOL_SPECS[name];
|
|
197
|
+
if (!spec) return null;
|
|
198
|
+
const asset = spec.assets[`${platform}-${arch}`];
|
|
199
|
+
if (!asset) return null;
|
|
200
|
+
const [fileName, sha256] = asset;
|
|
201
|
+
return {
|
|
202
|
+
...spec,
|
|
203
|
+
fileName,
|
|
204
|
+
sha256,
|
|
205
|
+
url: `https://github.com/${spec.repository}/releases/download/${spec.tag}/${fileName}`,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function readState(yeaftDir) {
|
|
210
|
+
try {
|
|
211
|
+
const parsed = JSON.parse(readFileSync(join(yeaftDir, STATE_FILE), 'utf8'));
|
|
212
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
213
|
+
} catch {
|
|
214
|
+
return {};
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function writeState(yeaftDir, state) {
|
|
219
|
+
const target = join(yeaftDir, STATE_FILE);
|
|
220
|
+
const temporary = `${target}.${process.pid}.${Date.now()}.tmp`;
|
|
221
|
+
writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
222
|
+
renameSync(temporary, target);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function sleep(ms) {
|
|
226
|
+
return new Promise(resolveSleep => setTimeout(resolveSleep, ms));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function acquireLock(lockDir, waitMs = LOCK_WAIT_MS, ready = null) {
|
|
230
|
+
const startedAt = Date.now();
|
|
231
|
+
for (;;) {
|
|
232
|
+
try {
|
|
233
|
+
mkdirSync(lockDir);
|
|
234
|
+
return true;
|
|
235
|
+
} catch (error) {
|
|
236
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
237
|
+
if (ready?.()) return false;
|
|
238
|
+
|
|
239
|
+
let invalidLock = false;
|
|
240
|
+
let staleLock = false;
|
|
241
|
+
try {
|
|
242
|
+
const lockStat = lstatSync(lockDir);
|
|
243
|
+
invalidLock = !lockStat.isDirectory();
|
|
244
|
+
staleLock = !invalidLock && Date.now() - lockStat.mtimeMs > LOCK_STALE_MS;
|
|
245
|
+
} catch {
|
|
246
|
+
// Inspection failures are treated as a busy lock until the deadline.
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (invalidLock) {
|
|
250
|
+
try { unlinkSync(lockDir); } catch {}
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
if (staleLock) {
|
|
254
|
+
try { rmSync(lockDir, { recursive: true, force: true }); } catch {}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const remainingMs = waitMs - (Date.now() - startedAt);
|
|
258
|
+
if (remainingMs <= 0) return false;
|
|
259
|
+
await sleep(Math.min(250, remainingMs));
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function updateState(yeaftDir, update) {
|
|
265
|
+
const lockDir = join(yeaftDir, '.managed-cli-state.lock');
|
|
266
|
+
const acquired = await acquireLock(lockDir, LOCK_WAIT_MS);
|
|
267
|
+
if (!acquired) throw new Error('managed CLI state is busy');
|
|
268
|
+
try {
|
|
269
|
+
const current = readState(yeaftDir);
|
|
270
|
+
const next = update(current && typeof current === 'object' ? current : {});
|
|
271
|
+
writeState(yeaftDir, next);
|
|
272
|
+
return next;
|
|
273
|
+
} finally {
|
|
274
|
+
rmSync(lockDir, { recursive: true, force: true });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function downloadArchive(asset, fetchFn, timeoutMs) {
|
|
279
|
+
const controller = new AbortController();
|
|
280
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
281
|
+
try {
|
|
282
|
+
const response = await fetchFn(asset.url, {
|
|
283
|
+
redirect: 'follow',
|
|
284
|
+
signal: controller.signal,
|
|
285
|
+
headers: { 'User-Agent': 'yeaft-agent-managed-cli' },
|
|
286
|
+
});
|
|
287
|
+
if (!response.ok) throw new Error(`download returned HTTP ${response.status}`);
|
|
288
|
+
const declaredSize = Number(response.headers?.get?.('content-length'));
|
|
289
|
+
if (Number.isFinite(declaredSize) && declaredSize > MAX_ARCHIVE_BYTES) {
|
|
290
|
+
throw new Error(`archive is too large (${declaredSize} bytes)`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const chunks = [];
|
|
294
|
+
let total = 0;
|
|
295
|
+
for await (const chunk of response.body || []) {
|
|
296
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
297
|
+
total += buffer.length;
|
|
298
|
+
if (total > MAX_ARCHIVE_BYTES) throw new Error('archive exceeds download limit');
|
|
299
|
+
chunks.push(buffer);
|
|
300
|
+
}
|
|
301
|
+
const archive = Buffer.concat(chunks);
|
|
302
|
+
if (archive.length === 0) throw new Error('download returned an empty archive');
|
|
303
|
+
const actual = createHash('sha256').update(archive).digest('hex');
|
|
304
|
+
if (actual !== asset.sha256) throw new Error(`checksum mismatch for ${asset.fileName}`);
|
|
305
|
+
return archive;
|
|
306
|
+
} finally {
|
|
307
|
+
clearTimeout(timer);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function tarString(buffer, start, length) {
|
|
312
|
+
return buffer.subarray(start, start + length).toString('utf8').replace(/\0.*$/, '');
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function extractTarBinary(archive, binaryFileName) {
|
|
316
|
+
const tar = gunzipSync(archive, { maxOutputLength: 64 * 1024 * 1024 });
|
|
317
|
+
for (let offset = 0; offset + 512 <= tar.length;) {
|
|
318
|
+
if (tar.subarray(offset, offset + 512).every(byte => byte === 0)) break;
|
|
319
|
+
const name = tarString(tar, offset, 100);
|
|
320
|
+
const prefix = tarString(tar, offset + 345, 155);
|
|
321
|
+
const entryName = prefix ? `${prefix}/${name}` : name;
|
|
322
|
+
const sizeText = tarString(tar, offset + 124, 12).trim();
|
|
323
|
+
const size = Number.parseInt(sizeText || '0', 8);
|
|
324
|
+
if (!Number.isSafeInteger(size) || size < 0 || size > MAX_BINARY_BYTES) {
|
|
325
|
+
throw new Error('invalid tar entry size');
|
|
326
|
+
}
|
|
327
|
+
const dataStart = offset + 512;
|
|
328
|
+
const dataEnd = dataStart + size;
|
|
329
|
+
if (dataEnd > tar.length) throw new Error('truncated tar archive');
|
|
330
|
+
const type = tar[offset + 156];
|
|
331
|
+
if ((type === 0 || type === 48) && basename(entryName) === binaryFileName) {
|
|
332
|
+
return Buffer.from(tar.subarray(dataStart, dataEnd));
|
|
333
|
+
}
|
|
334
|
+
offset = dataStart + Math.ceil(size / 512) * 512;
|
|
335
|
+
}
|
|
336
|
+
throw new Error(`binary ${binaryFileName} was not found in tar archive`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function findZipEnd(buffer) {
|
|
340
|
+
const minimum = Math.max(0, buffer.length - 65_557);
|
|
341
|
+
for (let offset = buffer.length - 22; offset >= minimum; offset -= 1) {
|
|
342
|
+
if (buffer.readUInt32LE(offset) === 0x06054b50) return offset;
|
|
343
|
+
}
|
|
344
|
+
return -1;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function extractZipBinary(archive, binaryFileName) {
|
|
348
|
+
const endOffset = findZipEnd(archive);
|
|
349
|
+
if (endOffset < 0) throw new Error('zip central directory was not found');
|
|
350
|
+
const entryCount = archive.readUInt16LE(endOffset + 10);
|
|
351
|
+
let offset = archive.readUInt32LE(endOffset + 16);
|
|
352
|
+
|
|
353
|
+
for (let index = 0; index < entryCount; index += 1) {
|
|
354
|
+
if (offset + 46 > archive.length || archive.readUInt32LE(offset) !== 0x02014b50) {
|
|
355
|
+
throw new Error('invalid zip central directory');
|
|
356
|
+
}
|
|
357
|
+
const compression = archive.readUInt16LE(offset + 10);
|
|
358
|
+
const compressedSize = archive.readUInt32LE(offset + 20);
|
|
359
|
+
const uncompressedSize = archive.readUInt32LE(offset + 24);
|
|
360
|
+
const nameLength = archive.readUInt16LE(offset + 28);
|
|
361
|
+
const extraLength = archive.readUInt16LE(offset + 30);
|
|
362
|
+
const commentLength = archive.readUInt16LE(offset + 32);
|
|
363
|
+
const localOffset = archive.readUInt32LE(offset + 42);
|
|
364
|
+
const entryName = archive.subarray(offset + 46, offset + 46 + nameLength).toString('utf8');
|
|
365
|
+
|
|
366
|
+
if (basename(entryName) === binaryFileName) {
|
|
367
|
+
if (uncompressedSize > MAX_BINARY_BYTES) throw new Error('binary exceeds extraction limit');
|
|
368
|
+
if (localOffset + 30 > archive.length || archive.readUInt32LE(localOffset) !== 0x04034b50) {
|
|
369
|
+
throw new Error('invalid zip local header');
|
|
370
|
+
}
|
|
371
|
+
const localNameLength = archive.readUInt16LE(localOffset + 26);
|
|
372
|
+
const localExtraLength = archive.readUInt16LE(localOffset + 28);
|
|
373
|
+
const dataStart = localOffset + 30 + localNameLength + localExtraLength;
|
|
374
|
+
const dataEnd = dataStart + compressedSize;
|
|
375
|
+
if (dataEnd > archive.length) throw new Error('truncated zip archive');
|
|
376
|
+
const compressed = archive.subarray(dataStart, dataEnd);
|
|
377
|
+
const binary = compression === 0
|
|
378
|
+
? Buffer.from(compressed)
|
|
379
|
+
: (compression === 8
|
|
380
|
+
? inflateRawSync(compressed, { maxOutputLength: MAX_BINARY_BYTES })
|
|
381
|
+
: null);
|
|
382
|
+
if (!binary) throw new Error(`unsupported zip compression method ${compression}`);
|
|
383
|
+
if (binary.length !== uncompressedSize) throw new Error('zip binary size mismatch');
|
|
384
|
+
return binary;
|
|
385
|
+
}
|
|
386
|
+
offset += 46 + nameLength + extraLength + commentLength;
|
|
387
|
+
}
|
|
388
|
+
throw new Error(`binary ${binaryFileName} was not found in zip archive`);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export function extractManagedCliBinary(archive, archiveName, commandName, platform = process.platform) {
|
|
392
|
+
const binaryFileName = executableName(commandName, platform);
|
|
393
|
+
if (archiveName.endsWith('.tar.gz')) return extractTarBinary(archive, binaryFileName);
|
|
394
|
+
if (archiveName.endsWith('.zip')) return extractZipBinary(archive, binaryFileName);
|
|
395
|
+
throw new Error(`unsupported archive format: ${archiveName}`);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function versionOutputMatches(name, version, output) {
|
|
399
|
+
const labels = name === 'rg' ? ['ripgrep'] : [name];
|
|
400
|
+
const firstLine = String(output || '').trim().split(/\r?\n/, 1)[0]?.toLowerCase();
|
|
401
|
+
return labels.some(label => firstLine === `${label} ${version}`.toLowerCase()
|
|
402
|
+
|| firstLine.startsWith(`${label} ${version} `));
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function replaceManagedBinary(temporary, installedPath, platform) {
|
|
406
|
+
if (platform !== 'win32' || !existsSync(installedPath)) {
|
|
407
|
+
renameSync(temporary, installedPath);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const backup = `${installedPath}.${process.pid}.${Date.now()}.backup`;
|
|
411
|
+
renameSync(installedPath, backup);
|
|
412
|
+
try {
|
|
413
|
+
renameSync(temporary, installedPath);
|
|
414
|
+
rmSync(backup, { force: true });
|
|
415
|
+
} catch (error) {
|
|
416
|
+
try { renameSync(backup, installedPath); } catch {}
|
|
417
|
+
throw error;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async function installOne(name, options) {
|
|
422
|
+
const { yeaftDir, platform, arch, env, fetchFn, timeoutMs, lockWaitMs } = options;
|
|
423
|
+
let managed = inspectManagedBinary(name, { yeaftDir, platform, arch });
|
|
424
|
+
if (managed.valid) return { name, status: 'available', path: managed.path };
|
|
425
|
+
if (!managed.exists) {
|
|
426
|
+
const external = resolveExternalCommand(name, { yeaftDir, platform, env });
|
|
427
|
+
if (external) return { name, status: 'available', path: external, source: 'system' };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const asset = selectAsset(name, platform, arch);
|
|
431
|
+
if (!asset) return { name, status: 'unsupported', platform, arch };
|
|
432
|
+
|
|
433
|
+
const binDir = managedCliBinDir(yeaftDir);
|
|
434
|
+
mkdirSync(binDir, { recursive: true, mode: 0o755 });
|
|
435
|
+
const installedPath = managed.path;
|
|
436
|
+
const lockDir = join(binDir, `.install-${name}.lock`);
|
|
437
|
+
const acquired = await acquireLock(lockDir, lockWaitMs, () => (
|
|
438
|
+
inspectManagedBinary(name, { yeaftDir, platform, arch }).valid
|
|
439
|
+
));
|
|
440
|
+
if (!acquired) {
|
|
441
|
+
managed = inspectManagedBinary(name, { yeaftDir, platform, arch });
|
|
442
|
+
return managed.valid
|
|
443
|
+
? { name, status: 'available', path: managed.path }
|
|
444
|
+
: { name, status: 'busy' };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
try {
|
|
448
|
+
managed = inspectManagedBinary(name, { yeaftDir, platform, arch });
|
|
449
|
+
if (managed.valid) return { name, status: 'available', path: managed.path };
|
|
450
|
+
if (!managed.exists) {
|
|
451
|
+
const external = resolveExternalCommand(name, { yeaftDir, platform, env });
|
|
452
|
+
if (external) return { name, status: 'available', path: external, source: 'system' };
|
|
453
|
+
}
|
|
454
|
+
const archive = await downloadArchive(asset, fetchFn, timeoutMs);
|
|
455
|
+
const binary = extractManagedCliBinary(archive, asset.fileName, name, platform);
|
|
456
|
+
if (binary.length === 0) throw new Error('archive contained an empty binary');
|
|
457
|
+
const temporary = `${installedPath}.${process.pid}.${Date.now()}.tmp`;
|
|
458
|
+
try {
|
|
459
|
+
writeFileSync(temporary, binary, { mode: 0o755, flag: 'wx' });
|
|
460
|
+
if (platform !== 'win32') chmodSync(temporary, 0o755);
|
|
461
|
+
const verification = spawnSync(temporary, ['--version'], {
|
|
462
|
+
encoding: 'utf8',
|
|
463
|
+
timeout: 5000,
|
|
464
|
+
windowsHide: true,
|
|
465
|
+
});
|
|
466
|
+
if (verification.error || verification.status !== 0
|
|
467
|
+
|| !versionOutputMatches(name, asset.version, verification.stdout)) {
|
|
468
|
+
throw new Error(`installed ${name} binary failed its version check`);
|
|
469
|
+
}
|
|
470
|
+
replaceManagedBinary(temporary, installedPath, platform);
|
|
471
|
+
} finally {
|
|
472
|
+
rmSync(temporary, { force: true });
|
|
473
|
+
}
|
|
474
|
+
const binarySha256 = hashFile(installedPath);
|
|
475
|
+
await updateState(yeaftDir, state => ({
|
|
476
|
+
...state,
|
|
477
|
+
version: 2,
|
|
478
|
+
updatedAt: Date.now(),
|
|
479
|
+
installations: {
|
|
480
|
+
...(state.installations && typeof state.installations === 'object'
|
|
481
|
+
? state.installations
|
|
482
|
+
: {}),
|
|
483
|
+
[name]: {
|
|
484
|
+
version: asset.version,
|
|
485
|
+
platform,
|
|
486
|
+
arch,
|
|
487
|
+
assetFileName: asset.fileName,
|
|
488
|
+
archiveSha256: asset.sha256,
|
|
489
|
+
binarySha256,
|
|
490
|
+
},
|
|
491
|
+
},
|
|
492
|
+
}));
|
|
493
|
+
return {
|
|
494
|
+
name,
|
|
495
|
+
status: 'installed',
|
|
496
|
+
path: installedPath,
|
|
497
|
+
version: asset.version,
|
|
498
|
+
binarySha256,
|
|
499
|
+
};
|
|
500
|
+
} finally {
|
|
501
|
+
rmSync(lockDir, { recursive: true, force: true });
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
export function ensureManagedCliTools(options = {}) {
|
|
506
|
+
const yeaftDir = resolve(options.yeaftDir || DEFAULT_ROOT);
|
|
507
|
+
const platform = options.platform || process.platform;
|
|
508
|
+
const arch = options.arch || process.arch;
|
|
509
|
+
const env = options.env || process.env;
|
|
510
|
+
|
|
511
|
+
const flightKey = `${yeaftDir}:${platform}:${arch}`;
|
|
512
|
+
if (installFlights.has(flightKey)) return installFlights.get(flightKey);
|
|
513
|
+
|
|
514
|
+
const toolReady = Object.fromEntries(Object.keys(TOOL_SPECS).map(name => {
|
|
515
|
+
let resolveReady;
|
|
516
|
+
const promise = new Promise(resolveTool => { resolveReady = resolveTool; });
|
|
517
|
+
return [name, { promise, resolve: resolveReady }];
|
|
518
|
+
}));
|
|
519
|
+
const skipInstall = options.skipInstall || env.YEAFT_SKIP_MANAGED_CLI_INSTALLS === 'true';
|
|
520
|
+
|
|
521
|
+
const flight = (async () => {
|
|
522
|
+
mkdirSync(yeaftDir, { recursive: true, mode: 0o755 });
|
|
523
|
+
const now = typeof options.now === 'function' ? options.now() : Date.now();
|
|
524
|
+
const state = readState(yeaftDir);
|
|
525
|
+
const failures = state.failures && typeof state.failures === 'object' ? { ...state.failures } : {};
|
|
526
|
+
const fetchFn = options.fetchFn || globalThis.fetch;
|
|
527
|
+
const timeoutMs = Number.isFinite(options.timeoutMs)
|
|
528
|
+
? Math.max(1000, options.timeoutMs)
|
|
529
|
+
: DOWNLOAD_TIMEOUT_MS;
|
|
530
|
+
const lockWaitMs = Number.isFinite(options.lockWaitMs)
|
|
531
|
+
? Math.max(0, options.lockWaitMs)
|
|
532
|
+
: LOCK_WAIT_MS;
|
|
533
|
+
|
|
534
|
+
const results = await Promise.all(Object.keys(TOOL_SPECS).map(async name => {
|
|
535
|
+
let result;
|
|
536
|
+
try {
|
|
537
|
+
if (skipInstall) {
|
|
538
|
+
result = { name, status: 'skipped' };
|
|
539
|
+
} else {
|
|
540
|
+
const managed = inspectManagedBinary(name, { yeaftDir, platform, arch });
|
|
541
|
+
const external = managed.exists
|
|
542
|
+
? null
|
|
543
|
+
: resolveExternalCommand(name, { yeaftDir, platform, env });
|
|
544
|
+
if (managed.valid) {
|
|
545
|
+
result = { name, status: 'available', path: managed.path };
|
|
546
|
+
} else if (external) {
|
|
547
|
+
result = { name, status: 'available', path: external, source: 'system' };
|
|
548
|
+
} else {
|
|
549
|
+
const failure = failures[name];
|
|
550
|
+
if (!options.force && Number.isFinite(failure?.at) && now - failure.at < FAILURE_COOLDOWN_MS) {
|
|
551
|
+
result = { name, status: 'cooldown', reason: failure.reason };
|
|
552
|
+
} else if (typeof fetchFn !== 'function') {
|
|
553
|
+
result = { name, status: 'failed', reason: 'fetch is unavailable' };
|
|
554
|
+
} else {
|
|
555
|
+
try {
|
|
556
|
+
result = await installOne(name, {
|
|
557
|
+
yeaftDir, platform, arch, env, fetchFn, timeoutMs, lockWaitMs,
|
|
558
|
+
});
|
|
559
|
+
} catch (error) {
|
|
560
|
+
result = { name, status: 'failed', reason: error?.message || String(error) };
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return result;
|
|
566
|
+
} finally {
|
|
567
|
+
toolReady[name].resolve(result || { name, status: 'failed', reason: 'setup did not complete' });
|
|
568
|
+
}
|
|
569
|
+
}));
|
|
570
|
+
|
|
571
|
+
for (const result of results) {
|
|
572
|
+
if (result.status === 'failed') {
|
|
573
|
+
failures[result.name] = { at: now, reason: result.reason || result.status };
|
|
574
|
+
} else if (result.status !== 'busy') {
|
|
575
|
+
delete failures[result.name];
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
try {
|
|
579
|
+
await updateState(yeaftDir, state => ({
|
|
580
|
+
...state,
|
|
581
|
+
version: 2,
|
|
582
|
+
updatedAt: now,
|
|
583
|
+
failures,
|
|
584
|
+
installations: state.installations && typeof state.installations === 'object'
|
|
585
|
+
? state.installations
|
|
586
|
+
: {},
|
|
587
|
+
}));
|
|
588
|
+
} catch {
|
|
589
|
+
// Tool installation remains usable when the diagnostic state is unwritable.
|
|
590
|
+
}
|
|
591
|
+
return results;
|
|
592
|
+
})();
|
|
593
|
+
|
|
594
|
+
flight.toolReady = Object.fromEntries(
|
|
595
|
+
Object.entries(toolReady).map(([name, entry]) => [name, entry.promise]),
|
|
596
|
+
);
|
|
597
|
+
installFlights.set(flightKey, flight);
|
|
598
|
+
flight.finally(() => {
|
|
599
|
+
for (const [name, entry] of Object.entries(toolReady)) {
|
|
600
|
+
entry.resolve({ name, status: 'failed', reason: 'setup did not complete' });
|
|
601
|
+
}
|
|
602
|
+
installFlights.delete(flightKey);
|
|
603
|
+
}).catch(() => {});
|
|
604
|
+
return flight;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
export function managedCliToolReady(ready, name) {
|
|
608
|
+
return ready?.toolReady?.[name] || ready || Promise.resolve([]);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
export function summarizeManagedCliResults(results) {
|
|
612
|
+
return (results || []).map(result => {
|
|
613
|
+
const detail = result.path || result.reason || `${result.platform || ''}-${result.arch || ''}`;
|
|
614
|
+
return `${result.name}:${result.status}${detail ? `(${detail})` : ''}`;
|
|
615
|
+
}).join(', ');
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
export const managedCliToolSpecs = TOOL_SPECS;
|
package/yeaft/session.js
CHANGED
|
@@ -81,6 +81,7 @@ const DEFAULT_COMPACT_TRIGGER_RATIO = 0.7;
|
|
|
81
81
|
* @property {boolean} [skipSkills] — Skip skill loading
|
|
82
82
|
* @property {object[]} [extraTools] — Additional ToolDef objects to register
|
|
83
83
|
* @property {object} [configOverrides] — Additional config overrides
|
|
84
|
+
* @property {Promise<Array>} [managedCliReady] — optional entrypoint-owned CLI setup
|
|
84
85
|
*/
|
|
85
86
|
|
|
86
87
|
/**
|
|
@@ -95,6 +96,7 @@ const DEFAULT_COMPACT_TRIGGER_RATIO = 0.7;
|
|
|
95
96
|
* @property {import('./tools/registry.js').ToolRegistry} toolRegistry — Tool registry
|
|
96
97
|
* @property {import('./debug-trace.js').DebugTrace|import('./debug-trace.js').NullTrace} trace
|
|
97
98
|
* @property {string} yeaftDir — Resolved data directory path
|
|
99
|
+
* @property {Promise<Array>} managedCliReady — optional CLI setup completion
|
|
98
100
|
* @property {{ skills: number, mcpServers: string[], mcpFailed: object[], tools: number }} status
|
|
99
101
|
* @property {() => Promise<void>} shutdown — Graceful shutdown
|
|
100
102
|
*/
|
|
@@ -148,6 +150,7 @@ export async function loadSession(options = {}) {
|
|
|
148
150
|
extraTools = [],
|
|
149
151
|
configOverrides = {},
|
|
150
152
|
serverMode = false,
|
|
153
|
+
managedCliReady = null,
|
|
151
154
|
} = options;
|
|
152
155
|
|
|
153
156
|
// ─── 1. Determine config + store directories ─────────────
|
|
@@ -169,6 +172,8 @@ export async function loadSession(options = {}) {
|
|
|
169
172
|
const storeInitResult = configInitResult;
|
|
170
173
|
overrides.dir = configDir;
|
|
171
174
|
|
|
175
|
+
const managedCliInstall = managedCliReady || Promise.resolve([]);
|
|
176
|
+
|
|
172
177
|
// Log any warnings from directory initialization.
|
|
173
178
|
const initWarnings = configInitResult.warnings;
|
|
174
179
|
for (const w of initWarnings) {
|
|
@@ -479,6 +484,7 @@ export async function loadSession(options = {}) {
|
|
|
479
484
|
yeaftDir,
|
|
480
485
|
toolStats,
|
|
481
486
|
taskManager,
|
|
487
|
+
managedCliReady: managedCliInstall,
|
|
482
488
|
});
|
|
483
489
|
|
|
484
490
|
// ─── 9a-pre. Create per-group history Compactor ────────
|
|
@@ -633,6 +639,7 @@ export async function loadSession(options = {}) {
|
|
|
633
639
|
amsRegistry,
|
|
634
640
|
toolStats,
|
|
635
641
|
taskManager,
|
|
642
|
+
managedCliReady: managedCliInstall,
|
|
636
643
|
shutdown,
|
|
637
644
|
// task-325c: user-initiated abort API. Delegates to web-bridge which
|
|
638
645
|
// owns the single AbortController. Lazy-imported to avoid a hard cycle
|
|
@@ -158,6 +158,7 @@ export function startSubAgent(agent, deps = {}) {
|
|
|
158
158
|
skillManager: deps.skillManager || null,
|
|
159
159
|
mcpManager: deps.mcpManager || null,
|
|
160
160
|
yeaftDir: deps.yeaftDir || null,
|
|
161
|
+
managedCliReady: deps.managedCliReady || null,
|
|
161
162
|
toolStats: deps.toolStats || null,
|
|
162
163
|
taskManager: deps.taskManager || null,
|
|
163
164
|
});
|