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 +9 -5
- package/dist/ast.d.ts +5 -2
- package/dist/ast.js +4 -2
- package/dist/cli.js +2 -1
- package/dist/commands/files.js +19 -3
- package/dist/commands/sysinfo.js +8 -1
- package/dist/commands/text-filters.js +93 -13
- package/dist/executor.d.ts +2 -0
- package/dist/executor.js +36 -2
- package/dist/mcp.js +6 -8
- package/dist/parser.js +73 -8
- package/dist/ps-host.d.ts +2 -0
- package/dist/ps-host.js +4 -0
- package/dist/translator.d.ts +5 -1
- package/dist/translator.js +93 -2
- package/dist/version.d.ts +1 -0
- package/dist/version.js +8 -0
- package/package.json +3 -1
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
|
|
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`,
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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(
|
|
30
|
+
console.log(`fauxnix ${packageVersion}`);
|
|
30
31
|
return;
|
|
31
32
|
}
|
|
32
33
|
if (verb === 'list') {
|
package/dist/commands/files.js
CHANGED
|
@@ -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
|
|
521
|
-
maxDepthS
|
|
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 }' : '',
|
package/dist/commands/sysinfo.js
CHANGED
|
@@ -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
|
|
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 (
|
|
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
|
|
242
|
-
const
|
|
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('$
|
|
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(' $
|
|
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(' }
|
|
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');
|
package/dist/executor.d.ts
CHANGED
|
@@ -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
|
-
},
|
|
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 +=
|
|
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,
|
|
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
|
|
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:
|
|
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
|
-
/**
|
|
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
|
-
// $((...))
|
|
260
|
-
//
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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);
|
package/dist/translator.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/translator.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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;
|
package/dist/version.js
ADDED
|
@@ -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.
|
|
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"
|