fauxnix-cli 0.9.1 → 0.9.3

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.
@@ -511,7 +511,9 @@ const head = (args, ctx) => {
511
511
  lines.push(STDIN_INLINES);
512
512
  lines.push(...psCollectFiles(operandWords, (g) => qErr('head', g, 'No such file or directory'), (g) => qErr('head', g, 'Is a directory', 'error reading ')), '$fx_count = [int](' + countLit + ')', '$fx_hdr = ((($fx_srcs.Count -gt 1) -and ' + pb(!quiet) + ') -or ' + pb(verbose) + ')', '$fx_first = $true');
513
513
  if (bytesMode) {
514
- lines.push('$fx_out = New-Object System.Text.StringBuilder', 'for ($fx_k = 0; $fx_k -lt $fx_srcs.Count; $fx_k++) {', ' $fx_g = $fx_srcs[$fx_k]', " if ($fx_g -eq '-') { $fx_txt = (fx-stdinraw $fx_items); $fx_disp = 'standard input' }", ' else { $fx_txt = fx-read $fx_g; $fx_disp = $fx_names[$fx_k] }', ' if ($fx_hdr) {', ' if (-not $fx_first) { [void]$fx_out.Append([string][char]10) }', " [void]$fx_out.Append('==> ' + $fx_disp + ' <==' + [string][char]10)", ' }', ' $fx_first = $false', ' $fx_len = [math]::Min($fx_count, $fx_txt.Length)', ' if ($fx_len -lt 0) { $fx_len = 0 }', ' if ($fx_len -gt 0) { [void]$fx_out.Append($fx_txt.Substring(0, $fx_len)) }', '}', 'fx-write $fx_out.ToString() $fx_term');
514
+ lines.push('$fx_out = New-Object System.Text.StringBuilder', 'for ($fx_k = 0; $fx_k -lt $fx_srcs.Count; $fx_k++) {', ' $fx_g = $fx_srcs[$fx_k]', " if ($fx_g -eq '-') { $fx_txt = (fx-stdinraw $fx_items); $fx_disp = 'standard input' }", ' else { $fx_txt = fx-read $fx_g; $fx_disp = $fx_names[$fx_k] }', ' if ($fx_hdr) {', ' if (-not $fx_first) { [void]$fx_out.Append([string][char]10) }', " [void]$fx_out.Append('==> ' + $fx_disp + ' <==' + [string][char]10)", ' }', ' $fx_first = $false',
515
+ // GNU: --bytes=-N prints all but last N; -0 / N≥size → empty
516
+ ' if ($fx_count -lt 0) { $fx_len = [math]::Max(0, $fx_txt.Length + $fx_count) }', ' else { $fx_len = [math]::Min($fx_count, $fx_txt.Length) }', ' if ($fx_len -gt 0) { [void]$fx_out.Append($fx_txt.Substring(0, $fx_len)) }', '}', 'fx-write $fx_out.ToString() $fx_term');
515
517
  }
516
518
  else {
517
519
  lines.push('for ($fx_k = 0; $fx_k -lt $fx_srcs.Count; $fx_k++) {', ' $fx_g = $fx_srcs[$fx_k]', " if ($fx_g -eq '-') { $fx_ls = @($fx_in); $fx_disp = 'standard input' }", ' else { $fx_ls = @(fx-splitlines (fx-read $fx_g)); $fx_disp = $fx_names[$fx_k] }', ' if ($fx_hdr) {', " if (-not $fx_first) { '' }", " '==> ' + $fx_disp + ' <=='", ' }', ' $fx_first = $false', ' $fx_lim = $fx_ls.Count', ' if ($fx_count -lt 0) { $fx_lim = $fx_ls.Count + $fx_count }', ' elseif ($fx_lim -gt $fx_count) { $fx_lim = $fx_count }', ' if ($fx_lim -lt 0) { $fx_lim = 0 }', ' for ($fx_i = 0; $fx_i -lt $fx_lim; $fx_i++) { $fx_ls[$fx_i] }', '}');
@@ -1091,6 +1093,9 @@ const xargs = (args) => {
1091
1093
  noRunIfEmpty = true;
1092
1094
  else if (ch === 't')
1093
1095
  trace = true;
1096
+ else if (ch === '0') {
1097
+ return psErrExpr(psStr('xargs: -0 is not supported by fauxnix'));
1098
+ }
1094
1099
  else if (ch === 'n' || ch === 'I' || ch === 'L') {
1095
1100
  const restv = body.slice(c + 1);
1096
1101
  let val;
@@ -1138,12 +1143,12 @@ const xargs = (args) => {
1138
1143
  }
1139
1144
  const n = chunkN !== null && Number.isFinite(chunkN) && chunkN > 0 ? chunkN : 0;
1140
1145
  const replExpr = repl !== null ? psStr(repl) : null;
1146
+ // fx-native already records $script:fx_exit from ExitCode.
1141
1147
  const invoke = [
1142
1148
  ' if (' +
1143
1149
  pb(trace) +
1144
1150
  ") { [Console]::Error.WriteLine(((@($fx_cmd) + @($fx_argv)) -join ' ')) }",
1145
- ' & $fx_cmd @fx_argv',
1146
- ' if ($LASTEXITCODE -ne 0 -and $script:fx_exit -eq 0) { $script:fx_exit = $LASTEXITCODE }',
1151
+ ' fx-native $fx_cmd $fx_argv',
1147
1152
  ];
1148
1153
  let dispatch;
1149
1154
  const guard = ' if (' + pb(noRunIfEmpty) + ' -and $fx_args.Count -eq 0) { }' + '\n' + ' else {';
@@ -1152,7 +1157,7 @@ const xargs = (args) => {
1152
1157
  dispatch = [
1153
1158
  guard,
1154
1159
  ' foreach ($fx_l in $fx_args) {',
1155
- ' $fx_argv = @()',
1160
+ ' $fx_argv = [object[]]@()',
1156
1161
  ' $fx_hit = $false',
1157
1162
  ' foreach ($fx_a in $fx_base) {',
1158
1163
  ' if ($fx_a.Contains(' + replExpr + ')) {',
@@ -1173,7 +1178,7 @@ const xargs = (args) => {
1173
1178
  ' $fx_i = 0',
1174
1179
  ' $fx_ran = $false',
1175
1180
  ' while ($fx_i -lt $fx_args.Count) {',
1176
- ' $fx_argv = @($fx_base)',
1181
+ ' $fx_argv = [object[]]@($fx_base)',
1177
1182
  ' $fx_j = 0',
1178
1183
  ' while ($fx_j -lt ' + n + ' -and $fx_i -lt $fx_args.Count) {',
1179
1184
  ' $fx_argv += $fx_args[$fx_i]',
@@ -1183,10 +1188,8 @@ const xargs = (args) => {
1183
1188
  ...invoke,
1184
1189
  ' }',
1185
1190
  ' if (-not $fx_ran) {',
1186
- ' $fx_argv = @($fx_base)',
1187
- ' ' + invoke[0],
1188
- ' ' + invoke[1],
1189
- ' ' + invoke[2],
1191
+ ' $fx_argv = [object[]]@($fx_base)',
1192
+ ...invoke.map((line) => ' ' + line),
1190
1193
  ' }',
1191
1194
  ' }',
1192
1195
  ];
@@ -1194,17 +1197,27 @@ const xargs = (args) => {
1194
1197
  else {
1195
1198
  dispatch = [
1196
1199
  guard,
1197
- ' $fx_argv = @($fx_base) + @($fx_args)',
1200
+ ' $fx_argv = [object[]](@($fx_base) + @($fx_args))',
1198
1201
  ...invoke,
1199
1202
  ' }',
1200
1203
  ];
1201
1204
  }
1205
+ // Default GNU xargs splits on blanks; -I keeps whole lines as one item.
1206
+ const collectArgs = replExpr !== null
1207
+ ? "$fx_args = @($fx_in | Where-Object { $_ -ne '' })"
1208
+ : [
1209
+ '$fx_args = @()',
1210
+ 'foreach ($fx_l in $fx_in) {',
1211
+ " if ($fx_l -eq '') { continue }",
1212
+ " $fx_args += @($fx_l -split '[ \\t]+' | Where-Object { $_ -ne '' })",
1213
+ '}',
1214
+ ].join('\n');
1202
1215
  return [
1203
1216
  PS_SPLITLINES_FN,
1204
1217
  STDIN_INLINES,
1205
1218
  '$fx_tg = ' + argListExpr(target, exprOfWord),
1206
1219
  "if ($fx_tg.Count -eq 0) { $fx_cmd = ''; $fx_base = @() } else { $fx_cmd = [string]$fx_tg[0]; $fx_base = $(if ($fx_tg.Count -gt 1) { @($fx_tg[1..($fx_tg.Count - 1)]) } else { @() }) }",
1207
- "$fx_args = @($fx_in | Where-Object { $_ -ne '' })",
1220
+ collectArgs,
1208
1221
  ...dispatch,
1209
1222
  ].join('\n');
1210
1223
  };
@@ -1234,6 +1247,74 @@ export const specs = [
1234
1247
  dispatch: 'translated',
1235
1248
  handler: head,
1236
1249
  },
1250
+ {
1251
+ names: ['echo'],
1252
+ options: [
1253
+ { short: 'n', support: 'implemented' },
1254
+ { short: 'e', support: 'implemented' },
1255
+ { short: 'E', support: 'implemented' },
1256
+ ],
1257
+ effects: [],
1258
+ platform: 'windows-ps51',
1259
+ dispatch: 'translated',
1260
+ usageExit: 2,
1261
+ leadingOptions: true,
1262
+ handler: echo,
1263
+ },
1264
+ {
1265
+ names: ['printf'],
1266
+ options: [],
1267
+ effects: [],
1268
+ platform: 'windows-ps51',
1269
+ dispatch: 'translated',
1270
+ usageExit: 2,
1271
+ leadingOptions: true,
1272
+ handler: printf,
1273
+ },
1274
+ {
1275
+ names: ['cat'],
1276
+ options: [
1277
+ { short: 'n', support: 'implemented' },
1278
+ { short: 'b', support: 'implemented' },
1279
+ { short: 's', support: 'implemented' },
1280
+ { short: 'E', support: 'implemented' },
1281
+ { short: 'T', support: 'implemented' },
1282
+ { short: 'A', support: 'implemented' },
1283
+ ],
1284
+ effects: ['read'],
1285
+ platform: 'windows-ps51',
1286
+ dispatch: 'translated',
1287
+ handler: cat,
1288
+ },
1289
+ {
1290
+ names: ['tail'],
1291
+ options: [
1292
+ { short: 'n', long: '--lines', takesValue: true, support: 'implemented' },
1293
+ { short: 'c', long: '--bytes', takesValue: true, support: 'implemented' },
1294
+ { short: 'q', long: '--quiet', support: 'implemented' },
1295
+ { long: '--silent', support: 'implemented' },
1296
+ { short: 'v', long: '--verbose', support: 'implemented' },
1297
+ { short: 'f', support: 'unsupported', reason: 'no persistent tty' },
1298
+ { short: 'F', support: 'unsupported', reason: 'no persistent tty' },
1299
+ ],
1300
+ effects: ['read'],
1301
+ platform: 'windows-ps51',
1302
+ dispatch: 'translated',
1303
+ handler: tail,
1304
+ },
1305
+ {
1306
+ names: ['wc'],
1307
+ options: [
1308
+ { short: 'l', support: 'implemented' },
1309
+ { short: 'w', support: 'implemented' },
1310
+ { short: 'c', support: 'implemented' },
1311
+ { short: 'm', support: 'implemented' },
1312
+ ],
1313
+ effects: ['read'],
1314
+ platform: 'windows-ps51',
1315
+ dispatch: 'translated',
1316
+ handler: wc,
1317
+ },
1237
1318
  ];
1238
1319
  export const handlers = {
1239
1320
  echo,
package/dist/errors.d.ts CHANGED
@@ -2,4 +2,8 @@
2
2
  * Error normalization — make PowerShell failures look like bash failures
3
3
  * so agents can pattern-match on familiar Linux error styles.
4
4
  */
5
+ /** Missing `python3` on Windows (Mac/Linux agents emit this; do not alias). */
6
+ export declare const PYTHON3_WINDOWS_HINT = " (fauxnix: try `python` or `py` on Windows)";
7
+ /** `.sh` cannot be CreateProcess'd; fx-native never emits the PS not-recognized line. */
8
+ export declare const SH_SCRIPT_WINDOWS_HINT = " (fauxnix: .sh scripts cannot run natively on Windows)";
5
9
  export declare function normalizeStderr(stderr: string): string;
package/dist/errors.js CHANGED
@@ -2,6 +2,19 @@
2
2
  * Error normalization — make PowerShell failures look like bash failures
3
3
  * so agents can pattern-match on familiar Linux error styles.
4
4
  */
5
+ /** Missing `python3` on Windows (Mac/Linux agents emit this; do not alias). */
6
+ export const PYTHON3_WINDOWS_HINT = ' (fauxnix: try `python` or `py` on Windows)';
7
+ /** `.sh` cannot be CreateProcess'd; fx-native never emits the PS not-recognized line. */
8
+ export const SH_SCRIPT_WINDOWS_HINT = ' (fauxnix: .sh scripts cannot run natively on Windows)';
9
+ function commandNotFound(name) {
10
+ const msg = 'bash: ' + name + ': command not found';
11
+ const base = name.replace(/^.*[/\\]/, '');
12
+ if (/^python3(\.exe)?$/i.test(base))
13
+ return msg + PYTHON3_WINDOWS_HINT;
14
+ if (/\.sh$/i.test(name))
15
+ return msg + SH_SCRIPT_WINDOWS_HINT;
16
+ return msg;
17
+ }
5
18
  /** Lines produced by PowerShell error formatting that bash would never show. */
6
19
  const PS_NOISE = [
7
20
  /^\s*\+ CategoryInfo\s*:/,
@@ -62,15 +75,15 @@ export function normalizeStderr(stderr) {
62
75
  // "The term 'x' is not recognized as a name of a cmdlet, function, ..."
63
76
  let m = line.match(/^The term '(.+?)' is not recognized/);
64
77
  if (m)
65
- return 'bash: ' + m[1] + ': command not found';
78
+ return commandNotFound(m[1]);
66
79
  // zh-CN: 无法将"x"项识别为 cmdlet、函数、脚本文件或可运行程序的名称
67
80
  m = line.match(/^无法将["'”]?([^"'”]+)["'”]?项识别为/);
68
81
  if (m)
69
- return 'bash: ' + m[1] + ': command not found';
82
+ return commandNotFound(m[1]);
70
83
  // "x : The term 'y' is not recognized ..." (with source prefix)
71
84
  m = line.match(/^(\S+)\s*:\s*The term '(.+?)' is not recognized/);
72
85
  if (m)
73
- return 'bash: ' + m[2] + ': command not found';
86
+ return commandNotFound(m[2]);
74
87
  // "cat : Cannot find path 'D:\x' because it does not exist."
75
88
  m = line.match(/^(\S+)\s*:\s*Cannot find path '(.+?)' because it does not exist\.?$/);
76
89
  if (m) {
@@ -93,9 +106,9 @@ export function normalizeStderr(stderr) {
93
106
  m = line.match(/^(\S+)\s*:\s*(.*)Access to the path '(.+?)' is denied\.?$/);
94
107
  if (m)
95
108
  return m[1].toLowerCase() + ': cannot remove \'' + m[3] + '\': Permission denied';
96
- // helpful hint for bash scripts
109
+ // leftover PS not-recognized lines that the rewrites above did not catch
97
110
  if (/\.sh'?/.test(line) && /is not recognized/.test(line)) {
98
- return line + ' (fauxnix: .sh scripts cannot run natively on Windows)';
111
+ return line + SH_SCRIPT_WINDOWS_HINT;
99
112
  }
100
113
  return line;
101
114
  });
package/dist/executor.js CHANGED
@@ -21,7 +21,40 @@ function winTarget(target) {
21
21
  return path.join(os.tmpdir(), p.slice('$env:TEMP\\'.length));
22
22
  return p;
23
23
  }
24
- function emitToPrepDest(dest, msg, fds, fallback) {
24
+ /** Windows NUL device `NUL`, `\\.\NUL`, and `cwd\NUL` after path.resolve. */
25
+ function isNulPath(p) {
26
+ const base = p.split(/[/\\]/).pop() ?? p;
27
+ return /^NUL$/i.test(base);
28
+ }
29
+ function applyRedirectDest(op, target, stdout, stderr) {
30
+ if (op === '2>&1')
31
+ return { stdout, stderr: stdout };
32
+ if (op === '1>&2')
33
+ return { stdout: stderr, stderr };
34
+ if (op === '<' || target === undefined)
35
+ return { stdout, stderr };
36
+ const dest = isNulPath(target) ? { kind: 'nul' } : { kind: 'file', path: target };
37
+ if (op === '>' || op === '>>')
38
+ return { stdout: dest, stderr };
39
+ if (op === '2>' || op === '2>>')
40
+ return { stdout, stderr: dest };
41
+ if (op === '&>' || op === '&>>')
42
+ return { stdout: dest, stderr: dest };
43
+ return { stdout, stderr };
44
+ }
45
+ /** Last-stage output fds only — captured stdout/stderr apply. */
46
+ function lastStageOutputDests(redirects, resolveTarget) {
47
+ let stdout = { kind: 'caller', fd: 1 };
48
+ let stderr = { kind: 'caller', fd: 2 };
49
+ for (const r of redirects) {
50
+ const target = r.op === '2>&1' || r.op === '1>&2' || r.op === '<'
51
+ ? undefined
52
+ : resolveTarget(winTarget(r.target));
53
+ ({ stdout, stderr } = applyRedirectDest(r.op, target, stdout, stderr));
54
+ }
55
+ return { stdout, stderr };
56
+ }
57
+ function emitToPrepDest(dest, msg, fds, caller) {
25
58
  if (dest.kind === 'nul')
26
59
  return;
27
60
  if (dest.kind === 'file') {
@@ -30,10 +63,14 @@ function emitToPrepDest(dest, msg, fds, fallback) {
30
63
  return;
31
64
  }
32
65
  catch {
33
- /* fall through to caller */
66
+ caller.stderr(msg);
67
+ return;
34
68
  }
35
69
  }
36
- fallback(msg);
70
+ if (dest.fd === 1)
71
+ caller.stdout(msg);
72
+ else
73
+ caller.stderr(msg);
37
74
  }
38
75
  function writeAllSync(fd, data) {
39
76
  let off = 0;
@@ -89,19 +126,20 @@ function planRedirects(redirects) {
89
126
  appendStdout: false,
90
127
  stderrFile: null,
91
128
  appendStderr: false,
92
- mergeStderr: false,
93
- devNull: false,
94
129
  };
95
130
  for (const red of redirects) {
96
131
  const target = winTarget(red.target);
97
132
  switch (red.op) {
98
133
  case '<':
99
- r.stdinFile = target;
134
+ r.stdinFile = isNulPath(target) ? null : target;
100
135
  break;
101
136
  case '>':
102
137
  case '&>':
103
- if (target === 'NUL')
104
- r.devNull = true;
138
+ if (isNulPath(target)) {
139
+ r.stdoutFile = null;
140
+ if (red.op === '&>')
141
+ r.stderrFile = null;
142
+ }
105
143
  else {
106
144
  r.stdoutFile = target;
107
145
  r.appendStdout = false;
@@ -111,8 +149,11 @@ function planRedirects(redirects) {
111
149
  break;
112
150
  case '>>':
113
151
  case '&>>':
114
- if (target === 'NUL')
115
- r.devNull = true;
152
+ if (isNulPath(target)) {
153
+ r.stdoutFile = null;
154
+ if (red.op === '&>>')
155
+ r.stderrFile = null;
156
+ }
116
157
  else {
117
158
  r.stdoutFile = target;
118
159
  r.appendStdout = true;
@@ -123,11 +164,9 @@ function planRedirects(redirects) {
123
164
  }
124
165
  break;
125
166
  case '2>':
126
- if (target === 'NUL') {
127
- // 2>/dev/null swallows stderr only
167
+ if (isNulPath(target)) {
168
+ // stderr only — must not undo a prior >/dev/null
128
169
  r.stderrFile = null;
129
- r.devNull = false;
130
- r.swallowStderr = true;
131
170
  }
132
171
  else {
133
172
  r.stderrFile = target;
@@ -135,17 +174,15 @@ function planRedirects(redirects) {
135
174
  }
136
175
  break;
137
176
  case '2>>':
138
- if (target !== 'NUL') {
177
+ if (isNulPath(target)) {
178
+ r.stderrFile = null;
179
+ }
180
+ else {
139
181
  r.stderrFile = target;
140
182
  r.appendStderr = true;
141
183
  }
142
184
  break;
143
- case '2>&1':
144
- r.mergeStderr = true;
145
- break;
146
- case '1>&2':
147
- r.mergeStderr = false;
148
- r.stdoutToStderr = true;
185
+ default:
149
186
  break;
150
187
  }
151
188
  }
@@ -319,7 +356,9 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
319
356
  session.prevExit = exitCode;
320
357
  break;
321
358
  }
322
- const red = planRedirects(plan.redirects);
359
+ const red = planRedirects(plan.outputRedirects);
360
+ const inRed = planRedirects(plan.stdinRedirects);
361
+ red.stdinFile = inRed.stdinFile;
323
362
  red.stdinFile = red.stdinFile ? resolveTarget(red.stdinFile) : null;
324
363
  red.stdoutFile = red.stdoutFile ? resolveTarget(red.stdoutFile) : null;
325
364
  red.stderrFile = red.stderrFile ? resolveTarget(red.stderrFile) : null;
@@ -334,22 +373,25 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
334
373
  let redirectPrepFailed = false;
335
374
  // Snapshot fd destinations as we walk. `2>&1` copies stdout *at that
336
375
  // moment*; a later `>file` must not drag stderr along (bash fd dup).
337
- let prepStdout = { kind: 'caller' };
338
- let prepStderr = { kind: 'caller' };
339
- const emitPrepError = (msg) => emitToPrepDest(prepStderr, msg, prepFds, (s) => {
340
- stderr += s;
376
+ let prepStdout = { kind: 'caller', fd: 1 };
377
+ let prepStderr = { kind: 'caller', fd: 2 };
378
+ const emitPrepError = (msg) => emitToPrepDest(prepStderr, msg, prepFds, {
379
+ stdout: (s) => {
380
+ stdout += s;
381
+ },
382
+ stderr: (s) => {
383
+ stderr += s;
384
+ },
341
385
  });
342
386
  for (const r of plan.redirects) {
343
- if (r.op === '2>&1') {
344
- prepStderr = prepStdout;
345
- continue;
346
- }
347
- if (r.op === '1>&2') {
348
- prepStdout = prepStderr;
387
+ if (r.op === '2>&1' || r.op === '1>&2') {
388
+ ({ stdout: prepStdout, stderr: prepStderr } = applyRedirectDest(r.op, undefined, prepStdout, prepStderr));
349
389
  continue;
350
390
  }
351
391
  const target = resolveTarget(winTarget(r.target));
352
392
  if (r.op === '<') {
393
+ if (isNulPath(target))
394
+ continue;
353
395
  if (!existsSync(target)) {
354
396
  emitPrepError('bash: ' + target + ': No such file or directory\n');
355
397
  redirectPrepFailed = true;
@@ -357,33 +399,16 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
357
399
  }
358
400
  continue;
359
401
  }
360
- if (target === 'NUL') {
361
- if (r.op === '>' || r.op === '>>')
362
- prepStdout = { kind: 'nul' };
363
- else if (r.op === '2>' || r.op === '2>>')
364
- prepStderr = { kind: 'nul' };
365
- else if (r.op === '&>' || r.op === '&>>') {
366
- prepStdout = { kind: 'nul' };
367
- prepStderr = { kind: 'nul' };
402
+ if (!isNulPath(target)) {
403
+ const append = r.op === '>>' || r.op === '2>>' || r.op === '&>>';
404
+ const fail = prepareRedirectFile(target, append, prepFds);
405
+ if (fail) {
406
+ emitPrepError('bash: ' + fail + '\n');
407
+ redirectPrepFailed = true;
408
+ break;
368
409
  }
369
- continue;
370
- }
371
- const append = r.op === '>>' || r.op === '2>>' || r.op === '&>>';
372
- const fail = prepareRedirectFile(target, append, prepFds);
373
- if (fail) {
374
- emitPrepError('bash: ' + fail + '\n');
375
- redirectPrepFailed = true;
376
- break;
377
- }
378
- const fileDest = { kind: 'file', path: target };
379
- if (r.op === '>' || r.op === '>>')
380
- prepStdout = fileDest;
381
- else if (r.op === '2>' || r.op === '2>>')
382
- prepStderr = fileDest;
383
- else if (r.op === '&>' || r.op === '&>>') {
384
- prepStdout = fileDest;
385
- prepStderr = fileDest;
386
410
  }
411
+ ({ stdout: prepStdout, stderr: prepStderr } = applyRedirectDest(r.op, target, prepStdout, prepStderr));
387
412
  }
388
413
  if (redirectPrepFailed) {
389
414
  exitCode = 1;
@@ -406,11 +431,15 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
406
431
  break;
407
432
  }
408
433
  const encoded = wrapScript(plan.body, { mode: 'host' });
434
+ // Last-stage fds last-win independently. `2>&1 >/dev/null` snapshots
435
+ // stderr onto the caller's stdout before stdout is pointed at NUL, so
436
+ // captured stderr is still returned; `>/dev/null 2>&1` points both at NUL.
437
+ const applyDests = lastStageOutputDests(plan.outputRedirects, resolveTarget);
409
438
  // Response budgets cap what the CALLER receives — streams redirected to
410
439
  // files must never be truncated by them (Codex-review P1: `printf … > f`
411
440
  // with a small stdoutLimit was writing a clipped file). The final
412
441
  // clipUtf8 below still enforces the returned-data budget.
413
- const fileRedirected = !!(red.stdoutFile || red.stderrFile);
442
+ const fileRedirected = applyDests.stdout.kind === 'file' || applyDests.stderr.kind === 'file';
414
443
  const inv = await ensureHost().invoke(encoded, {
415
444
  FAUXNIX_CWD: currentDir,
416
445
  FAUXNIX_PREV_EXIT: session.prevExit === null ? '' : String(session.prevExit),
@@ -438,51 +467,45 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
438
467
  // GNU line discipline: the PS host terminates Write-Output lines with
439
468
  // CRLF. Exact writers (fx-write / printf / echo -n) must keep embedded
440
469
  // CR so `printf 'a\r\nb' > out` stays 4 bytes.
441
- let segOut = normalizeHostNewlines(decodeOutput(inv.stdout, decodePref));
470
+ const segOut = normalizeHostNewlines(decodeOutput(inv.stdout, decodePref));
442
471
  let segErr = normalizeHostNewlines(normalizeStderr(decodeOutput(inv.stderr, decodePref)));
443
472
  if (inv.timedOut) {
444
473
  segErr += timeoutMessage;
445
474
  }
446
- if (red.mergeStderr) {
447
- segOut += (segOut && !segOut.endsWith('\n') && segErr ? '\n' : '') + segErr;
448
- segErr = '';
449
- }
450
- const stdoutToStderr = red.stdoutToStderr;
451
- if (stdoutToStderr) {
452
- segErr += segOut;
453
- segOut = '';
454
- }
455
- const swallowStderr = red.swallowStderr;
456
- if (swallowStderr)
457
- segErr = '';
458
475
  // Write captured streams through the fds opened during preflight
459
476
  // (bash: the redirect refers to the open file, not the path). Reopening
460
477
  // the path would recreate a file the command just unlinked
461
478
  // (`rm out.txt > out.txt`).
462
479
  let redirectOk = true;
463
- if (red.stdoutFile) {
464
- try {
465
- writeToPrepFd(prepFds, red.stdoutFile, segOut);
466
- segOut = '';
467
- }
468
- catch (e) {
469
- segErr += 'bash: ' + red.stdoutFile + ': cannot create: ' + e.message + '\n';
470
- exitCode = 1;
471
- redirectOk = false;
472
- }
473
- }
474
- if (red.stderrFile) {
475
- try {
476
- const body = red.stderrFile === red.stdoutFile ? segOut + segErr : segErr;
477
- writeToPrepFd(prepFds, red.stderrFile, body);
478
- segErr = '';
479
- }
480
- catch {
481
- /* best effort */
480
+ const deliverCaptured = (dest, data, fromStdout) => {
481
+ if (!data)
482
+ return;
483
+ if (dest.kind === 'nul')
484
+ return;
485
+ if (dest.kind === 'file') {
486
+ try {
487
+ writeToPrepFd(prepFds, dest.path, data);
488
+ }
489
+ catch (e) {
490
+ if (fromStdout) {
491
+ stderr += 'bash: ' + dest.path + ': cannot create: ' + e.message + '\n';
492
+ exitCode = 1;
493
+ redirectOk = false;
494
+ stdout += data;
495
+ }
496
+ else {
497
+ stderr += data;
498
+ }
499
+ }
500
+ return;
482
501
  }
483
- }
484
- stdout += segOut;
485
- stderr += segErr;
502
+ if (dest.fd === 1)
503
+ stdout += data;
504
+ else
505
+ stderr += data;
506
+ };
507
+ deliverCaptured(applyDests.stdout, segOut, true);
508
+ deliverCaptured(applyDests.stderr, segErr, false);
486
509
  if (inv.truncated)
487
510
  truncated = true;
488
511
  exitCode = inv.timedOut ? 124 : inv.cancelled ? 130 : inv.exitCode;
@@ -99,6 +99,8 @@ export interface CommandSpec {
99
99
  dispatch?: 'translated' | 'native' | 'dynamic';
100
100
  /** GNU usage/syntax exit (grep uses 2; cp/mv/rm use 1). */
101
101
  usageExit?: number;
102
+ /** First non-option operand ends option scanning (echo/printf). */
103
+ leadingOptions?: boolean;
102
104
  handler: Handler;
103
105
  }
104
106
  /** Register a spec'd command. Unknown/unsupported options become GNU-style usage errors. */
package/dist/registry.js CHANGED
@@ -197,7 +197,8 @@ export function specsMarkdown() {
197
197
  for (const spec of registeredSpecs()) {
198
198
  lines.push('## `' + spec.names.join('` / `') + '`');
199
199
  lines.push('');
200
- lines.push('Effects: ' + spec.effects.map((e) => '`' + e + '`').join(', '));
200
+ lines.push('Effects: ' +
201
+ (spec.effects.length ? spec.effects.map((e) => '`' + e + '`').join(', ') : 'none'));
201
202
  lines.push('');
202
203
  if (!spec.options.length) {
203
204
  lines.push('No options declared.');
@@ -319,6 +320,8 @@ export function specOptionError(spec, args, cmdName) {
319
320
  i++;
320
321
  continue;
321
322
  }
323
+ if (spec.leadingOptions)
324
+ onlyOperands = true;
322
325
  i++;
323
326
  }
324
327
  return null;
@@ -84,8 +84,12 @@ export interface SegmentPlan {
84
84
  script: string;
85
85
  /** Pipeline body before wrapScript — executor host mode re-wraps this. */
86
86
  body: string;
87
- /** All redirects collected from this segment (executor handles them). */
87
+ /** Every stage's redirects, in source order executor prep (open/fail). */
88
88
  redirects: Redirect[];
89
+ /** Last-stage redirects — captured stdout/stderr apply / >/dev/null. */
90
+ outputRedirects: Redirect[];
91
+ /** First-stage `<` only — FAUXNIX_STDIN_FILE feed. */
92
+ stdinRedirects: Redirect[];
89
93
  }
90
94
  export declare function translateCommandList(list: CommandList): SegmentPlan[];
91
95
  export type WrapMode = 'spawn' | 'host';