fauxnix-cli 0.11.0 → 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
  /* ------------------------------------------------------------------ */
@@ -421,8 +595,8 @@ export function argListExpr(words, fn = exprOfWord) {
421
595
  * Lists (`;` `&&` `||`) reuse translateListInline inside the fx-csub
422
596
  * scriptblock so the newline contract is unchanged.
423
597
  */
424
- export function translateCmdSub(cmdText, keepNl = false) {
425
- const inner = translateListInline(parseCommand(cmdText));
598
+ export function translateCmdSub(cmdText, keepNl = false, translation = EXECUTE_TRANSLATION) {
599
+ const inner = translateListInline(parseCommand(cmdText), translation);
426
600
  const collected = '(fx-csub { ' + inner + ' })';
427
601
  if (keepNl)
428
602
  return collected;
@@ -439,7 +613,10 @@ function indentBlock(s) {
439
613
  .map((l) => (l ? ' ' + l : l))
440
614
  .join('\n');
441
615
  }
442
- 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
+ '))';
443
620
  // assignment-only segment (`X=1; cmd`): bash semantics are "set for the
444
621
  // rest of the shell". Reuse the export code path — persist + env shadow —
445
622
  // so empty values (`X=`) and `[[ -v X ]]` behave like bash (documented
@@ -456,8 +633,9 @@ export function translateSimple(cmd, position, hasStdin) {
456
633
  }
457
634
  else {
458
635
  const words = [[{ kind: 'Text', text: a.name + '=' }, ...a.value]];
459
- if (exportHandler)
460
- chunks.push(exportHandler(words, { position, hasStdin }));
636
+ if (exportHandler) {
637
+ chunks.push(exportHandler(words, { position, hasStdin, translationMode: translation.mode }));
638
+ }
461
639
  }
462
640
  }
463
641
  return chunks.join('\n');
@@ -475,7 +653,7 @@ export function translateSimple(cmd, position, hasStdin) {
475
653
  name: cmd.args[0],
476
654
  args: cmd.args.slice(1),
477
655
  redirects: cmd.redirects,
478
- }, position, hasStdin);
656
+ }, position, hasStdin, translation);
479
657
  const emptyCmdLines = [
480
658
  '$fx_cw = @(' + splatLoadCall(nameSplat.name) + ')',
481
659
  ];
@@ -491,20 +669,23 @@ export function translateSimple(cmd, position, hasStdin) {
491
669
  emptyCmdLines.push('if ($fx_cw.Count -eq 0) {',
492
670
  // No words left → bash null command (exit 0). Remaining words are
493
671
  // known at compile time, so reuse translateSimple (handlers, not `&`).
494
- 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), '}');
495
676
  body = emptyCmdLines.join('\n');
496
677
  }
497
678
  else if (nameLit !== null) {
498
679
  const handler = lookup(nameLit);
499
680
  if (handler && !(nameLit === '[[' && !isUnquotedLiteral(cmd.name, '[['))) {
500
- body = handler(cmd.args, { position, hasStdin });
681
+ body = handler(cmd.args, { position, hasStdin, translationMode: translation.mode });
501
682
  }
502
683
  else {
503
684
  // passthrough: native command (git, node, npm, python, cargo, ...)
504
685
  // via fx-native (Win32 command line + Process). `& name @array` on
505
686
  // PS 5.1 drops empty argv entries and eats embedded quotes.
506
687
  const nameExpr = psStr(nameLit);
507
- const invoke = 'fx-native ' + nameExpr + ' $fx_na';
688
+ const invoke = 'fx-native ' + nameExpr + ' $fx_na ' + nativeTerm;
508
689
  body = [
509
690
  '$fx_na = [object[]](' + argListExpr(cmd.args) + ')',
510
691
  (hasStdin ? '($input | ' + invoke + ')' : invoke),
@@ -514,7 +695,7 @@ export function translateSimple(cmd, position, hasStdin) {
514
695
  else {
515
696
  // dynamic command name — evaluate it
516
697
  const nameExpr = exprOfWord(cmd.name);
517
- const invoke = 'fx-native (' + nameExpr + ') $fx_na';
698
+ const invoke = 'fx-native (' + nameExpr + ') $fx_na ' + nativeTerm;
518
699
  body = [
519
700
  '$fx_na = [object[]](' + argListExpr(cmd.args) + ')',
520
701
  (hasStdin ? '($input | ' + invoke + ')' : invoke),
@@ -807,10 +988,10 @@ let pipelineSeq = 0;
807
988
  * multi-command pipelines become generated functions chained with `|`
808
989
  * (PS 5.1 forbids parenthesized expressions as non-first pipeline elements).
809
990
  */
810
- function translateListInline(list) {
991
+ function translateListInline(list, translation = EXECUTE_TRANSLATION) {
811
992
  const chunks = [];
812
993
  for (const seg of list.segments) {
813
- const { defs, call } = translatePipelineBody(seg.pipeline);
994
+ const { defs, call } = translatePipelineBody(seg.pipeline, translation);
814
995
  const body = (defs ? defs + '\n' : '') + call;
815
996
  if (seg.op === '&&') {
816
997
  chunks.push('if ($script:fx_exit -eq 0) {\n' + body + '\n}');
@@ -824,29 +1005,29 @@ function translateListInline(list) {
824
1005
  }
825
1006
  return chunks.join('\n');
826
1007
  }
827
- function translateIf(cmd) {
1008
+ function translateIf(cmd, translation) {
828
1009
  // Branch bodies reset fx_exit first: the compound's exit status must come
829
1010
  // from the taken branch's last command (bash semantics), not leak the test's
830
1011
  // failure — `if false; then A; else B; fi` exits 0 in bash.
831
- const lines = [translateListInline(cmd.test), 'if ($script:fx_exit -eq 0) {', ' $script:fx_exit = 0'];
832
- 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'))
833
1014
  lines.push(l ? ' ' + l : l);
834
1015
  lines.push('} else {', ' $script:fx_exit = 0');
835
1016
  if (cmd.else) {
836
- for (const l of translateListInline(cmd.else).split('\n'))
1017
+ for (const l of translateListInline(cmd.else, translation).split('\n'))
837
1018
  lines.push(l ? ' ' + l : l);
838
1019
  }
839
1020
  lines.push('}');
840
1021
  return lines.join('\n');
841
1022
  }
842
- function translateCase(cmd) {
1023
+ function translateCase(cmd, translation) {
843
1024
  const lines = ['$script:fx_exit = 0', '$fx_cw = ' + exprOfWord(cmd.word)];
844
1025
  for (let i = 0; i < cmd.arms.length; i++) {
845
1026
  const arm = cmd.arms[i];
846
1027
  const pats = arm.patterns.map((w) => exprOfWord(w)).join(',');
847
1028
  const head = i === 0 ? 'if' : 'elseif';
848
1029
  lines.push(head + ' (fx-casematch $fx_cw @(' + pats + ')) {');
849
- const body = translateListInline(arm.body);
1030
+ const body = translateListInline(arm.body, translation);
850
1031
  if (body) {
851
1032
  for (const l of body.split('\n'))
852
1033
  lines.push(l ? ' ' + l : l);
@@ -855,7 +1036,7 @@ function translateCase(cmd) {
855
1036
  }
856
1037
  return lines.join('\n');
857
1038
  }
858
- function translateFor(cmd) {
1039
+ function translateFor(cmd, translation) {
859
1040
  const n = cmd.name.replace(/'/g, "''");
860
1041
  const lines = [
861
1042
  '$fx_for = ' + argListExpr(cmd.words),
@@ -877,22 +1058,22 @@ function translateFor(cmd) {
877
1058
  n +
878
1059
  "' + [string][char]61 + (fx-svenc ([string]$fx_it))); $env:FAUXNIX_SETVALS = ($fx_sv -join [string][char]10)",
879
1060
  ];
880
- for (const l of translateListInline(cmd.body).split('\n'))
1061
+ for (const l of translateListInline(cmd.body, translation).split('\n'))
881
1062
  lines.push(l ? ' ' + l : l);
882
1063
  lines.push('}');
883
1064
  return lines.join('\n');
884
1065
  }
885
- function translateWhile(cmd) {
1066
+ function translateWhile(cmd, translation) {
886
1067
  // Bash: last executed body owns status; a test that ends the loop does not.
887
1068
  // Never-entered loops (`while false; do …; done`, `until true; do …; done`)
888
1069
  // exit 0. Save the body status and restore it on the failing test.
889
1070
  const fail = cmd.until ? '$script:fx_exit -eq 0' : '$script:fx_exit -ne 0';
890
1071
  const lines = ['$fx_wst = 0', 'do {'];
891
- for (const l of translateListInline(cmd.test).split('\n'))
1072
+ for (const l of translateListInline(cmd.test, translation).split('\n'))
892
1073
  lines.push(l ? ' ' + l : l);
893
1074
  lines.push(' if (' + fail + ') { $script:fx_exit = $fx_wst; break }');
894
1075
  lines.push(' $script:fx_exit = 0');
895
- for (const l of translateListInline(cmd.body).split('\n'))
1076
+ for (const l of translateListInline(cmd.body, translation).split('\n'))
896
1077
  lines.push(l ? ' ' + l : l);
897
1078
  lines.push(' $fx_wst = $script:fx_exit');
898
1079
  lines.push('} while ($true)');
@@ -917,19 +1098,41 @@ function isStdoutFileRedirect(op) {
917
1098
  return op === '>' || op === '>>' || op === '&>' || op === '&>>';
918
1099
  }
919
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)';
920
- function rejectNonLastStdoutRedirects(commands) {
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) {
921
1117
  if (commands.length < 2)
922
1118
  return;
923
1119
  for (let i = 0; i < commands.length - 1; i++) {
924
- if (commands[i].redirects.some((r) => isStdoutFileRedirect(r.op))) {
925
- throw new FauxnixParseError(NONLAST_STDOUT_REDIRECT_MSG);
1120
+ for (const redirect of commands[i].redirects) {
1121
+ const message = nonLastFdRedirectMessage(redirect.op);
1122
+ if (message)
1123
+ throw new FauxnixParseError(message);
926
1124
  }
927
1125
  }
928
1126
  }
929
- export function translatePipelineBody(p) {
930
- // Last-stage `>` is Node apply; a non-last `>` would truncate the file and
931
- // still feed the pipe. Fail loud until in-stage writes (#157).
932
- rejectNonLastStdoutRedirects(p.commands);
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);
933
1136
  // Every pipeline stage needs its own status slot. Handlers deliberately use
934
1137
  // `$script:fx_exit` because their helper functions run in child scopes; in a
935
1138
  // pipeline that shared flag lets an earlier failure leak into a successful
@@ -942,15 +1145,15 @@ export function translatePipelineBody(p) {
942
1145
  const hasStdin = i > 0 || c.redirects.some((r) => r.op === '<');
943
1146
  const position = i === 0 ? 'first' : i === p.commands.length - 1 ? 'last' : 'middle';
944
1147
  if (c.kind === 'If')
945
- bodies.push(translateIf(c));
1148
+ bodies.push(translateIf(c, translation));
946
1149
  else if (c.kind === 'For')
947
- bodies.push(translateFor(c));
1150
+ bodies.push(translateFor(c, translation));
948
1151
  else if (c.kind === 'While')
949
- bodies.push(translateWhile(c));
1152
+ bodies.push(translateWhile(c, translation));
950
1153
  else if (c.kind === 'Case')
951
- bodies.push(translateCase(c));
1154
+ bodies.push(translateCase(c, translation));
952
1155
  else
953
- bodies.push(translateSimple(c, position, hasStdin));
1156
+ bodies.push(translateSimple(c, position, hasStdin, translation));
954
1157
  }
955
1158
  if (bodies.length === 1) {
956
1159
  return { defs: '', call: '(& {\n' + bodies[0] + '\n})' };
@@ -1009,7 +1212,7 @@ export function translatePipelineBody(p) {
1009
1212
  ].join('\n'));
1010
1213
  return { defs: defs.join('\n'), call: pipelineName };
1011
1214
  }
1012
- export function translateCommandList(list) {
1215
+ export function translateCommandList(list, translation = EXECUTE_TRANSLATION) {
1013
1216
  const plans = [];
1014
1217
  for (const seg of list.segments) {
1015
1218
  const cmds = seg.pipeline.commands;
@@ -1020,7 +1223,7 @@ export function translateCommandList(list) {
1020
1223
  const stdinRedirects = cmds.length
1021
1224
  ? cmds[0].redirects.filter((r) => r.op === '<')
1022
1225
  : [];
1023
- const { defs, call } = translatePipelineBody(seg.pipeline);
1226
+ const { defs, call } = translatePipelineBody(seg.pipeline, translation);
1024
1227
  let body = defs ? defs + '\n' + call : call;
1025
1228
  // First-stage `< file` feeds stage zero via FAUXNIX_STDIN_FILE.
1026
1229
  // Later-stage `<` is owned inside the pipeline body, not this wrapper.
@@ -1070,6 +1273,7 @@ const WRAP_HELPER_ORDER = [
1070
1273
  'fx-subst',
1071
1274
  'fx-slice',
1072
1275
  'fx-winargv',
1276
+ 'fx-cmdargv',
1073
1277
  'fx-native',
1074
1278
  ];
1075
1279
  const WRAP_HELPER_DEPS = {
@@ -1095,7 +1299,8 @@ const WRAP_HELPER_DEPS = {
1095
1299
  'fx-subst': [],
1096
1300
  'fx-slice': [],
1097
1301
  'fx-winargv': [],
1098
- 'fx-native': ['fx-winargv'],
1302
+ 'fx-cmdargv': ['fx-winargv'],
1303
+ 'fx-native': ['fx-cmdargv'],
1099
1304
  };
1100
1305
  /** Helpers the body calls that wrapScript still has to emit (not already defined there). */
1101
1306
  function wrapHelpersNeeded(body) {
@@ -1529,8 +1734,48 @@ export function wrapScript(body, opts = {}) {
1529
1734
  " return (($parts.ToArray()) -join ' ')",
1530
1735
  '}',
1531
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
+ ],
1532
1754
  'fx-native': [
1533
- '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) {',
1534
1779
  ' if ($null -eq $argv) { $argv = @() } else { $argv = [object[]]@($argv) }',
1535
1780
  ' $app = Get-Command -Name $name -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1',
1536
1781
  ' if ($null -eq $app) {',
@@ -1559,8 +1804,8 @@ export function wrapScript(body, opts = {}) {
1559
1804
  ' $psi = New-Object System.Diagnostics.ProcessStartInfo',
1560
1805
  ' $ext = [IO.Path]::GetExtension([string]$app.Source)',
1561
1806
  // CreateProcess cannot launch .cmd/.bat with UseShellExecute=false (npm.cmd).
1562
- // /s strips one outer quote pair from the /c tail; CRT-quote cmd
1563
- // 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.
1564
1809
  " if ($ext -eq '.cmd' -or $ext -eq '.bat') {",
1565
1810
  ' $comspec = Get-Command -Name cmd -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1',
1566
1811
  ' if ($null -eq $comspec) {',
@@ -1569,10 +1814,16 @@ export function wrapScript(body, opts = {}) {
1569
1814
  ' return',
1570
1815
  ' }',
1571
1816
  ' $psi.FileName = $comspec.Source',
1572
- ' $fx_app = fx-winargv $app.Source $true',
1573
- ' $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
+ ' }',
1574
1825
  " if ($fx_rest.Length -gt 0) { $fx_tail = $fx_app + ' ' + $fx_rest } else { $fx_tail = $fx_app }",
1575
- ' $psi.Arguments = \'/d /s /c "\' + $fx_tail + \'"\'',
1826
+ ' $psi.Arguments = \'/d /s /v:off /c "\' + $fx_tail + \'"\'',
1576
1827
  ' } else {',
1577
1828
  ' $psi.FileName = $app.Source',
1578
1829
  ' $psi.Arguments = fx-winargv $argv',
@@ -1583,35 +1834,40 @@ export function wrapScript(body, opts = {}) {
1583
1834
  ' $psi.RedirectStandardError = $true',
1584
1835
  ' $psi.CreateNoWindow = $true',
1585
1836
  ' $psi.WorkingDirectory = [Environment]::CurrentDirectory',
1586
- // StreamReader.ReadToEndAsync is .NET 4.5 (PS 5.1). Start readers
1587
- // 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.
1588
1840
  " if ($env:FAUXNIX_NATIVE_ENCODING -eq 'ansi') { $enc = [System.Text.Encoding]::GetEncoding(936) } else { $enc = New-Object System.Text.UTF8Encoding $false }",
1589
1841
  ' $psi.StandardOutputEncoding = $enc',
1590
1842
  ' $psi.StandardErrorEncoding = $enc',
1591
1843
  ' $p = New-Object System.Diagnostics.Process',
1592
1844
  ' $p.StartInfo = $psi',
1593
- ' [void]$p.Start()',
1594
- ' $outTask = $p.StandardOutput.ReadToEndAsync()',
1595
- ' $errTask = $p.StandardError.ReadToEndAsync()',
1596
- ' $ins = @($input)',
1597
- ' if ($ins.Count -gt 0) {',
1598
- ' foreach ($fx_ln in $ins) { $p.StandardInput.WriteLine([string]$fx_ln) }',
1599
- ' }',
1600
- ' $p.StandardInput.Close()',
1601
- ' [void][System.Threading.Tasks.Task]::WaitAll(@($outTask, $errTask))',
1602
- ' [void]$p.WaitForExit()',
1603
- ' $errt = [string]$errTask.Result',
1604
- ' if ($errt.Length -gt 0) { [Console]::Error.Write($errt) }',
1605
- ' $t = [string]$outTask.Result',
1606
- " $t = $t.Replace(([string][char]13 + [string][char]10), [string][char]10).Replace([string][char]13, [string][char]10)",
1607
- " if ($t -ne '') {",
1608
- ' $parts = @($t.Split([char]10))',
1609
- " if ($parts.Count -gt 0 -and $parts[$parts.Count - 1] -eq '') { $parts = $parts[0..($parts.Count - 2)] }",
1610
- ' 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 }',
1611
1870
  ' }',
1612
- ' $code = [int]$p.ExitCode',
1613
- ' if ($code -gt 0) { $script:fx_exit = $code } elseif ($code -lt 0) { $script:fx_exit = 1 }',
1614
- ' try { $p.Close() } catch {}',
1615
1871
  '}',
1616
1872
  ],
1617
1873
  };
@@ -1631,7 +1887,7 @@ function wrapHelperCatalog() {
1631
1887
  }
1632
1888
  /**
1633
1889
  * Resident-host bootstrap: encoding + full fx-* catalog + JSON-line RPC loop.
1634
- * 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.
1635
1891
  */
1636
1892
  export function hostBootstrapScript() {
1637
1893
  const helpers = wrapHelperCatalog();
@@ -1647,6 +1903,46 @@ export function hostBootstrapScript() {
1647
1903
  }
1648
1904
  /** Raw UTF-8 JSON lines on stdin/stdout; command streams captured per frame. */
1649
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
+ }
1650
1946
  $fx_utf8 = New-Object System.Text.UTF8Encoding $false
1651
1947
  $fx_in = [Console]::OpenStandardInput()
1652
1948
  $fx_out = [Console]::OpenStandardOutput()
@@ -1666,8 +1962,8 @@ function fx-emit-chunks($type, $id, [byte[]]$bytes, $limit, [ref]$seq) {
1666
1962
  if ($null -ne $bytes) { $n = $bytes.Length }
1667
1963
  $use = $n
1668
1964
  $trunc = $false
1669
- # limit 0 = uncapped (file-redirected streams must never be budget-clipped)
1670
- 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) {
1671
1967
  $use = $limit
1672
1968
  $trunc = $true
1673
1969
  # back the cut off to a valid UTF-8 boundary — a split codepoint makes
@@ -1693,6 +1989,18 @@ function fx-emit-chunks($type, $id, [byte[]]$bytes, $limit, [ref]$seq) {
1693
1989
  }
1694
1990
  return $trunc
1695
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
+ }
1696
2004
  while ($true) {
1697
2005
  $fx_line = $fx_reader.ReadLine()
1698
2006
  if ($null -eq $fx_line) { break }
@@ -1720,12 +2028,24 @@ while ($true) {
1720
2028
  }
1721
2029
  }
1722
2030
  $fx_script = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String([string]$fx_req.scriptB64))
1723
- $fx_msOut = New-Object System.IO.MemoryStream
1724
- $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
1725
2045
  $fx_outW = New-Object System.IO.StreamWriter($fx_msOut, $fx_utf8, 1024, $true)
1726
2046
  $fx_errW = New-Object System.IO.StreamWriter($fx_msErr, $fx_utf8, 1024, $true)
1727
- $fx_outW.NewLine = [string][char]13 + [string][char]10
1728
- $fx_errW.NewLine = [string][char]13 + [string][char]10
2047
+ $fx_outW.NewLine = [string][char]10
2048
+ $fx_errW.NewLine = [string][char]10
1729
2049
  $fx_outW.AutoFlush = $true
1730
2050
  $fx_errW.AutoFlush = $true
1731
2051
  [Console]::SetOut($fx_outW)
@@ -1753,24 +2073,29 @@ while ($true) {
1753
2073
  }
1754
2074
  $fx_outBytes = New-Object byte[] 0
1755
2075
  $fx_errBytes = New-Object byte[] 0
1756
- if ($null -ne $fx_msOut) { $fx_outBytes = $fx_msOut.ToArray() }
1757
- 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 {}
1758
2082
  if ($fx_v2) {
1759
- $fx_outLimit = 8388608
1760
- $fx_errLimit = 1048576
1761
- if ($null -ne $fx_req.PSObject.Properties['stdoutLimit']) { $fx_outLimit = [int]$fx_req.stdoutLimit }
1762
- if ($null -ne $fx_req.PSObject.Properties['stderrLimit']) { $fx_errLimit = [int]$fx_req.stderrLimit }
1763
2083
  $fx_outSeq = 0
1764
2084
  $fx_errSeq = 0
1765
- $fx_trunc = $false
1766
- if (fx-emit-chunks 'stdout' $fx_id $fx_outBytes $fx_outLimit ([ref]$fx_outSeq)) { $fx_trunc = $true }
1767
- 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 }
1768
2093
  $fx_nativeErr = [Console]::OpenStandardError()
1769
2094
  $fx_mark = $fx_utf8.GetBytes(('FAUXNIX_ERR_END:' + $fx_id + [char]10))
1770
2095
  $fx_nativeErr.Write($fx_mark, 0, $fx_mark.Length)
1771
2096
  $fx_nativeErr.Flush()
1772
- $fx_end = '{"v":2,"type":"end","id":"' + $fx_id + '","exitCode":' + $fx_code + ',"timedOut":false,"cancelled":false,"truncated":'
1773
- 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() + '}'
1774
2099
  $fx_proto.WriteLine($fx_end)
1775
2100
  } else {
1776
2101
  $fx_outB64 = ''