fauxnix-cli 0.2.1 → 0.3.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.
@@ -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.3.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;