fauxnix-cli 0.11.0 → 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 +52 -28
- package/dist/cli.d.ts +2 -1
- package/dist/cli.js +61 -21
- package/dist/commands/archive.js +87 -24
- package/dist/commands/install-all.js +2 -1
- 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 +564 -72
- package/dist/commands/text-filters.js +36 -11
- package/dist/commands/text-io.js +72 -43
- package/dist/doctor.d.ts +1 -0
- package/dist/doctor.js +44 -3
- package/dist/errors.js +14 -4
- package/dist/executor.d.ts +4 -1
- package/dist/executor.js +194 -51
- package/dist/install.js +43 -2
- package/dist/mcp.d.ts +13 -0
- package/dist/mcp.js +15 -14
- package/dist/parser.js +11 -3
- 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 +13 -6
- package/dist/translator.js +411 -86
- package/package.json +3 -1
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
|
+
}
|
package/dist/install.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
|
-
import { claudeUserConfigPath, codexConfigPath, hasCodexFauxnix, hasOpenCodeFauxnix, isServerMap, openCodeConfigPath, serverMapHasFauxnix, } from './doctor.js';
|
|
4
|
+
import { claudeUserConfigPath, codexConfigPath, fauxnixServerNames, hasCodexFauxnix, hasOpenCodeFauxnix, isServerMap, openCodeConfigPath, serverMapHasFauxnix, } from './doctor.js';
|
|
5
|
+
import { resolveQwenLaunchTuple, sameQwenLaunchTuple } from './qwen-launch.js';
|
|
5
6
|
export const INSTALL_FLAGS = ['claude', 'codex', 'opencode', 'kimi', 'qwen'];
|
|
6
7
|
const STDIO = { command: 'fauxnix', args: ['mcp'] };
|
|
7
8
|
const OPENCODE_STDIO = { type: 'local', command: ['fauxnix', 'mcp'] };
|
|
@@ -71,9 +72,49 @@ function installHarness(name, ctx) {
|
|
|
71
72
|
case 'kimi':
|
|
72
73
|
return patchMcpServers(kimiConfigPath(ctx.home, ctx.env), 'kimi');
|
|
73
74
|
case 'qwen':
|
|
74
|
-
return
|
|
75
|
+
return patchQwen(qwenConfigPath(ctx.home, ctx.env));
|
|
75
76
|
}
|
|
76
77
|
}
|
|
78
|
+
function patchQwen(path) {
|
|
79
|
+
const launch = resolveQwenLaunchTuple();
|
|
80
|
+
if (!launch.ok)
|
|
81
|
+
return { ok: false, line: `qwen: ${launch.reason} — not modified` };
|
|
82
|
+
const read = readJsonObject(path);
|
|
83
|
+
if (read.state === 'invalid') {
|
|
84
|
+
return { ok: false, line: `qwen: ${path} is ${read.reason} — not modified` };
|
|
85
|
+
}
|
|
86
|
+
const existed = read.state !== 'missing';
|
|
87
|
+
const data = read.state === 'ok' ? read.data : {};
|
|
88
|
+
if (data.mcpServers != null && !isServerMap(data.mcpServers)) {
|
|
89
|
+
return { ok: false, line: `qwen: ${path} mcpServers is not an object — not modified` };
|
|
90
|
+
}
|
|
91
|
+
if (!isServerMap(data.mcpServers))
|
|
92
|
+
data.mcpServers = {};
|
|
93
|
+
const servers = data.mcpServers;
|
|
94
|
+
const extraNames = fauxnixServerNames(servers).filter((name) => name !== 'fauxnix');
|
|
95
|
+
if (extraNames.length) {
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
line: `qwen: ${path} has another fauxnix MCP entry (${extraNames.join(', ')}) — remove the extra entry, then retry; not modified`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const current = servers.fauxnix;
|
|
102
|
+
if (current != null && !isServerMap(current)) {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
line: `qwen: ${path} mcpServers.fauxnix is not an object — not modified`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
if (sameQwenLaunchTuple(current, launch.value)) {
|
|
109
|
+
return { ok: true, line: `qwen: already configured with an absolute launcher (${path})` };
|
|
110
|
+
}
|
|
111
|
+
servers.fauxnix = {
|
|
112
|
+
...(isServerMap(current) ? current : {}),
|
|
113
|
+
command: launch.value.command,
|
|
114
|
+
args: [...launch.value.args],
|
|
115
|
+
};
|
|
116
|
+
return writeJson(path, data, 'qwen', existed, current == null ? 'added mcpServers.fauxnix' : 'updated mcpServers.fauxnix launcher');
|
|
117
|
+
}
|
|
77
118
|
function patchMcpServers(path, harness) {
|
|
78
119
|
const read = readJsonObject(path);
|
|
79
120
|
if (read.state === 'invalid') {
|
package/dist/mcp.d.ts
CHANGED
|
@@ -19,6 +19,19 @@ export declare function bashToolResult(r: ExecResult, sessionId: string, infra:
|
|
|
19
19
|
sessionId: string;
|
|
20
20
|
};
|
|
21
21
|
};
|
|
22
|
+
export declare function translateToolResult(command: string): {
|
|
23
|
+
content: {
|
|
24
|
+
type: "text";
|
|
25
|
+
text: string;
|
|
26
|
+
}[];
|
|
27
|
+
isError?: undefined;
|
|
28
|
+
} | {
|
|
29
|
+
content: {
|
|
30
|
+
type: "text";
|
|
31
|
+
text: string;
|
|
32
|
+
}[];
|
|
33
|
+
isError: true;
|
|
34
|
+
};
|
|
22
35
|
export declare function positionalCountFromEnv(env: Record<string, string>): number;
|
|
23
36
|
export declare function formatSessionStatus(session: {
|
|
24
37
|
cwd: string | null;
|
package/dist/mcp.js
CHANGED
|
@@ -3,7 +3,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { FauxnixSession } from './executor.js';
|
|
5
5
|
import { parseCommand } from './parser.js';
|
|
6
|
-
import { translateCommandList, wrapScript, translatePipelineBody } from './translator.js';
|
|
6
|
+
import { EXECUTE_TRANSLATION, PURE_TRANSLATION, translateCommandList, wrapScript, translatePipelineBody, } from './translator.js';
|
|
7
7
|
import { registeredNames } from './registry.js';
|
|
8
8
|
import { packageVersion } from './version.js';
|
|
9
9
|
import './commands/install-all.js';
|
|
@@ -69,6 +69,18 @@ export function bashToolResult(r, sessionId, infra) {
|
|
|
69
69
|
...(infra ? { isError: true } : {}),
|
|
70
70
|
};
|
|
71
71
|
}
|
|
72
|
+
export function translateToolResult(command) {
|
|
73
|
+
try {
|
|
74
|
+
const list = parseCommand(command);
|
|
75
|
+
const plans = translateCommandList(list, PURE_TRANSLATION);
|
|
76
|
+
const script = wrapScript(plans.map((p) => p.script).join('\n# ---- next segment ----\n'));
|
|
77
|
+
return { content: [{ type: 'text', text: script }] };
|
|
78
|
+
}
|
|
79
|
+
catch (e) {
|
|
80
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
81
|
+
return { content: [{ type: 'text', text: msg }], isError: true };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
72
84
|
/** Packed FAUXNIX_POS uses char-30 separators (same as array sidecar). */
|
|
73
85
|
const POS_SEP = '\x1e';
|
|
74
86
|
export function positionalCountFromEnv(env) {
|
|
@@ -112,7 +124,7 @@ export async function startMcpServer() {
|
|
|
112
124
|
.describe('Timeout in milliseconds (default 120000)'),
|
|
113
125
|
}, EXEC_ANNOTATIONS, async ({ command, timeout_ms }, extra) => {
|
|
114
126
|
try {
|
|
115
|
-
const plans = translateCommandList(parseCommand(command));
|
|
127
|
+
const plans = translateCommandList(parseCommand(command), EXECUTE_TRANSLATION);
|
|
116
128
|
const result = await session.run(plans, {
|
|
117
129
|
timeoutMs: timeout_ms,
|
|
118
130
|
signal: extra.signal,
|
|
@@ -132,18 +144,7 @@ export async function startMcpServer() {
|
|
|
132
144
|
}, session.id, true);
|
|
133
145
|
}
|
|
134
146
|
});
|
|
135
|
-
server.tool('fauxnix_translate', 'Translate a bash-style command into the equivalent PowerShell script WITHOUT executing it. Useful for learning/debugging what fauxnix does under the hood.', { command: z.string().describe('The bash-style command line to translate (never executed)') }, TRANSLATE_ANNOTATIONS, async ({ command }) =>
|
|
136
|
-
try {
|
|
137
|
-
const list = parseCommand(command);
|
|
138
|
-
const plans = translateCommandList(list);
|
|
139
|
-
const script = wrapScript(plans.map((p) => p.script).join('\n# ---- next segment ----\n'));
|
|
140
|
-
return { content: [{ type: 'text', text: script }] };
|
|
141
|
-
}
|
|
142
|
-
catch (e) {
|
|
143
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
144
|
-
return { content: [{ type: 'text', text: msg }], isError: true };
|
|
145
|
-
}
|
|
146
|
-
});
|
|
147
|
+
server.tool('fauxnix_translate', 'Translate a bash-style command into the equivalent PowerShell script WITHOUT executing it. Useful for learning/debugging what fauxnix does under the hood.', { command: z.string().describe('The bash-style command line to translate (never executed)') }, TRANSLATE_ANNOTATIONS, async ({ command }) => translateToolResult(command));
|
|
147
148
|
server.tool('fauxnix_session', 'Inspect or reset the persistent fauxnix shell session (current directory, environment, positional count, session id). Actions: "status" (default) or "reset".', {
|
|
148
149
|
action: z
|
|
149
150
|
.enum(['status', 'reset'])
|
package/dist/parser.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { FauxnixParseError, isUnquotedLiteral, wordToString, } from './ast.js';
|
|
2
2
|
const OPERATORS = [
|
|
3
|
-
'&&', '||', '>>', '<<', '2>&1', '1>&2', '2>', '&>>', '&>', '>', '<', '|', ';;', ';', '&',
|
|
3
|
+
'&&', '||', '2>>', '>>', '<<', '2>&1', '1>&2', '2>', '&>>', '&>', '>', '<', '|', ';;', ';', '&',
|
|
4
4
|
];
|
|
5
5
|
const BACKGROUND_MSG = 'fauxnix: background & is not supported yet. Run the command in the foreground instead.';
|
|
6
6
|
const WHILE_UNTIL_MSG = 'fauxnix: while/until loops are not supported yet. Use `for x in ...; do ...; done` over a known list instead.';
|
|
@@ -61,6 +61,12 @@ export function tokenize(input) {
|
|
|
61
61
|
// try operators (longest first — list above is ordered)
|
|
62
62
|
let matched;
|
|
63
63
|
for (const op of OPERATORS) {
|
|
64
|
+
// A digit-leading fd operator is only a direct token at a word boundary.
|
|
65
|
+
// Otherwise consume the digit normally: `file2>>out` is word `file2`
|
|
66
|
+
// plus stdout `>>out`, while `12>>out` stays one unsupported fd token
|
|
67
|
+
// and fails loud instead of being stolen by `2>>`.
|
|
68
|
+
if (cur.length > 0 && /^\d/.test(op))
|
|
69
|
+
continue;
|
|
64
70
|
if (input.startsWith(op, i)) {
|
|
65
71
|
matched = op;
|
|
66
72
|
break;
|
|
@@ -184,8 +190,10 @@ export function tokenize(input) {
|
|
|
184
190
|
i += 2;
|
|
185
191
|
continue;
|
|
186
192
|
}
|
|
187
|
-
//
|
|
188
|
-
|
|
193
|
+
// Track an all-digit word as a potential fd number. Keep consuming every
|
|
194
|
+
// leading digit so unsupported multi-digit fds stay intact and fail loud
|
|
195
|
+
// instead of becoming argv plus a different redirect.
|
|
196
|
+
if (/[0-9]/.test(ch) && (cur.length === 0 || fdDigits.length === cur.length)) {
|
|
189
197
|
beginWordPart();
|
|
190
198
|
fdDigits += ch;
|
|
191
199
|
cur.push({ kind: 'Text', text: ch });
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export declare const POWERSHELL_ARGS: readonly ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass"];
|
|
2
|
+
export type PowerShellEdition = 'Desktop' | 'Core';
|
|
3
|
+
export interface PowerShellSelection {
|
|
4
|
+
/** Absolute executable path resolved once for this check/session. */
|
|
5
|
+
readonly executable: string;
|
|
6
|
+
readonly expectedEdition: PowerShellEdition;
|
|
7
|
+
readonly configured: boolean;
|
|
8
|
+
readonly requested?: string;
|
|
9
|
+
readonly error?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface PowerShellResolveOptions {
|
|
12
|
+
cwd?: string;
|
|
13
|
+
exists?: (candidate: string) => boolean;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Select the process-wide PowerShell host. FAUXNIX_PS is intentionally a
|
|
17
|
+
* small enum, not a command line: spawn() receives one executable and the
|
|
18
|
+
* fixed fauxnix arguments separately.
|
|
19
|
+
*/
|
|
20
|
+
export declare function resolvePowerShell(env?: NodeJS.ProcessEnv, options?: PowerShellResolveOptions): PowerShellSelection;
|
|
21
|
+
export declare function powerShellDisplay(selection: PowerShellSelection): string;
|
|
22
|
+
export declare function powerShellMissingMessage(selection: PowerShellSelection): string;
|