fauxnix-cli 0.2.0 → 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,118 +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
- let segOut = decodeOutput(Buffer.concat(outBufs), decodePref);
238
- let segErr = normalizeStderr(decodeOutput(Buffer.concat(errBufs), decodePref));
239
- if (running.killed) {
240
- segErr += '\nbash: command timed out after ' + Math.round(timeoutMs / 1000) + 's';
241
- }
242
- if (red.mergeStderr) {
243
- segOut += (segOut && !segOut.endsWith('\n') && segErr ? '\n' : '') + segErr;
244
- segErr = '';
245
- }
246
- const stdoutToStderr = red.stdoutToStderr;
247
- if (stdoutToStderr) {
248
- segErr += segOut;
249
- segOut = '';
250
- }
251
- const swallowStderr = red.swallowStderr;
252
- if (swallowStderr)
253
- segErr = '';
254
- // redirect stdout to file instead of the result stream
255
- if (red.stdoutFile) {
256
- try {
257
- if (red.appendStdout) {
258
- const prev = existsSync(red.stdoutFile) ? readFileSync(red.stdoutFile) : Buffer.alloc(0);
259
- 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;
260
268
  }
261
- else {
262
- 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;
263
308
  }
264
- segOut = '';
265
309
  }
266
- catch (e) {
267
- segErr += 'bash: ' + red.stdoutFile + ': cannot create: ' + e.message + '\n';
310
+ if (redirectPrepFailed) {
268
311
  exitCode = 1;
312
+ session.prevExit = exitCode;
313
+ chainOk = false;
314
+ continue;
269
315
  }
270
- }
271
- if (red.stderrFile) {
272
- try {
273
- const body = red.stderrFile === red.stdoutFile ? segOut + segErr : segErr;
274
- if (red.appendStderr && existsSync(red.stderrFile)) {
275
- const prev = readFileSync(red.stderrFile);
276
- writeFileSync(red.stderrFile, Buffer.concat([prev, Buffer.from(body, 'utf8')]));
277
- }
278
- else if (red.stderrFile === red.stdoutFile && existsSync(red.stderrFile)) {
279
- const prev = readFileSync(red.stderrFile);
280
- 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();
281
346
  }
282
- else {
283
- writeFileSync(red.stderrFile, body, 'utf8');
347
+ catch {
348
+ /* best effort */
284
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)
285
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
+ }
286
396
  }
287
- catch {
288
- /* 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
+ }
289
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);
290
420
  }
291
- stdout += segOut;
292
- stderr += segErr;
293
- exitCode = code ?? 0;
294
- session.prevExit = exitCode;
295
- chainOk = exitCode === 0;
296
421
  }
297
422
  return { stdout, stderr, exitCode };
298
423
  }
package/dist/mcp.js CHANGED
@@ -7,19 +7,19 @@ import { translateCommandList, wrapScript, translatePipelineBody } from './trans
7
7
  import { registeredNames } from './registry.js';
8
8
  import './commands/install-all.js';
9
9
  const TOOL_NAME = process.env.FAUXNIX_TOOL_NAME || 'bash';
10
- const TOOL_DESCRIPTION = `Execute a Linux/bash-style command on this Windows machine.
11
-
12
- Commands are deterministically translated to PowerShell and executed natively — no WSL or VM.
13
- Output is formatted to look like GNU/Linux tooling (ls -l, ps aux, df -h ...), errors look like bash errors, and text encoding (UTF-8/GBK) is handled automatically.
14
-
15
- Supported: pipes (|), && / || / ;, redirections (> >> 2> 2>&1 < /dev/null), variables ($VAR $HOME ~), command substitution $(...), and ${registeredNames().length}+ coreutils-style commands (${registeredNames().slice(0, 18).join(', ')}...).
16
- Unknown commands (git, node, npm, python, cargo...) are passed through and executed natively with argv-style quoting.
17
- Not supported: heredocs, backticks, control flow (if/for/while), background jobs.
18
-
19
- CWD, environment variables, export/unset and cd persist across calls within this session.
10
+ const TOOL_DESCRIPTION = `Execute a Linux/bash-style command on this Windows machine.
11
+
12
+ Commands are deterministically translated to PowerShell and executed natively — no WSL or VM.
13
+ Output is formatted to look like GNU/Linux tooling (ls -l, ps aux, df -h ...), errors look like bash errors, and text encoding (UTF-8/GBK) is handled automatically.
14
+
15
+ Supported: pipes (|), && / || / ;, redirections (> >> 2> 2>&1 < /dev/null), variables ($VAR $HOME ~), command substitution $(...), and ${registeredNames().length}+ coreutils-style commands (${registeredNames().slice(0, 18).join(', ')}...).
16
+ Unknown commands (git, node, npm, python, cargo...) are passed through and executed natively with argv-style quoting.
17
+ Not supported: heredocs, backticks, control flow (if/for/while), background jobs.
18
+
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.0' }, { 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;