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.
@@ -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
- if (words.length === 0)
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) {
@@ -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
- if (words.length === 0)
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 = ' + (lit !== null ? psStr(lit) : exprOfWord(w)));
140
- out.push('foreach ($fx_g in (fx-glob ' + operandExpr(w) + ')) {');
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
- '$fx_a = [string](' + first + ')',
941
- '$fx_b = [string](' + inc + ')',
942
- '$fx_c = [string](' + last + ')',
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
- '$fx_cmd = ' + cmdExpr,
1141
- '$fx_base = ' + baseArgs,
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');
@@ -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
- // redirect targets are relative to the session cwd, not this node process
173
- const baseDir = opts.cwd ?? session.cwd ?? process.cwd();
174
- const resolveTarget = (t) => path.isAbsolute(t) || /^[A-Za-z]:[\\/]/.test(t) ? t : path.resolve(baseDir, t);
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: a missing `< file` target aborts the segment before running it
185
- if (red.stdinFile && !existsSync(red.stdinFile)) {
186
- stderr += 'bash: ' + red.stdinFile + ': No such file or directory\n';
187
- exitCode = 1;
188
- session.prevExit = exitCode;
189
- chainOk = false;
190
- continue;
191
- }
192
- const encoded = encodeCommand(plan.script);
193
- // -EncodedCommand is capped by the ~32K command-line limit; heavy
194
- // pipelines fall back to a UTF-8 BOM temp script (PS 5.1 honors the BOM
195
- // regardless of the console codepage, so non-ASCII stays intact).
196
- let psArgs;
197
- if (encoded.length > 28000) {
198
- writeFileSync(scriptFile, '\ufeff' + plan.script, 'utf8');
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
- child.on('close', (c) => resolve(running.killed ? 124 : (c ?? 0)));
233
- });
234
- clearTimeout(timer);
235
- afterSegment();
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
- else {
265
- writeFileSync(red.stdoutFile, segOut, 'utf8');
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
- catch (e) {
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
- if (red.stderrFile) {
275
- try {
276
- const body = red.stderrFile === red.stdoutFile ? segOut + segErr : segErr;
277
- if (red.appendStderr && existsSync(red.stderrFile)) {
278
- const prev = readFileSync(red.stderrFile);
279
- writeFileSync(red.stderrFile, Buffer.concat([prev, Buffer.from(body, 'utf8')]));
280
- }
281
- else if (red.stderrFile === red.stdoutFile && existsSync(red.stderrFile)) {
282
- const prev = readFileSync(red.stderrFile);
283
- writeFileSync(red.stderrFile, Buffer.concat([prev, Buffer.from(body, 'utf8')]));
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
- else {
286
- writeFileSync(red.stderrFile, body, 'utf8');
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
- catch {
291
- /* best effort */
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.2.1' }, { capabilities: { tools: {} } });
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;