fauxnix-cli 0.6.0 → 0.7.1

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 CHANGED
@@ -67,7 +67,9 @@ npm install -g fauxnix-cli
67
67
  Or from source:
68
68
 
69
69
  ```bash
70
- git clone https://github.com/20000419/fauxnix && cd fauxnix && npm install -g .
70
+ git clone https://github.com/20000419/fauxnix && cd fauxnix
71
+ npm ci
72
+ npm install -g .
71
73
  ```
72
74
 
73
75
  > npm package name is `fauxnix-cli` (the `fauxnix` name on npm belongs to an
@@ -193,11 +195,10 @@ fauxnix optimizes for the commands agents actually run. Documented deviations:
193
195
  word expansion precedes the temporary environment).
194
196
  - `yes` is capped at 65,536 lines — PS 5.1 pipelines cannot signal upstream producers to stop, so
195
197
  an unbounded `yes | head` would hang.
196
- - `tail -f`, `eval`, `alias`, heredocs, `while`/`until`/`case`, word-level
197
- `$((...))` arithmetic expansion
198
+ - `tail -f`, `eval`, `alias`, heredocs, `while`/`until`/`case`,
198
199
  and background `&` are rejected with actionable error messages instead of misbehaving.
199
- (`if/then/else/fi`, `for x in ...`, backtick substitution, `command -v`, pipeline `read`,
200
- and dotenv-style `source` are supported.)
200
+ (`if/then/elif/else/fi`, `for x in ...`, backtick substitution, `command -v`, pipeline `read`,
201
+ dotenv-style `source`, and word-level `$((...))` arithmetic expansion are supported.)
201
202
  - `command -v <builtin>` prints `/usr/bin/<name>` where bash prints the bare builtin name;
202
203
  exit codes and empty-result semantics match.
203
204
  - `chmod` maps only the read-only bit; exec bits are no-ops on Windows. `chown` is a silent no-op
@@ -231,6 +232,9 @@ Architecture map: `src/parser.ts` (bash subset → AST) · `src/translator.ts` (
231
232
  executor wrapper) · `src/executor.ts` (spawn, redirects, session persistence) ·
232
233
  `src/commands/*.ts` (per-command generators) · `src/mcp.ts` (MCP server) · `src/cli.ts`.
233
234
 
235
+ Roadmap: [docs/rfc-roadmap-to-1.0.md](docs/rfc-roadmap-to-1.0.md) — tracks, milestones,
236
+ and the RFC process for proposing waves.
237
+
234
238
  ## License
235
239
 
236
240
  MIT © 20000419
package/dist/ast.d.ts CHANGED
@@ -8,12 +8,12 @@
8
8
  * - quoting: 'literal' "interp $VAR $(cmd)"
9
9
  * - variables: $VAR ${VAR} ${VAR[n]} plus special cases ($HOME $USER $PATH ...)
10
10
  * - command substitution: $(...) and `...` (recursively translated)
11
+ * - arithmetic expansion: $((...)) (existing fx-arith engine)
11
12
  * - env assignment prefix: VAR=value cmd
12
13
  *
13
14
  * Explicitly unsupported (parser throws a helpful FauxnixError):
14
15
  * heredocs, subshells (...), background &, while/until/case,
15
- * word-level $((...)) arithmetic expansion, globs inside quotes,
16
- * process substitution <(...).
16
+ * globs inside quotes, process substitution <(...).
17
17
  */
18
18
  export interface CommandList {
19
19
  kind: 'CommandList';
@@ -89,6 +89,9 @@ export type WordPart = {
89
89
  } | {
90
90
  kind: 'CmdSub';
91
91
  cmd: string;
92
+ } | {
93
+ kind: 'Arith';
94
+ parts: WordPart[];
92
95
  };
93
96
  export declare function wordToString(w: Word): string;
94
97
  /** True when every part is unquoted Text and the concatenation equals `tok`. */
package/dist/ast.js CHANGED
@@ -8,12 +8,12 @@
8
8
  * - quoting: 'literal' "interp $VAR $(cmd)"
9
9
  * - variables: $VAR ${VAR} ${VAR[n]} plus special cases ($HOME $USER $PATH ...)
10
10
  * - command substitution: $(...) and `...` (recursively translated)
11
+ * - arithmetic expansion: $((...)) (existing fx-arith engine)
11
12
  * - env assignment prefix: VAR=value cmd
12
13
  *
13
14
  * Explicitly unsupported (parser throws a helpful FauxnixError):
14
15
  * heredocs, subshells (...), background &, while/until/case,
15
- * word-level $((...)) arithmetic expansion, globs inside quotes,
16
- * process substitution <(...).
16
+ * globs inside quotes, process substitution <(...).
17
17
  */
18
18
  export function wordToString(w) {
19
19
  return w.map(partToString).join('');
@@ -40,6 +40,8 @@ function partToString(p) {
40
40
  return p.index !== undefined ? `\${${p.name}[${p.index}]}` : `$${p.name}`;
41
41
  case 'CmdSub':
42
42
  return '$(' + p.cmd + ')';
43
+ case 'Arith':
44
+ return '$((' + p.parts.map(partToString).join('') + '))';
43
45
  }
44
46
  }
45
47
  /** Best-effort "raw literal" view: is this word free of interpolation? */
package/dist/cli.js CHANGED
@@ -5,6 +5,7 @@ import { translateCommandList } from './translator.js';
5
5
  import { registeredNames } from './registry.js';
6
6
  import { encodeCommand } from './encoding.js';
7
7
  import { startMcpServer } from './mcp.js';
8
+ import { packageVersion } from './version.js';
8
9
  import './commands/install-all.js';
9
10
  const USAGE = `fauxnix — run Linux-style commands on Windows via PowerShell translation
10
11
 
@@ -26,7 +27,7 @@ export async function runCli(argv) {
26
27
  }
27
28
  const [verb, ...rest] = argv;
28
29
  if (verb === '--version' || verb === '-v') {
29
- console.log('fauxnix 0.4.0');
30
+ console.log(`fauxnix ${packageVersion}`);
30
31
  return;
31
32
  }
32
33
  if (verb === 'list') {
@@ -492,6 +492,22 @@ const find = (args) => {
492
492
  const mtimeExpr = extractValue(preds, ['-mtime']);
493
493
  const wantDelete = preds.includes('-delete');
494
494
  const paths = pathWords.length ? argListExpr(pathWords) : "@('.')";
495
+ for (const option of ['-maxdepth', '-mindepth']) {
496
+ const optionIndex = preds.indexOf(option);
497
+ if (optionIndex < 0)
498
+ continue;
499
+ if (optionIndex + 1 >= preds.length) {
500
+ return ('[Console]::Error.WriteLine(' +
501
+ psStr(`find: missing argument to '${option}'`) +
502
+ '); $script:fx_exit = 1');
503
+ }
504
+ const value = preds[optionIndex + 1];
505
+ if (!/^\d+$/.test(value)) {
506
+ return ('[Console]::Error.WriteLine(' +
507
+ psStr(`find: expected a non-negative decimal integer argument to ${option}, but got '${value}'`) +
508
+ '); $script:fx_exit = 1');
509
+ }
510
+ }
495
511
  const conditions = [];
496
512
  if (namePat !== null)
497
513
  conditions.push("($fx_i.Name -like '" + likeOf(namePat) + "')");
@@ -516,9 +532,9 @@ const find = (args) => {
516
532
  ' foreach ($fx_i in $fx_all) {',
517
533
  " $fx_rel = $fx_i.FullName.Substring($fx_root.Length).TrimStart('\\').Replace('\\', '/')",
518
534
  " if ($fx_rel -eq '') { $fx_disp = $fx_p } else { $fx_disp = ($fx_p.TrimEnd('/') + '/' + $fx_rel) }",
519
- ' $fx_depth = 0; foreach ($fx_c in $fx_rel.ToCharArray()) { if ($fx_c -eq \'/\') { $fx_depth++ } }',
520
- ' if ($fx_depth -lt ' + (minDepthS && /^\d+$/.test(minDepthS) ? minDepthS : '0') + ') { continue }',
521
- maxDepthS && /^\d+$/.test(maxDepthS) ? ' if ($fx_depth -gt ' + maxDepthS + ') { continue }' : '',
535
+ ' $fx_depth = 0; if ($fx_rel -ne \'\') { $fx_depth = 1; foreach ($fx_c in $fx_rel.ToCharArray()) { if ($fx_c -eq \'/\') { $fx_depth++ } } }',
536
+ ' if ($fx_depth -lt ' + (minDepthS ?? '0') + ') { continue }',
537
+ maxDepthS !== null ? ' if ($fx_depth -gt ' + maxDepthS + ') { continue }' : '',
522
538
  ' if (-not (' + cond + ')) { continue }',
523
539
  sizeCond ? ' $fx_sz = 0; if (-not $fx_i.PSIsContainer) { try { $fx_sz = $fx_i.Length } catch {} }' : '',
524
540
  sizeCond ? ' if (-not (' + sizeCond + ')) { continue }' : '',
@@ -1,6 +1,6 @@
1
1
  import { FauxnixParseError, isUnquotedLiteral, wordToString } from '../ast.js';
2
2
  import { lookup, parseWords, psStr, registeredNames } from '../registry.js';
3
- import { argListExpr, exprOfWord, operandExpr, translateSimple, wrapTempEnv, encodeSetValExpr, escapeDq, translateCmdSub, normalizeLiteralPath, pathExpr, paramExpr, varExpr, } from '../translator.js';
3
+ import { argListExpr, exprOfWord, operandExpr, translateSimple, wrapTempEnv, encodeSetValExpr, escapeDq, translateCmdSub, normalizeLiteralPath, pathExpr, paramExpr, varExpr, arithExpr, setArithHelperPreamble, } from '../translator.js';
4
4
  import { handlers as textIoHandlers } from './text-io.js';
5
5
  /* ------------------------------------------------------------------ */
6
6
  /* Shared TS helpers */
@@ -1373,6 +1373,7 @@ const FX_ENVGET_FN = [
1373
1373
  ' return [string]$fx_ev.Value',
1374
1374
  '}',
1375
1375
  ].join('\n');
1376
+ setArithHelperPreamble(FX_TNK_FN + '\n' + FX_ENVGET_FN);
1376
1377
  /** Like exprOfWord, but $var is an exact-case env lookup (bash, not $env:). */
1377
1378
  function kshExprOfWord(w) {
1378
1379
  const expanded = [];
@@ -1392,6 +1393,9 @@ function kshExprOfWord(w) {
1392
1393
  }
1393
1394
  if (tilde && expanded.length === 0)
1394
1395
  return '(fx-home)';
1396
+ if (!tilde && expanded.length === 1 && expanded[0].kind === 'Arith') {
1397
+ return arithExpr(expanded[0].parts);
1398
+ }
1395
1399
  if (!tilde && expanded.length === 1 && expanded[0].kind === 'Var') {
1396
1400
  if (expanded[0].param) {
1397
1401
  return paramExpr(expanded[0].name, expanded[0].param.op, expanded[0].param.word);
@@ -1434,6 +1438,9 @@ function kshExprOfWord(w) {
1434
1438
  // [[ ]] does not IFS-split, so keep the newline contract.
1435
1439
  out += '$(' + translateCmdSub(p.cmd, true) + ')';
1436
1440
  break;
1441
+ case 'Arith':
1442
+ out += arithExpr(p.parts);
1443
+ break;
1437
1444
  }
1438
1445
  };
1439
1446
  for (const p of expanded)
@@ -77,7 +77,7 @@ function textExpr(w) {
77
77
  return exprOfWord(w);
78
78
  }
79
79
  /** Collect EVERY value of a short option (-kN, -k N) — parseWords keeps only the last. */
80
- function collectShortValues(args, letter, longName) {
80
+ function collectShortValues(args, letter) {
81
81
  const out = [];
82
82
  let onlyOps = false;
83
83
  for (let i = 0; i < args.length; i++) {
@@ -88,9 +88,7 @@ function collectShortValues(args, letter, longName) {
88
88
  }
89
89
  if (onlyOps)
90
90
  continue;
91
- if (longName && t.startsWith(longName + '='))
92
- out.push(t.slice(longName.length + 1));
93
- else if (t === '-' + letter) {
91
+ if (t === '-' + letter) {
94
92
  if (i + 1 < args.length) {
95
93
  out.push(wordToString(args[i + 1]));
96
94
  i++;
@@ -102,6 +100,32 @@ function collectShortValues(args, letter, longName) {
102
100
  }
103
101
  return out;
104
102
  }
103
+ /** Collect repeated value-taking long options without mistaking short bundles for values. */
104
+ function collectLongValues(args, names) {
105
+ const out = [];
106
+ let onlyOps = false;
107
+ for (let i = 0; i < args.length; i++) {
108
+ const t = wordToString(args[i]);
109
+ if (t === '--') {
110
+ onlyOps = true;
111
+ continue;
112
+ }
113
+ if (onlyOps || !t.startsWith('--'))
114
+ continue;
115
+ const eq = t.indexOf('=');
116
+ const name = eq >= 0 ? t.slice(0, eq) : t;
117
+ if (!names.includes(name))
118
+ continue;
119
+ if (eq >= 0) {
120
+ out.push({ name, value: t.slice(eq + 1) });
121
+ }
122
+ else if (i + 1 < args.length) {
123
+ out.push({ name, value: wordToString(args[i + 1]) });
124
+ i++;
125
+ }
126
+ }
127
+ return out;
128
+ }
105
129
  /** Build the "collect file operands through fx-glob" PS prologue. */
106
130
  function psCollectSources(filesExpr, cmdErr, leafOnly) {
107
131
  const test = leafOnly
@@ -238,8 +262,19 @@ function ereToDotNet(re) {
238
262
  /* grep */
239
263
  /* ------------------------------------------------------------------ */
240
264
  const grep = (args) => {
241
- const includeGlobs = collectShortValues(args, '', '--include');
242
- const { flags, operandWords, values } = parseWords(args, ['A', 'B', 'C']);
265
+ const filterOptionNames = ['--include', '--exclude', '--exclude-dir'];
266
+ const filterOptions = collectLongValues(args, filterOptionNames);
267
+ const fileFilterOptions = filterOptions.filter((o) => o.name !== '--exclude-dir');
268
+ const excludeDirGlobs = filterOptions
269
+ .filter((o) => o.name === '--exclude-dir')
270
+ .map((o) => o.value.replace(/[\\/]+$/, ''));
271
+ const { flags, operandWords, values, missingValue } = parseWords(args, ['A', 'B', 'C'], filterOptionNames);
272
+ const missingFilterOption = missingValue.find((o) => filterOptionNames.includes(o));
273
+ if (missingFilterOption) {
274
+ return ('[Console]::Error.WriteLine(' +
275
+ psStr("grep: option '" + missingFilterOption + "' requires an argument") +
276
+ '); $script:fx_exit = 2');
277
+ }
243
278
  const ci = flags.has('i');
244
279
  const inv = flags.has('v');
245
280
  const num = flags.has('n');
@@ -320,23 +355,68 @@ const grep = (args) => {
320
355
  // --- source collection -------------------------------------------------
321
356
  if (fileWords.length > 0) {
322
357
  lines.push(PS_GLOB_FN);
323
- lines.push('$fx_inc = @(' + includeGlobs.map((g) => psStr(g)).join(', ') + ')', '$fx_srcs = @()', '$fx_err = $false', '$fx_recd = $false');
358
+ lines.push('$fx_fsel = @(' +
359
+ fileFilterOptions
360
+ .map((o) => '[pscustomobject]@{ Keep = ' +
361
+ pb(o.name === '--include') +
362
+ '; Glob = ' +
363
+ psStr(o.value) +
364
+ ' }')
365
+ .join(', ') +
366
+ ')', '$fx_excd = @(' + excludeDirGlobs.map((g) => psStr(g)).join(', ') + ')', '$fx_srcs = @()', '$fx_err = $false', '$fx_recd = $false');
367
+ lines.push('function fx-globmatch($fx_name, $fx_glob, $fx_suffix) {');
368
+ lines.push(" $fx_n = ([string]$fx_name).Replace('\\', '/')");
369
+ lines.push(" $fx_p = ([string]$fx_glob).Replace('\\', '/')");
370
+ lines.push(' if ($fx_n -like $fx_p) { return $true }');
371
+ lines.push(' if ($fx_suffix) {');
372
+ lines.push(" $fx_slash = $fx_n.IndexOf('/')");
373
+ lines.push(' while ($fx_slash -ge 0 -and $fx_slash + 1 -lt $fx_n.Length) {');
374
+ lines.push(' $fx_n = $fx_n.Substring($fx_slash + 1)');
375
+ lines.push(' if ($fx_n -like $fx_p) { return $true }');
376
+ lines.push(" $fx_slash = $fx_n.IndexOf('/')");
377
+ lines.push(' }');
378
+ lines.push(' }');
379
+ lines.push(' return $false');
380
+ lines.push('}');
381
+ lines.push('function fx-anyglob($fx_name, $fx_globs, $fx_suffix) {');
382
+ lines.push(' foreach ($fx_glob in $fx_globs) { if (fx-globmatch $fx_name $fx_glob $fx_suffix) { return $true } }');
383
+ lines.push(' return $false');
384
+ lines.push('}');
385
+ lines.push('function fx-filewanted($fx_path, $fx_suffix) {');
386
+ lines.push(' if ($fx_fsel.Count -eq 0) { return $true }');
387
+ lines.push(' $fx_name = if ($fx_suffix) { [string]$fx_path } else { [IO.Path]::GetFileName([string]$fx_path) }');
388
+ lines.push(' $fx_keep = -not [bool]$fx_fsel[0].Keep');
389
+ lines.push(' foreach ($fx_rule in $fx_fsel) { if (fx-globmatch $fx_name $fx_rule.Glob $fx_suffix) { $fx_keep = [bool]$fx_rule.Keep } }');
390
+ lines.push(' return $fx_keep');
391
+ lines.push('}');
392
+ if (rec) {
393
+ lines.push('function fx-walkfiles($fx_root) {');
394
+ lines.push(' $fx_dirs = New-Object System.Collections.Stack');
395
+ lines.push(' $fx_dirs.Push([string]$fx_root)');
396
+ lines.push(' while ($fx_dirs.Count -gt 0) {');
397
+ lines.push(' $fx_cur = [string]$fx_dirs.Pop()');
398
+ lines.push(' foreach ($fx_item in @(Get-ChildItem -LiteralPath $fx_cur -Force -ErrorAction SilentlyContinue)) {');
399
+ lines.push(' if ($fx_item.PSIsContainer) {');
400
+ lines.push(' if (($fx_item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0 -and -not (fx-anyglob $fx_item.Name $fx_excd $false)) { $fx_dirs.Push($fx_item.FullName) }');
401
+ lines.push(' } elseif (fx-filewanted $fx_item.FullName $false) { $fx_item.FullName }');
402
+ lines.push(' }');
403
+ lines.push(' }');
404
+ lines.push('}');
405
+ }
324
406
  lines.push('foreach ($fx_o in ' + psArray(fileWords) + ') {');
325
407
  lines.push(' foreach ($fx_g in (fx-glob $fx_o)) {');
326
408
  lines.push(" if (-not (Test-Path -LiteralPath $fx_g)) { [Console]::Error.WriteLine('grep: ' + $fx_g + ': No such file or directory'); $fx_err = $true; continue }");
327
409
  lines.push(' if (Test-Path -LiteralPath $fx_g -PathType Container) {');
328
410
  if (rec) {
411
+ lines.push(' $fx_dir = Get-Item -LiteralPath $fx_g');
412
+ lines.push(' if (fx-anyglob $fx_g $fx_excd $true) { continue }');
329
413
  lines.push(' $fx_recd = $true');
330
- lines.push(' $fx_subs = @(Get-ChildItem -LiteralPath $fx_g -Recurse -Force -File -ErrorAction SilentlyContinue)');
331
- lines.push(' if ($fx_inc.Count -gt 0) {');
332
- lines.push(" $fx_subs = @($fx_subs | Where-Object { $fx_ok = $false; foreach ($fx_gi in $fx_inc) { if ($_.Name -like $fx_gi) { $fx_ok = $true; break } }; $fx_ok })");
333
- lines.push(' }');
334
- lines.push(' foreach ($fx_s in $fx_subs) { $fx_srcs += $fx_s.FullName }');
414
+ lines.push(' $fx_srcs += @(fx-walkfiles $fx_dir.FullName)');
335
415
  }
336
416
  else {
337
417
  lines.push(" [Console]::Error.WriteLine('grep: ' + $fx_g + ': Is a directory'); $fx_err = $true");
338
418
  }
339
- lines.push(' } else { $fx_srcs += $fx_g }');
419
+ lines.push(' } elseif (fx-filewanted $fx_g $true) { $fx_srcs += $fx_g }');
340
420
  lines.push(' }');
341
421
  lines.push('}');
342
422
  lines.push('$fx_pre = $false');
@@ -25,6 +25,8 @@ export declare class FauxnixSession {
25
25
  private bindFiles;
26
26
  private syncFromDisk;
27
27
  private ensureHost;
28
+ /** Boot powershell.exe now so the first run() is not the 1.1s cold start. */
29
+ prewarm(): Promise<void>;
28
30
  dispose(): Promise<void>;
29
31
  /** env for the child powershell process. */
30
32
  childEnv(cwdOverride?: string, stdinFile?: string | null): NodeJS.ProcessEnv;
package/dist/executor.js CHANGED
@@ -7,6 +7,11 @@ import { decodeOutput, normalizeHostNewlines, resolveNativePref } from './encodi
7
7
  import { normalizeStderr } from './errors.js';
8
8
  import { PowerShellHost, PS_MISSING_MESSAGE } from './ps-host.js';
9
9
  const DEFAULT_TIMEOUT_MS = 120_000;
10
+ const DEFAULT_WINDOWS_PATHEXT = '.COM;.EXE;.BAT;.CMD';
11
+ function hasEnvKey(env, name) {
12
+ const normalized = name.toUpperCase();
13
+ return Object.keys(env).some((key) => key.toUpperCase() === normalized);
14
+ }
10
15
  /** Resolve /dev/null and POSIX-ish literal targets to real Windows paths. */
11
16
  function winTarget(target) {
12
17
  const p = normalizeLiteralPath(target);
@@ -195,6 +200,10 @@ export class FauxnixSession {
195
200
  }
196
201
  return this.host;
197
202
  }
203
+ /** Boot powershell.exe now so the first run() is not the 1.1s cold start. */
204
+ async prewarm() {
205
+ await this.ensureHost().ready();
206
+ }
198
207
  async dispose() {
199
208
  if (this.host) {
200
209
  await this.host.stop();
@@ -220,6 +229,14 @@ export class FauxnixSession {
220
229
  else
221
230
  env[k] = v;
222
231
  }
232
+ // The MCP SDK's safe Windows stdio environment omits PATHEXT. Without it,
233
+ // PowerShell cannot resolve extensionless native commands such as `node`.
234
+ // Windows environment names are case-insensitive, so preserve any explicit
235
+ // spelling/value supplied by the caller and only restore the OS default
236
+ // when no variant is present at all.
237
+ if (process.platform === 'win32' && !hasEnvKey(env, 'PATHEXT')) {
238
+ env.PATHEXT = DEFAULT_WINDOWS_PATHEXT;
239
+ }
223
240
  env.FAUXNIX_CWD_FILE = this.cwdFile;
224
241
  env.FAUXNIX_ENV_FILE = this.envFile;
225
242
  if (stdinFile)
@@ -245,6 +262,8 @@ export class FauxnixSession {
245
262
  }
246
263
  async function runPlans(plans, session, opts, afterSegment, ensureHost) {
247
264
  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
265
+ const deadline = Date.now() + timeoutMs;
266
+ const timeoutMessage = '\nbash: command timed out after ' + Math.round(timeoutMs / 1000) + 's';
248
267
  let stdout = '';
249
268
  let stderr = '';
250
269
  let exitCode = 0;
@@ -263,6 +282,12 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
263
282
  continue;
264
283
  if (plan.op === '||' && chainOk)
265
284
  continue;
285
+ if (Date.now() >= deadline) {
286
+ stderr += timeoutMessage;
287
+ exitCode = 124;
288
+ session.prevExit = exitCode;
289
+ break;
290
+ }
266
291
  const red = planRedirects(plan.redirects);
267
292
  red.stdinFile = red.stdinFile ? resolveTarget(red.stdinFile) : null;
268
293
  red.stdoutFile = red.stdoutFile ? resolveTarget(red.stdoutFile) : null;
@@ -335,12 +360,19 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
335
360
  chainOk = false;
336
361
  continue;
337
362
  }
363
+ const remainingMs = deadline - Date.now();
364
+ if (remainingMs <= 0) {
365
+ stderr += timeoutMessage;
366
+ exitCode = 124;
367
+ session.prevExit = exitCode;
368
+ break;
369
+ }
338
370
  const encoded = wrapScript(plan.body, { mode: 'host' });
339
371
  const inv = await ensureHost().invoke(encoded, {
340
372
  FAUXNIX_CWD: currentDir,
341
373
  FAUXNIX_PREV_EXIT: session.prevExit === null ? '' : String(session.prevExit),
342
374
  FAUXNIX_STDIN_FILE: red.stdinFile || '',
343
- }, timeoutMs);
375
+ }, remainingMs);
344
376
  if (inv.spawnError === 'ENOENT') {
345
377
  stderr += inv.stderr.toString('utf8') || PS_MISSING_MESSAGE;
346
378
  exitCode = 127;
@@ -356,7 +388,7 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
356
388
  let segOut = normalizeHostNewlines(decodeOutput(inv.stdout, decodePref));
357
389
  let segErr = normalizeHostNewlines(normalizeStderr(decodeOutput(inv.stderr, decodePref)));
358
390
  if (inv.timedOut) {
359
- segErr += '\nbash: command timed out after ' + Math.round(timeoutMs / 1000) + 's';
391
+ segErr += timeoutMessage;
360
392
  }
361
393
  if (red.mergeStderr) {
362
394
  segOut += (segOut && !segOut.endsWith('\n') && segErr ? '\n' : '') + segErr;
@@ -406,6 +438,8 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
406
438
  // not move later relative redirects.
407
439
  if (redirectOk && session.cwd)
408
440
  currentDir = session.cwd;
441
+ if (inv.timedOut)
442
+ break;
409
443
  }
410
444
  finally {
411
445
  closePrepFds(prepFds);
package/dist/mcp.js CHANGED
@@ -1,16 +1,12 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
- import { readFileSync } from 'node:fs';
4
- import { fileURLToPath } from 'node:url';
5
3
  import { z } from 'zod';
6
4
  import { FauxnixSession } from './executor.js';
7
5
  import { parseCommand } from './parser.js';
8
6
  import { translateCommandList, wrapScript, translatePipelineBody } from './translator.js';
9
7
  import { registeredNames } from './registry.js';
8
+ import { packageVersion } from './version.js';
10
9
  import './commands/install-all.js';
11
- // single source of truth: the npm package version in package.json
12
- // (src/ and dist/ sit one level below the root, so the relative path holds in both)
13
- const pkgVersion = JSON.parse(readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8')).version;
14
10
  const TOOL_NAME = process.env.FAUXNIX_TOOL_NAME || 'bash';
15
11
  const EXEC_ANNOTATIONS = {
16
12
  readOnlyHint: false,
@@ -37,15 +33,16 @@ Output is formatted to look like GNU/Linux tooling (ls -l, ps aux, df -h ...), e
37
33
 
38
34
  Supported: pipes (|), && / || / ;, redirections (> >> 2> 2>&1 < /dev/null), variables ($VAR $HOME ~), command substitution $(...), and ${registeredNames().length}+ coreutils-style commands (${registeredNames().slice(0, 18).join(', ')}...).
39
35
  Unknown commands (git, node, npm, python, cargo...) are passed through and executed natively with argv-style quoting.
40
- Not supported: heredocs, while/until/case, word-level \$((...)) arithmetic expansion, background jobs. if/then/else/fi and for-in loops are supported.
36
+ Not supported: heredocs, while/until/case, background jobs. if/then/elif/else/fi, for-in loops, and word-level \$((...)) arithmetic expansion are supported.
41
37
 
42
- CWD, environment variables, export/unset and cd persist across calls within this session — a resident PowerShell 5.1 host is reused, so batching with ; or && is still nicer but many tiny calls no longer each pay a powershell.exe spawn.
38
+ CWD, environment variables, export/unset and cd persist across calls within this session — a resident PowerShell 5.1 host is started when the MCP session begins (and after reset), so the first bash tool call is already warm.
43
39
  Exit codes follow bash conventions (0 ok, 1 fail, 2 usage/serious, 127 command not found, 124 timeout).
44
40
 
45
41
  Platform requirement: the execution backend is native Windows PowerShell 5.1+. On hosts without PowerShell on PATH (e.g. Linux containers/sandboxes), the bash tool returns exit code 127 with an actionable error instead of running the command.`;
46
42
  export async function startMcpServer() {
47
- const server = new McpServer({ name: 'fauxnix', version: pkgVersion }, { capabilities: { tools: {} } });
43
+ const server = new McpServer({ name: 'fauxnix', version: packageVersion }, { capabilities: { tools: {} } });
48
44
  let session = new FauxnixSession();
45
+ await session.prewarm();
49
46
  server.tool(TOOL_NAME, TOOL_DESCRIPTION, {
50
47
  command: z.string().describe('The bash-style command line to run'),
51
48
  timeout_ms: z
@@ -95,6 +92,7 @@ export async function startMcpServer() {
95
92
  if (action === 'reset') {
96
93
  await session.dispose();
97
94
  session = new FauxnixSession();
95
+ await session.prewarm();
98
96
  return { content: [{ type: 'text', text: 'fauxnix: session reset' }] };
99
97
  }
100
98
  const envKeys = Object.keys(session.env).sort();
package/dist/parser.js CHANGED
@@ -217,7 +217,32 @@ function readBacktick(input, i) {
217
217
  }
218
218
  throw new FauxnixParseError('fauxnix: unclosed backtick');
219
219
  }
220
- /** Parse $VAR, ${VAR}, $(cmd substitution). Returns null when not a valid dollar construct. */
220
+ /** Expand `$…` constructs that appear inside `$((…))`. */
221
+ function readArithParts(input) {
222
+ const parts = [];
223
+ let buf = '';
224
+ let i = 0;
225
+ while (i < input.length) {
226
+ if (input[i] === '$') {
227
+ const v = readDollar(input, i);
228
+ if (v) {
229
+ if (buf) {
230
+ parts.push({ kind: 'Text', text: buf });
231
+ buf = '';
232
+ }
233
+ parts.push(v.part);
234
+ i = v.next;
235
+ continue;
236
+ }
237
+ }
238
+ buf += input[i];
239
+ i++;
240
+ }
241
+ if (buf)
242
+ parts.push({ kind: 'Text', text: buf });
243
+ return parts;
244
+ }
245
+ /** Parse $VAR, ${VAR}, $(cmd substitution), $((arith)). Returns null when not a valid dollar construct. */
221
246
  function readDollar(input, i) {
222
247
  const n = input.length;
223
248
  if (input[i] !== '$')
@@ -256,11 +281,41 @@ function readDollar(input, i) {
256
281
  }
257
282
  return { part: { kind: 'Var', name }, next: end + 1 };
258
283
  }
259
- // $((...)) is arithmetic expansion, not command substitution of a
260
- // parenthesized body. Today it was parsed as $( (expr) ) and became an
261
- // empty/confusing command. Reject loudly until word-level arith lands.
284
+ // $((...)) arithmetic expansion distinct from `$( (cmd) )` (space after
285
+ // the first paren is command substitution of a grouped body).
262
286
  if (input[j] === '(' && j + 1 < n && input[j + 1] === '(') {
263
- throw new FauxnixParseError('fauxnix: $((...)) arithmetic expansion is not supported; compute the value in the agent or compare with [[ $n -eq m ]]');
287
+ let depth = 0;
288
+ let k = j;
289
+ while (k < n) {
290
+ const c = input[k];
291
+ if (c === "'" || c === '"') {
292
+ const q = c;
293
+ k++;
294
+ while (k < n && input[k] !== q) {
295
+ if (input[k] === '\\')
296
+ k++;
297
+ k++;
298
+ }
299
+ k++;
300
+ continue;
301
+ }
302
+ if (c === '(')
303
+ depth++;
304
+ else if (c === ')') {
305
+ depth--;
306
+ if (depth === 0) {
307
+ k++;
308
+ break;
309
+ }
310
+ }
311
+ k++;
312
+ }
313
+ if (depth !== 0)
314
+ throw new FauxnixParseError('fauxnix: unclosed $(( ))');
315
+ return {
316
+ part: { kind: 'Arith', parts: readArithParts(input.slice(j + 2, k - 2)) },
317
+ next: k,
318
+ };
264
319
  }
265
320
  // $(cmd substitution) — captured with balanced parens; the translator
266
321
  // recursively translates this text before embedding it.
@@ -642,14 +697,24 @@ export function parseCommand(input) {
642
697
  }
643
698
  return { kind: 'CommandList', segments };
644
699
  };
645
- const parseIf = () => {
646
- expectKw('if');
700
+ const parseIf = (start = 'if') => {
701
+ expectKw(start);
647
702
  const test = parseListUntil(['then']);
648
703
  expectKw('then');
649
704
  const thenL = parseListUntil(['else', 'elif', 'fi']);
650
705
  let elseL;
651
706
  if (peekKw() === 'elif') {
652
- throw new FauxnixParseError('fauxnix: elif is not supported yet; use else + if');
707
+ // bash `elif` is `else` + nested `if`; the innermost clause eats `fi`.
708
+ elseL = {
709
+ kind: 'CommandList',
710
+ segments: [
711
+ {
712
+ op: ';',
713
+ pipeline: { kind: 'Pipeline', commands: [parseIf('elif')] },
714
+ },
715
+ ],
716
+ };
717
+ return { kind: 'If', test, then: thenL, else: elseL, redirects: [] };
653
718
  }
654
719
  if (peekKw() === 'else') {
655
720
  next();
package/dist/ps-host.d.ts CHANGED
@@ -37,6 +37,8 @@ export declare class PowerShellHost {
37
37
  private startLock;
38
38
  private invokeLock;
39
39
  constructor(hostFile: string, envFn: () => NodeJS.ProcessEnv);
40
+ /** Start the resident process and wait for the ready handshake (B1 prewarm). */
41
+ ready(): Promise<HostInvokeResult | null>;
40
42
  invoke(script: string, env: HostRequestEnv, timeoutMs: number): Promise<HostInvokeResult>;
41
43
  stop(): Promise<void>;
42
44
  private invokeSerial;
package/dist/ps-host.js CHANGED
@@ -46,6 +46,10 @@ export class PowerShellHost {
46
46
  this.hostFile = hostFile;
47
47
  this.envFn = envFn;
48
48
  }
49
+ /** Start the resident process and wait for the ready handshake (B1 prewarm). */
50
+ async ready() {
51
+ return this.ensureStarted();
52
+ }
49
53
  async invoke(script, env, timeoutMs) {
50
54
  const run = this.invokeLock.then(() => this.invokeSerial(script, env, timeoutMs));
51
55
  this.invokeLock = run.then(() => undefined, () => undefined);
@@ -1,4 +1,4 @@
1
- import { Assignment, CommandList, Redirect, SimpleCommand, IfCommand, ForCommand, Word } from './ast.js';
1
+ import { Assignment, CommandList, Redirect, SimpleCommand, IfCommand, ForCommand, Word, WordPart } from './ast.js';
2
2
  import { PipelineCtx } from './registry.js';
3
3
  /** `${name:-word}` and friends using case-exact fx-scalar0. */
4
4
  export declare function paramExpr(name: string, op: ':-' | ':=' | ':+' | ':?' | '-' | '+' | '?', word: string): string;
@@ -25,6 +25,10 @@ export declare function pathExpr(s: string): string;
25
25
  export declare function exprOfWord(w: Word, opts?: {
26
26
  preserveCmdSub?: boolean;
27
27
  }): string;
28
+ /** Registered by sysinfo so wrapScript can emit fx-arith without a circular import. */
29
+ export declare function setArithHelperPreamble(s: string): void;
30
+ /** PowerShell expression: evaluate `$((…))` via fx-arith; errors are loud, expansion empty. */
31
+ export declare function arithExpr(parts: WordPart[]): string;
28
32
  /** Literal text of a word when it contains no interpolation, else null. */
29
33
  export declare function literalOfWord(w: Word): string | null;
30
34
  /**
@@ -161,6 +161,9 @@ export function exprOfWord(w, opts) {
161
161
  if (expanded.length === 1 && expanded[0].kind === 'CmdSub') {
162
162
  return '$(' + translateCmdSub(expanded[0].cmd, opts?.preserveCmdSub === true) + ')';
163
163
  }
164
+ if (expanded.length === 1 && expanded[0].kind === 'Arith') {
165
+ return arithExpr(expanded[0].parts);
166
+ }
164
167
  const literal = expanded.every((p) => p.kind === 'Text' || p.kind === 'SingleQuoted');
165
168
  if (literal) {
166
169
  const text = expanded.map((p) => p.text).join('');
@@ -186,6 +189,9 @@ export function exprOfWord(w, opts) {
186
189
  case 'CmdSub':
187
190
  out += '$(' + translateCmdSub(p.cmd, quoted || opts?.preserveCmdSub === true) + ')';
188
191
  break;
192
+ case 'Arith':
193
+ out += arithExpr(p.parts);
194
+ break;
189
195
  }
190
196
  };
191
197
  for (const p of expanded)
@@ -193,6 +199,62 @@ export function exprOfWord(w, opts) {
193
199
  out += '"';
194
200
  return out;
195
201
  }
202
+ let arithHelperPreamble = '';
203
+ /** Registered by sysinfo so wrapScript can emit fx-arith without a circular import. */
204
+ export function setArithHelperPreamble(s) {
205
+ arithHelperPreamble = s;
206
+ }
207
+ function injectArithHelpers(body) {
208
+ if (!arithHelperPreamble)
209
+ return body;
210
+ if (!/\bfx-arith\b/.test(body))
211
+ return body;
212
+ if (/function\s+fx-arith\b/.test(body))
213
+ return body;
214
+ return arithHelperPreamble + '\n' + body;
215
+ }
216
+ /** PowerShell expression: evaluate `$((…))` via fx-arith; errors are loud, expansion empty. */
217
+ export function arithExpr(parts) {
218
+ const src = arithSourceExpr(parts);
219
+ return ('$(try { [string](fx-arith (' +
220
+ src +
221
+ ')) } catch { [Console]::Error.WriteLine((\'bash: \' + [string](' +
222
+ src +
223
+ ') + \': integer expression expected\')); $script:fx_exit = 1; \'\' })');
224
+ }
225
+ function arithSourceExpr(parts) {
226
+ if (parts.length === 0)
227
+ return "''";
228
+ if (parts.every((p) => p.kind === 'Text')) {
229
+ return psStr(parts.map((p) => p.text).join(''));
230
+ }
231
+ let out = '"';
232
+ const emit = (p) => {
233
+ switch (p.kind) {
234
+ case 'Text':
235
+ case 'SingleQuoted':
236
+ out += escapeDq(p.text);
237
+ break;
238
+ case 'DoubleQuoted':
239
+ for (const q of p.parts)
240
+ emit(q);
241
+ break;
242
+ case 'Var':
243
+ out += '$(' + varExpr(p.name, p.index, p.param, p.length === true) + ')';
244
+ break;
245
+ case 'CmdSub':
246
+ out += '$(' + translateCmdSub(p.cmd, true) + ')';
247
+ break;
248
+ case 'Arith':
249
+ out += arithExpr(p.parts);
250
+ break;
251
+ }
252
+ };
253
+ for (const p of parts)
254
+ emit(p);
255
+ out += '"';
256
+ return out;
257
+ }
196
258
  /** Literal text of a word when it contains no interpolation, else null. */
197
259
  export function literalOfWord(w) {
198
260
  if (!w.every((p) => p.kind === 'Text' || p.kind === 'SingleQuoted'))
@@ -657,6 +719,8 @@ export function wrapTempEnv(sets, body, extra) {
657
719
  }
658
720
  /** Unique suffix for generated stage functions (nested pipelines included). */
659
721
  let stageSeq = 0;
722
+ /** Unique suffix for generated pipeline wrappers and their local status arrays. */
723
+ let pipelineSeq = 0;
660
724
  /**
661
725
  * Pipeline body. A lone command runs as a plain script-block expression;
662
726
  * multi-command pipelines become generated functions chained with `|`
@@ -722,6 +786,12 @@ function translateFor(cmd) {
722
786
  return lines.join('\n');
723
787
  }
724
788
  export function translatePipelineBody(p) {
789
+ // Every pipeline stage needs its own status slot. Handlers deliberately use
790
+ // `$script:fx_exit` because their helper functions run in child scopes; in a
791
+ // pipeline that shared flag lets an earlier failure leak into a successful
792
+ // last stage. Reserve the wrapper id before translating bodies so nested
793
+ // command substitutions cannot reuse it.
794
+ const pipelineId = p.commands.length > 1 ? pipelineSeq++ : -1;
725
795
  const bodies = [];
726
796
  for (let i = 0; i < p.commands.length; i++) {
727
797
  const c = p.commands[i];
@@ -739,16 +809,36 @@ export function translatePipelineBody(p) {
739
809
  }
740
810
  const names = [];
741
811
  const defs = [];
812
+ const statusVar = '$fx_pipe_status' + pipelineId;
742
813
  for (let i = 0; i < bodies.length; i++) {
743
814
  const name = '__fx_s' + stageSeq++;
744
815
  names.push(name);
745
- const indented = bodies[i]
816
+ const isolatedBody = bodies[i].split('$script:fx_exit').join(statusVar + '[' + i + ']');
817
+ const indented = isolatedBody
746
818
  .split('\n')
747
819
  .map((l) => (l ? ' ' + l : l))
748
820
  .join('\n');
749
821
  defs.push('function ' + name + ' {\n' + indented + '\n}');
750
822
  }
751
- return { defs: defs.join('\n'), call: names.join(' | ') };
823
+ const pipelineName = '__fx_p' + pipelineId;
824
+ const statuses = bodies.map(() => '0').join(', ');
825
+ const pipelineCall = names.join(' | ');
826
+ defs.push([
827
+ 'function ' + pipelineName + ' {',
828
+ ' ' + statusVar + ' = @(' + statuses + ')',
829
+ ' try {',
830
+ // The wrapper forwards redirect input to stage zero. With no input,
831
+ // PowerShell still invokes a regular function once, which preserves the
832
+ // existing no-stdin pipeline behavior on Windows PowerShell 5.1.
833
+ ' $input | ' + pipelineCall,
834
+ ' } finally {',
835
+ // Bash defaults to pipefail off: only the last stage controls the list
836
+ // status used by a following && / || segment.
837
+ ' $script:fx_exit = [int]' + statusVar + '[' + (bodies.length - 1) + ']',
838
+ ' }',
839
+ '}',
840
+ ].join('\n'));
841
+ return { defs: defs.join('\n'), call: pipelineName };
752
842
  }
753
843
  export function translateCommandList(list) {
754
844
  const plans = [];
@@ -895,6 +985,7 @@ function wrapBodyAndPersist(body, exitProcess) {
895
985
  */
896
986
  export function wrapScript(body, opts = {}) {
897
987
  const mode = opts.mode ?? 'spawn';
988
+ body = injectArithHelpers(body);
898
989
  const needed = mode === 'host' ? new Set() : wrapHelpersNeeded(body);
899
990
  const lines = mode === 'host'
900
991
  ? wrapCwdPreamble()
@@ -0,0 +1 @@
1
+ export declare const packageVersion: string;
@@ -0,0 +1,8 @@
1
+ import { readFileSync } from 'node:fs';
2
+ // src/ and dist/ are both one level below the package root, so package.json is
3
+ // the runtime source of truth in development and in the published tarball.
4
+ const metadata = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
5
+ if (typeof metadata.version !== 'string' || metadata.version.length === 0) {
6
+ throw new Error('fauxnix: package.json does not contain a valid version');
7
+ }
8
+ export const packageVersion = metadata.version;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fauxnix-cli",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "description": "Fauxnix — run Linux-style commands on Windows via deterministic PowerShell translation. No VM, no WSL. MCP server + CLI for AI agents.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,7 +13,9 @@
13
13
  ],
14
14
  "scripts": {
15
15
  "build": "tsc",
16
+ "prepare": "npm run build",
16
17
  "test": "vitest run",
18
+ "test:package": "node scripts/package-smoke.mjs",
17
19
  "test:watch": "vitest",
18
20
  "typecheck": "tsc --noEmit",
19
21
  "dev": "tsx src/index.ts"