fauxnix-cli 0.9.2 → 0.11.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,12 +1,35 @@
1
- import { Assignment, CommandList, Redirect, SimpleCommand, IfCommand, ForCommand, Word, WordPart } from './ast.js';
1
+ import { Assignment, CommandList, Redirect, SimpleCommand, IfCommand, ForCommand, WhileCommand, CaseCommand, Word, WordPart } from './ast.js';
2
2
  import { PipelineCtx } from './registry.js';
3
3
  /** `${name:-word}` and friends using case-exact fx-scalar0. */
4
4
  export declare function paramExpr(name: string, op: ':-' | ':=' | ':+' | ':?' | '-' | '+' | '?', word: string): string;
5
+ export declare function varExtraOf(p: WordPart): {
6
+ replace?: {
7
+ global: boolean;
8
+ pat: string;
9
+ repl: string;
10
+ };
11
+ slice?: {
12
+ offset: string;
13
+ length?: string;
14
+ };
15
+ } | undefined;
5
16
  /** Map a bash $VAR name to a PowerShell expression (usable inside $(...)). */
6
17
  export declare function varExpr(name: string, index?: string, param?: {
7
18
  op: ':-' | ':=' | ':+' | ':?' | '-' | '+' | '?';
8
19
  word: string;
9
- }, length?: boolean): string;
20
+ }, length?: boolean, extra?: {
21
+ replace?: {
22
+ global: boolean;
23
+ pat: string;
24
+ repl: string;
25
+ };
26
+ slice?: {
27
+ offset: string;
28
+ length?: string;
29
+ };
30
+ }): string;
31
+ /** `$?` `$$` `$0`–`$n` `$#` `$@` `$*` — not ordinary `$env:` names. */
32
+ export declare function isSpecialShellVar(name: string): boolean;
10
33
  /** Escape text destined for the inside of a PS double-quoted string. */
11
34
  export declare function escapeDq(s: string): string;
12
35
  /** Normalize a literal POSIX-ish path to its Windows equivalent. */
@@ -39,6 +62,7 @@ export declare function operandExpr(w: Word): string;
39
62
  /**
40
63
  * `${name[@]}` / `"pre${name[@]}post"` — one argv per element.
41
64
  * Unquoted `${name[*]}` also splats (bash); quoted `"${name[*]}"` stays one join.
65
+ * `$@` / unquoted `$*` splat like `${arr[@]}`; quoted `"$@"` still splats.
42
66
  */
43
67
  export declare function splatSpec(w: Word): {
44
68
  name: string;
@@ -53,6 +77,8 @@ export declare function argListExpr(words: Word[], fn?: (w: Word) => string): st
53
77
  * Unquoted command words join non-empty lines with a space (IFS
54
78
  * word-split approximation). Handlers often emit one string object, so
55
79
  * a bare `$(…)` interpolation would keep those newlines.
80
+ * Lists (`;` `&&` `||`) reuse translateListInline inside the fx-csub
81
+ * scriptblock so the newline contract is unchanged.
56
82
  */
57
83
  export declare function translateCmdSub(cmdText: string, keepNl?: boolean): string;
58
84
  export declare function translateSimple(cmd: SimpleCommand, position: PipelineCtx['position'], hasStdin: boolean): string;
@@ -76,7 +102,7 @@ export interface PipelineParts {
76
102
  call: string;
77
103
  }
78
104
  export declare function translatePipelineBody(p: {
79
- commands: Array<SimpleCommand | IfCommand | ForCommand>;
105
+ commands: Array<SimpleCommand | IfCommand | ForCommand | WhileCommand | CaseCommand>;
80
106
  }): PipelineParts;
81
107
  export interface SegmentPlan {
82
108
  op: ';' | '&&' | '||';
@@ -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
  /* ------------------------------------------------------------------ */
@@ -38,8 +39,43 @@ export function paramExpr(name, op, word) {
38
39
  psStr('bash: ' + msg) +
39
40
  '); $script:fx_exit = 1; \'\' } else { $fx_pv } )');
40
41
  }
42
+ function sliceArgExpr(s) {
43
+ if (s.length > 0 && s[0] === '$') {
44
+ return '(fx-scalar0 ' + psStr(s.slice(1)) + ')';
45
+ }
46
+ return psStr(s);
47
+ }
48
+ export function varExtraOf(p) {
49
+ if (p.kind !== 'Var')
50
+ return undefined;
51
+ if (!p.replace && !p.slice)
52
+ return undefined;
53
+ const extra = {};
54
+ if (p.replace)
55
+ extra.replace = p.replace;
56
+ if (p.slice)
57
+ extra.slice = p.slice;
58
+ return extra;
59
+ }
41
60
  /** Map a bash $VAR name to a PowerShell expression (usable inside $(...)). */
42
- export function varExpr(name, index, param, length = false) {
61
+ export function varExpr(name, index, param, length = false, extra) {
62
+ if (extra && extra.replace) {
63
+ const r = extra.replace;
64
+ return ('(fx-subst (fx-scalar0 ' +
65
+ psStr(name) +
66
+ ') ' +
67
+ psStr(r.pat) +
68
+ ' ' +
69
+ psStr(r.repl) +
70
+ ' ' +
71
+ (r.global ? '$true' : '$false') +
72
+ ')');
73
+ }
74
+ if (extra && extra.slice) {
75
+ const off = sliceArgExpr(extra.slice.offset);
76
+ const len = extra.slice.length !== undefined ? sliceArgExpr(extra.slice.length) : '$null';
77
+ return '(fx-slice (fx-scalar0 ' + psStr(name) + ') ' + off + ' ' + len + ')';
78
+ }
43
79
  if (param)
44
80
  return paramExpr(name, param.op, param.word);
45
81
  if (length) {
@@ -59,6 +95,12 @@ export function varExpr(name, index, param, length = false) {
59
95
  if (index !== undefined) {
60
96
  return '(fx-subget ' + psStr(name) + ' ' + psStr(index) + ')';
61
97
  }
98
+ if (/^[0-9]+$/.test(name)) {
99
+ if (name === '0') {
100
+ return "$(if ($env:FAUXNIX_ARG0) { [string]$env:FAUXNIX_ARG0 } else { 'fauxnix' })";
101
+ }
102
+ return '(fx-posget ' + name + ')';
103
+ }
62
104
  switch (name) {
63
105
  case 'HOME':
64
106
  return '$HOME';
@@ -81,10 +123,24 @@ export function varExpr(name, index, param, length = false) {
81
123
  return '[string]$PID';
82
124
  case 'HOSTNAME':
83
125
  return '$env:COMPUTERNAME';
126
+ case '#':
127
+ return '@(fx-posload).Count';
128
+ case '@':
129
+ case '*':
130
+ return '((@(fx-posload) -join (fx-ifs1)))';
84
131
  default:
85
132
  return '$env:' + name;
86
133
  }
87
134
  }
135
+ /** `$?` `$$` `$0`–`$n` `$#` `$@` `$*` — not ordinary `$env:` names. */
136
+ export function isSpecialShellVar(name) {
137
+ return (name === '?' ||
138
+ name === '$' ||
139
+ name === '#' ||
140
+ name === '@' ||
141
+ name === '*' ||
142
+ /^[0-9]+$/.test(name));
143
+ }
88
144
  /* ------------------------------------------------------------------ */
89
145
  /* Word → PowerShell expression */
90
146
  /* ------------------------------------------------------------------ */
@@ -154,7 +210,7 @@ export function exprOfWord(w, opts) {
154
210
  // single bare variable → bare expression
155
211
  if (expanded.length === 1 && expanded[0].kind === 'Var') {
156
212
  const v = expanded[0];
157
- return varExpr(v.name, v.index, v.param, v.length === true);
213
+ return varExpr(v.name, v.index, v.param, v.length === true, varExtraOf(v));
158
214
  }
159
215
  // Bare `$(...)` must not sit inside a PS expandable string: the
160
216
  // substitution body contains `"` / `$_` that would break interpolation.
@@ -184,7 +240,7 @@ export function exprOfWord(w, opts) {
184
240
  emitPart(q, true);
185
241
  break;
186
242
  case 'Var':
187
- out += '$(' + varExpr(p.name, p.index, p.param, p.length === true) + ')';
243
+ out += '$(' + varExpr(p.name, p.index, p.param, p.length === true, varExtraOf(p)) + ')';
188
244
  break;
189
245
  case 'CmdSub':
190
246
  out += '$(' + translateCmdSub(p.cmd, quoted || opts?.preserveCmdSub === true) + ')';
@@ -240,7 +296,7 @@ function arithSourceExpr(parts) {
240
296
  emit(q);
241
297
  break;
242
298
  case 'Var':
243
- out += '$(' + varExpr(p.name, p.index, p.param, p.length === true) + ')';
299
+ out += '$(' + varExpr(p.name, p.index, p.param, p.length === true, varExtraOf(p)) + ')';
244
300
  break;
245
301
  case 'CmdSub':
246
302
  out += '$(' + translateCmdSub(p.cmd, true) + ')';
@@ -291,6 +347,7 @@ function wordPartsForSplat(w) {
291
347
  /**
292
348
  * `${name[@]}` / `"pre${name[@]}post"` — one argv per element.
293
349
  * Unquoted `${name[*]}` also splats (bash); quoted `"${name[*]}"` stays one join.
350
+ * `$@` / unquoted `$*` splat like `${arr[@]}`; quoted `"$@"` still splats.
294
351
  */
295
352
  export function splatSpec(w) {
296
353
  const parts = wordPartsForSplat(w);
@@ -301,7 +358,10 @@ export function splatSpec(w) {
301
358
  for (const { part: p, quoted } of parts) {
302
359
  const splat = p.kind === 'Var' &&
303
360
  !p.length &&
304
- (p.index === '@' || (p.index === '*' && !quoted));
361
+ (p.name === '@' ||
362
+ (p.name === '*' && !quoted) ||
363
+ p.index === '@' ||
364
+ (p.index === '*' && !quoted));
305
365
  if (splat) {
306
366
  if (seen)
307
367
  return null;
@@ -318,6 +378,12 @@ export function splatSpec(w) {
318
378
  }
319
379
  return name ? { name, prefix, suffix } : null;
320
380
  }
381
+ /** Load the splat source: positionals (`@`/`*`) vs named arrays. */
382
+ function splatLoadCall(name) {
383
+ if (name === '@' || name === '*')
384
+ return 'fx-posload';
385
+ return 'fx-arrload ' + psStr(name);
386
+ }
321
387
  /** PS expression of a string[]: `@` words splat, others stay one element. */
322
388
  export function argListExpr(words, fn = exprOfWord) {
323
389
  if (words.length === 0)
@@ -329,9 +395,9 @@ export function argListExpr(words, fn = exprOfWord) {
329
395
  if (!s)
330
396
  return '@(' + fn(w) + ')';
331
397
  if (!s.prefix && !s.suffix)
332
- return '@(fx-arrload ' + psStr(s.name) + ')';
333
- return ('@($( $fx_sp = @(fx-arrload ' +
334
- psStr(s.name) +
398
+ return '@(' + splatLoadCall(s.name) + ')';
399
+ return ('@($( $fx_sp = @(' +
400
+ splatLoadCall(s.name) +
335
401
  '); if ($fx_sp.Count -eq 0) { $fx_sp = @(' +
336
402
  psStr(s.prefix + s.suffix) +
337
403
  ') } else { $fx_sp[0] = ' +
@@ -352,14 +418,11 @@ export function argListExpr(words, fn = exprOfWord) {
352
418
  * Unquoted command words join non-empty lines with a space (IFS
353
419
  * word-split approximation). Handlers often emit one string object, so
354
420
  * a bare `$(…)` interpolation would keep those newlines.
421
+ * Lists (`;` `&&` `||`) reuse translateListInline inside the fx-csub
422
+ * scriptblock so the newline contract is unchanged.
355
423
  */
356
424
  export function translateCmdSub(cmdText, keepNl = false) {
357
- const list = parseCommand(cmdText);
358
- if (list.segments.length !== 1) {
359
- throw new FauxnixParseError('fauxnix: command substitution with ; && || is not supported yet');
360
- }
361
- const { defs, call } = translatePipelineBody(list.segments[0].pipeline);
362
- const inner = defs ? defs + '\n' + call : call;
425
+ const inner = translateListInline(parseCommand(cmdText));
363
426
  const collected = '(fx-csub { ' + inner + ' })';
364
427
  if (keepNl)
365
428
  return collected;
@@ -383,11 +446,21 @@ export function translateSimple(cmd, position, hasStdin) {
383
446
  // deviation: shell var vs exported var are indistinguishable here).
384
447
  if (cmd.name === null) {
385
448
  const exportHandler = lookup('export');
386
- const words = cmd.assignments.map((a) => [
387
- { kind: 'Text', text: a.name + '=' },
388
- ...a.value,
389
- ]);
390
- return exportHandler ? exportHandler(words, { position, hasStdin }) : '';
449
+ const chunks = [];
450
+ for (const a of cmd.assignments) {
451
+ if (a.values) {
452
+ chunks.push('fx-arrput ' +
453
+ psStr(a.name) +
454
+ ' ' +
455
+ argListExpr(a.values, (w) => exprOfWord(w, { preserveCmdSub: true })));
456
+ }
457
+ else {
458
+ const words = [[{ kind: 'Text', text: a.name + '=' }, ...a.value]];
459
+ if (exportHandler)
460
+ chunks.push(exportHandler(words, { position, hasStdin }));
461
+ }
462
+ }
463
+ return chunks.join('\n');
391
464
  }
392
465
  const nameLit = literalOfWord(cmd.name);
393
466
  const nameSplat = splatSpec(cmd.name);
@@ -404,7 +477,7 @@ export function translateSimple(cmd, position, hasStdin) {
404
477
  redirects: cmd.redirects,
405
478
  }, position, hasStdin);
406
479
  const emptyCmdLines = [
407
- '$fx_cw = @(fx-arrload ' + psStr(nameSplat.name) + ')',
480
+ '$fx_cw = @(' + splatLoadCall(nameSplat.name) + ')',
408
481
  ];
409
482
  if (hasAffix) {
410
483
  emptyCmdLines.push('if ($fx_cw.Count -eq 0) { $fx_cw = @(' +
@@ -546,10 +619,20 @@ export function wrapTempEnv(sets, body, extra) {
546
619
  lines.push(arrSave + '[' + psStr(n) + '] = (fx-arrpackget ' + psStr(n) + ')');
547
620
  }
548
621
  const valVars = [];
622
+ const valIsArr = [];
549
623
  for (let i = 0; i < sets.length; i++) {
550
624
  const vn = '$fx_ev' + id + '_' + i;
551
625
  valVars.push(vn);
552
- lines.push(vn + ' = ' + exprOfWord(sets[i].value, { preserveCmdSub: true }));
626
+ if (sets[i].values) {
627
+ valIsArr.push(true);
628
+ lines.push(vn +
629
+ ' = ' +
630
+ argListExpr(sets[i].values, (w) => exprOfWord(w, { preserveCmdSub: true })));
631
+ }
632
+ else {
633
+ valIsArr.push(false);
634
+ lines.push(vn + ' = ' + exprOfWord(sets[i].value, { preserveCmdSub: true }));
635
+ }
553
636
  }
554
637
  lines.push('try {');
555
638
  for (const u of unsets) {
@@ -575,6 +658,10 @@ export function wrapTempEnv(sets, body, extra) {
575
658
  for (let i = 0; i < sets.length; i++) {
576
659
  const n = sets[i].name;
577
660
  const nq = n.replace(/'/g, "''");
661
+ if (valIsArr[i]) {
662
+ lines.push(' fx-arrput ' + psStr(n) + ' ' + valVars[i]);
663
+ continue;
664
+ }
578
665
  lines.push(' $env:' + n + ' = ' + valVars[i]);
579
666
  lines.push(' fx-arrdrop ' + psStr(n));
580
667
  lines.push(" $env:FAUXNIX_SETVARS = ((@($env:FAUXNIX_SETVARS -split ';' | Where-Object { $_ -ne '' -and $_ -cne '" +
@@ -752,6 +839,22 @@ function translateIf(cmd) {
752
839
  lines.push('}');
753
840
  return lines.join('\n');
754
841
  }
842
+ function translateCase(cmd) {
843
+ const lines = ['$script:fx_exit = 0', '$fx_cw = ' + exprOfWord(cmd.word)];
844
+ for (let i = 0; i < cmd.arms.length; i++) {
845
+ const arm = cmd.arms[i];
846
+ const pats = arm.patterns.map((w) => exprOfWord(w)).join(',');
847
+ const head = i === 0 ? 'if' : 'elseif';
848
+ lines.push(head + ' (fx-casematch $fx_cw @(' + pats + ')) {');
849
+ const body = translateListInline(arm.body);
850
+ if (body) {
851
+ for (const l of body.split('\n'))
852
+ lines.push(l ? ' ' + l : l);
853
+ }
854
+ lines.push('}');
855
+ }
856
+ return lines.join('\n');
857
+ }
755
858
  function translateFor(cmd) {
756
859
  const n = cmd.name.replace(/'/g, "''");
757
860
  const lines = [
@@ -779,6 +882,22 @@ function translateFor(cmd) {
779
882
  lines.push('}');
780
883
  return lines.join('\n');
781
884
  }
885
+ function translateWhile(cmd) {
886
+ // Bash: last executed body owns status; a test that ends the loop does not.
887
+ // Never-entered loops (`while false; do …; done`, `until true; do …; done`)
888
+ // exit 0. Save the body status and restore it on the failing test.
889
+ const fail = cmd.until ? '$script:fx_exit -eq 0' : '$script:fx_exit -ne 0';
890
+ const lines = ['$fx_wst = 0', 'do {'];
891
+ for (const l of translateListInline(cmd.test).split('\n'))
892
+ lines.push(l ? ' ' + l : l);
893
+ lines.push(' if (' + fail + ') { $script:fx_exit = $fx_wst; break }');
894
+ lines.push(' $script:fx_exit = 0');
895
+ for (const l of translateListInline(cmd.body).split('\n'))
896
+ lines.push(l ? ' ' + l : l);
897
+ lines.push(' $fx_wst = $script:fx_exit');
898
+ lines.push('} while ($true)');
899
+ return lines.join('\n');
900
+ }
782
901
  function stdinTarget(c) {
783
902
  let t = null;
784
903
  for (const r of c.redirects) {
@@ -793,7 +912,24 @@ function stdinReadExpr(target) {
793
912
  return '@()';
794
913
  return '@(fx-readlines ' + pathExpr(n) + ')';
795
914
  }
915
+ /** `>` `>>` `&>` `&>>` — Node last-stage apply cannot honor these on earlier stages. */
916
+ function isStdoutFileRedirect(op) {
917
+ return op === '>' || op === '>>' || op === '&>' || op === '&>>';
918
+ }
919
+ 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) {
921
+ if (commands.length < 2)
922
+ return;
923
+ 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);
926
+ }
927
+ }
928
+ }
796
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);
797
933
  // Every pipeline stage needs its own status slot. Handlers deliberately use
798
934
  // `$script:fx_exit` because their helper functions run in child scopes; in a
799
935
  // pipeline that shared flag lets an earlier failure leak into a successful
@@ -809,6 +945,10 @@ export function translatePipelineBody(p) {
809
945
  bodies.push(translateIf(c));
810
946
  else if (c.kind === 'For')
811
947
  bodies.push(translateFor(c));
948
+ else if (c.kind === 'While')
949
+ bodies.push(translateWhile(c));
950
+ else if (c.kind === 'Case')
951
+ bodies.push(translateCase(c));
812
952
  else
813
953
  bodies.push(translateSimple(c, position, hasStdin));
814
954
  }
@@ -912,6 +1052,10 @@ const WRAP_HELPER_ORDER = [
912
1052
  'fx-csub',
913
1053
  'fx-svenc',
914
1054
  'fx-svdec',
1055
+ 'fx-posload',
1056
+ 'fx-posset',
1057
+ 'fx-posget',
1058
+ 'fx-posshift',
915
1059
  'fx-arrload',
916
1060
  'fx-scalar0',
917
1061
  'fx-ifs1',
@@ -922,6 +1066,9 @@ const WRAP_HELPER_ORDER = [
922
1066
  'fx-arrput',
923
1067
  'fx-arrclr',
924
1068
  'fx-subget',
1069
+ 'fx-casematch',
1070
+ 'fx-subst',
1071
+ 'fx-slice',
925
1072
  'fx-winargv',
926
1073
  'fx-native',
927
1074
  ];
@@ -930,6 +1077,10 @@ const WRAP_HELPER_DEPS = {
930
1077
  'fx-csub': [],
931
1078
  'fx-svenc': [],
932
1079
  'fx-svdec': [],
1080
+ 'fx-posload': ['fx-svdec'],
1081
+ 'fx-posset': ['fx-svenc'],
1082
+ 'fx-posget': ['fx-posload'],
1083
+ 'fx-posshift': ['fx-posload', 'fx-posset'],
933
1084
  'fx-arrload': ['fx-scalar0', 'fx-svdec'],
934
1085
  'fx-scalar0': ['fx-svdec'],
935
1086
  'fx-ifs1': ['fx-scalar0'],
@@ -940,6 +1091,9 @@ const WRAP_HELPER_DEPS = {
940
1091
  'fx-arrput': ['fx-arrdrop', 'fx-svenc'],
941
1092
  'fx-arrclr': ['fx-arrdrop'],
942
1093
  'fx-subget': ['fx-arrload', 'fx-ifs1'],
1094
+ 'fx-casematch': [],
1095
+ 'fx-subst': [],
1096
+ 'fx-slice': [],
943
1097
  'fx-winargv': [],
944
1098
  'fx-native': ['fx-winargv'],
945
1099
  };
@@ -1057,7 +1211,16 @@ export function wrapScript(body, opts = {}) {
1057
1211
  ' $script:fx_csub = $true',
1058
1212
  ' try { $fx_o = @(& $b | ForEach-Object { [string]$_ }) }',
1059
1213
  ' finally { $script:fx_csub = $fx_prevcs }',
1060
- ' $fx_s = ($fx_o -join [string][char]10)',
1214
+ // Line items (no trailing NL) get a NL between them. Chunks that
1215
+ // already end in NL concatenate, so $(echo a; echo b) is a\nb.
1216
+ " $fx_s = ''",
1217
+ ' foreach ($fx_x in $fx_o) {',
1218
+ ' $fx_t = [string]$fx_x',
1219
+ ' if ($fx_s.Length -gt 0 -and $fx_s[$fx_s.Length - 1] -ne [char]10) {',
1220
+ ' $fx_s += [string][char]10',
1221
+ ' }',
1222
+ ' $fx_s += $fx_t',
1223
+ ' }',
1061
1224
  ' while ($fx_s.Length -gt 0 -and $fx_s[$fx_s.Length - 1] -eq [char]10) {',
1062
1225
  ' $fx_s = $fx_s.Substring(0, $fx_s.Length - 1)',
1063
1226
  ' }',
@@ -1088,6 +1251,52 @@ export function wrapScript(body, opts = {}) {
1088
1251
  ' return [string]$sb',
1089
1252
  '}',
1090
1253
  ],
1254
+ 'fx-posload': [
1255
+ 'function fx-posload {',
1256
+ ' if ($null -eq $env:FAUXNIX_POS -or [string]$env:FAUXNIX_POS -eq \'\') { return @() }',
1257
+ ' $out = @()',
1258
+ ' foreach ($el in @($env:FAUXNIX_POS -split [string][char]30)) { $out += ,(fx-svdec $el) }',
1259
+ ' return $out',
1260
+ '}',
1261
+ ],
1262
+ 'fx-posset': [
1263
+ 'function fx-posset($vals) {',
1264
+ ' if ($null -eq $vals) { $vals = @() }',
1265
+ ' $vals = @($vals)',
1266
+ ' if ($vals.Count -eq 0) { $env:FAUXNIX_POS = \'\'; return }',
1267
+ ' $encs = @(); foreach ($v in $vals) { $encs += (fx-svenc $v) }',
1268
+ ' $env:FAUXNIX_POS = ($encs -join [string][char]30)',
1269
+ '}',
1270
+ ],
1271
+ 'fx-posget': [
1272
+ 'function fx-posget($i) {',
1273
+ ' $arr = @(fx-posload)',
1274
+ ' $n = 0',
1275
+ ' if (-not [int]::TryParse([string]$i, [ref]$n)) { return \'\' }',
1276
+ ' if ($n -lt 1 -or $n -gt $arr.Count) { return \'\' }',
1277
+ ' return [string]$arr[$n - 1]',
1278
+ '}',
1279
+ ],
1280
+ 'fx-posshift': [
1281
+ 'function fx-posshift($n) {',
1282
+ ' $arr = @(fx-posload)',
1283
+ ' $i = 1',
1284
+ ' if ($null -ne $n -and [string]$n -ne \'\') {',
1285
+ ' $parsed = 0',
1286
+ ' if (-not [int]::TryParse([string]$n, [ref]$parsed)) {',
1287
+ ' [Console]::Error.WriteLine(\'bash: shift: \' + [string]$n + \': numeric argument required\')',
1288
+ ' $script:fx_exit = 1',
1289
+ ' return',
1290
+ ' }',
1291
+ ' $i = $parsed',
1292
+ ' }',
1293
+ ' if ($i -lt 0 -or $i -gt $arr.Count) { $script:fx_exit = 1; return }',
1294
+ ' if ($i -eq 0) { return }',
1295
+ ' if ($i -eq $arr.Count) { fx-posset @(); return }',
1296
+ ' $new = @($arr[$i..($arr.Count - 1)])',
1297
+ ' fx-posset $new',
1298
+ '}',
1299
+ ],
1091
1300
  'fx-arrload': [
1092
1301
  'function fx-arrload($n) {',
1093
1302
  ' $n = [string]$n',
@@ -1095,8 +1304,10 @@ export function wrapScript(body, opts = {}) {
1095
1304
  ' $fx_eq = $fx_pair.IndexOf([char]61)',
1096
1305
  ' if ($fx_eq -lt 1) { continue }',
1097
1306
  ' if ($fx_pair.Substring(0, $fx_eq) -cne $n) { continue }',
1307
+ ' $pay = $fx_pair.Substring($fx_eq + 1)',
1308
+ ' if ($pay -eq [string][char]1) { return @() }',
1098
1309
  ' $out = @()',
1099
- ' foreach ($el in @($fx_pair.Substring($fx_eq + 1) -split [string][char]30)) { $out += ,(fx-svdec $el) }',
1310
+ ' foreach ($el in @($pay -split [string][char]30)) { $out += ,(fx-svdec $el) }',
1100
1311
  ' return $out',
1101
1312
  ' }',
1102
1313
  ' $s0 = fx-scalar0 $n',
@@ -1178,9 +1389,12 @@ export function wrapScript(body, opts = {}) {
1178
1389
  'fx-arrput': [
1179
1390
  'function fx-arrput($n, $vals) {',
1180
1391
  ' $n = [string]$n',
1181
- ' $vals = @($vals)',
1392
+ ' if ($null -eq $vals) { $vals = @() } else { $vals = @($vals) }',
1182
1393
  ' fx-arrdrop $n',
1183
- ' if ($vals.Count -eq 0) { } else {',
1394
+ ' if ($vals.Count -eq 0) {',
1395
+ // SOH payload: empty array, distinct from scalar '' and from A=('')
1396
+ " $env:FAUXNIX_ARRS = ((@($env:FAUXNIX_ARRS -split [string][char]10 | Where-Object { $_ -ne '' }) + ($n + [string][char]61 + [string][char]1)) -join [string][char]10)",
1397
+ ' } else {',
1184
1398
  ' $encs = @(); foreach ($v in $vals) { $encs += (fx-svenc $v) }',
1185
1399
  " $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)",
1186
1400
  ' }',
@@ -1215,9 +1429,74 @@ export function wrapScript(body, opts = {}) {
1215
1429
  ' return [string]$arr[$i]',
1216
1430
  '}',
1217
1431
  ],
1432
+ 'fx-casematch': [
1433
+ 'function fx-casematch($w, $pats) {',
1434
+ ' $w = [string]$w',
1435
+ ' foreach ($p in @($pats)) {',
1436
+ ' $pat = [string]$p',
1437
+ ' try {',
1438
+ ' $wp = [WildcardPattern]::new($pat, [System.Management.Automation.WildcardOptions]::None)',
1439
+ ' if ($wp.IsMatch($w)) { return $true }',
1440
+ ' } catch {}',
1441
+ ' }',
1442
+ ' return $false',
1443
+ '}',
1444
+ ],
1445
+ 'fx-subst': [
1446
+ 'function fx-subst($s, $pat, $repl, $all) {',
1447
+ ' if ($null -eq $s) { return \'\' }',
1448
+ ' $s = [string]$s',
1449
+ ' $pat = [string]$pat',
1450
+ ' $repl = [string]$repl',
1451
+ ' if ($pat -eq \'\') { return $s }',
1452
+ ' $glob = $false',
1453
+ ' foreach ($fx_c in $pat.ToCharArray()) {',
1454
+ ' if ($fx_c -eq [char]42 -or $fx_c -eq [char]63) { $glob = $true; break }',
1455
+ ' }',
1456
+ ' if (-not $glob) {',
1457
+ ' if ($all) { return $s.Replace($pat, $repl) }',
1458
+ ' $i = $s.IndexOf($pat)',
1459
+ ' if ($i -lt 0) { return $s }',
1460
+ ' return $s.Substring(0, $i) + $repl + $s.Substring($i + $pat.Length)',
1461
+ ' }',
1462
+ ' $sb = New-Object System.Text.StringBuilder',
1463
+ ' foreach ($fx_c in $pat.ToCharArray()) {',
1464
+ ' if ($fx_c -eq [char]42) { [void]$sb.Append(\'.*\'); continue }',
1465
+ ' if ($fx_c -eq [char]63) { [void]$sb.Append(\'.\'); continue }',
1466
+ ' [void]$sb.Append([regex]::Escape([string]$fx_c))',
1467
+ ' }',
1468
+ ' $rx = New-Object System.Text.RegularExpressions.Regex($sb.ToString(), [System.Text.RegularExpressions.RegexOptions]::Singleline)',
1469
+ ' $fx_rep = $repl.Replace([string][char]36, ([string][char]36 + [string][char]36))',
1470
+ ' if ($all) { return $rx.Replace($s, $fx_rep) }',
1471
+ ' return $rx.Replace($s, $fx_rep, 1)',
1472
+ '}',
1473
+ ],
1474
+ 'fx-slice': [
1475
+ 'function fx-slice($s, $off, $len) {',
1476
+ ' if ($null -eq $s) { return \'\' }',
1477
+ ' $s = [string]$s',
1478
+ ' $n = $s.Length',
1479
+ ' $o = 0',
1480
+ ' if (-not [int]::TryParse([string]$off, [ref]$o)) { return \'\' }',
1481
+ ' if ($o -lt 0) { $o = $n + $o }',
1482
+ ' if ($o -lt 0 -or $o -ge $n) { return \'\' }',
1483
+ ' if ($null -eq $len -or [string]$len -eq \'\') { return $s.Substring($o) }',
1484
+ ' $l = 0',
1485
+ ' if (-not [int]::TryParse([string]$len, [ref]$l)) { return \'\' }',
1486
+ ' if ($l -lt 0) {',
1487
+ ' $fx_end = $n + $l',
1488
+ ' if ($fx_end -lt $o) { return \'\' }',
1489
+ ' $l = $fx_end - $o',
1490
+ ' }',
1491
+ ' if ($l -le 0) { return \'\' }',
1492
+ ' if (($o + $l) -gt $n) { $l = $n - $o }',
1493
+ ' return $s.Substring($o, $l)',
1494
+ '}',
1495
+ ],
1218
1496
  'fx-winargv': [
1219
- 'function fx-winargv($argv) {',
1497
+ 'function fx-winargv($argv, $cmdmeta) {',
1220
1498
  // Empty [object[]] unwraps to $null on PS 5.1; @($null) is one empty arg.
1499
+ // $cmdmeta: also quote & | () <> ^ so cmd.exe /c does not split the tail.
1221
1500
  ' if ($null -eq $argv) { $argv = @() }',
1222
1501
  ' $parts = New-Object System.Collections.Generic.List[string]',
1223
1502
  ' foreach ($a in @($argv)) {',
@@ -1226,6 +1505,7 @@ export function wrapScript(body, opts = {}) {
1226
1505
  ' $need = $false',
1227
1506
  ' foreach ($ch in $s.ToCharArray()) {',
1228
1507
  " if ($ch -eq ' ' -or $ch -eq ([char]9) -or $ch -eq [char]34) { $need = $true; break }",
1508
+ " 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 }",
1229
1509
  ' }',
1230
1510
  ' if (-not $need) { $parts.Add($s); continue }',
1231
1511
  ' $sb = New-Object System.Text.StringBuilder',
@@ -1259,7 +1539,14 @@ export function wrapScript(body, opts = {}) {
1259
1539
  // operator is only for names that are not executables.
1260
1540
  ' $cmd = Get-Command -Name $name -ErrorAction SilentlyContinue | Select-Object -First 1',
1261
1541
  ' if ($null -eq $cmd) {',
1262
- " [Console]::Error.WriteLine('bash: ' + $name + ': command not found')",
1542
+ " $fx_nf = 'bash: ' + $name + ': command not found'",
1543
+ " $fx_n = [string]$name",
1544
+ // Hint only — never alias python3→python (wrong interpreter).
1545
+ " if ($fx_n -eq 'python3' -or $fx_n -eq 'python3.exe') { $fx_nf += '" +
1546
+ PYTHON3_WINDOWS_HINT +
1547
+ "' }",
1548
+ " elseif ($fx_n -like '*.sh') { $fx_nf += '" + SH_SCRIPT_WINDOWS_HINT + "' }",
1549
+ ' [Console]::Error.WriteLine($fx_nf)',
1263
1550
  ' $script:fx_exit = 127',
1264
1551
  ' return',
1265
1552
  ' }',
@@ -1272,6 +1559,8 @@ export function wrapScript(body, opts = {}) {
1272
1559
  ' $psi = New-Object System.Diagnostics.ProcessStartInfo',
1273
1560
  ' $ext = [IO.Path]::GetExtension([string]$app.Source)',
1274
1561
  // 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.
1275
1564
  " if ($ext -eq '.cmd' -or $ext -eq '.bat') {",
1276
1565
  ' $comspec = Get-Command -Name cmd -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1',
1277
1566
  ' if ($null -eq $comspec) {',
@@ -1280,8 +1569,10 @@ export function wrapScript(body, opts = {}) {
1280
1569
  ' return',
1281
1570
  ' }',
1282
1571
  ' $psi.FileName = $comspec.Source',
1283
- ' $fx_rest = fx-winargv $argv',
1284
- " if ($fx_rest.Length -gt 0) { $psi.Arguments = '/d /s /c ' + (fx-winargv $app.Source) + ' ' + $fx_rest } else { $psi.Arguments = '/d /s /c ' + (fx-winargv $app.Source) }",
1572
+ ' $fx_app = fx-winargv $app.Source $true',
1573
+ ' $fx_rest = fx-winargv $argv $true',
1574
+ " 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 + \'"\'',
1285
1576
  ' } else {',
1286
1577
  ' $psi.FileName = $app.Source',
1287
1578
  ' $psi.Arguments = fx-winargv $argv',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fauxnix-cli",
3
- "version": "0.9.2",
3
+ "version": "0.11.0",
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": {