fauxnix-cli 0.9.3 → 0.12.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.
@@ -1,7 +1,181 @@
1
- import { FauxnixParseError, isUnquotedLiteral, } from './ast.js';
1
+ import { FauxnixParseError, isUnquotedLiteral, wordToString, } from './ast.js';
2
2
  import { parseCommand } from './parser.js';
3
3
  import { lookup, psStr } from './registry.js';
4
4
  import { PYTHON3_WINDOWS_HINT, SH_SCRIPT_WINDOWS_HINT } from './errors.js';
5
+ export const EXECUTE_TRANSLATION = Object.freeze({ mode: 'execute' });
6
+ export const PURE_TRANSLATION = Object.freeze({ mode: 'pure' });
7
+ export const PURE_SED_FILE_MESSAGE = 'fauxnix: translate does not read sed script files; use -e with the script text, or run the command to use -f';
8
+ /** Match the sed option rules without opening the referenced script file. */
9
+ function sedUsesScriptFile(args) {
10
+ const raw = args.map((word) => wordToString(word));
11
+ let onlyOperands = false;
12
+ for (let i = 0; i < raw.length; i++) {
13
+ const arg = raw[i];
14
+ if (!onlyOperands && arg === '--') {
15
+ onlyOperands = true;
16
+ continue;
17
+ }
18
+ if (onlyOperands || !arg.startsWith('-') || arg.length === 1 || arg.startsWith('--')) {
19
+ continue;
20
+ }
21
+ const body = arg.slice(1);
22
+ for (let c = 0; c < body.length; c++) {
23
+ const flag = body[c];
24
+ if (flag === 'f')
25
+ return c < body.length - 1 || i + 1 < raw.length;
26
+ if (flag === 'e') {
27
+ if (c === body.length - 1)
28
+ i++;
29
+ break;
30
+ }
31
+ if (flag === 'i')
32
+ break;
33
+ if (!['n', 'E', 'r', 's', 'u', 'z'].includes(flag))
34
+ return false;
35
+ }
36
+ }
37
+ return false;
38
+ }
39
+ function assertPureWord(word) {
40
+ const visitPart = (part) => {
41
+ if (part.kind === 'CmdSub') {
42
+ assertPureCommandList(parseCommand(part.cmd));
43
+ }
44
+ else if (part.kind === 'DoubleQuoted' || part.kind === 'Arith') {
45
+ for (const nested of part.parts)
46
+ visitPart(nested);
47
+ }
48
+ };
49
+ for (const part of word)
50
+ visitPart(part);
51
+ }
52
+ function wrappedSimpleCommand(command, name) {
53
+ const raw = command.args.map((word) => wordToString(word));
54
+ let commandIndex = -1;
55
+ if (name === 'env') {
56
+ for (let i = 0; i < raw.length;) {
57
+ const arg = raw[i];
58
+ if (arg === '--') {
59
+ commandIndex = i + 1;
60
+ break;
61
+ }
62
+ if (arg === '-i' || arg === '--ignore-environment')
63
+ return null;
64
+ if (arg === '-u' || arg === '--unset') {
65
+ i += 2;
66
+ continue;
67
+ }
68
+ if (arg.startsWith('-u=') || arg.startsWith('--unset=')) {
69
+ i++;
70
+ continue;
71
+ }
72
+ if (arg.startsWith('-')) {
73
+ i++;
74
+ continue;
75
+ }
76
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(arg)) {
77
+ i++;
78
+ continue;
79
+ }
80
+ commandIndex = i;
81
+ break;
82
+ }
83
+ }
84
+ else if (name === 'command') {
85
+ let identify = false;
86
+ let i = 0;
87
+ while (i < raw.length) {
88
+ const arg = raw[i];
89
+ if (arg === '-v' || arg === '-V') {
90
+ identify = true;
91
+ i++;
92
+ continue;
93
+ }
94
+ if (arg === '--') {
95
+ i++;
96
+ break;
97
+ }
98
+ if (arg.startsWith('-')) {
99
+ i++;
100
+ continue;
101
+ }
102
+ break;
103
+ }
104
+ if (identify)
105
+ return null;
106
+ commandIndex = i;
107
+ }
108
+ else if (name === 'timeout') {
109
+ let i = 0;
110
+ while (i < raw.length && raw[i].startsWith('-') && raw[i] !== '-' && raw[i] !== '--')
111
+ i++;
112
+ if (i < raw.length && raw[i] === '--')
113
+ i++;
114
+ commandIndex = i + 1;
115
+ }
116
+ if (commandIndex < 0 || commandIndex >= command.args.length)
117
+ return null;
118
+ return {
119
+ kind: 'SimpleCommand',
120
+ assignments: [],
121
+ name: command.args[commandIndex],
122
+ args: command.args.slice(commandIndex + 1),
123
+ redirects: [],
124
+ };
125
+ }
126
+ function assertPureShellCommand(command) {
127
+ if (command.kind === 'SimpleCommand') {
128
+ const name = command.name === null ? null : literalOfWord(command.name);
129
+ if (name === 'sed' && sedUsesScriptFile(command.args)) {
130
+ throw new FauxnixParseError(PURE_SED_FILE_MESSAGE);
131
+ }
132
+ if (command.name)
133
+ assertPureWord(command.name);
134
+ for (const arg of command.args)
135
+ assertPureWord(arg);
136
+ for (const assignment of command.assignments) {
137
+ assertPureWord(assignment.value);
138
+ for (const value of assignment.values ?? [])
139
+ assertPureWord(value);
140
+ }
141
+ if (name === 'env' || name === 'command' || name === 'timeout') {
142
+ const nested = wrappedSimpleCommand(command, name);
143
+ if (nested)
144
+ assertPureShellCommand(nested);
145
+ }
146
+ return;
147
+ }
148
+ if (command.kind === 'If') {
149
+ assertPureCommandList(command.test);
150
+ assertPureCommandList(command.then);
151
+ if (command.else)
152
+ assertPureCommandList(command.else);
153
+ return;
154
+ }
155
+ if (command.kind === 'For') {
156
+ for (const word of command.words)
157
+ assertPureWord(word);
158
+ assertPureCommandList(command.body);
159
+ return;
160
+ }
161
+ if (command.kind === 'While') {
162
+ assertPureCommandList(command.test);
163
+ assertPureCommandList(command.body);
164
+ return;
165
+ }
166
+ assertPureWord(command.word);
167
+ for (const arm of command.arms) {
168
+ for (const pattern of arm.patterns)
169
+ assertPureWord(pattern);
170
+ assertPureCommandList(arm.body);
171
+ }
172
+ }
173
+ function assertPureCommandList(list) {
174
+ for (const segment of list.segments) {
175
+ for (const command of segment.pipeline.commands)
176
+ assertPureShellCommand(command);
177
+ }
178
+ }
5
179
  /* ------------------------------------------------------------------ */
6
180
  /* Variable mapping */
7
181
  /* ------------------------------------------------------------------ */
@@ -39,8 +213,43 @@ export function paramExpr(name, op, word) {
39
213
  psStr('bash: ' + msg) +
40
214
  '); $script:fx_exit = 1; \'\' } else { $fx_pv } )');
41
215
  }
216
+ function sliceArgExpr(s) {
217
+ if (s.length > 0 && s[0] === '$') {
218
+ return '(fx-scalar0 ' + psStr(s.slice(1)) + ')';
219
+ }
220
+ return psStr(s);
221
+ }
222
+ export function varExtraOf(p) {
223
+ if (p.kind !== 'Var')
224
+ return undefined;
225
+ if (!p.replace && !p.slice)
226
+ return undefined;
227
+ const extra = {};
228
+ if (p.replace)
229
+ extra.replace = p.replace;
230
+ if (p.slice)
231
+ extra.slice = p.slice;
232
+ return extra;
233
+ }
42
234
  /** Map a bash $VAR name to a PowerShell expression (usable inside $(...)). */
43
- export function varExpr(name, index, param, length = false) {
235
+ export function varExpr(name, index, param, length = false, extra) {
236
+ if (extra && extra.replace) {
237
+ const r = extra.replace;
238
+ return ('(fx-subst (fx-scalar0 ' +
239
+ psStr(name) +
240
+ ') ' +
241
+ psStr(r.pat) +
242
+ ' ' +
243
+ psStr(r.repl) +
244
+ ' ' +
245
+ (r.global ? '$true' : '$false') +
246
+ ')');
247
+ }
248
+ if (extra && extra.slice) {
249
+ const off = sliceArgExpr(extra.slice.offset);
250
+ const len = extra.slice.length !== undefined ? sliceArgExpr(extra.slice.length) : '$null';
251
+ return '(fx-slice (fx-scalar0 ' + psStr(name) + ') ' + off + ' ' + len + ')';
252
+ }
44
253
  if (param)
45
254
  return paramExpr(name, param.op, param.word);
46
255
  if (length) {
@@ -60,6 +269,12 @@ export function varExpr(name, index, param, length = false) {
60
269
  if (index !== undefined) {
61
270
  return '(fx-subget ' + psStr(name) + ' ' + psStr(index) + ')';
62
271
  }
272
+ if (/^[0-9]+$/.test(name)) {
273
+ if (name === '0') {
274
+ return "$(if ($env:FAUXNIX_ARG0) { [string]$env:FAUXNIX_ARG0 } else { 'fauxnix' })";
275
+ }
276
+ return '(fx-posget ' + name + ')';
277
+ }
63
278
  switch (name) {
64
279
  case 'HOME':
65
280
  return '$HOME';
@@ -82,10 +297,24 @@ export function varExpr(name, index, param, length = false) {
82
297
  return '[string]$PID';
83
298
  case 'HOSTNAME':
84
299
  return '$env:COMPUTERNAME';
300
+ case '#':
301
+ return '@(fx-posload).Count';
302
+ case '@':
303
+ case '*':
304
+ return '((@(fx-posload) -join (fx-ifs1)))';
85
305
  default:
86
306
  return '$env:' + name;
87
307
  }
88
308
  }
309
+ /** `$?` `$$` `$0`–`$n` `$#` `$@` `$*` — not ordinary `$env:` names. */
310
+ export function isSpecialShellVar(name) {
311
+ return (name === '?' ||
312
+ name === '$' ||
313
+ name === '#' ||
314
+ name === '@' ||
315
+ name === '*' ||
316
+ /^[0-9]+$/.test(name));
317
+ }
89
318
  /* ------------------------------------------------------------------ */
90
319
  /* Word → PowerShell expression */
91
320
  /* ------------------------------------------------------------------ */
@@ -155,7 +384,7 @@ export function exprOfWord(w, opts) {
155
384
  // single bare variable → bare expression
156
385
  if (expanded.length === 1 && expanded[0].kind === 'Var') {
157
386
  const v = expanded[0];
158
- return varExpr(v.name, v.index, v.param, v.length === true);
387
+ return varExpr(v.name, v.index, v.param, v.length === true, varExtraOf(v));
159
388
  }
160
389
  // Bare `$(...)` must not sit inside a PS expandable string: the
161
390
  // substitution body contains `"` / `$_` that would break interpolation.
@@ -185,7 +414,7 @@ export function exprOfWord(w, opts) {
185
414
  emitPart(q, true);
186
415
  break;
187
416
  case 'Var':
188
- out += '$(' + varExpr(p.name, p.index, p.param, p.length === true) + ')';
417
+ out += '$(' + varExpr(p.name, p.index, p.param, p.length === true, varExtraOf(p)) + ')';
189
418
  break;
190
419
  case 'CmdSub':
191
420
  out += '$(' + translateCmdSub(p.cmd, quoted || opts?.preserveCmdSub === true) + ')';
@@ -241,7 +470,7 @@ function arithSourceExpr(parts) {
241
470
  emit(q);
242
471
  break;
243
472
  case 'Var':
244
- out += '$(' + varExpr(p.name, p.index, p.param, p.length === true) + ')';
473
+ out += '$(' + varExpr(p.name, p.index, p.param, p.length === true, varExtraOf(p)) + ')';
245
474
  break;
246
475
  case 'CmdSub':
247
476
  out += '$(' + translateCmdSub(p.cmd, true) + ')';
@@ -292,6 +521,7 @@ function wordPartsForSplat(w) {
292
521
  /**
293
522
  * `${name[@]}` / `"pre${name[@]}post"` — one argv per element.
294
523
  * Unquoted `${name[*]}` also splats (bash); quoted `"${name[*]}"` stays one join.
524
+ * `$@` / unquoted `$*` splat like `${arr[@]}`; quoted `"$@"` still splats.
295
525
  */
296
526
  export function splatSpec(w) {
297
527
  const parts = wordPartsForSplat(w);
@@ -302,7 +532,10 @@ export function splatSpec(w) {
302
532
  for (const { part: p, quoted } of parts) {
303
533
  const splat = p.kind === 'Var' &&
304
534
  !p.length &&
305
- (p.index === '@' || (p.index === '*' && !quoted));
535
+ (p.name === '@' ||
536
+ (p.name === '*' && !quoted) ||
537
+ p.index === '@' ||
538
+ (p.index === '*' && !quoted));
306
539
  if (splat) {
307
540
  if (seen)
308
541
  return null;
@@ -319,6 +552,12 @@ export function splatSpec(w) {
319
552
  }
320
553
  return name ? { name, prefix, suffix } : null;
321
554
  }
555
+ /** Load the splat source: positionals (`@`/`*`) vs named arrays. */
556
+ function splatLoadCall(name) {
557
+ if (name === '@' || name === '*')
558
+ return 'fx-posload';
559
+ return 'fx-arrload ' + psStr(name);
560
+ }
322
561
  /** PS expression of a string[]: `@` words splat, others stay one element. */
323
562
  export function argListExpr(words, fn = exprOfWord) {
324
563
  if (words.length === 0)
@@ -330,9 +569,9 @@ export function argListExpr(words, fn = exprOfWord) {
330
569
  if (!s)
331
570
  return '@(' + fn(w) + ')';
332
571
  if (!s.prefix && !s.suffix)
333
- return '@(fx-arrload ' + psStr(s.name) + ')';
334
- return ('@($( $fx_sp = @(fx-arrload ' +
335
- psStr(s.name) +
572
+ return '@(' + splatLoadCall(s.name) + ')';
573
+ return ('@($( $fx_sp = @(' +
574
+ splatLoadCall(s.name) +
336
575
  '); if ($fx_sp.Count -eq 0) { $fx_sp = @(' +
337
576
  psStr(s.prefix + s.suffix) +
338
577
  ') } else { $fx_sp[0] = ' +
@@ -353,14 +592,11 @@ export function argListExpr(words, fn = exprOfWord) {
353
592
  * Unquoted command words join non-empty lines with a space (IFS
354
593
  * word-split approximation). Handlers often emit one string object, so
355
594
  * a bare `$(…)` interpolation would keep those newlines.
595
+ * Lists (`;` `&&` `||`) reuse translateListInline inside the fx-csub
596
+ * scriptblock so the newline contract is unchanged.
356
597
  */
357
- export function translateCmdSub(cmdText, keepNl = false) {
358
- const list = parseCommand(cmdText);
359
- if (list.segments.length !== 1) {
360
- throw new FauxnixParseError('fauxnix: command substitution with ; && || is not supported yet');
361
- }
362
- const { defs, call } = translatePipelineBody(list.segments[0].pipeline);
363
- const inner = defs ? defs + '\n' + call : call;
598
+ export function translateCmdSub(cmdText, keepNl = false, translation = EXECUTE_TRANSLATION) {
599
+ const inner = translateListInline(parseCommand(cmdText), translation);
364
600
  const collected = '(fx-csub { ' + inner + ' })';
365
601
  if (keepNl)
366
602
  return collected;
@@ -377,18 +613,32 @@ function indentBlock(s) {
377
613
  .map((l) => (l ? ' ' + l : l))
378
614
  .join('\n');
379
615
  }
380
- export function translateSimple(cmd, position, hasStdin) {
616
+ export function translateSimple(cmd, position, hasStdin, translation = EXECUTE_TRANSLATION) {
617
+ const nativeTerm = "(-not $script:fx_csub -and (($MyInvocation.MyCommand.Name -eq '') -or " +
618
+ (position === 'last' ? '$true' : '$false') +
619
+ '))';
381
620
  // assignment-only segment (`X=1; cmd`): bash semantics are "set for the
382
621
  // rest of the shell". Reuse the export code path — persist + env shadow —
383
622
  // so empty values (`X=`) and `[[ -v X ]]` behave like bash (documented
384
623
  // deviation: shell var vs exported var are indistinguishable here).
385
624
  if (cmd.name === null) {
386
625
  const exportHandler = lookup('export');
387
- const words = cmd.assignments.map((a) => [
388
- { kind: 'Text', text: a.name + '=' },
389
- ...a.value,
390
- ]);
391
- return exportHandler ? exportHandler(words, { position, hasStdin }) : '';
626
+ const chunks = [];
627
+ for (const a of cmd.assignments) {
628
+ if (a.values) {
629
+ chunks.push('fx-arrput ' +
630
+ psStr(a.name) +
631
+ ' ' +
632
+ argListExpr(a.values, (w) => exprOfWord(w, { preserveCmdSub: true })));
633
+ }
634
+ else {
635
+ const words = [[{ kind: 'Text', text: a.name + '=' }, ...a.value]];
636
+ if (exportHandler) {
637
+ chunks.push(exportHandler(words, { position, hasStdin, translationMode: translation.mode }));
638
+ }
639
+ }
640
+ }
641
+ return chunks.join('\n');
392
642
  }
393
643
  const nameLit = literalOfWord(cmd.name);
394
644
  const nameSplat = splatSpec(cmd.name);
@@ -403,9 +653,9 @@ export function translateSimple(cmd, position, hasStdin) {
403
653
  name: cmd.args[0],
404
654
  args: cmd.args.slice(1),
405
655
  redirects: cmd.redirects,
406
- }, position, hasStdin);
656
+ }, position, hasStdin, translation);
407
657
  const emptyCmdLines = [
408
- '$fx_cw = @(fx-arrload ' + psStr(nameSplat.name) + ')',
658
+ '$fx_cw = @(' + splatLoadCall(nameSplat.name) + ')',
409
659
  ];
410
660
  if (hasAffix) {
411
661
  emptyCmdLines.push('if ($fx_cw.Count -eq 0) { $fx_cw = @(' +
@@ -419,20 +669,23 @@ export function translateSimple(cmd, position, hasStdin) {
419
669
  emptyCmdLines.push('if ($fx_cw.Count -eq 0) {',
420
670
  // No words left → bash null command (exit 0). Remaining words are
421
671
  // known at compile time, so reuse translateSimple (handlers, not `&`).
422
- promoted ? indentBlock(promoted) : ' ', '} else {', ' $fx_na = [object[]](' + argListExpr(cmd.args) + ')', ' $fx_cmd = [string]$fx_cw[0]', ' if ($fx_cw.Count -gt 1) { $fx_na = [object[]](@($fx_cw[1..($fx_cw.Count - 1)]) + $fx_na) }', ' ' + (hasStdin ? '($input | fx-native $fx_cmd $fx_na)' : 'fx-native $fx_cmd $fx_na'), '}');
672
+ promoted ? indentBlock(promoted) : ' ', '} else {', ' $fx_na = [object[]](' + argListExpr(cmd.args) + ')', ' $fx_cmd = [string]$fx_cw[0]', ' if ($fx_cw.Count -gt 1) { $fx_na = [object[]](@($fx_cw[1..($fx_cw.Count - 1)]) + $fx_na) }', ' ' +
673
+ (hasStdin
674
+ ? '($input | fx-native $fx_cmd $fx_na ' + nativeTerm + ')'
675
+ : 'fx-native $fx_cmd $fx_na ' + nativeTerm), '}');
423
676
  body = emptyCmdLines.join('\n');
424
677
  }
425
678
  else if (nameLit !== null) {
426
679
  const handler = lookup(nameLit);
427
680
  if (handler && !(nameLit === '[[' && !isUnquotedLiteral(cmd.name, '[['))) {
428
- body = handler(cmd.args, { position, hasStdin });
681
+ body = handler(cmd.args, { position, hasStdin, translationMode: translation.mode });
429
682
  }
430
683
  else {
431
684
  // passthrough: native command (git, node, npm, python, cargo, ...)
432
685
  // via fx-native (Win32 command line + Process). `& name @array` on
433
686
  // PS 5.1 drops empty argv entries and eats embedded quotes.
434
687
  const nameExpr = psStr(nameLit);
435
- const invoke = 'fx-native ' + nameExpr + ' $fx_na';
688
+ const invoke = 'fx-native ' + nameExpr + ' $fx_na ' + nativeTerm;
436
689
  body = [
437
690
  '$fx_na = [object[]](' + argListExpr(cmd.args) + ')',
438
691
  (hasStdin ? '($input | ' + invoke + ')' : invoke),
@@ -442,7 +695,7 @@ export function translateSimple(cmd, position, hasStdin) {
442
695
  else {
443
696
  // dynamic command name — evaluate it
444
697
  const nameExpr = exprOfWord(cmd.name);
445
- const invoke = 'fx-native (' + nameExpr + ') $fx_na';
698
+ const invoke = 'fx-native (' + nameExpr + ') $fx_na ' + nativeTerm;
446
699
  body = [
447
700
  '$fx_na = [object[]](' + argListExpr(cmd.args) + ')',
448
701
  (hasStdin ? '($input | ' + invoke + ')' : invoke),
@@ -547,10 +800,20 @@ export function wrapTempEnv(sets, body, extra) {
547
800
  lines.push(arrSave + '[' + psStr(n) + '] = (fx-arrpackget ' + psStr(n) + ')');
548
801
  }
549
802
  const valVars = [];
803
+ const valIsArr = [];
550
804
  for (let i = 0; i < sets.length; i++) {
551
805
  const vn = '$fx_ev' + id + '_' + i;
552
806
  valVars.push(vn);
553
- lines.push(vn + ' = ' + exprOfWord(sets[i].value, { preserveCmdSub: true }));
807
+ if (sets[i].values) {
808
+ valIsArr.push(true);
809
+ lines.push(vn +
810
+ ' = ' +
811
+ argListExpr(sets[i].values, (w) => exprOfWord(w, { preserveCmdSub: true })));
812
+ }
813
+ else {
814
+ valIsArr.push(false);
815
+ lines.push(vn + ' = ' + exprOfWord(sets[i].value, { preserveCmdSub: true }));
816
+ }
554
817
  }
555
818
  lines.push('try {');
556
819
  for (const u of unsets) {
@@ -576,6 +839,10 @@ export function wrapTempEnv(sets, body, extra) {
576
839
  for (let i = 0; i < sets.length; i++) {
577
840
  const n = sets[i].name;
578
841
  const nq = n.replace(/'/g, "''");
842
+ if (valIsArr[i]) {
843
+ lines.push(' fx-arrput ' + psStr(n) + ' ' + valVars[i]);
844
+ continue;
845
+ }
579
846
  lines.push(' $env:' + n + ' = ' + valVars[i]);
580
847
  lines.push(' fx-arrdrop ' + psStr(n));
581
848
  lines.push(" $env:FAUXNIX_SETVARS = ((@($env:FAUXNIX_SETVARS -split ';' | Where-Object { $_ -ne '' -and $_ -cne '" +
@@ -721,10 +988,10 @@ let pipelineSeq = 0;
721
988
  * multi-command pipelines become generated functions chained with `|`
722
989
  * (PS 5.1 forbids parenthesized expressions as non-first pipeline elements).
723
990
  */
724
- function translateListInline(list) {
991
+ function translateListInline(list, translation = EXECUTE_TRANSLATION) {
725
992
  const chunks = [];
726
993
  for (const seg of list.segments) {
727
- const { defs, call } = translatePipelineBody(seg.pipeline);
994
+ const { defs, call } = translatePipelineBody(seg.pipeline, translation);
728
995
  const body = (defs ? defs + '\n' : '') + call;
729
996
  if (seg.op === '&&') {
730
997
  chunks.push('if ($script:fx_exit -eq 0) {\n' + body + '\n}');
@@ -738,22 +1005,38 @@ function translateListInline(list) {
738
1005
  }
739
1006
  return chunks.join('\n');
740
1007
  }
741
- function translateIf(cmd) {
1008
+ function translateIf(cmd, translation) {
742
1009
  // Branch bodies reset fx_exit first: the compound's exit status must come
743
1010
  // from the taken branch's last command (bash semantics), not leak the test's
744
1011
  // failure — `if false; then A; else B; fi` exits 0 in bash.
745
- const lines = [translateListInline(cmd.test), 'if ($script:fx_exit -eq 0) {', ' $script:fx_exit = 0'];
746
- for (const l of translateListInline(cmd.then).split('\n'))
1012
+ const lines = [translateListInline(cmd.test, translation), 'if ($script:fx_exit -eq 0) {', ' $script:fx_exit = 0'];
1013
+ for (const l of translateListInline(cmd.then, translation).split('\n'))
747
1014
  lines.push(l ? ' ' + l : l);
748
1015
  lines.push('} else {', ' $script:fx_exit = 0');
749
1016
  if (cmd.else) {
750
- for (const l of translateListInline(cmd.else).split('\n'))
1017
+ for (const l of translateListInline(cmd.else, translation).split('\n'))
751
1018
  lines.push(l ? ' ' + l : l);
752
1019
  }
753
1020
  lines.push('}');
754
1021
  return lines.join('\n');
755
1022
  }
756
- function translateFor(cmd) {
1023
+ function translateCase(cmd, translation) {
1024
+ const lines = ['$script:fx_exit = 0', '$fx_cw = ' + exprOfWord(cmd.word)];
1025
+ for (let i = 0; i < cmd.arms.length; i++) {
1026
+ const arm = cmd.arms[i];
1027
+ const pats = arm.patterns.map((w) => exprOfWord(w)).join(',');
1028
+ const head = i === 0 ? 'if' : 'elseif';
1029
+ lines.push(head + ' (fx-casematch $fx_cw @(' + pats + ')) {');
1030
+ const body = translateListInline(arm.body, translation);
1031
+ if (body) {
1032
+ for (const l of body.split('\n'))
1033
+ lines.push(l ? ' ' + l : l);
1034
+ }
1035
+ lines.push('}');
1036
+ }
1037
+ return lines.join('\n');
1038
+ }
1039
+ function translateFor(cmd, translation) {
757
1040
  const n = cmd.name.replace(/'/g, "''");
758
1041
  const lines = [
759
1042
  '$fx_for = ' + argListExpr(cmd.words),
@@ -775,11 +1058,27 @@ function translateFor(cmd) {
775
1058
  n +
776
1059
  "' + [string][char]61 + (fx-svenc ([string]$fx_it))); $env:FAUXNIX_SETVALS = ($fx_sv -join [string][char]10)",
777
1060
  ];
778
- for (const l of translateListInline(cmd.body).split('\n'))
1061
+ for (const l of translateListInline(cmd.body, translation).split('\n'))
779
1062
  lines.push(l ? ' ' + l : l);
780
1063
  lines.push('}');
781
1064
  return lines.join('\n');
782
1065
  }
1066
+ function translateWhile(cmd, translation) {
1067
+ // Bash: last executed body owns status; a test that ends the loop does not.
1068
+ // Never-entered loops (`while false; do …; done`, `until true; do …; done`)
1069
+ // exit 0. Save the body status and restore it on the failing test.
1070
+ const fail = cmd.until ? '$script:fx_exit -eq 0' : '$script:fx_exit -ne 0';
1071
+ const lines = ['$fx_wst = 0', 'do {'];
1072
+ for (const l of translateListInline(cmd.test, translation).split('\n'))
1073
+ lines.push(l ? ' ' + l : l);
1074
+ lines.push(' if (' + fail + ') { $script:fx_exit = $fx_wst; break }');
1075
+ lines.push(' $script:fx_exit = 0');
1076
+ for (const l of translateListInline(cmd.body, translation).split('\n'))
1077
+ lines.push(l ? ' ' + l : l);
1078
+ lines.push(' $fx_wst = $script:fx_exit');
1079
+ lines.push('} while ($true)');
1080
+ return lines.join('\n');
1081
+ }
783
1082
  function stdinTarget(c) {
784
1083
  let t = null;
785
1084
  for (const r of c.redirects) {
@@ -794,7 +1093,46 @@ function stdinReadExpr(target) {
794
1093
  return '@()';
795
1094
  return '@(fx-readlines ' + pathExpr(n) + ')';
796
1095
  }
797
- export function translatePipelineBody(p) {
1096
+ /** `>` `>>` `&>` `&>>` — Node last-stage apply cannot honor these on earlier stages. */
1097
+ function isStdoutFileRedirect(op) {
1098
+ return op === '>' || op === '>>' || op === '&>' || op === '&>>';
1099
+ }
1100
+ const NONLAST_STDOUT_REDIRECT_MSG = 'fauxnix: stdout redirect on a non-last pipeline stage is not supported yet; write the file in a previous list segment (cmd >f; cat f) or wait for per-stage fds (#157)';
1101
+ const NONLAST_STDERR_FILE_REDIRECT_MSG = 'fauxnix: stderr redirect ({op}) on a non-last pipeline stage is not supported yet; spool the stage first (cmd >out {op}err; cat out | next) or wait for per-stage fds (#157)';
1102
+ function nonLastFdRedirectMessage(op) {
1103
+ if (isStdoutFileRedirect(op))
1104
+ return NONLAST_STDOUT_REDIRECT_MSG;
1105
+ if (op === '2>' || op === '2>>') {
1106
+ return NONLAST_STDERR_FILE_REDIRECT_MSG.replaceAll('{op}', op);
1107
+ }
1108
+ if (op === '2>&1') {
1109
+ return 'fauxnix: 2>&1 on a non-last pipeline stage is not supported yet; spool the merged output first (cmd >out 2>&1; cat out | next) or wait for per-stage fds (#157)';
1110
+ }
1111
+ if (op === '1>&2') {
1112
+ return 'fauxnix: 1>&2 on a non-last pipeline stage is not supported yet; run the stage separately (cmd 1>&2; next </dev/null) or wait for per-stage fds (#157)';
1113
+ }
1114
+ return null;
1115
+ }
1116
+ function rejectNonLastFdRedirects(commands) {
1117
+ if (commands.length < 2)
1118
+ return;
1119
+ for (let i = 0; i < commands.length - 1; i++) {
1120
+ for (const redirect of commands[i].redirects) {
1121
+ const message = nonLastFdRedirectMessage(redirect.op);
1122
+ if (message)
1123
+ throw new FauxnixParseError(message);
1124
+ }
1125
+ }
1126
+ }
1127
+ export function translatePipelineBody(p, translation = EXECUTE_TRANSLATION) {
1128
+ if (translation.mode === 'pure') {
1129
+ for (const command of p.commands)
1130
+ assertPureShellCommand(command);
1131
+ }
1132
+ // Last-stage output fds are applied by Node. On an earlier stage they would
1133
+ // be prepared but not owned by that stage, so output would still reach the
1134
+ // wrong destination. Fail loud until routed in-stage fds land (#157).
1135
+ rejectNonLastFdRedirects(p.commands);
798
1136
  // Every pipeline stage needs its own status slot. Handlers deliberately use
799
1137
  // `$script:fx_exit` because their helper functions run in child scopes; in a
800
1138
  // pipeline that shared flag lets an earlier failure leak into a successful
@@ -807,11 +1145,15 @@ export function translatePipelineBody(p) {
807
1145
  const hasStdin = i > 0 || c.redirects.some((r) => r.op === '<');
808
1146
  const position = i === 0 ? 'first' : i === p.commands.length - 1 ? 'last' : 'middle';
809
1147
  if (c.kind === 'If')
810
- bodies.push(translateIf(c));
1148
+ bodies.push(translateIf(c, translation));
811
1149
  else if (c.kind === 'For')
812
- bodies.push(translateFor(c));
1150
+ bodies.push(translateFor(c, translation));
1151
+ else if (c.kind === 'While')
1152
+ bodies.push(translateWhile(c, translation));
1153
+ else if (c.kind === 'Case')
1154
+ bodies.push(translateCase(c, translation));
813
1155
  else
814
- bodies.push(translateSimple(c, position, hasStdin));
1156
+ bodies.push(translateSimple(c, position, hasStdin, translation));
815
1157
  }
816
1158
  if (bodies.length === 1) {
817
1159
  return { defs: '', call: '(& {\n' + bodies[0] + '\n})' };
@@ -870,7 +1212,7 @@ export function translatePipelineBody(p) {
870
1212
  ].join('\n'));
871
1213
  return { defs: defs.join('\n'), call: pipelineName };
872
1214
  }
873
- export function translateCommandList(list) {
1215
+ export function translateCommandList(list, translation = EXECUTE_TRANSLATION) {
874
1216
  const plans = [];
875
1217
  for (const seg of list.segments) {
876
1218
  const cmds = seg.pipeline.commands;
@@ -881,7 +1223,7 @@ export function translateCommandList(list) {
881
1223
  const stdinRedirects = cmds.length
882
1224
  ? cmds[0].redirects.filter((r) => r.op === '<')
883
1225
  : [];
884
- const { defs, call } = translatePipelineBody(seg.pipeline);
1226
+ const { defs, call } = translatePipelineBody(seg.pipeline, translation);
885
1227
  let body = defs ? defs + '\n' + call : call;
886
1228
  // First-stage `< file` feeds stage zero via FAUXNIX_STDIN_FILE.
887
1229
  // Later-stage `<` is owned inside the pipeline body, not this wrapper.
@@ -913,6 +1255,10 @@ const WRAP_HELPER_ORDER = [
913
1255
  'fx-csub',
914
1256
  'fx-svenc',
915
1257
  'fx-svdec',
1258
+ 'fx-posload',
1259
+ 'fx-posset',
1260
+ 'fx-posget',
1261
+ 'fx-posshift',
916
1262
  'fx-arrload',
917
1263
  'fx-scalar0',
918
1264
  'fx-ifs1',
@@ -923,7 +1269,11 @@ const WRAP_HELPER_ORDER = [
923
1269
  'fx-arrput',
924
1270
  'fx-arrclr',
925
1271
  'fx-subget',
1272
+ 'fx-casematch',
1273
+ 'fx-subst',
1274
+ 'fx-slice',
926
1275
  'fx-winargv',
1276
+ 'fx-cmdargv',
927
1277
  'fx-native',
928
1278
  ];
929
1279
  const WRAP_HELPER_DEPS = {
@@ -931,6 +1281,10 @@ const WRAP_HELPER_DEPS = {
931
1281
  'fx-csub': [],
932
1282
  'fx-svenc': [],
933
1283
  'fx-svdec': [],
1284
+ 'fx-posload': ['fx-svdec'],
1285
+ 'fx-posset': ['fx-svenc'],
1286
+ 'fx-posget': ['fx-posload'],
1287
+ 'fx-posshift': ['fx-posload', 'fx-posset'],
934
1288
  'fx-arrload': ['fx-scalar0', 'fx-svdec'],
935
1289
  'fx-scalar0': ['fx-svdec'],
936
1290
  'fx-ifs1': ['fx-scalar0'],
@@ -941,8 +1295,12 @@ const WRAP_HELPER_DEPS = {
941
1295
  'fx-arrput': ['fx-arrdrop', 'fx-svenc'],
942
1296
  'fx-arrclr': ['fx-arrdrop'],
943
1297
  'fx-subget': ['fx-arrload', 'fx-ifs1'],
1298
+ 'fx-casematch': [],
1299
+ 'fx-subst': [],
1300
+ 'fx-slice': [],
944
1301
  'fx-winargv': [],
945
- 'fx-native': ['fx-winargv'],
1302
+ 'fx-cmdargv': ['fx-winargv'],
1303
+ 'fx-native': ['fx-cmdargv'],
946
1304
  };
947
1305
  /** Helpers the body calls that wrapScript still has to emit (not already defined there). */
948
1306
  function wrapHelpersNeeded(body) {
@@ -1058,7 +1416,16 @@ export function wrapScript(body, opts = {}) {
1058
1416
  ' $script:fx_csub = $true',
1059
1417
  ' try { $fx_o = @(& $b | ForEach-Object { [string]$_ }) }',
1060
1418
  ' finally { $script:fx_csub = $fx_prevcs }',
1061
- ' $fx_s = ($fx_o -join [string][char]10)',
1419
+ // Line items (no trailing NL) get a NL between them. Chunks that
1420
+ // already end in NL concatenate, so $(echo a; echo b) is a\nb.
1421
+ " $fx_s = ''",
1422
+ ' foreach ($fx_x in $fx_o) {',
1423
+ ' $fx_t = [string]$fx_x',
1424
+ ' if ($fx_s.Length -gt 0 -and $fx_s[$fx_s.Length - 1] -ne [char]10) {',
1425
+ ' $fx_s += [string][char]10',
1426
+ ' }',
1427
+ ' $fx_s += $fx_t',
1428
+ ' }',
1062
1429
  ' while ($fx_s.Length -gt 0 -and $fx_s[$fx_s.Length - 1] -eq [char]10) {',
1063
1430
  ' $fx_s = $fx_s.Substring(0, $fx_s.Length - 1)',
1064
1431
  ' }',
@@ -1089,6 +1456,52 @@ export function wrapScript(body, opts = {}) {
1089
1456
  ' return [string]$sb',
1090
1457
  '}',
1091
1458
  ],
1459
+ 'fx-posload': [
1460
+ 'function fx-posload {',
1461
+ ' if ($null -eq $env:FAUXNIX_POS -or [string]$env:FAUXNIX_POS -eq \'\') { return @() }',
1462
+ ' $out = @()',
1463
+ ' foreach ($el in @($env:FAUXNIX_POS -split [string][char]30)) { $out += ,(fx-svdec $el) }',
1464
+ ' return $out',
1465
+ '}',
1466
+ ],
1467
+ 'fx-posset': [
1468
+ 'function fx-posset($vals) {',
1469
+ ' if ($null -eq $vals) { $vals = @() }',
1470
+ ' $vals = @($vals)',
1471
+ ' if ($vals.Count -eq 0) { $env:FAUXNIX_POS = \'\'; return }',
1472
+ ' $encs = @(); foreach ($v in $vals) { $encs += (fx-svenc $v) }',
1473
+ ' $env:FAUXNIX_POS = ($encs -join [string][char]30)',
1474
+ '}',
1475
+ ],
1476
+ 'fx-posget': [
1477
+ 'function fx-posget($i) {',
1478
+ ' $arr = @(fx-posload)',
1479
+ ' $n = 0',
1480
+ ' if (-not [int]::TryParse([string]$i, [ref]$n)) { return \'\' }',
1481
+ ' if ($n -lt 1 -or $n -gt $arr.Count) { return \'\' }',
1482
+ ' return [string]$arr[$n - 1]',
1483
+ '}',
1484
+ ],
1485
+ 'fx-posshift': [
1486
+ 'function fx-posshift($n) {',
1487
+ ' $arr = @(fx-posload)',
1488
+ ' $i = 1',
1489
+ ' if ($null -ne $n -and [string]$n -ne \'\') {',
1490
+ ' $parsed = 0',
1491
+ ' if (-not [int]::TryParse([string]$n, [ref]$parsed)) {',
1492
+ ' [Console]::Error.WriteLine(\'bash: shift: \' + [string]$n + \': numeric argument required\')',
1493
+ ' $script:fx_exit = 1',
1494
+ ' return',
1495
+ ' }',
1496
+ ' $i = $parsed',
1497
+ ' }',
1498
+ ' if ($i -lt 0 -or $i -gt $arr.Count) { $script:fx_exit = 1; return }',
1499
+ ' if ($i -eq 0) { return }',
1500
+ ' if ($i -eq $arr.Count) { fx-posset @(); return }',
1501
+ ' $new = @($arr[$i..($arr.Count - 1)])',
1502
+ ' fx-posset $new',
1503
+ '}',
1504
+ ],
1092
1505
  'fx-arrload': [
1093
1506
  'function fx-arrload($n) {',
1094
1507
  ' $n = [string]$n',
@@ -1096,8 +1509,10 @@ export function wrapScript(body, opts = {}) {
1096
1509
  ' $fx_eq = $fx_pair.IndexOf([char]61)',
1097
1510
  ' if ($fx_eq -lt 1) { continue }',
1098
1511
  ' if ($fx_pair.Substring(0, $fx_eq) -cne $n) { continue }',
1512
+ ' $pay = $fx_pair.Substring($fx_eq + 1)',
1513
+ ' if ($pay -eq [string][char]1) { return @() }',
1099
1514
  ' $out = @()',
1100
- ' foreach ($el in @($fx_pair.Substring($fx_eq + 1) -split [string][char]30)) { $out += ,(fx-svdec $el) }',
1515
+ ' foreach ($el in @($pay -split [string][char]30)) { $out += ,(fx-svdec $el) }',
1101
1516
  ' return $out',
1102
1517
  ' }',
1103
1518
  ' $s0 = fx-scalar0 $n',
@@ -1179,9 +1594,12 @@ export function wrapScript(body, opts = {}) {
1179
1594
  'fx-arrput': [
1180
1595
  'function fx-arrput($n, $vals) {',
1181
1596
  ' $n = [string]$n',
1182
- ' $vals = @($vals)',
1597
+ ' if ($null -eq $vals) { $vals = @() } else { $vals = @($vals) }',
1183
1598
  ' fx-arrdrop $n',
1184
- ' if ($vals.Count -eq 0) { } else {',
1599
+ ' if ($vals.Count -eq 0) {',
1600
+ // SOH payload: empty array, distinct from scalar '' and from A=('')
1601
+ " $env:FAUXNIX_ARRS = ((@($env:FAUXNIX_ARRS -split [string][char]10 | Where-Object { $_ -ne '' }) + ($n + [string][char]61 + [string][char]1)) -join [string][char]10)",
1602
+ ' } else {',
1185
1603
  ' $encs = @(); foreach ($v in $vals) { $encs += (fx-svenc $v) }',
1186
1604
  " $env:FAUXNIX_ARRS = ((@($env:FAUXNIX_ARRS -split [string][char]10 | Where-Object { $_ -ne '' }) + ($n + [string][char]61 + ($encs -join [string][char]30))) -join [string][char]10)",
1187
1605
  ' }',
@@ -1216,6 +1634,70 @@ export function wrapScript(body, opts = {}) {
1216
1634
  ' return [string]$arr[$i]',
1217
1635
  '}',
1218
1636
  ],
1637
+ 'fx-casematch': [
1638
+ 'function fx-casematch($w, $pats) {',
1639
+ ' $w = [string]$w',
1640
+ ' foreach ($p in @($pats)) {',
1641
+ ' $pat = [string]$p',
1642
+ ' try {',
1643
+ ' $wp = [WildcardPattern]::new($pat, [System.Management.Automation.WildcardOptions]::None)',
1644
+ ' if ($wp.IsMatch($w)) { return $true }',
1645
+ ' } catch {}',
1646
+ ' }',
1647
+ ' return $false',
1648
+ '}',
1649
+ ],
1650
+ 'fx-subst': [
1651
+ 'function fx-subst($s, $pat, $repl, $all) {',
1652
+ ' if ($null -eq $s) { return \'\' }',
1653
+ ' $s = [string]$s',
1654
+ ' $pat = [string]$pat',
1655
+ ' $repl = [string]$repl',
1656
+ ' if ($pat -eq \'\') { return $s }',
1657
+ ' $glob = $false',
1658
+ ' foreach ($fx_c in $pat.ToCharArray()) {',
1659
+ ' if ($fx_c -eq [char]42 -or $fx_c -eq [char]63) { $glob = $true; break }',
1660
+ ' }',
1661
+ ' if (-not $glob) {',
1662
+ ' if ($all) { return $s.Replace($pat, $repl) }',
1663
+ ' $i = $s.IndexOf($pat)',
1664
+ ' if ($i -lt 0) { return $s }',
1665
+ ' return $s.Substring(0, $i) + $repl + $s.Substring($i + $pat.Length)',
1666
+ ' }',
1667
+ ' $sb = New-Object System.Text.StringBuilder',
1668
+ ' foreach ($fx_c in $pat.ToCharArray()) {',
1669
+ ' if ($fx_c -eq [char]42) { [void]$sb.Append(\'.*\'); continue }',
1670
+ ' if ($fx_c -eq [char]63) { [void]$sb.Append(\'.\'); continue }',
1671
+ ' [void]$sb.Append([regex]::Escape([string]$fx_c))',
1672
+ ' }',
1673
+ ' $rx = New-Object System.Text.RegularExpressions.Regex($sb.ToString(), [System.Text.RegularExpressions.RegexOptions]::Singleline)',
1674
+ ' $fx_rep = $repl.Replace([string][char]36, ([string][char]36 + [string][char]36))',
1675
+ ' if ($all) { return $rx.Replace($s, $fx_rep) }',
1676
+ ' return $rx.Replace($s, $fx_rep, 1)',
1677
+ '}',
1678
+ ],
1679
+ 'fx-slice': [
1680
+ 'function fx-slice($s, $off, $len) {',
1681
+ ' if ($null -eq $s) { return \'\' }',
1682
+ ' $s = [string]$s',
1683
+ ' $n = $s.Length',
1684
+ ' $o = 0',
1685
+ ' if (-not [int]::TryParse([string]$off, [ref]$o)) { return \'\' }',
1686
+ ' if ($o -lt 0) { $o = $n + $o }',
1687
+ ' if ($o -lt 0 -or $o -ge $n) { return \'\' }',
1688
+ ' if ($null -eq $len -or [string]$len -eq \'\') { return $s.Substring($o) }',
1689
+ ' $l = 0',
1690
+ ' if (-not [int]::TryParse([string]$len, [ref]$l)) { return \'\' }',
1691
+ ' if ($l -lt 0) {',
1692
+ ' $fx_end = $n + $l',
1693
+ ' if ($fx_end -lt $o) { return \'\' }',
1694
+ ' $l = $fx_end - $o',
1695
+ ' }',
1696
+ ' if ($l -le 0) { return \'\' }',
1697
+ ' if (($o + $l) -gt $n) { $l = $n - $o }',
1698
+ ' return $s.Substring($o, $l)',
1699
+ '}',
1700
+ ],
1219
1701
  'fx-winargv': [
1220
1702
  'function fx-winargv($argv, $cmdmeta) {',
1221
1703
  // Empty [object[]] unwraps to $null on PS 5.1; @($null) is one empty arg.
@@ -1252,8 +1734,48 @@ export function wrapScript(body, opts = {}) {
1252
1734
  " return (($parts.ToArray()) -join ' ')",
1253
1735
  '}',
1254
1736
  ],
1737
+ 'fx-cmdargv': [
1738
+ 'function fx-cmdargv($argv) {',
1739
+ // cmd.exe performs percent expansion even inside quotes, and embedded
1740
+ // quotes can reopen its metacharacter grammar. CR/LF/NUL cannot be
1741
+ // represented as one batch argument. Reject those values instead of
1742
+ // silently handing a different argv to the shim.
1743
+ ' if ($null -eq $argv) { $argv = @() }',
1744
+ ' foreach ($a in @($argv)) {',
1745
+ ' $s = [string]$a',
1746
+ " if ($s.IndexOf('%') -ge 0) { throw \"fauxnix: cannot pass '%' to a .cmd/.bat file without changing the argument; invoke the underlying executable directly\" }",
1747
+ ' if ($s.IndexOf([char]34) -ge 0) { throw \'fauxnix: cannot pass a double quote to a .cmd/.bat file without changing the argument; invoke the underlying executable directly\' }',
1748
+ ' if ($s.IndexOf([char]13) -ge 0 -or $s.IndexOf([char]10) -ge 0) { throw \'fauxnix: cannot pass a line break to a .cmd/.bat file as one argument; invoke the underlying executable directly\' }',
1749
+ ' if ($s.IndexOf([char]0) -ge 0) { throw \'fauxnix: cannot pass NUL to a .cmd/.bat file as one argument; invoke the underlying executable directly\' }',
1750
+ ' }',
1751
+ ' return (fx-winargv $argv $true)',
1752
+ '}',
1753
+ ],
1255
1754
  'fx-native': [
1256
- 'function fx-native($name, $argv) {',
1755
+ "if (-not ('FauxnixTextPump' -as [type])) {",
1756
+ " Add-Type -TypeDefinition @'",
1757
+ 'using System;',
1758
+ 'using System.IO;',
1759
+ 'using System.Text;',
1760
+ 'using System.Threading.Tasks;',
1761
+ 'public static class FauxnixTextPump {',
1762
+ ' public static async Task CopyAsync(TextReader reader, TextWriter writer) {',
1763
+ ' var buffer = new char[4096];',
1764
+ ' int read;',
1765
+ ' while ((read = await reader.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false)) > 0) {',
1766
+ ' await writer.WriteAsync(buffer, 0, read).ConfigureAwait(false);',
1767
+ ' }',
1768
+ ' await writer.FlushAsync().ConfigureAwait(false);',
1769
+ ' }',
1770
+ ' public static async Task CopyFileAsync(TextReader reader, string path) {',
1771
+ ' using (var writer = new StreamWriter(path, false, new UTF8Encoding(false))) {',
1772
+ ' await CopyAsync(reader, writer).ConfigureAwait(false);',
1773
+ ' }',
1774
+ ' }',
1775
+ '}',
1776
+ "'@",
1777
+ '}',
1778
+ 'function fx-native($name, $argv, $term) {',
1257
1779
  ' if ($null -eq $argv) { $argv = @() } else { $argv = [object[]]@($argv) }',
1258
1780
  ' $app = Get-Command -Name $name -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1',
1259
1781
  ' if ($null -eq $app) {',
@@ -1282,8 +1804,8 @@ export function wrapScript(body, opts = {}) {
1282
1804
  ' $psi = New-Object System.Diagnostics.ProcessStartInfo',
1283
1805
  ' $ext = [IO.Path]::GetExtension([string]$app.Source)',
1284
1806
  // CreateProcess cannot launch .cmd/.bat with UseShellExecute=false (npm.cmd).
1285
- // /s strips one outer quote pair from the /c tail; CRT-quote cmd
1286
- // metacharacters so `&`/`|`/`()` do not start a second command.
1807
+ // /s strips one outer quote pair from the /c tail. Build only the
1808
+ // subset of batch argv that cmd.exe can pass through unchanged.
1287
1809
  " if ($ext -eq '.cmd' -or $ext -eq '.bat') {",
1288
1810
  ' $comspec = Get-Command -Name cmd -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1',
1289
1811
  ' if ($null -eq $comspec) {',
@@ -1292,10 +1814,16 @@ export function wrapScript(body, opts = {}) {
1292
1814
  ' return',
1293
1815
  ' }',
1294
1816
  ' $psi.FileName = $comspec.Source',
1295
- ' $fx_app = fx-winargv $app.Source $true',
1296
- ' $fx_rest = fx-winargv $argv $true',
1817
+ ' try {',
1818
+ ' $fx_app = fx-cmdargv $app.Source',
1819
+ ' $fx_rest = fx-cmdargv $argv',
1820
+ ' } catch {',
1821
+ // Let the common wrapper report the validation error and stop the
1822
+ // current pipeline. Returning here would let xargs mask the failure.
1823
+ ' throw $_.Exception',
1824
+ ' }',
1297
1825
  " if ($fx_rest.Length -gt 0) { $fx_tail = $fx_app + ' ' + $fx_rest } else { $fx_tail = $fx_app }",
1298
- ' $psi.Arguments = \'/d /s /c "\' + $fx_tail + \'"\'',
1826
+ ' $psi.Arguments = \'/d /s /v:off /c "\' + $fx_tail + \'"\'',
1299
1827
  ' } else {',
1300
1828
  ' $psi.FileName = $app.Source',
1301
1829
  ' $psi.Arguments = fx-winargv $argv',
@@ -1306,35 +1834,40 @@ export function wrapScript(body, opts = {}) {
1306
1834
  ' $psi.RedirectStandardError = $true',
1307
1835
  ' $psi.CreateNoWindow = $true',
1308
1836
  ' $psi.WorkingDirectory = [Environment]::CurrentDirectory',
1309
- // StreamReader.ReadToEndAsync is .NET 4.5 (PS 5.1). Start readers
1310
- // before writing stdin so a chatty child cannot fill the 64KB pipe.
1837
+ // Drain both child pipes concurrently into disk-backed spools before
1838
+ // replaying them. This prevents either 64KB OS pipe from blocking the
1839
+ // child and avoids retaining the complete output in a .NET string.
1311
1840
  " if ($env:FAUXNIX_NATIVE_ENCODING -eq 'ansi') { $enc = [System.Text.Encoding]::GetEncoding(936) } else { $enc = New-Object System.Text.UTF8Encoding $false }",
1312
1841
  ' $psi.StandardOutputEncoding = $enc',
1313
1842
  ' $psi.StandardErrorEncoding = $enc',
1314
1843
  ' $p = New-Object System.Diagnostics.Process',
1315
1844
  ' $p.StartInfo = $psi',
1316
- ' [void]$p.Start()',
1317
- ' $outTask = $p.StandardOutput.ReadToEndAsync()',
1318
- ' $errTask = $p.StandardError.ReadToEndAsync()',
1319
- ' $ins = @($input)',
1320
- ' if ($ins.Count -gt 0) {',
1321
- ' foreach ($fx_ln in $ins) { $p.StandardInput.WriteLine([string]$fx_ln) }',
1322
- ' }',
1323
- ' $p.StandardInput.Close()',
1324
- ' [void][System.Threading.Tasks.Task]::WaitAll(@($outTask, $errTask))',
1325
- ' [void]$p.WaitForExit()',
1326
- ' $errt = [string]$errTask.Result',
1327
- ' if ($errt.Length -gt 0) { [Console]::Error.Write($errt) }',
1328
- ' $t = [string]$outTask.Result',
1329
- " $t = $t.Replace(([string][char]13 + [string][char]10), [string][char]10).Replace([string][char]13, [string][char]10)",
1330
- " if ($t -ne '') {",
1331
- ' $parts = @($t.Split([char]10))',
1332
- " if ($parts.Count -gt 0 -and $parts[$parts.Count - 1] -eq '') { $parts = $parts[0..($parts.Count - 2)] }",
1333
- ' foreach ($fx_ol in $parts) { $fx_ol }',
1845
+ ' $fx_no = $null',
1846
+ ' $fx_spoolUtf8 = New-Object System.Text.UTF8Encoding $false',
1847
+ ' try {',
1848
+ ' if (-not $term) {',
1849
+ " if ($env:FAUXNIX_NATIVE_SPOOL_DIR) { $fx_no = Join-Path $env:FAUXNIX_NATIVE_SPOOL_DIR (([guid]::NewGuid().ToString('N')) + '.out') }",
1850
+ ' else { $fx_no = [IO.Path]::GetTempFileName() }',
1851
+ ' }',
1852
+ ' [void]$p.Start()',
1853
+ ' if ($term) { $outTask = [FauxnixTextPump]::CopyAsync($p.StandardOutput, [Console]::Out) }',
1854
+ ' else { $outTask = [FauxnixTextPump]::CopyFileAsync($p.StandardOutput, $fx_no) }',
1855
+ ' $errTask = [FauxnixTextPump]::CopyAsync($p.StandardError, [Console]::Error)',
1856
+ ' foreach ($fx_ln in $input) { $p.StandardInput.WriteLine([string]$fx_ln) }',
1857
+ ' $p.StandardInput.Close()',
1858
+ ' [void][System.Threading.Tasks.Task]::WaitAll(@($outTask, $errTask))',
1859
+ ' [void]$p.WaitForExit()',
1860
+ ' if (-not $term) {',
1861
+ ' $fx_or = New-Object System.IO.StreamReader($fx_no, $fx_spoolUtf8)',
1862
+ ' try { while (($fx_line = $fx_or.ReadLine()) -ne $null) { $fx_line } }',
1863
+ ' finally { $fx_or.Dispose() }',
1864
+ ' }',
1865
+ ' $code = [int]$p.ExitCode',
1866
+ ' if ($code -gt 0) { $script:fx_exit = $code } elseif ($code -lt 0) { $script:fx_exit = 1 }',
1867
+ ' } finally {',
1868
+ ' try { $p.Close() } catch {}',
1869
+ ' if ($null -ne $fx_no) { Remove-Item -LiteralPath $fx_no -Force -ErrorAction SilentlyContinue }',
1334
1870
  ' }',
1335
- ' $code = [int]$p.ExitCode',
1336
- ' if ($code -gt 0) { $script:fx_exit = $code } elseif ($code -lt 0) { $script:fx_exit = 1 }',
1337
- ' try { $p.Close() } catch {}',
1338
1871
  '}',
1339
1872
  ],
1340
1873
  };
@@ -1354,7 +1887,7 @@ function wrapHelperCatalog() {
1354
1887
  }
1355
1888
  /**
1356
1889
  * Resident-host bootstrap: encoding + full fx-* catalog + JSON-line RPC loop.
1357
- * Loaded once via `powershell.exe -File`. Must never `exit` a successful frame.
1890
+ * Loaded once via the selected PowerShell's `-File`. Must never `exit` a successful frame.
1358
1891
  */
1359
1892
  export function hostBootstrapScript() {
1360
1893
  const helpers = wrapHelperCatalog();
@@ -1370,6 +1903,46 @@ export function hostBootstrapScript() {
1370
1903
  }
1371
1904
  /** Raw UTF-8 JSON lines on stdin/stdout; command streams captured per frame. */
1372
1905
  const HOST_RPC_LOOP = `
1906
+ if (-not ('FauxnixBoundedStream' -as [type])) {
1907
+ Add-Type -TypeDefinition @'
1908
+ using System;
1909
+ using System.IO;
1910
+ public sealed class FauxnixBoundedStream : Stream {
1911
+ private readonly MemoryStream inner;
1912
+ private readonly long limit;
1913
+ private readonly long storageLimit;
1914
+ private long totalWritten;
1915
+ public bool Truncated { get; private set; }
1916
+ public FauxnixBoundedStream(long limit) {
1917
+ this.limit = Math.Max(0, limit);
1918
+ this.storageLimit = this.limit + 3;
1919
+ this.inner = new MemoryStream((int)Math.Min(this.storageLimit, 65536));
1920
+ }
1921
+ public byte[] ToArray() { return inner.ToArray(); }
1922
+ public override bool CanRead { get { return false; } }
1923
+ public override bool CanSeek { get { return false; } }
1924
+ public override bool CanWrite { get { return true; } }
1925
+ public override long Length { get { return inner.Length; } }
1926
+ public override long Position { get { return inner.Position; } set { throw new NotSupportedException(); } }
1927
+ public override void Flush() { }
1928
+ public override int Read(byte[] buffer, int offset, int count) { throw new NotSupportedException(); }
1929
+ public override long Seek(long offset, SeekOrigin origin) { throw new NotSupportedException(); }
1930
+ public override void SetLength(long value) { throw new NotSupportedException(); }
1931
+ public override void Write(byte[] buffer, int offset, int count) {
1932
+ long remaining = Math.Max(0, storageLimit - inner.Length);
1933
+ int keep = (int)Math.Min((long)count, remaining);
1934
+ if (keep > 0) inner.Write(buffer, offset, keep);
1935
+ totalWritten += count;
1936
+ if (totalWritten > limit) Truncated = true;
1937
+ }
1938
+ public override void WriteByte(byte value) {
1939
+ if (inner.Length < storageLimit) inner.WriteByte(value);
1940
+ totalWritten++;
1941
+ if (totalWritten > limit) Truncated = true;
1942
+ }
1943
+ }
1944
+ '@
1945
+ }
1373
1946
  $fx_utf8 = New-Object System.Text.UTF8Encoding $false
1374
1947
  $fx_in = [Console]::OpenStandardInput()
1375
1948
  $fx_out = [Console]::OpenStandardOutput()
@@ -1389,8 +1962,8 @@ function fx-emit-chunks($type, $id, [byte[]]$bytes, $limit, [ref]$seq) {
1389
1962
  if ($null -ne $bytes) { $n = $bytes.Length }
1390
1963
  $use = $n
1391
1964
  $trunc = $false
1392
- # limit 0 = uncapped (file-redirected streams must never be budget-clipped)
1393
- if ($limit -gt 0 -and $use -gt $limit) {
1965
+ # limit -1 = uncapped; zero is a real empty caller budget
1966
+ if ($limit -ge 0 -and $use -gt $limit) {
1394
1967
  $use = $limit
1395
1968
  $trunc = $true
1396
1969
  # back the cut off to a valid UTF-8 boundary — a split codepoint makes
@@ -1416,6 +1989,18 @@ function fx-emit-chunks($type, $id, [byte[]]$bytes, $limit, [ref]$seq) {
1416
1989
  }
1417
1990
  return $trunc
1418
1991
  }
1992
+ function fx-new-capture($mode, $limit, $spoolPath) {
1993
+ if ([string]$mode -eq 'discard') { return [System.IO.Stream]::Null }
1994
+ if ([string]$mode -eq 'spool') {
1995
+ return (New-Object System.IO.FileStream([string]$spoolPath, [System.IO.FileMode]::Create, [System.IO.FileAccess]::Write, [System.IO.FileShare]::Read))
1996
+ }
1997
+ return (New-Object FauxnixBoundedStream ([Math]::Max(0, [long]$limit)))
1998
+ }
1999
+ function fx-capture-bytes($stream) {
2000
+ if ($stream -is [FauxnixBoundedStream]) { return $stream.ToArray() }
2001
+ if ($stream -is [System.IO.MemoryStream]) { return $stream.ToArray() }
2002
+ return (New-Object byte[] 0)
2003
+ }
1419
2004
  while ($true) {
1420
2005
  $fx_line = $fx_reader.ReadLine()
1421
2006
  if ($null -eq $fx_line) { break }
@@ -1443,12 +2028,24 @@ while ($true) {
1443
2028
  }
1444
2029
  }
1445
2030
  $fx_script = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String([string]$fx_req.scriptB64))
1446
- $fx_msOut = New-Object System.IO.MemoryStream
1447
- $fx_msErr = New-Object System.IO.MemoryStream
2031
+ $fx_outLimit = 8388608
2032
+ $fx_errLimit = 1048576
2033
+ if ($null -ne $fx_req.PSObject.Properties['stdoutLimit']) { $fx_outLimit = [int]$fx_req.stdoutLimit }
2034
+ if ($null -ne $fx_req.PSObject.Properties['stderrLimit']) { $fx_errLimit = [int]$fx_req.stderrLimit }
2035
+ $fx_outMode = 'capture'
2036
+ $fx_errMode = 'capture'
2037
+ $fx_outSpool = ''
2038
+ $fx_errSpool = ''
2039
+ if ($null -ne $fx_req.PSObject.Properties['stdoutMode']) { $fx_outMode = [string]$fx_req.stdoutMode }
2040
+ if ($null -ne $fx_req.PSObject.Properties['stderrMode']) { $fx_errMode = [string]$fx_req.stderrMode }
2041
+ if ($null -ne $fx_req.PSObject.Properties['stdoutSpoolPath']) { $fx_outSpool = [string]$fx_req.stdoutSpoolPath }
2042
+ if ($null -ne $fx_req.PSObject.Properties['stderrSpoolPath']) { $fx_errSpool = [string]$fx_req.stderrSpoolPath }
2043
+ $fx_msOut = fx-new-capture $fx_outMode $fx_outLimit $fx_outSpool
2044
+ $fx_msErr = fx-new-capture $fx_errMode $fx_errLimit $fx_errSpool
1448
2045
  $fx_outW = New-Object System.IO.StreamWriter($fx_msOut, $fx_utf8, 1024, $true)
1449
2046
  $fx_errW = New-Object System.IO.StreamWriter($fx_msErr, $fx_utf8, 1024, $true)
1450
- $fx_outW.NewLine = [string][char]13 + [string][char]10
1451
- $fx_errW.NewLine = [string][char]13 + [string][char]10
2047
+ $fx_outW.NewLine = [string][char]10
2048
+ $fx_errW.NewLine = [string][char]10
1452
2049
  $fx_outW.AutoFlush = $true
1453
2050
  $fx_errW.AutoFlush = $true
1454
2051
  [Console]::SetOut($fx_outW)
@@ -1476,24 +2073,29 @@ while ($true) {
1476
2073
  }
1477
2074
  $fx_outBytes = New-Object byte[] 0
1478
2075
  $fx_errBytes = New-Object byte[] 0
1479
- if ($null -ne $fx_msOut) { $fx_outBytes = $fx_msOut.ToArray() }
1480
- if ($null -ne $fx_msErr) { $fx_errBytes = $fx_msErr.ToArray() }
2076
+ if ($null -ne $fx_msOut) { $fx_outBytes = fx-capture-bytes $fx_msOut }
2077
+ if ($null -ne $fx_msErr) { $fx_errBytes = fx-capture-bytes $fx_msErr }
2078
+ try { if ($null -ne $fx_outW) { $fx_outW.Dispose() } } catch {}
2079
+ try { if ($null -ne $fx_errW) { $fx_errW.Dispose() } } catch {}
2080
+ try { if ($null -ne $fx_msOut -and $fx_msOut -ne [System.IO.Stream]::Null) { $fx_msOut.Dispose() } } catch {}
2081
+ try { if ($null -ne $fx_msErr -and $fx_msErr -ne [System.IO.Stream]::Null) { $fx_msErr.Dispose() } } catch {}
1481
2082
  if ($fx_v2) {
1482
- $fx_outLimit = 8388608
1483
- $fx_errLimit = 1048576
1484
- if ($null -ne $fx_req.PSObject.Properties['stdoutLimit']) { $fx_outLimit = [int]$fx_req.stdoutLimit }
1485
- if ($null -ne $fx_req.PSObject.Properties['stderrLimit']) { $fx_errLimit = [int]$fx_req.stderrLimit }
1486
2083
  $fx_outSeq = 0
1487
2084
  $fx_errSeq = 0
1488
- $fx_trunc = $false
1489
- if (fx-emit-chunks 'stdout' $fx_id $fx_outBytes $fx_outLimit ([ref]$fx_outSeq)) { $fx_trunc = $true }
1490
- if (fx-emit-chunks 'stderr' $fx_id $fx_errBytes $fx_errLimit ([ref]$fx_errSeq)) { $fx_trunc = $true }
2085
+ $fx_outTrunc = $false
2086
+ $fx_errTrunc = $false
2087
+ if ($fx_msOut -is [FauxnixBoundedStream] -and $fx_msOut.Truncated) { $fx_outTrunc = $true }
2088
+ if ($fx_msErr -is [FauxnixBoundedStream] -and $fx_msErr.Truncated) { $fx_errTrunc = $true }
2089
+ $fx_outEmitLimit = $(if ($fx_outMode -eq 'capture') { $fx_outLimit } else { -1 })
2090
+ $fx_errEmitLimit = $(if ($fx_errMode -eq 'capture') { $fx_errLimit } else { -1 })
2091
+ if (fx-emit-chunks 'stdout' $fx_id $fx_outBytes $fx_outEmitLimit ([ref]$fx_outSeq)) { $fx_outTrunc = $true }
2092
+ if (fx-emit-chunks 'stderr' $fx_id $fx_errBytes $fx_errEmitLimit ([ref]$fx_errSeq)) { $fx_errTrunc = $true }
1491
2093
  $fx_nativeErr = [Console]::OpenStandardError()
1492
2094
  $fx_mark = $fx_utf8.GetBytes(('FAUXNIX_ERR_END:' + $fx_id + [char]10))
1493
2095
  $fx_nativeErr.Write($fx_mark, 0, $fx_mark.Length)
1494
2096
  $fx_nativeErr.Flush()
1495
- $fx_end = '{"v":2,"type":"end","id":"' + $fx_id + '","exitCode":' + $fx_code + ',"timedOut":false,"cancelled":false,"truncated":'
1496
- if ($fx_trunc) { $fx_end = $fx_end + 'true}' } else { $fx_end = $fx_end + 'false}' }
2097
+ $fx_trunc = $fx_outTrunc -or $fx_errTrunc
2098
+ $fx_end = '{"v":2,"type":"end","id":"' + $fx_id + '","exitCode":' + $fx_code + ',"timedOut":false,"cancelled":false,"truncated":' + ([string]$fx_trunc).ToLowerInvariant() + ',"stdoutTruncated":' + ([string]$fx_outTrunc).ToLowerInvariant() + ',"stderrTruncated":' + ([string]$fx_errTrunc).ToLowerInvariant() + '}'
1497
2099
  $fx_proto.WriteLine($fx_end)
1498
2100
  } else {
1499
2101
  $fx_outB64 = ''