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.
@@ -1,6 +1,7 @@
1
1
  import { FauxnixParseError, isUnquotedLiteral, } from './ast.js';
2
2
  import { parseCommand } from './parser.js';
3
3
  import { lookup, psStr } from './registry.js';
4
+ import { PYTHON3_WINDOWS_HINT, SH_SCRIPT_WINDOWS_HINT } from './errors.js';
4
5
  /* ------------------------------------------------------------------ */
5
6
  /* Variable mapping */
6
7
  /* ------------------------------------------------------------------ */
@@ -393,7 +394,6 @@ export function translateSimple(cmd, position, hasStdin) {
393
394
  const nameSplat = splatSpec(cmd.name);
394
395
  let body;
395
396
  if (nameSplat) {
396
- const invoke = '& $fx_cmd @fx_na';
397
397
  const hasAffix = !!(nameSplat.prefix || nameSplat.suffix);
398
398
  const promoted = cmd.args.length === 0
399
399
  ? ''
@@ -419,9 +419,7 @@ export function translateSimple(cmd, position, hasStdin) {
419
419
  emptyCmdLines.push('if ($fx_cw.Count -eq 0) {',
420
420
  // No words left → bash null command (exit 0). Remaining words are
421
421
  // 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 = @($fx_cw[1..($fx_cw.Count - 1)]) + $fx_na }', ' ' +
423
- (hasStdin ? '($input | ' + invoke + ')' : invoke) +
424
- ' | ForEach-Object { [string]$_ }', ' if ($LASTEXITCODE -gt 0) { $script:fx_exit = $LASTEXITCODE } elseif ($LASTEXITCODE -lt 0) { $script:fx_exit = 1 }', '}');
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'), '}');
425
423
  body = emptyCmdLines.join('\n');
426
424
  }
427
425
  else if (nameLit !== null) {
@@ -431,26 +429,23 @@ export function translateSimple(cmd, position, hasStdin) {
431
429
  }
432
430
  else {
433
431
  // passthrough: native command (git, node, npm, python, cargo, ...)
434
- // invoked with the call operator and an argv-style argument array
435
- // no string re-parsing of user text.
432
+ // via fx-native (Win32 command line + Process). `& name @array` on
433
+ // PS 5.1 drops empty argv entries and eats embedded quotes.
436
434
  const nameExpr = psStr(nameLit);
437
- const invoke = '& ' + nameExpr + ' @fx_na';
435
+ const invoke = 'fx-native ' + nameExpr + ' $fx_na';
438
436
  body = [
439
- '$fx_na = ' + argListExpr(cmd.args),
440
- // feed pipeline stdin into the native process when we are a non-first stage
441
- (hasStdin ? '($input | ' + invoke + ')' : invoke) + ' | ForEach-Object { [string]$_ }',
442
- 'if ($LASTEXITCODE -gt 0) { $script:fx_exit = $LASTEXITCODE } elseif ($LASTEXITCODE -lt 0) { $script:fx_exit = 1 }',
437
+ '$fx_na = [object[]](' + argListExpr(cmd.args) + ')',
438
+ (hasStdin ? '($input | ' + invoke + ')' : invoke),
443
439
  ].join('\n');
444
440
  }
445
441
  }
446
442
  else {
447
443
  // dynamic command name — evaluate it
448
444
  const nameExpr = exprOfWord(cmd.name);
449
- const invoke = '& (' + nameExpr + ') @fx_na';
445
+ const invoke = 'fx-native (' + nameExpr + ') $fx_na';
450
446
  body = [
451
- '$fx_na = ' + argListExpr(cmd.args),
452
- (hasStdin ? '($input | ' + invoke + ')' : invoke) + ' | ForEach-Object { [string]$_ }',
453
- 'if ($LASTEXITCODE -gt 0) { $script:fx_exit = $LASTEXITCODE } elseif ($LASTEXITCODE -lt 0) { $script:fx_exit = 1 }',
447
+ '$fx_na = [object[]](' + argListExpr(cmd.args) + ')',
448
+ (hasStdin ? '($input | ' + invoke + ')' : invoke),
454
449
  ].join('\n');
455
450
  }
456
451
  // `VAR=value cmd` is command-scoped. Values are captured in the
@@ -785,6 +780,20 @@ function translateFor(cmd) {
785
780
  lines.push('}');
786
781
  return lines.join('\n');
787
782
  }
783
+ function stdinTarget(c) {
784
+ let t = null;
785
+ for (const r of c.redirects) {
786
+ if (r.op === '<')
787
+ t = r.target;
788
+ }
789
+ return t;
790
+ }
791
+ function stdinReadExpr(target) {
792
+ const n = normalizeLiteralPath(target);
793
+ if (n === 'NUL')
794
+ return '@()';
795
+ return '@(fx-readlines ' + pathExpr(n) + ')';
796
+ }
788
797
  export function translatePipelineBody(p) {
789
798
  // Every pipeline stage needs its own status slot. Handlers deliberately use
790
799
  // `$script:fx_exit` because their helper functions run in child scopes; in a
@@ -822,7 +831,28 @@ export function translatePipelineBody(p) {
822
831
  }
823
832
  const pipelineName = '__fx_p' + pipelineId;
824
833
  const statuses = bodies.map(() => '0').join(', ');
825
- const pipelineCall = names.join(' | ');
834
+ const stageStdin = p.commands.map((c) => stdinTarget(c));
835
+ const middleStdin = stageStdin.some((t, i) => i > 0 && t !== null);
836
+ let pipelineInner;
837
+ if (middleStdin) {
838
+ // A non-first `< file` replaces the pipe as that stage's stdin. Run
839
+ // earlier stages anyway (they may have side effects) but do not feed
840
+ // their stream into the redirected stage.
841
+ const seq = [' $fx_cur = @($input)'];
842
+ for (let i = 0; i < names.length; i++) {
843
+ if (stageStdin[i] && i > 0)
844
+ seq.push(' $fx_cur = ' + stdinReadExpr(stageStdin[i]));
845
+ if (i === names.length - 1)
846
+ seq.push(' $fx_cur | ' + names[i]);
847
+ else
848
+ seq.push(' $fx_cur = @($fx_cur | ' + names[i] + ')');
849
+ }
850
+ pipelineInner = seq.join('\n');
851
+ }
852
+ else {
853
+ pipelineInner =
854
+ ' $input | ' + names.join(' | ');
855
+ }
826
856
  defs.push([
827
857
  'function ' + pipelineName + ' {',
828
858
  ' ' + statusVar + ' = @(' + statuses + ')',
@@ -830,7 +860,7 @@ export function translatePipelineBody(p) {
830
860
  // The wrapper forwards redirect input to stage zero. With no input,
831
861
  // PowerShell still invokes a regular function once, which preserves the
832
862
  // existing no-stdin pipeline behavior on Windows PowerShell 5.1.
833
- ' $input | ' + pipelineCall,
863
+ pipelineInner,
834
864
  ' } finally {',
835
865
  // Bash defaults to pipefail off: only the last stage controls the list
836
866
  // status used by a following && / || segment.
@@ -843,13 +873,19 @@ export function translatePipelineBody(p) {
843
873
  export function translateCommandList(list) {
844
874
  const plans = [];
845
875
  for (const seg of list.segments) {
876
+ const cmds = seg.pipeline.commands;
846
877
  const redirects = [];
847
- for (const c of seg.pipeline.commands)
878
+ for (const c of cmds)
848
879
  redirects.push(...c.redirects);
880
+ const outputRedirects = cmds.length ? cmds[cmds.length - 1].redirects.slice() : [];
881
+ const stdinRedirects = cmds.length
882
+ ? cmds[0].redirects.filter((r) => r.op === '<')
883
+ : [];
849
884
  const { defs, call } = translatePipelineBody(seg.pipeline);
850
885
  let body = defs ? defs + '\n' + call : call;
851
- // `< file` redirects feed the pipeline via the FAUXNIX_STDIN_FILE channel
852
- if (redirects.some((r) => r.op === '<')) {
886
+ // First-stage `< file` feeds stage zero via FAUXNIX_STDIN_FILE.
887
+ // Later-stage `<` is owned inside the pipeline body, not this wrapper.
888
+ if (stdinRedirects.length) {
853
889
  // `& { ... }` (no parens) so the scriptblock can be a non-first
854
890
  // pipeline element receiving the fed lines.
855
891
  const pipeCall = call.startsWith('(& {') ? call.slice(1, -1) : call;
@@ -861,7 +897,14 @@ export function translateCommandList(list) {
861
897
  call +
862
898
  ' }';
863
899
  }
864
- plans.push({ op: seg.op, script: wrapScript(body), body, redirects });
900
+ plans.push({
901
+ op: seg.op,
902
+ script: wrapScript(body),
903
+ body,
904
+ redirects,
905
+ outputRedirects,
906
+ stdinRedirects,
907
+ });
865
908
  }
866
909
  return plans;
867
910
  }
@@ -880,6 +923,8 @@ const WRAP_HELPER_ORDER = [
880
923
  'fx-arrput',
881
924
  'fx-arrclr',
882
925
  'fx-subget',
926
+ 'fx-winargv',
927
+ 'fx-native',
883
928
  ];
884
929
  const WRAP_HELPER_DEPS = {
885
930
  'fx-readlines': [],
@@ -896,6 +941,8 @@ const WRAP_HELPER_DEPS = {
896
941
  'fx-arrput': ['fx-arrdrop', 'fx-svenc'],
897
942
  'fx-arrclr': ['fx-arrdrop'],
898
943
  'fx-subget': ['fx-arrload', 'fx-ifs1'],
944
+ 'fx-winargv': [],
945
+ 'fx-native': ['fx-winargv'],
899
946
  };
900
947
  /** Helpers the body calls that wrapScript still has to emit (not already defined there). */
901
948
  function wrapHelpersNeeded(body) {
@@ -1169,6 +1216,127 @@ export function wrapScript(body, opts = {}) {
1169
1216
  ' return [string]$arr[$i]',
1170
1217
  '}',
1171
1218
  ],
1219
+ 'fx-winargv': [
1220
+ 'function fx-winargv($argv, $cmdmeta) {',
1221
+ // Empty [object[]] unwraps to $null on PS 5.1; @($null) is one empty arg.
1222
+ // $cmdmeta: also quote & | () <> ^ so cmd.exe /c does not split the tail.
1223
+ ' if ($null -eq $argv) { $argv = @() }',
1224
+ ' $parts = New-Object System.Collections.Generic.List[string]',
1225
+ ' foreach ($a in @($argv)) {',
1226
+ ' $s = [string]$a',
1227
+ ' if ($s.Length -eq 0) { $parts.Add(\'""\'); continue }',
1228
+ ' $need = $false',
1229
+ ' foreach ($ch in $s.ToCharArray()) {',
1230
+ " if ($ch -eq ' ' -or $ch -eq ([char]9) -or $ch -eq [char]34) { $need = $true; break }",
1231
+ " if ($cmdmeta -and ($ch -eq '&' -or $ch -eq '|' -or $ch -eq '(' -or $ch -eq ')' -or $ch -eq '<' -or $ch -eq '>' -or $ch -eq '^')) { $need = $true; break }",
1232
+ ' }',
1233
+ ' if (-not $need) { $parts.Add($s); continue }',
1234
+ ' $sb = New-Object System.Text.StringBuilder',
1235
+ ' [void]$sb.Append([char]34)',
1236
+ ' $bs = 0',
1237
+ ' foreach ($ch in $s.ToCharArray()) {',
1238
+ ' if ($ch -eq [char]92) { $bs++ }',
1239
+ ' elseif ($ch -eq [char]34) {',
1240
+ ' [void]$sb.Append(([string][char]92) * (2 * $bs + 1))',
1241
+ ' [void]$sb.Append([char]34)',
1242
+ ' $bs = 0',
1243
+ ' } else {',
1244
+ ' if ($bs -gt 0) { [void]$sb.Append(([string][char]92) * $bs); $bs = 0 }',
1245
+ ' [void]$sb.Append($ch)',
1246
+ ' }',
1247
+ ' }',
1248
+ ' if ($bs -gt 0) { [void]$sb.Append(([string][char]92) * (2 * $bs)) }',
1249
+ ' [void]$sb.Append([char]34)',
1250
+ ' $parts.Add($sb.ToString())',
1251
+ ' }',
1252
+ " return (($parts.ToArray()) -join ' ')",
1253
+ '}',
1254
+ ],
1255
+ 'fx-native': [
1256
+ 'function fx-native($name, $argv) {',
1257
+ ' if ($null -eq $argv) { $argv = @() } else { $argv = [object[]]@($argv) }',
1258
+ ' $app = Get-Command -Name $name -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1',
1259
+ ' if ($null -eq $app) {',
1260
+ // Dynamic/splat names can resolve to PS echo/cat aliases, not an .exe.
1261
+ // Application-first keeps node/git on the Win32 argv path; the call
1262
+ // operator is only for names that are not executables.
1263
+ ' $cmd = Get-Command -Name $name -ErrorAction SilentlyContinue | Select-Object -First 1',
1264
+ ' if ($null -eq $cmd) {',
1265
+ " $fx_nf = 'bash: ' + $name + ': command not found'",
1266
+ " $fx_n = [string]$name",
1267
+ // Hint only — never alias python3→python (wrong interpreter).
1268
+ " if ($fx_n -eq 'python3' -or $fx_n -eq 'python3.exe') { $fx_nf += '" +
1269
+ PYTHON3_WINDOWS_HINT +
1270
+ "' }",
1271
+ " elseif ($fx_n -like '*.sh') { $fx_nf += '" + SH_SCRIPT_WINDOWS_HINT + "' }",
1272
+ ' [Console]::Error.WriteLine($fx_nf)',
1273
+ ' $script:fx_exit = 127',
1274
+ ' return',
1275
+ ' }',
1276
+ ' $ins = @($input)',
1277
+ ' $global:LASTEXITCODE = 0',
1278
+ ' if ($ins.Count -gt 0) { $ins | & $name @argv } else { & $name @argv }',
1279
+ ' if ($LASTEXITCODE -gt 0) { $script:fx_exit = $LASTEXITCODE }',
1280
+ ' return',
1281
+ ' }',
1282
+ ' $psi = New-Object System.Diagnostics.ProcessStartInfo',
1283
+ ' $ext = [IO.Path]::GetExtension([string]$app.Source)',
1284
+ // 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.
1287
+ " if ($ext -eq '.cmd' -or $ext -eq '.bat') {",
1288
+ ' $comspec = Get-Command -Name cmd -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1',
1289
+ ' if ($null -eq $comspec) {',
1290
+ " [Console]::Error.WriteLine('bash: cmd.exe: command not found')",
1291
+ ' $script:fx_exit = 127',
1292
+ ' return',
1293
+ ' }',
1294
+ ' $psi.FileName = $comspec.Source',
1295
+ ' $fx_app = fx-winargv $app.Source $true',
1296
+ ' $fx_rest = fx-winargv $argv $true',
1297
+ " 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 + \'"\'',
1299
+ ' } else {',
1300
+ ' $psi.FileName = $app.Source',
1301
+ ' $psi.Arguments = fx-winargv $argv',
1302
+ ' }',
1303
+ ' $psi.UseShellExecute = $false',
1304
+ ' $psi.RedirectStandardInput = $true',
1305
+ ' $psi.RedirectStandardOutput = $true',
1306
+ ' $psi.RedirectStandardError = $true',
1307
+ ' $psi.CreateNoWindow = $true',
1308
+ ' $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.
1311
+ " if ($env:FAUXNIX_NATIVE_ENCODING -eq 'ansi') { $enc = [System.Text.Encoding]::GetEncoding(936) } else { $enc = New-Object System.Text.UTF8Encoding $false }",
1312
+ ' $psi.StandardOutputEncoding = $enc',
1313
+ ' $psi.StandardErrorEncoding = $enc',
1314
+ ' $p = New-Object System.Diagnostics.Process',
1315
+ ' $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 }',
1334
+ ' }',
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
+ '}',
1339
+ ],
1172
1340
  };
1173
1341
  cachedWrapHelpers = helpers;
1174
1342
  for (const name of WRAP_HELPER_ORDER) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fauxnix-cli",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
4
4
  "description": "Fauxnix — run Linux-style commands on Windows via deterministic PowerShell translation. No VM, no WSL. MCP server + CLI for AI agents.",
5
5
  "type": "module",
6
6
  "bin": {