fauxnix-cli 0.2.1 → 0.4.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 +203 -199
- package/dist/ast.d.ts +9 -2
- package/dist/ast.js +12 -2
- package/dist/cli.js +13 -13
- package/dist/commands/archive.js +5 -5
- package/dist/commands/files.js +28 -39
- package/dist/commands/net.js +8 -5
- package/dist/commands/sysinfo.js +1317 -53
- package/dist/commands/text-filters.js +2 -4
- package/dist/commands/text-io.js +22 -24
- package/dist/encoding.d.ts +9 -0
- package/dist/encoding.js +19 -0
- package/dist/executor.js +230 -108
- package/dist/mcp.js +1 -1
- package/dist/parser.d.ts +2 -0
- package/dist/parser.js +425 -16
- package/dist/registry.js +10 -0
- package/dist/translator.d.ts +39 -5
- package/dist/translator.js +547 -31
- package/package.json +1 -1
|
@@ -3,7 +3,7 @@ import os from 'node:os';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { FauxnixParseError, wordToString } from '../ast.js';
|
|
5
5
|
import { parseWords, psStr } from '../registry.js';
|
|
6
|
-
import { exprOfWord, literalOfWord, operandExpr } from '../translator.js';
|
|
6
|
+
import { argListExpr, exprOfWord, literalOfWord, operandExpr } from '../translator.js';
|
|
7
7
|
/* ------------------------------------------------------------------ */
|
|
8
8
|
/* Shared PS snippets (same shape as files.ts) */
|
|
9
9
|
/* ------------------------------------------------------------------ */
|
|
@@ -34,9 +34,7 @@ const PS_SPLITLINES_FN = [
|
|
|
34
34
|
const STDIN_LINES = '@($input | ForEach-Object { [string]$_ })';
|
|
35
35
|
/** Operand Words → PS array expression of string exprs. */
|
|
36
36
|
function psArray(words, fn = operandExpr) {
|
|
37
|
-
|
|
38
|
-
return '@()';
|
|
39
|
-
return '@(' + words.map(fn).join(', ') + ')';
|
|
37
|
+
return argListExpr(words, fn);
|
|
40
38
|
}
|
|
41
39
|
/** PS boolean literal. */
|
|
42
40
|
function pb(v) {
|
package/dist/commands/text-io.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { wordToString } from '../ast.js';
|
|
2
2
|
import { lookup, parseWords, psStr } from '../registry.js';
|
|
3
|
-
import { exprOfWord, operandExpr } from '../translator.js';
|
|
3
|
+
import { argListExpr, exprOfWord, operandExpr } from '../translator.js';
|
|
4
4
|
/* ------------------------------------------------------------------ */
|
|
5
5
|
/* Shared PS snippets (same shape as files.ts / text-filters.ts) */
|
|
6
6
|
/* ------------------------------------------------------------------ */
|
|
@@ -80,6 +80,9 @@ const PS_UNESQ_FN = [
|
|
|
80
80
|
const PS_WRITE_FN = [
|
|
81
81
|
'function fx-write($s, $term) {',
|
|
82
82
|
" if ($s -eq '') { return }",
|
|
83
|
+
// Inside quoted/assignment $(...) the collector wants one string object
|
|
84
|
+
// so interior newlines survive (PS would otherwise join lines with spaces).
|
|
85
|
+
' if ($script:fx_csub) { $s; return }',
|
|
83
86
|
' if (-not $term) { $s; return }',
|
|
84
87
|
' if (-not $s.EndsWith([string][char]10)) { [Console]::Out.Write($s); return }',
|
|
85
88
|
' $t = $s.Substring(0, $s.Length - 1)',
|
|
@@ -110,9 +113,7 @@ function qErr(cmd, g, msg, lead = 'cannot open ') {
|
|
|
110
113
|
}
|
|
111
114
|
/** Operand Words → PS array expression of string exprs. */
|
|
112
115
|
function psArray(words, fn = operandExpr) {
|
|
113
|
-
|
|
114
|
-
return '@()';
|
|
115
|
-
return '@(' + words.map(fn).join(', ') + ')';
|
|
116
|
+
return argListExpr(words, fn);
|
|
116
117
|
}
|
|
117
118
|
/**
|
|
118
119
|
* Collect file operands through fx-glob into `$fx_srcs`. A literal `-`
|
|
@@ -136,8 +137,8 @@ function psCollectFiles(operandWords, missErr, dirErr, stdinDefault = true) {
|
|
|
136
137
|
const lit = w.every((p) => p.kind === 'Text' || p.kind === 'SingleQuoted')
|
|
137
138
|
? w.map((p) => p.text).join('')
|
|
138
139
|
: null;
|
|
139
|
-
out.push('$fx_d
|
|
140
|
-
out.push('foreach ($fx_g in (fx-glob
|
|
140
|
+
out.push('foreach ($fx_d in ' + argListExpr([w], lit !== null ? operandExpr : exprOfWord) + ') {');
|
|
141
|
+
out.push('foreach ($fx_g in (fx-glob $fx_d)) {');
|
|
141
142
|
if (dirErr) {
|
|
142
143
|
const dis = lit !== null && /[*?]/.test(lit) ? '$fx_g' : '$fx_d';
|
|
143
144
|
out.push(' if (Test-Path -LiteralPath $fx_g -PathType Container) { ' +
|
|
@@ -153,7 +154,7 @@ function psCollectFiles(operandWords, missErr, dirErr, stdinDefault = true) {
|
|
|
153
154
|
(lit !== null && /[*?]/.test(lit)
|
|
154
155
|
? '$fx_names += $fx_g'
|
|
155
156
|
: '$fx_names += $fx_d'));
|
|
156
|
-
out.push('}');
|
|
157
|
+
out.push('}', '}');
|
|
157
158
|
}
|
|
158
159
|
return out;
|
|
159
160
|
}
|
|
@@ -915,16 +916,6 @@ const base64 = (args, ctx) => {
|
|
|
915
916
|
const seq = (args, ctx) => {
|
|
916
917
|
const { flags, values, operandWords } = parseWords(args, ['s']);
|
|
917
918
|
const eq = flags.has('w');
|
|
918
|
-
const nums = operandWords.map((w) => exprOfWord(w));
|
|
919
|
-
if (nums.length === 0) {
|
|
920
|
-
return psErrExpr(psStr('seq: missing operand'));
|
|
921
|
-
}
|
|
922
|
-
if (nums.length > 3) {
|
|
923
|
-
return psErrExpr(psStr('seq: extra operand ') + ' + ' + nums[3]);
|
|
924
|
-
}
|
|
925
|
-
const first = nums.length >= 2 ? nums[0] : '1';
|
|
926
|
-
const inc = nums.length === 3 ? nums[1] : '1';
|
|
927
|
-
const last = nums.length === 3 ? nums[2] : nums[nums.length - 1];
|
|
928
919
|
const sepExpr = values.has('-s') ? psStr(values.get('-s')) : '[string][char]10';
|
|
929
920
|
return [
|
|
930
921
|
PS_WRITE_FN,
|
|
@@ -937,9 +928,17 @@ const seq = (args, ctx) => {
|
|
|
937
928
|
" if ([string]$s -match '^[+-]?[0-9]*\\.([0-9]+)') { return $Matches[1].Length }",
|
|
938
929
|
' return 0',
|
|
939
930
|
'}',
|
|
940
|
-
'$
|
|
941
|
-
|
|
942
|
-
|
|
931
|
+
'$fx_nums = ' + argListExpr(operandWords),
|
|
932
|
+
"if ($fx_nums.Count -eq 0) { " +
|
|
933
|
+
psErrExpr(psStr('seq: missing operand')) +
|
|
934
|
+
' }',
|
|
935
|
+
"elseif ($fx_nums.Count -gt 3) { " +
|
|
936
|
+
psErrExpr(psStr('seq: extra operand ') + ' + $fx_nums[3]') +
|
|
937
|
+
' }',
|
|
938
|
+
'else {',
|
|
939
|
+
" $fx_a = [string]$(if ($fx_nums.Count -ge 2) { $fx_nums[0] } else { '1' })",
|
|
940
|
+
" $fx_b = [string]$(if ($fx_nums.Count -eq 3) { $fx_nums[1] } else { '1' })",
|
|
941
|
+
' $fx_c = [string]$(if ($fx_nums.Count -eq 3) { $fx_nums[2] } else { $fx_nums[$fx_nums.Count - 1] })',
|
|
943
942
|
'$fx_first = fx-tod $fx_a',
|
|
944
943
|
'$fx_inc = fx-tod $fx_b',
|
|
945
944
|
'$fx_last = fx-tod $fx_c',
|
|
@@ -968,6 +967,7 @@ const seq = (args, ctx) => {
|
|
|
968
967
|
' }',
|
|
969
968
|
' if ($fx_strs.Count -eq 0) { }',
|
|
970
969
|
' else { fx-write (($fx_strs -join (' + sepExpr + ')) + [string][char]10) $fx_term }',
|
|
970
|
+
' }',
|
|
971
971
|
'}',
|
|
972
972
|
].join('\n');
|
|
973
973
|
};
|
|
@@ -1069,8 +1069,6 @@ const xargs = (args) => {
|
|
|
1069
1069
|
if (firstLit !== null && lookup(firstLit) !== undefined) {
|
|
1070
1070
|
return psErrExpr(psStr(XARGS_BUILTIN_MSG));
|
|
1071
1071
|
}
|
|
1072
|
-
const cmdExpr = exprOfWord(target[0]);
|
|
1073
|
-
const baseArgs = psArray(target.slice(1), exprOfWord);
|
|
1074
1072
|
const n = chunkN !== null && Number.isFinite(chunkN) && chunkN > 0 ? chunkN : 0;
|
|
1075
1073
|
const replExpr = repl !== null ? psStr(repl) : null;
|
|
1076
1074
|
const invoke = [
|
|
@@ -1137,8 +1135,8 @@ const xargs = (args) => {
|
|
|
1137
1135
|
return [
|
|
1138
1136
|
PS_SPLITLINES_FN,
|
|
1139
1137
|
STDIN_INLINES,
|
|
1140
|
-
'$
|
|
1141
|
-
'$fx_base =
|
|
1138
|
+
'$fx_tg = ' + argListExpr(target, exprOfWord),
|
|
1139
|
+
"if ($fx_tg.Count -eq 0) { $fx_cmd = ''; $fx_base = @() } else { $fx_cmd = [string]$fx_tg[0]; $fx_base = $(if ($fx_tg.Count -gt 1) { @($fx_tg[1..($fx_tg.Count - 1)]) } else { @() }) }",
|
|
1142
1140
|
"$fx_args = @($fx_in | Where-Object { $_ -ne '' })",
|
|
1143
1141
|
...dispatch,
|
|
1144
1142
|
].join('\n');
|
package/dist/encoding.d.ts
CHANGED
|
@@ -14,5 +14,14 @@ export declare function resolveNativePref(): NativeEncodingPref;
|
|
|
14
14
|
* setting (GBK decoding is lenient and cannot be validity-tested).
|
|
15
15
|
*/
|
|
16
16
|
export declare function decodeOutput(buf: Buffer, prefer?: NativeEncodingPref): string;
|
|
17
|
+
/**
|
|
18
|
+
* Convert PowerShell-host CRLF line endings to LF without destroying
|
|
19
|
+
* intentional CR/CRLF from exact writers (`fx-write`, `printf`, `echo -n`).
|
|
20
|
+
*
|
|
21
|
+
* The console host terminates each Write-Output object with CRLF and a
|
|
22
|
+
* final newline. Exact writers do not. So we only rewrite when every LF
|
|
23
|
+
* is part of a CRLF pair *and* the buffer ends with a newline.
|
|
24
|
+
*/
|
|
25
|
+
export declare function normalizeHostNewlines(s: string): string;
|
|
17
26
|
/** Encode a PowerShell script for -EncodedCommand (UTF-16LE base64). */
|
|
18
27
|
export declare function encodeCommand(script: string): string;
|
package/dist/encoding.js
CHANGED
|
@@ -33,6 +33,25 @@ export function decodeOutput(buf, prefer = 'utf8') {
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Convert PowerShell-host CRLF line endings to LF without destroying
|
|
38
|
+
* intentional CR/CRLF from exact writers (`fx-write`, `printf`, `echo -n`).
|
|
39
|
+
*
|
|
40
|
+
* The console host terminates each Write-Output object with CRLF and a
|
|
41
|
+
* final newline. Exact writers do not. So we only rewrite when every LF
|
|
42
|
+
* is part of a CRLF pair *and* the buffer ends with a newline.
|
|
43
|
+
*/
|
|
44
|
+
export function normalizeHostNewlines(s) {
|
|
45
|
+
if (s.length === 0 || !s.includes('\n'))
|
|
46
|
+
return s;
|
|
47
|
+
if (!s.endsWith('\n'))
|
|
48
|
+
return s;
|
|
49
|
+
for (let i = 0; i < s.length; i++) {
|
|
50
|
+
if (s[i] === '\n' && (i === 0 || s[i - 1] !== '\r'))
|
|
51
|
+
return s;
|
|
52
|
+
}
|
|
53
|
+
return s.replace(/\r\n/g, '\n');
|
|
54
|
+
}
|
|
36
55
|
/** Encode a PowerShell script for -EncodedCommand (UTF-16LE base64). */
|
|
37
56
|
export function encodeCommand(script) {
|
|
38
57
|
return Buffer.from(script, 'utf16le').toString('base64');
|
package/dist/executor.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import { promises as fs, readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
3
|
+
import { promises as fs, readFileSync, writeFileSync, existsSync, openSync, closeSync, writeSync } from 'node:fs';
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { normalizeLiteralPath } from './translator.js';
|
|
7
|
-
import { decodeOutput, encodeCommand, resolveNativePref } from './encoding.js';
|
|
7
|
+
import { decodeOutput, encodeCommand, normalizeHostNewlines, resolveNativePref } from './encoding.js';
|
|
8
8
|
import { normalizeStderr } from './errors.js';
|
|
9
9
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
10
10
|
const PS_ARGS = ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass'];
|
|
@@ -17,6 +17,67 @@ function winTarget(target) {
|
|
|
17
17
|
return path.join(os.tmpdir(), p.slice('$env:TEMP\\'.length));
|
|
18
18
|
return p;
|
|
19
19
|
}
|
|
20
|
+
function emitToPrepDest(dest, msg, fds, fallback) {
|
|
21
|
+
if (dest.kind === 'nul')
|
|
22
|
+
return;
|
|
23
|
+
if (dest.kind === 'file') {
|
|
24
|
+
try {
|
|
25
|
+
writeToPrepFd(fds, dest.path, msg);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
/* fall through to caller */
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
fallback(msg);
|
|
33
|
+
}
|
|
34
|
+
function writeAllSync(fd, data) {
|
|
35
|
+
let off = 0;
|
|
36
|
+
while (off < data.length) {
|
|
37
|
+
const n = writeSync(fd, data, off, data.length - off);
|
|
38
|
+
if (n <= 0)
|
|
39
|
+
throw new Error('fauxnix: short write to redirect');
|
|
40
|
+
off += n;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function writeToPrepFd(fds, file, data) {
|
|
44
|
+
const fd = fds.get(file);
|
|
45
|
+
if (fd === undefined)
|
|
46
|
+
throw new Error('fauxnix: redirect fd missing for ' + file);
|
|
47
|
+
writeAllSync(fd, Buffer.from(data, 'utf8'));
|
|
48
|
+
}
|
|
49
|
+
function closePrepFds(fds) {
|
|
50
|
+
for (const fd of fds.values()) {
|
|
51
|
+
try {
|
|
52
|
+
closeSync(fd);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
/* already closed */
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
fds.clear();
|
|
59
|
+
}
|
|
60
|
+
function prepareRedirectFile(file, append, fds) {
|
|
61
|
+
try {
|
|
62
|
+
const prev = fds.get(file);
|
|
63
|
+
if (prev !== undefined) {
|
|
64
|
+
closeSync(prev);
|
|
65
|
+
fds.delete(file);
|
|
66
|
+
}
|
|
67
|
+
fds.set(file, openSync(file, append ? 'a' : 'w'));
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
const err = e;
|
|
72
|
+
if (err.code === 'ENOENT')
|
|
73
|
+
return file + ': No such file or directory';
|
|
74
|
+
if (err.code === 'EACCES' || err.code === 'EPERM')
|
|
75
|
+
return file + ': Permission denied';
|
|
76
|
+
if (err.code === 'EISDIR')
|
|
77
|
+
return file + ': Is a directory';
|
|
78
|
+
return file + ': cannot create: ' + err.message;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
20
81
|
function planRedirects(redirects) {
|
|
21
82
|
const r = {
|
|
22
83
|
stdinFile: null,
|
|
@@ -169,9 +230,12 @@ async function runPlans(plans, session, opts, afterSegment, scriptFile) {
|
|
|
169
230
|
// skips b AND c when a fails. chainOk models the value of the current
|
|
170
231
|
// &&/|| chain; `;` segments always run and restart the chain.
|
|
171
232
|
let chainOk = true;
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
233
|
+
// Redirect targets are relative to the *current* session cwd, not this
|
|
234
|
+
// Node process. Re-read after every segment so `cd src && echo x > out.txt`
|
|
235
|
+
// writes under src (same as two separate session calls). A one-shot
|
|
236
|
+
// capture of the entry cwd would land the file in the old directory.
|
|
237
|
+
let currentDir = opts.cwd ?? session.cwd ?? process.cwd();
|
|
238
|
+
const resolveTarget = (t) => path.isAbsolute(t) || /^[A-Za-z]:[\\/]/.test(t) ? t : path.resolve(currentDir, t);
|
|
175
239
|
for (const plan of plans) {
|
|
176
240
|
if (plan.op === '&&' && !chainOk)
|
|
177
241
|
continue;
|
|
@@ -181,121 +245,179 @@ async function runPlans(plans, session, opts, afterSegment, scriptFile) {
|
|
|
181
245
|
red.stdinFile = red.stdinFile ? resolveTarget(red.stdinFile) : null;
|
|
182
246
|
red.stdoutFile = red.stdoutFile ? resolveTarget(red.stdoutFile) : null;
|
|
183
247
|
red.stderrFile = red.stderrFile ? resolveTarget(red.stderrFile) : null;
|
|
184
|
-
// bash
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
psArgs = [...PS_ARGS, '-File', scriptFile];
|
|
200
|
-
}
|
|
201
|
-
else {
|
|
202
|
-
psArgs = [...PS_ARGS, '-EncodedCommand', encoded];
|
|
203
|
-
}
|
|
204
|
-
const child = spawn('powershell.exe', psArgs, {
|
|
205
|
-
env: session.childEnv(opts.cwd, red.stdinFile),
|
|
206
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
207
|
-
windowsHide: true,
|
|
208
|
-
});
|
|
209
|
-
const running = { proc: child, killed: false };
|
|
210
|
-
const outBufs = [];
|
|
211
|
-
const errBufs = [];
|
|
212
|
-
child.stdout.on('data', (d) => outBufs.push(d));
|
|
213
|
-
child.stderr.on('data', (d) => errBufs.push(d));
|
|
214
|
-
child.stdin.end();
|
|
215
|
-
const timer = setTimeout(() => {
|
|
216
|
-
running.killed = true;
|
|
217
|
-
// Node-native termination — no external kill process, nothing injectable.
|
|
218
|
-
// Grandchildren of a timed-out script may survive; the `kill -9`/`pkill`
|
|
219
|
-
// builtins remain available for explicit Windows tree kills.
|
|
220
|
-
try {
|
|
221
|
-
child.kill();
|
|
222
|
-
}
|
|
223
|
-
catch {
|
|
224
|
-
/* best effort */
|
|
225
|
-
}
|
|
226
|
-
}, timeoutMs);
|
|
227
|
-
const code = await new Promise((resolve) => {
|
|
228
|
-
child.on('error', (e) => {
|
|
229
|
-
stderr += 'fauxnix: failed to start powershell.exe: ' + e.message + '\n';
|
|
230
|
-
resolve(127);
|
|
248
|
+
// bash applies redirects left-to-right *before* the command runs.
|
|
249
|
+
// Walk the parsed list in source order so a failing earlier redirect
|
|
250
|
+
// (e.g. `2>nosuch/err >important.txt`) does not truncate a later file,
|
|
251
|
+
// and a failing redirected `cd` cannot change cwd. Setup errors after
|
|
252
|
+
// an earlier `2>file` go to that file, not the caller (bash already
|
|
253
|
+
// applied the stderr redirect).
|
|
254
|
+
const prepFds = new Map();
|
|
255
|
+
try {
|
|
256
|
+
let redirectPrepFailed = false;
|
|
257
|
+
// Snapshot fd destinations as we walk. `2>&1` copies stdout *at that
|
|
258
|
+
// moment*; a later `>file` must not drag stderr along (bash fd dup).
|
|
259
|
+
let prepStdout = { kind: 'caller' };
|
|
260
|
+
let prepStderr = { kind: 'caller' };
|
|
261
|
+
const emitPrepError = (msg) => emitToPrepDest(prepStderr, msg, prepFds, (s) => {
|
|
262
|
+
stderr += s;
|
|
231
263
|
});
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
const decodePref = resolveNativePref();
|
|
237
|
-
// GNU line discipline: PowerShell's console layer terminates every line
|
|
238
|
-
// with CRLF; bash tools expect LF (redirect-written files and byte counts
|
|
239
|
-
// must match coreutils, e.g. `head -2 f > out.txt; wc -c out.txt`)
|
|
240
|
-
let segOut = decodeOutput(Buffer.concat(outBufs), decodePref).replace(/\r\n/g, '\n');
|
|
241
|
-
let segErr = normalizeStderr(decodeOutput(Buffer.concat(errBufs), decodePref)).replace(/\r\n/g, '\n');
|
|
242
|
-
if (running.killed) {
|
|
243
|
-
segErr += '\nbash: command timed out after ' + Math.round(timeoutMs / 1000) + 's';
|
|
244
|
-
}
|
|
245
|
-
if (red.mergeStderr) {
|
|
246
|
-
segOut += (segOut && !segOut.endsWith('\n') && segErr ? '\n' : '') + segErr;
|
|
247
|
-
segErr = '';
|
|
248
|
-
}
|
|
249
|
-
const stdoutToStderr = red.stdoutToStderr;
|
|
250
|
-
if (stdoutToStderr) {
|
|
251
|
-
segErr += segOut;
|
|
252
|
-
segOut = '';
|
|
253
|
-
}
|
|
254
|
-
const swallowStderr = red.swallowStderr;
|
|
255
|
-
if (swallowStderr)
|
|
256
|
-
segErr = '';
|
|
257
|
-
// redirect stdout to file instead of the result stream
|
|
258
|
-
if (red.stdoutFile) {
|
|
259
|
-
try {
|
|
260
|
-
if (red.appendStdout) {
|
|
261
|
-
const prev = existsSync(red.stdoutFile) ? readFileSync(red.stdoutFile) : Buffer.alloc(0);
|
|
262
|
-
writeFileSync(red.stdoutFile, Buffer.concat([prev, Buffer.from(segOut, 'utf8')]));
|
|
264
|
+
for (const r of plan.redirects) {
|
|
265
|
+
if (r.op === '2>&1') {
|
|
266
|
+
prepStderr = prepStdout;
|
|
267
|
+
continue;
|
|
263
268
|
}
|
|
264
|
-
|
|
265
|
-
|
|
269
|
+
if (r.op === '1>&2') {
|
|
270
|
+
prepStdout = prepStderr;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
const target = resolveTarget(winTarget(r.target));
|
|
274
|
+
if (r.op === '<') {
|
|
275
|
+
if (!existsSync(target)) {
|
|
276
|
+
emitPrepError('bash: ' + target + ': No such file or directory\n');
|
|
277
|
+
redirectPrepFailed = true;
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
if (target === 'NUL') {
|
|
283
|
+
if (r.op === '>' || r.op === '>>')
|
|
284
|
+
prepStdout = { kind: 'nul' };
|
|
285
|
+
else if (r.op === '2>' || r.op === '2>>')
|
|
286
|
+
prepStderr = { kind: 'nul' };
|
|
287
|
+
else if (r.op === '&>' || r.op === '&>>') {
|
|
288
|
+
prepStdout = { kind: 'nul' };
|
|
289
|
+
prepStderr = { kind: 'nul' };
|
|
290
|
+
}
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
const append = r.op === '>>' || r.op === '2>>' || r.op === '&>>';
|
|
294
|
+
const fail = prepareRedirectFile(target, append, prepFds);
|
|
295
|
+
if (fail) {
|
|
296
|
+
emitPrepError('bash: ' + fail + '\n');
|
|
297
|
+
redirectPrepFailed = true;
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
const fileDest = { kind: 'file', path: target };
|
|
301
|
+
if (r.op === '>' || r.op === '>>')
|
|
302
|
+
prepStdout = fileDest;
|
|
303
|
+
else if (r.op === '2>' || r.op === '2>>')
|
|
304
|
+
prepStderr = fileDest;
|
|
305
|
+
else if (r.op === '&>' || r.op === '&>>') {
|
|
306
|
+
prepStdout = fileDest;
|
|
307
|
+
prepStderr = fileDest;
|
|
266
308
|
}
|
|
267
|
-
segOut = '';
|
|
268
309
|
}
|
|
269
|
-
|
|
270
|
-
segErr += 'bash: ' + red.stdoutFile + ': cannot create: ' + e.message + '\n';
|
|
310
|
+
if (redirectPrepFailed) {
|
|
271
311
|
exitCode = 1;
|
|
312
|
+
session.prevExit = exitCode;
|
|
313
|
+
chainOk = false;
|
|
314
|
+
continue;
|
|
272
315
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
316
|
+
const encoded = encodeCommand(plan.script);
|
|
317
|
+
// -EncodedCommand is capped by the ~32K command-line limit; heavy
|
|
318
|
+
// pipelines fall back to a UTF-8 BOM temp script (PS 5.1 honors the BOM
|
|
319
|
+
// regardless of the console codepage, so non-ASCII stays intact).
|
|
320
|
+
let psArgs;
|
|
321
|
+
if (encoded.length > 28000) {
|
|
322
|
+
writeFileSync(scriptFile, '\ufeff' + plan.script, 'utf8');
|
|
323
|
+
psArgs = [...PS_ARGS, '-File', scriptFile];
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
psArgs = [...PS_ARGS, '-EncodedCommand', encoded];
|
|
327
|
+
}
|
|
328
|
+
const child = spawn('powershell.exe', psArgs, {
|
|
329
|
+
env: session.childEnv(currentDir, red.stdinFile),
|
|
330
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
331
|
+
windowsHide: true,
|
|
332
|
+
});
|
|
333
|
+
const running = { proc: child, killed: false };
|
|
334
|
+
const outBufs = [];
|
|
335
|
+
const errBufs = [];
|
|
336
|
+
child.stdout.on('data', (d) => outBufs.push(d));
|
|
337
|
+
child.stderr.on('data', (d) => errBufs.push(d));
|
|
338
|
+
child.stdin.end();
|
|
339
|
+
const timer = setTimeout(() => {
|
|
340
|
+
running.killed = true;
|
|
341
|
+
// Node-native termination — no external kill process, nothing injectable.
|
|
342
|
+
// Grandchildren of a timed-out script may survive; the `kill -9`/`pkill`
|
|
343
|
+
// builtins remain available for explicit Windows tree kills.
|
|
344
|
+
try {
|
|
345
|
+
child.kill();
|
|
284
346
|
}
|
|
285
|
-
|
|
286
|
-
|
|
347
|
+
catch {
|
|
348
|
+
/* best effort */
|
|
287
349
|
}
|
|
350
|
+
}, timeoutMs);
|
|
351
|
+
const code = await new Promise((resolve) => {
|
|
352
|
+
child.on('error', (e) => {
|
|
353
|
+
stderr += 'fauxnix: failed to start powershell.exe: ' + e.message + '\n';
|
|
354
|
+
resolve(127);
|
|
355
|
+
});
|
|
356
|
+
child.on('close', (c) => resolve(running.killed ? 124 : (c ?? 0)));
|
|
357
|
+
});
|
|
358
|
+
clearTimeout(timer);
|
|
359
|
+
afterSegment();
|
|
360
|
+
const decodePref = resolveNativePref();
|
|
361
|
+
// GNU line discipline: the PS host terminates Write-Output lines with
|
|
362
|
+
// CRLF. Exact writers (fx-write / printf / echo -n) must keep embedded
|
|
363
|
+
// CR so `printf 'a\r\nb' > out` stays 4 bytes.
|
|
364
|
+
let segOut = normalizeHostNewlines(decodeOutput(Buffer.concat(outBufs), decodePref));
|
|
365
|
+
let segErr = normalizeHostNewlines(normalizeStderr(decodeOutput(Buffer.concat(errBufs), decodePref)));
|
|
366
|
+
if (running.killed) {
|
|
367
|
+
segErr += '\nbash: command timed out after ' + Math.round(timeoutMs / 1000) + 's';
|
|
368
|
+
}
|
|
369
|
+
if (red.mergeStderr) {
|
|
370
|
+
segOut += (segOut && !segOut.endsWith('\n') && segErr ? '\n' : '') + segErr;
|
|
371
|
+
segErr = '';
|
|
372
|
+
}
|
|
373
|
+
const stdoutToStderr = red.stdoutToStderr;
|
|
374
|
+
if (stdoutToStderr) {
|
|
375
|
+
segErr += segOut;
|
|
376
|
+
segOut = '';
|
|
377
|
+
}
|
|
378
|
+
const swallowStderr = red.swallowStderr;
|
|
379
|
+
if (swallowStderr)
|
|
288
380
|
segErr = '';
|
|
381
|
+
// Write captured streams through the fds opened during preflight
|
|
382
|
+
// (bash: the redirect refers to the open file, not the path). Reopening
|
|
383
|
+
// the path would recreate a file the command just unlinked
|
|
384
|
+
// (`rm out.txt > out.txt`).
|
|
385
|
+
let redirectOk = true;
|
|
386
|
+
if (red.stdoutFile) {
|
|
387
|
+
try {
|
|
388
|
+
writeToPrepFd(prepFds, red.stdoutFile, segOut);
|
|
389
|
+
segOut = '';
|
|
390
|
+
}
|
|
391
|
+
catch (e) {
|
|
392
|
+
segErr += 'bash: ' + red.stdoutFile + ': cannot create: ' + e.message + '\n';
|
|
393
|
+
exitCode = 1;
|
|
394
|
+
redirectOk = false;
|
|
395
|
+
}
|
|
289
396
|
}
|
|
290
|
-
|
|
291
|
-
|
|
397
|
+
if (red.stderrFile) {
|
|
398
|
+
try {
|
|
399
|
+
const body = red.stderrFile === red.stdoutFile ? segOut + segErr : segErr;
|
|
400
|
+
writeToPrepFd(prepFds, red.stderrFile, body);
|
|
401
|
+
segErr = '';
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
/* best effort */
|
|
405
|
+
}
|
|
292
406
|
}
|
|
407
|
+
stdout += segOut;
|
|
408
|
+
stderr += segErr;
|
|
409
|
+
exitCode = code ?? 0;
|
|
410
|
+
session.prevExit = exitCode;
|
|
411
|
+
chainOk = exitCode === 0;
|
|
412
|
+
// Only inherit cwd from a segment that actually ran and whose
|
|
413
|
+
// output redirects succeeded. A failed `cd dir > missing/out` must
|
|
414
|
+
// not move later relative redirects.
|
|
415
|
+
if (redirectOk && session.cwd)
|
|
416
|
+
currentDir = session.cwd;
|
|
417
|
+
}
|
|
418
|
+
finally {
|
|
419
|
+
closePrepFds(prepFds);
|
|
293
420
|
}
|
|
294
|
-
stdout += segOut;
|
|
295
|
-
stderr += segErr;
|
|
296
|
-
exitCode = code ?? 0;
|
|
297
|
-
session.prevExit = exitCode;
|
|
298
|
-
chainOk = exitCode === 0;
|
|
299
421
|
}
|
|
300
422
|
return { stdout, stderr, exitCode };
|
|
301
423
|
}
|
package/dist/mcp.js
CHANGED
|
@@ -19,7 +19,7 @@ Not supported: heredocs, backticks, control flow (if/for/while), background jobs
|
|
|
19
19
|
CWD, environment variables, export/unset and cd persist across calls within this session — but prefer COMBINING related commands in one call with ; or && (e.g. 'cd src && ls | wc -l'); each call is a fresh translation+process, so batching is faster than many tiny calls.
|
|
20
20
|
Exit codes follow bash conventions (0 ok, 1 fail, 2 usage/serious, 127 command not found, 124 timeout).`;
|
|
21
21
|
export async function startMcpServer() {
|
|
22
|
-
const server = new McpServer({ name: 'fauxnix', version: '0.
|
|
22
|
+
const server = new McpServer({ name: 'fauxnix', version: '0.4.0' }, { capabilities: { tools: {} } });
|
|
23
23
|
const session = new FauxnixSession();
|
|
24
24
|
server.tool(TOOL_NAME, TOOL_DESCRIPTION, {
|
|
25
25
|
command: z.string().describe('The bash-style command line to run'),
|
package/dist/parser.d.ts
CHANGED
|
@@ -5,6 +5,8 @@ interface Token {
|
|
|
5
5
|
/** For WORD: the parsed parts. For OP: the operator text. */
|
|
6
6
|
op?: string;
|
|
7
7
|
parts?: WordPart[];
|
|
8
|
+
/** True when this token was not preceded by whitespace. */
|
|
9
|
+
tightLeft?: boolean;
|
|
8
10
|
}
|
|
9
11
|
export declare function tokenize(input: string): Token[];
|
|
10
12
|
export declare function parseCommand(input: string): CommandList;
|