fauxnix-cli 0.9.3 → 0.12.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 +84 -26
- package/dist/ast.d.ts +38 -3
- package/dist/ast.js +22 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +84 -20
- package/dist/commands/archive.d.ts +2 -1
- package/dist/commands/archive.js +169 -25
- package/dist/commands/install-all.js +4 -2
- package/dist/commands/net.d.ts +9 -0
- package/dist/commands/net.js +51 -15
- package/dist/commands/sysinfo.d.ts +2 -1
- package/dist/commands/sysinfo.js +610 -82
- package/dist/commands/text-filters.d.ts +1 -0
- package/dist/commands/text-filters.js +99 -13
- package/dist/commands/text-io.js +72 -43
- package/dist/doctor.d.ts +21 -0
- package/dist/doctor.js +292 -0
- package/dist/errors.js +14 -4
- package/dist/executor.d.ts +4 -1
- package/dist/executor.js +194 -51
- package/dist/install.d.ts +15 -0
- package/dist/install.js +247 -0
- package/dist/mcp.d.ts +19 -0
- package/dist/mcp.js +49 -26
- package/dist/parser.js +432 -35
- package/dist/powershell.d.ts +22 -0
- package/dist/powershell.js +129 -0
- package/dist/ps-host.d.ts +41 -13
- package/dist/ps-host.js +302 -40
- package/dist/qwen-launch.d.ts +12 -0
- package/dist/qwen-launch.js +29 -0
- package/dist/registry.d.ts +22 -0
- package/dist/registry.js +109 -1
- package/dist/translator.d.ts +42 -9
- package/dist/translator.js +697 -95
- package/package.json +3 -1
package/dist/errors.js
CHANGED
|
@@ -43,7 +43,7 @@ function unescapeClixml(t) {
|
|
|
43
43
|
.trim();
|
|
44
44
|
}
|
|
45
45
|
/**
|
|
46
|
-
* When stderr is redirected,
|
|
46
|
+
* When stderr is redirected, PowerShell can serialize error records as
|
|
47
47
|
* CLIXML (`#< CLIXML` + XML). Unwrap the real message lines and drop
|
|
48
48
|
* progress records, so agents see plain bash-style text.
|
|
49
49
|
*/
|
|
@@ -76,8 +76,10 @@ export function normalizeStderr(stderr) {
|
|
|
76
76
|
let m = line.match(/^The term '(.+?)' is not recognized/);
|
|
77
77
|
if (m)
|
|
78
78
|
return commandNotFound(m[1]);
|
|
79
|
-
// zh-CN:
|
|
80
|
-
|
|
79
|
+
// zh-CN: & : 无法将“x”项识别为 cmdlet、函数、脚本文件或可运行程序的名称。
|
|
80
|
+
// Require the PowerShell-specific suffix so ordinary Chinese prose that
|
|
81
|
+
// happens to contain “无法将…项识别为” is not rewritten.
|
|
82
|
+
m = line.match(/^(?:\S+\s*:\s*)?无法将[“"'‘]?(.+?)[”"'’]?项识别为\s*cmdlet、函数、脚本文件或可运行程序的名称(?:。|$)/);
|
|
81
83
|
if (m)
|
|
82
84
|
return commandNotFound(m[1]);
|
|
83
85
|
// "x : The term 'y' is not recognized ..." (with source prefix)
|
|
@@ -94,18 +96,26 @@ export function normalizeStderr(stderr) {
|
|
|
94
96
|
': No such file or directory');
|
|
95
97
|
}
|
|
96
98
|
// zh-CN: "Get-Content : 找不到路径“X”,因为该路径不存在。"
|
|
97
|
-
m = line.match(/^(\S+)\s*:\s*找不到路径[“'
|
|
99
|
+
m = line.match(/^(\S+)\s*:\s*找不到路径[“"'‘](.+?)[”"'’],因为该路径不存在[。.]?$/);
|
|
98
100
|
if (m) {
|
|
99
101
|
return m[1].toLowerCase() + ': ' + m[2].replace(/\\/g, '/') + ': No such file or directory';
|
|
100
102
|
}
|
|
101
103
|
// "cat : Cannot find drive. A drive with the name 'z' does not exist."
|
|
102
104
|
m = line.match(/^(\S+)\s*:\s*Cannot find drive\..*name '(.+?)'.*$/);
|
|
105
|
+
if (m)
|
|
106
|
+
return m[1].toLowerCase() + ': ' + m[2] + ': No such file or directory';
|
|
107
|
+
// zh-CN: "Get-Content : 找不到驱动器。名为“Z”的驱动器不存在。"
|
|
108
|
+
m = line.match(/^(\S+)\s*:\s*找不到驱动器[。.]\s*名为[“"'‘](.+?)[”"'’]的驱动器不存在[。.]?$/);
|
|
103
109
|
if (m)
|
|
104
110
|
return m[1].toLowerCase() + ': ' + m[2] + ': No such file or directory';
|
|
105
111
|
// "rm : Cannot remove item ... Access is denied"
|
|
106
112
|
m = line.match(/^(\S+)\s*:\s*(.*)Access to the path '(.+?)' is denied\.?$/);
|
|
107
113
|
if (m)
|
|
108
114
|
return m[1].toLowerCase() + ': cannot remove \'' + m[3] + '\': Permission denied';
|
|
115
|
+
// zh-CN: "rm : 对路径“C:\\protected”的访问被拒绝。"
|
|
116
|
+
m = line.match(/^(\S+)\s*:\s*对路径[“"'‘](.+?)[”"'’]的访问被拒绝[。.]?$/);
|
|
117
|
+
if (m)
|
|
118
|
+
return m[1].toLowerCase() + ': cannot remove \'' + m[2] + '\': Permission denied';
|
|
109
119
|
// leftover PS not-recognized lines that the rewrites above did not catch
|
|
110
120
|
if (/\.sh'?/.test(line) && /is not recognized/.test(line)) {
|
|
111
121
|
return line + SH_SCRIPT_WINDOWS_HINT;
|
package/dist/executor.d.ts
CHANGED
|
@@ -29,12 +29,15 @@ export declare class FauxnixSession {
|
|
|
29
29
|
private hostFile;
|
|
30
30
|
private host;
|
|
31
31
|
private lifecycleLock;
|
|
32
|
+
/** True once `env` was loaded from the host's complete environment snapshot. */
|
|
33
|
+
private hasEnvSnapshot;
|
|
34
|
+
private readonly powerShell;
|
|
32
35
|
constructor();
|
|
33
36
|
private bindFiles;
|
|
34
37
|
private withLock;
|
|
35
38
|
private syncFromDisk;
|
|
36
39
|
private ensureHost;
|
|
37
|
-
/** Boot
|
|
40
|
+
/** Boot the selected PowerShell now so the first run() is not a cold start. */
|
|
38
41
|
prewarm(): Promise<void>;
|
|
39
42
|
dispose(): Promise<void>;
|
|
40
43
|
/** Kill the host and re-prewarm the same session object (no second FauxnixSession). */
|
package/dist/executor.js
CHANGED
|
@@ -1,13 +1,35 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { promises as fs, readFileSync, existsSync, openSync, closeSync, writeSync } from 'node:fs';
|
|
2
|
+
import { promises as fs, readFileSync, existsSync, openSync, closeSync, readSync, rmSync, writeSync, } from 'node:fs';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { normalizeLiteralPath, wrapScript } from './translator.js';
|
|
6
|
-
import { decodeOutput,
|
|
6
|
+
import { decodeOutput, resolveNativePref } from './encoding.js';
|
|
7
7
|
import { normalizeStderr } from './errors.js';
|
|
8
|
-
import { DEFAULT_STDERR_LIMIT, DEFAULT_STDOUT_LIMIT, PowerShellHost,
|
|
8
|
+
import { DEFAULT_STDERR_LIMIT, DEFAULT_STDOUT_LIMIT, PowerShellHost, } from './ps-host.js';
|
|
9
|
+
import { resolvePowerShell } from './powershell.js';
|
|
9
10
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
10
11
|
const DEFAULT_WINDOWS_PATHEXT = '.COM;.EXE;.BAT;.CMD';
|
|
12
|
+
const MAX_HOST_CAPTURE_LIMIT = 0x7fffffff;
|
|
13
|
+
const UTF8_BOUNDARY_SUFFIX = 4;
|
|
14
|
+
const MAX_CALLER_OUTPUT_LIMIT = MAX_HOST_CAPTURE_LIMIT - UTF8_BOUNDARY_SUFFIX;
|
|
15
|
+
function validateOutputLimit(name, value) {
|
|
16
|
+
if (!Number.isSafeInteger(value) || value < 0 || value > MAX_CALLER_OUTPUT_LIMIT) {
|
|
17
|
+
throw new RangeError('fauxnix: ' + name + ' must be an integer from 0 to ' + MAX_CALLER_OUTPUT_LIMIT);
|
|
18
|
+
}
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Retain one complete codepoint beyond the logical caller budget so Node can
|
|
23
|
+
* detect truncation and clip at a UTF-8 boundary. The host itself stays
|
|
24
|
+
* bounded even when the caller has no remaining budget.
|
|
25
|
+
*/
|
|
26
|
+
function rawHostCaptureLimit(logicalBytes) {
|
|
27
|
+
if (!Number.isFinite(logicalBytes)) {
|
|
28
|
+
return logicalBytes === Number.POSITIVE_INFINITY ? MAX_HOST_CAPTURE_LIMIT : 0;
|
|
29
|
+
}
|
|
30
|
+
const bytes = Math.max(0, Math.floor(logicalBytes));
|
|
31
|
+
return Math.min(MAX_HOST_CAPTURE_LIMIT, bytes + UTF8_BOUNDARY_SUFFIX);
|
|
32
|
+
}
|
|
11
33
|
function hasEnvKey(env, name) {
|
|
12
34
|
const normalized = name.toUpperCase();
|
|
13
35
|
return Object.keys(env).some((key) => key.toUpperCase() === normalized);
|
|
@@ -201,8 +223,12 @@ export class FauxnixSession {
|
|
|
201
223
|
hostFile;
|
|
202
224
|
host = null;
|
|
203
225
|
lifecycleLock = Promise.resolve();
|
|
226
|
+
/** True once `env` was loaded from the host's complete environment snapshot. */
|
|
227
|
+
hasEnvSnapshot = false;
|
|
228
|
+
powerShell;
|
|
204
229
|
constructor() {
|
|
205
230
|
this.id = randomUUID().slice(0, 8);
|
|
231
|
+
this.powerShell = resolvePowerShell();
|
|
206
232
|
this.bindFiles(this.id);
|
|
207
233
|
}
|
|
208
234
|
bindFiles(id) {
|
|
@@ -231,8 +257,10 @@ export class FauxnixSession {
|
|
|
231
257
|
try {
|
|
232
258
|
if (existsSync(this.envFile)) {
|
|
233
259
|
const raw = readFileSync(this.envFile, 'utf8');
|
|
234
|
-
if (raw.trim())
|
|
260
|
+
if (raw.trim()) {
|
|
235
261
|
this.env = JSON.parse(raw);
|
|
262
|
+
this.hasEnvSnapshot = true;
|
|
263
|
+
}
|
|
236
264
|
}
|
|
237
265
|
}
|
|
238
266
|
catch {
|
|
@@ -241,11 +269,11 @@ export class FauxnixSession {
|
|
|
241
269
|
}
|
|
242
270
|
ensureHost() {
|
|
243
271
|
if (!this.host) {
|
|
244
|
-
this.host = new PowerShellHost(this.hostFile, () => this.childEnv());
|
|
272
|
+
this.host = new PowerShellHost(this.hostFile, () => this.childEnv(), this.powerShell);
|
|
245
273
|
}
|
|
246
274
|
return this.host;
|
|
247
275
|
}
|
|
248
|
-
/** Boot
|
|
276
|
+
/** Boot the selected PowerShell now so the first run() is not a cold start. */
|
|
249
277
|
prewarm() {
|
|
250
278
|
return this.withLock(async () => {
|
|
251
279
|
await this.ensureHost().ready();
|
|
@@ -268,6 +296,7 @@ export class FauxnixSession {
|
|
|
268
296
|
}
|
|
269
297
|
this.cwd = null;
|
|
270
298
|
this.env = {};
|
|
299
|
+
this.hasEnvSnapshot = false;
|
|
271
300
|
this.prevExit = null;
|
|
272
301
|
await Promise.allSettled([
|
|
273
302
|
fs.rm(this.cwdFile, { force: true }),
|
|
@@ -279,12 +308,19 @@ export class FauxnixSession {
|
|
|
279
308
|
}
|
|
280
309
|
/** env for the child powershell process. */
|
|
281
310
|
childEnv(cwdOverride, stdinFile) {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
311
|
+
// Before the first completed request, `env` contains optional caller
|
|
312
|
+
// overrides and is layered over the process baseline. Afterwards it is a
|
|
313
|
+
// complete snapshot written by the resident host. Restore that snapshot
|
|
314
|
+
// verbatim on transparent host restarts: merging it with process.env would
|
|
315
|
+
// resurrect inherited variables that the shell successfully unset.
|
|
316
|
+
const env = this.hasEnvSnapshot ? { ...this.env } : { ...process.env };
|
|
317
|
+
if (!this.hasEnvSnapshot) {
|
|
318
|
+
for (const [k, v] of Object.entries(this.env)) {
|
|
319
|
+
if (v === undefined)
|
|
320
|
+
delete env[k];
|
|
321
|
+
else
|
|
322
|
+
env[k] = v;
|
|
323
|
+
}
|
|
288
324
|
}
|
|
289
325
|
// The MCP SDK's safe Windows stdio environment omits PATHEXT. Without it,
|
|
290
326
|
// PowerShell cannot resolve extensionless native commands such as `node`.
|
|
@@ -318,16 +354,70 @@ export class FauxnixSession {
|
|
|
318
354
|
async function runPlans(plans, session, opts, afterSegment, ensureHost) {
|
|
319
355
|
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
320
356
|
const deadline = Date.now() + timeoutMs;
|
|
321
|
-
const stdoutLimit = opts.stdoutLimit ?? DEFAULT_STDOUT_LIMIT;
|
|
322
|
-
const stderrLimit = opts.stderrLimit ?? DEFAULT_STDERR_LIMIT;
|
|
357
|
+
const stdoutLimit = validateOutputLimit('stdoutLimit', opts.stdoutLimit ?? DEFAULT_STDOUT_LIMIT);
|
|
358
|
+
const stderrLimit = validateOutputLimit('stderrLimit', opts.stderrLimit ?? DEFAULT_STDERR_LIMIT);
|
|
323
359
|
const timeoutMessage = '\nbash: command timed out after ' + Math.round(timeoutMs / 1000) + 's';
|
|
324
360
|
let stdout = '';
|
|
325
361
|
let stderr = '';
|
|
362
|
+
let stdoutBytes = 0;
|
|
363
|
+
let stderrBytes = 0;
|
|
364
|
+
let stdoutClosed = false;
|
|
365
|
+
let stderrClosed = false;
|
|
326
366
|
let exitCode = 0;
|
|
327
367
|
let timedOut = false;
|
|
328
368
|
let cancelled = false;
|
|
329
369
|
let truncated = false;
|
|
330
370
|
let spawnError;
|
|
371
|
+
const appendCaller = (fd, data) => {
|
|
372
|
+
if (!data)
|
|
373
|
+
return;
|
|
374
|
+
if (fd === 1 ? stdoutClosed : stderrClosed)
|
|
375
|
+
return;
|
|
376
|
+
const used = fd === 1 ? stdoutBytes : stderrBytes;
|
|
377
|
+
const limit = fd === 1 ? stdoutLimit : stderrLimit;
|
|
378
|
+
const clipped = clipUtf8(data, Math.max(0, limit - used));
|
|
379
|
+
if (fd === 1) {
|
|
380
|
+
stdout += clipped.text;
|
|
381
|
+
stdoutBytes += Buffer.byteLength(clipped.text, 'utf8');
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
stderr += clipped.text;
|
|
385
|
+
stderrBytes += Buffer.byteLength(clipped.text, 'utf8');
|
|
386
|
+
}
|
|
387
|
+
if (clipped.truncated) {
|
|
388
|
+
if (fd === 1)
|
|
389
|
+
stdoutClosed = true;
|
|
390
|
+
else
|
|
391
|
+
stderrClosed = true;
|
|
392
|
+
truncated = true;
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
const remainingFor = (dest) => {
|
|
396
|
+
if (dest.kind !== 'caller')
|
|
397
|
+
return 0;
|
|
398
|
+
if (dest.fd === 1 ? stdoutClosed : stderrClosed)
|
|
399
|
+
return 0;
|
|
400
|
+
const limit = dest.fd === 1 ? stdoutLimit : stderrLimit;
|
|
401
|
+
const used = dest.fd === 1 ? stdoutBytes : stderrBytes;
|
|
402
|
+
return Math.max(0, limit - used);
|
|
403
|
+
};
|
|
404
|
+
const closeCallerDest = (dest) => {
|
|
405
|
+
if (dest.kind !== 'caller')
|
|
406
|
+
return;
|
|
407
|
+
if (dest.fd === 1)
|
|
408
|
+
stdoutClosed = true;
|
|
409
|
+
else
|
|
410
|
+
stderrClosed = true;
|
|
411
|
+
};
|
|
412
|
+
const hostStream = (dest) => {
|
|
413
|
+
if (dest.kind === 'file')
|
|
414
|
+
return { mode: 'spool', limit: 0 };
|
|
415
|
+
if (dest.kind === 'nul')
|
|
416
|
+
return { mode: 'discard', limit: 0 };
|
|
417
|
+
if (dest.fd === 1 ? stdoutClosed : stderrClosed)
|
|
418
|
+
return { mode: 'discard', limit: 0 };
|
|
419
|
+
return { mode: 'capture', limit: rawHostCaptureLimit(remainingFor(dest)) };
|
|
420
|
+
};
|
|
331
421
|
// bash list semantics: `a && b ; c` runs c regardless of a; `a && b && c`
|
|
332
422
|
// skips b AND c when a fails. chainOk models the value of the current
|
|
333
423
|
// &&/|| chain; `;` segments always run and restart the chain.
|
|
@@ -350,7 +440,7 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
|
|
|
350
440
|
break;
|
|
351
441
|
}
|
|
352
442
|
if (Date.now() >= deadline) {
|
|
353
|
-
|
|
443
|
+
appendCaller(2, timeoutMessage);
|
|
354
444
|
exitCode = 124;
|
|
355
445
|
timedOut = true;
|
|
356
446
|
session.prevExit = exitCode;
|
|
@@ -377,10 +467,10 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
|
|
|
377
467
|
let prepStderr = { kind: 'caller', fd: 2 };
|
|
378
468
|
const emitPrepError = (msg) => emitToPrepDest(prepStderr, msg, prepFds, {
|
|
379
469
|
stdout: (s) => {
|
|
380
|
-
|
|
470
|
+
appendCaller(1, s);
|
|
381
471
|
},
|
|
382
472
|
stderr: (s) => {
|
|
383
|
-
|
|
473
|
+
appendCaller(2, s);
|
|
384
474
|
},
|
|
385
475
|
});
|
|
386
476
|
for (const r of plan.redirects) {
|
|
@@ -424,7 +514,7 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
|
|
|
424
514
|
break;
|
|
425
515
|
}
|
|
426
516
|
if (remainingMs <= 0) {
|
|
427
|
-
|
|
517
|
+
appendCaller(2, timeoutMessage);
|
|
428
518
|
exitCode = 124;
|
|
429
519
|
timedOut = true;
|
|
430
520
|
session.prevExit = exitCode;
|
|
@@ -435,20 +525,23 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
|
|
|
435
525
|
// stderr onto the caller's stdout before stdout is pointed at NUL, so
|
|
436
526
|
// captured stderr is still returned; `>/dev/null 2>&1` points both at NUL.
|
|
437
527
|
const applyDests = lastStageOutputDests(plan.outputRedirects, resolveTarget);
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
|
|
442
|
-
const
|
|
528
|
+
// Each source stream is bounded according to its final destination.
|
|
529
|
+
// A file keeps the complete stream, /dev/null retains nothing, and a
|
|
530
|
+
// caller stream receives only the budget left by earlier list segments.
|
|
531
|
+
const hostStdout = hostStream(applyDests.stdout);
|
|
532
|
+
const hostStderr = hostStream(applyDests.stderr);
|
|
443
533
|
const inv = await ensureHost().invoke(encoded, {
|
|
444
534
|
FAUXNIX_CWD: currentDir,
|
|
445
535
|
FAUXNIX_PREV_EXIT: session.prevExit === null ? '' : String(session.prevExit),
|
|
446
536
|
FAUXNIX_STDIN_FILE: red.stdinFile || '',
|
|
447
|
-
}, remainingMs, opts.signal,
|
|
448
|
-
|
|
449
|
-
:
|
|
537
|
+
}, remainingMs, opts.signal, {
|
|
538
|
+
stdoutLimit: hostStdout.limit,
|
|
539
|
+
stderrLimit: hostStderr.limit,
|
|
540
|
+
stdoutMode: hostStdout.mode,
|
|
541
|
+
stderrMode: hostStderr.mode,
|
|
542
|
+
});
|
|
450
543
|
if (inv.spawnError === 'ENOENT' || inv.spawnError === 'START') {
|
|
451
|
-
|
|
544
|
+
appendCaller(2, inv.stderr.toString('utf8') || 'fauxnix: failed to start the selected PowerShell host\n');
|
|
452
545
|
exitCode = 127;
|
|
453
546
|
spawnError = inv.spawnError;
|
|
454
547
|
session.prevExit = exitCode;
|
|
@@ -464,11 +557,14 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
|
|
|
464
557
|
}
|
|
465
558
|
afterSegment();
|
|
466
559
|
const decodePref = resolveNativePref();
|
|
467
|
-
//
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
|
|
471
|
-
|
|
560
|
+
// Captured protocol frames are always UTF-8. FAUXNIX_NATIVE_ENCODING is
|
|
561
|
+
// consumed at the native-process boundary inside fx-native; applying it
|
|
562
|
+
// again here double-decodes every translated/non-ASCII frame. Only bytes
|
|
563
|
+
// written directly to the host's OS stderr pipe retain a native encoding.
|
|
564
|
+
const segOut = decodeOutput(inv.stdout, 'utf8');
|
|
565
|
+
const framedErr = decodeOutput(inv.stderr, 'utf8');
|
|
566
|
+
const nativeErr = decodeOutput(inv.nativeStderr ?? Buffer.alloc(0), decodePref);
|
|
567
|
+
let segErr = normalizeStderr(framedErr + nativeErr);
|
|
472
568
|
if (inv.timedOut) {
|
|
473
569
|
segErr += timeoutMessage;
|
|
474
570
|
}
|
|
@@ -488,27 +584,53 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
|
|
|
488
584
|
}
|
|
489
585
|
catch (e) {
|
|
490
586
|
if (fromStdout) {
|
|
491
|
-
|
|
587
|
+
appendCaller(2, 'bash: ' + dest.path + ': cannot create: ' + e.message + '\n');
|
|
492
588
|
exitCode = 1;
|
|
493
589
|
redirectOk = false;
|
|
494
|
-
|
|
590
|
+
appendCaller(1, data);
|
|
495
591
|
}
|
|
496
592
|
else {
|
|
497
|
-
|
|
593
|
+
appendCaller(2, data);
|
|
498
594
|
}
|
|
499
595
|
}
|
|
500
596
|
return;
|
|
501
597
|
}
|
|
502
|
-
|
|
503
|
-
stdout += data;
|
|
504
|
-
else
|
|
505
|
-
stderr += data;
|
|
598
|
+
appendCaller(dest.fd, data);
|
|
506
599
|
};
|
|
507
|
-
|
|
508
|
-
|
|
600
|
+
const deliverSpool = (dest, spool, fromStdout) => {
|
|
601
|
+
if (!spool)
|
|
602
|
+
return;
|
|
603
|
+
if (dest.kind !== 'file') {
|
|
604
|
+
throw new Error('fauxnix: host returned a spool for a non-file destination');
|
|
605
|
+
}
|
|
606
|
+
try {
|
|
607
|
+
writeSpoolToPrepFd(prepFds, spool, dest.path);
|
|
608
|
+
}
|
|
609
|
+
catch (e) {
|
|
610
|
+
appendCaller(2, 'bash: ' + dest.path + ': cannot write: ' + e.message + '\n');
|
|
611
|
+
exitCode = 1;
|
|
612
|
+
redirectOk = false;
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
const spools = [inv.stdoutSpool, inv.stderrSpool, inv.nativeStderrSpool].filter((file) => !!file);
|
|
616
|
+
try {
|
|
617
|
+
deliverCaptured(applyDests.stdout, segOut, true);
|
|
618
|
+
if (inv.stdoutTruncated)
|
|
619
|
+
closeCallerDest(applyDests.stdout);
|
|
620
|
+
deliverCaptured(applyDests.stderr, segErr, false);
|
|
621
|
+
if (inv.stderrTruncated)
|
|
622
|
+
closeCallerDest(applyDests.stderr);
|
|
623
|
+
deliverSpool(applyDests.stdout, inv.stdoutSpool, true);
|
|
624
|
+
deliverSpool(applyDests.stderr, inv.stderrSpool, false);
|
|
625
|
+
deliverSpool(applyDests.stderr, inv.nativeStderrSpool, false);
|
|
626
|
+
}
|
|
627
|
+
finally {
|
|
628
|
+
for (const spool of spools)
|
|
629
|
+
rmSync(spool, { force: true });
|
|
630
|
+
}
|
|
509
631
|
if (inv.truncated)
|
|
510
632
|
truncated = true;
|
|
511
|
-
exitCode = inv.timedOut ? 124 : inv.cancelled ? 130 : inv.exitCode;
|
|
633
|
+
exitCode = !redirectOk ? 1 : inv.timedOut ? 124 : inv.cancelled ? 130 : inv.exitCode;
|
|
512
634
|
if (inv.timedOut)
|
|
513
635
|
timedOut = true;
|
|
514
636
|
session.prevExit = exitCode;
|
|
@@ -525,13 +647,9 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
|
|
|
525
647
|
closePrepFds(prepFds);
|
|
526
648
|
}
|
|
527
649
|
}
|
|
528
|
-
const clippedOut = clipUtf8(stdout, stdoutLimit);
|
|
529
|
-
const clippedErr = clipUtf8(stderr, stderrLimit);
|
|
530
|
-
if (clippedOut.truncated || clippedErr.truncated)
|
|
531
|
-
truncated = true;
|
|
532
650
|
return {
|
|
533
|
-
stdout
|
|
534
|
-
stderr
|
|
651
|
+
stdout,
|
|
652
|
+
stderr,
|
|
535
653
|
exitCode,
|
|
536
654
|
timedOut,
|
|
537
655
|
cancelled,
|
|
@@ -542,8 +660,33 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
|
|
|
542
660
|
function clipUtf8(text, limit) {
|
|
543
661
|
if (Buffer.byteLength(text, 'utf8') <= limit)
|
|
544
662
|
return { text, truncated: false };
|
|
545
|
-
let
|
|
546
|
-
|
|
547
|
-
|
|
663
|
+
let used = 0;
|
|
664
|
+
let end = 0;
|
|
665
|
+
for (const codepoint of text) {
|
|
666
|
+
const size = Buffer.byteLength(codepoint, 'utf8');
|
|
667
|
+
if (used + size > limit)
|
|
668
|
+
break;
|
|
669
|
+
used += size;
|
|
670
|
+
end += codepoint.length;
|
|
671
|
+
}
|
|
548
672
|
return { text: text.slice(0, end), truncated: true };
|
|
549
673
|
}
|
|
674
|
+
/** Copy a host spool into an already-open redirect fd with bounded memory. */
|
|
675
|
+
function writeSpoolToPrepFd(fds, file, target) {
|
|
676
|
+
const outFd = fds.get(target);
|
|
677
|
+
if (outFd === undefined)
|
|
678
|
+
throw new Error('fauxnix: redirect fd missing for ' + target);
|
|
679
|
+
const inFd = openSync(file, 'r');
|
|
680
|
+
const input = Buffer.allocUnsafe(65_536);
|
|
681
|
+
try {
|
|
682
|
+
while (true) {
|
|
683
|
+
const n = readSync(inFd, input, 0, input.length, null);
|
|
684
|
+
if (n <= 0)
|
|
685
|
+
break;
|
|
686
|
+
writeAllSync(outFd, input.subarray(0, n));
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
finally {
|
|
690
|
+
closeSync(inFd);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type InstallOptions = {
|
|
2
|
+
home?: string;
|
|
3
|
+
/** Accepted for parity with collectDoctorReport; install writes user-level configs only. */
|
|
4
|
+
cwd?: string;
|
|
5
|
+
env?: NodeJS.ProcessEnv;
|
|
6
|
+
};
|
|
7
|
+
export type InstallReport = {
|
|
8
|
+
lines: string[];
|
|
9
|
+
ok: boolean;
|
|
10
|
+
};
|
|
11
|
+
export declare const INSTALL_FLAGS: readonly ["claude", "codex", "opencode", "kimi", "qwen"];
|
|
12
|
+
export type HarnessName = (typeof INSTALL_FLAGS)[number];
|
|
13
|
+
export declare function kimiConfigPath(home: string, env: NodeJS.ProcessEnv): string;
|
|
14
|
+
export declare function qwenConfigPath(home: string, env: NodeJS.ProcessEnv): string;
|
|
15
|
+
export declare function runInstall(argv: string[], opts?: InstallOptions): InstallReport;
|