fauxnix-cli 0.9.3 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import { FauxnixParseError, isUnquotedLiteral, wordToString } from '../ast.js';
2
- import { lookup, parseWords, psStr, registeredNames } from '../registry.js';
3
- import { argListExpr, exprOfWord, operandExpr, translateSimple, wrapTempEnv, encodeSetValExpr, escapeDq, translateCmdSub, normalizeLiteralPath, pathExpr, paramExpr, varExpr, arithExpr, setArithHelperPreamble, } from '../translator.js';
2
+ import { lookup, parseWords, psErr, psStr, registeredNames } from '../registry.js';
3
+ import { argListExpr, exprOfWord, literalOfWord, operandExpr, translateSimple, wrapTempEnv, encodeSetValExpr, escapeDq, translateCmdSub, normalizeLiteralPath, pathExpr, paramExpr, varExpr, varExtraOf, arithExpr, setArithHelperPreamble, isSpecialShellVar, EXECUTE_TRANSLATION, PURE_TRANSLATION, } from '../translator.js';
4
4
  import { handlers as textIoHandlers } from './text-io.js';
5
5
  /* ------------------------------------------------------------------ */
6
6
  /* Shared TS helpers */
@@ -62,6 +62,32 @@ function splitAssignWord(w) {
62
62
  }
63
63
  return null;
64
64
  }
65
+ /** Remove an unquoted literal option prefix while preserving dynamic Word parts. */
66
+ function wordAfterPrefix(w, prefix) {
67
+ let remaining = prefix;
68
+ const out = [];
69
+ for (const part of w) {
70
+ if (remaining === '') {
71
+ out.push(part);
72
+ continue;
73
+ }
74
+ if (part.kind !== 'Text')
75
+ return null;
76
+ if (remaining.startsWith(part.text)) {
77
+ remaining = remaining.slice(part.text.length);
78
+ continue;
79
+ }
80
+ if (part.text.startsWith(remaining)) {
81
+ const tail = part.text.slice(remaining.length);
82
+ if (tail !== '')
83
+ out.push({ kind: 'Text', text: tail });
84
+ remaining = '';
85
+ continue;
86
+ }
87
+ return null;
88
+ }
89
+ return remaining === '' ? out : null;
90
+ }
65
91
  /** Text Words -> PS array expression. */
66
92
  function textArgs(words) {
67
93
  return argListExpr(words, exprOfWord);
@@ -97,10 +123,10 @@ const PS_WHICH_FN = [
97
123
  " foreach ($fx_d in ($env:PATH -split ';')) {",
98
124
  " if ($fx_d -eq '') { continue }",
99
125
  ' $fx_c = Join-Path $fx_d $n',
100
- ' if (Test-Path -LiteralPath $fx_c) { return $fx_c }',
126
+ ' if (Test-Path -LiteralPath $fx_c -PathType Leaf) { return $fx_c }',
101
127
  " $fx_exts = @(($env:PATHEXT -split ';') + '.exe') | Select-Object -Unique",
102
128
  ' foreach ($fx_e in $fx_exts) {',
103
- ' if (Test-Path -LiteralPath ($fx_c + $fx_e)) { return ($fx_c + $fx_e) }',
129
+ ' if (Test-Path -LiteralPath ($fx_c + $fx_e) -PathType Leaf) { return ($fx_c + $fx_e) }',
104
130
  ' }',
105
131
  ' }',
106
132
  " return ''",
@@ -112,23 +138,24 @@ const PS_MON_FN = "function fx-mon($m) { return @('Jan','Feb','Mar','Apr','May',
112
138
  /* cd / pwd */
113
139
  /* ------------------------------------------------------------------ */
114
140
  const cd = (args) => {
115
- if (args.length === 0) {
141
+ const { operandWords } = parseWords(args);
142
+ if (operandWords.length === 0) {
116
143
  return [
117
144
  'try { Set-Location -LiteralPath $HOME }',
118
145
  "catch { [Console]::Error.WriteLine('bash: cd: ' + $_.Exception.Message); $script:fx_exit = 1 }",
119
146
  ].join('\n');
120
147
  }
121
- if (args.length > 1) {
148
+ if (operandWords.length > 1) {
122
149
  return "[Console]::Error.WriteLine('bash: cd: too many arguments'); $script:fx_exit = 1";
123
150
  }
124
- if (wordToString(args[0]) === '-') {
151
+ if (wordToString(operandWords[0]) === '-') {
125
152
  return [
126
153
  "if (-not $env:FAUXNIX_OLDPWD) { [Console]::Error.WriteLine('bash: cd: OLDPWD not set'); $script:fx_exit = 1 }",
127
154
  'else { try { Set-Location -LiteralPath $env:FAUXNIX_OLDPWD } catch { [Console]::Error.WriteLine("bash: cd: " + $env:FAUXNIX_OLDPWD + ": No such file or directory"); $script:fx_exit = 1 } }',
128
155
  ].join('\n');
129
156
  }
130
157
  return [
131
- '$fx_ds = ' + argListExpr([args[0]]),
158
+ '$fx_ds = ' + argListExpr([operandWords[0]]),
132
159
  "if ($fx_ds.Count -ne 1) { [Console]::Error.WriteLine('bash: cd: too many arguments'); $script:fx_exit = 1 }",
133
160
  'else { $fx_d = [string]$fx_ds[0]',
134
161
  'if (-not (Test-Path -LiteralPath $fx_d)) { [Console]::Error.WriteLine("bash: cd: " + $fx_d + ": No such file or directory"); $script:fx_exit = 1 }',
@@ -137,7 +164,10 @@ const cd = (args) => {
137
164
  ].join('\n');
138
165
  };
139
166
  const pwd = (args) => {
140
- void args; // -P accepted; physical == logical for Windows providers
167
+ const { operandWords } = parseWords(args);
168
+ if (operandWords.length > 0)
169
+ return psErr('pwd', "extra operand '" + wordToString(operandWords[0]) + "'. Try 'pwd --help'.");
170
+ // -L is the default. CommandSpec rejects -P because junctions make it distinct.
141
171
  return "(Get-Location).Path.Replace('\\', '/')";
142
172
  };
143
173
  /* ------------------------------------------------------------------ */
@@ -150,8 +180,10 @@ const exportCmd = (args) => {
150
180
  const sets = [];
151
181
  for (const w of args) {
152
182
  const t = wordToString(w);
183
+ if (t === '-')
184
+ return psErr('export', "invalid option '-'. Try 'export --help'.");
153
185
  if (t.startsWith('-'))
154
- continue; // -n / -f accepted and ignored
186
+ continue; // validated by CommandSpec
155
187
  const sp = splitAssignWord(w);
156
188
  if (sp === null)
157
189
  continue; // `export VAR` (bare name) -> no-op success
@@ -202,31 +234,68 @@ const env = (args, ctx) => {
202
234
  const unsets = [];
203
235
  let i = 0;
204
236
  let cmdIdx = -1;
205
- while (i < raw.length) {
206
- const t = raw[i];
207
- if (t === '--') {
208
- cmdIdx = i + 1;
209
- break;
210
- }
211
- if (t === '-i' || t === '--ignore-environment') {
237
+ let optionsDone = false;
238
+ const addUnset = (word) => {
239
+ const name = word ? literalOfWord(word) : null;
240
+ if (name === null || !NAME_RE.test(name)) {
212
241
  return ('[Console]::Error.WriteLine(' +
213
- psStr('fauxnix: env -i/--ignore-environment is not supported (would silently keep inherited secrets)') +
214
- '); $script:fx_exit = 2');
215
- }
216
- if (t === '-u' || t === '--unset') {
217
- if (i + 1 < raw.length)
218
- unsets.push(raw[i + 1]);
219
- i += 2;
220
- continue;
242
+ psStr('env: -u/--unset requires a literal variable name in fauxnix; expand and unset the name in a separate command instead') +
243
+ '); $script:fx_exit = 125');
221
244
  }
222
- if (t.startsWith('-u=') || t.startsWith('--unset=')) {
223
- unsets.push(t.slice(t.indexOf('=') + 1));
224
- i++;
225
- continue;
226
- }
227
- if (t.startsWith('-')) {
228
- i++; // unknown flags ignored
229
- continue;
245
+ unsets.push(name);
246
+ return null;
247
+ };
248
+ while (i < raw.length) {
249
+ const t = raw[i];
250
+ if (!optionsDone) {
251
+ if (t === '-') {
252
+ return ('[Console]::Error.WriteLine(' +
253
+ psStr('env: option - (ignore environment) is not supported by fauxnix; use env -u NAME or unset first') +
254
+ '); $script:fx_exit = 125');
255
+ }
256
+ if (t === '--') {
257
+ optionsDone = true;
258
+ i++;
259
+ continue;
260
+ }
261
+ if (t === '-i' || t === '--ignore-environment') {
262
+ return ('[Console]::Error.WriteLine(' +
263
+ psStr('fauxnix: env -i/--ignore-environment is not supported (would silently keep inherited secrets). Use env -u NAME or unset first instead.') +
264
+ '); $script:fx_exit = 2');
265
+ }
266
+ if (t === '-u' || t === '--unset') {
267
+ const err = addUnset(args[i + 1]);
268
+ if (err)
269
+ return err;
270
+ i += 2;
271
+ continue;
272
+ }
273
+ if (t.startsWith('-u=')) {
274
+ const err = addUnset(wordAfterPrefix(args[i], '-u=') ?? undefined);
275
+ if (err)
276
+ return err;
277
+ i++;
278
+ continue;
279
+ }
280
+ if (t.startsWith('--unset=')) {
281
+ const err = addUnset(wordAfterPrefix(args[i], '--unset=') ?? undefined);
282
+ if (err)
283
+ return err;
284
+ i++;
285
+ continue;
286
+ }
287
+ if (t.startsWith('-u') && t.length > 2) {
288
+ const err = addUnset(wordAfterPrefix(args[i], '-u') ?? undefined);
289
+ if (err)
290
+ return err;
291
+ i++;
292
+ continue;
293
+ }
294
+ if (t.startsWith('-')) {
295
+ i++; // unknown flags are rejected by CommandSpec
296
+ continue;
297
+ }
298
+ optionsDone = true;
230
299
  }
231
300
  const sp = splitAssignWord(args[i]);
232
301
  if (sp !== null && NAME_RE.test(sp.name)) {
@@ -240,7 +309,7 @@ const env = (args, ctx) => {
240
309
  const lines = [];
241
310
  if (cmdIdx >= 0 && cmdIdx < args.length) {
242
311
  const cmdWords = args.slice(cmdIdx);
243
- lines.push(translateSimple({ kind: 'SimpleCommand', assignments: [], name: cmdWords[0], args: cmdWords.slice(1), redirects: [] }, ctx.position, ctx.hasStdin));
312
+ lines.push(translateSimple({ kind: 'SimpleCommand', assignments: [], name: cmdWords[0], args: cmdWords.slice(1), redirects: [] }, ctx.position, ctx.hasStdin, ctx.translationMode === 'pure' ? PURE_TRANSLATION : EXECUTE_TRANSLATION));
244
313
  }
245
314
  else {
246
315
  lines.push(ENV_LIST_PS);
@@ -249,7 +318,7 @@ const env = (args, ctx) => {
249
318
  return wrapTempEnv(sets, lines.join('\n'), { unsets });
250
319
  };
251
320
  const printenv = (args) => {
252
- const names = stripFlags(args);
321
+ const { operandWords: names } = parseWords(args);
253
322
  if (names.length === 0)
254
323
  return ENV_LIST_PS;
255
324
  return [
@@ -264,11 +333,12 @@ const printenv = (args) => {
264
333
  /* ps */
265
334
  /* ------------------------------------------------------------------ */
266
335
  const ps = (args) => {
267
- const raw = args.map(wordToString);
268
336
  const { flags, operandWords } = parseWords(args);
269
- const full = operandWords.some((w) => wordToString(w) === 'aux') ||
270
- (flags.has('e') && flags.has('f')) ||
271
- raw.includes('-ef');
337
+ const badOperand = operandWords.find((w) => wordToString(w) !== 'aux');
338
+ if (badOperand) {
339
+ return psErr('ps', "unsupported operand '" + wordToString(badOperand) + "'. Use 'ps', 'ps -ef', or 'ps aux'.");
340
+ }
341
+ const full = operandWords.some((w) => wordToString(w) === 'aux') || flags.has('f');
272
342
  if (full) {
273
343
  return [
274
344
  PS_MON_FN,
@@ -516,7 +586,7 @@ const sleep = (args) => {
516
586
  const which = (args) => {
517
587
  const ws = stripFlags(args);
518
588
  if (ws.length === 0)
519
- return '';
589
+ return psErr('which', "missing command name. Try 'which --help'.");
520
590
  return [
521
591
  PS_WHICH_FN,
522
592
  '$fx_b = @(' + builtinNames().map(psStr).join(', ') + ')',
@@ -529,7 +599,7 @@ const which = (args) => {
529
599
  ].join('\n');
530
600
  };
531
601
  const type = (args) => {
532
- const ws = stripFlags(args);
602
+ const { operandWords: ws } = parseWords(args);
533
603
  if (ws.length === 0)
534
604
  return '';
535
605
  return [
@@ -549,11 +619,16 @@ const type = (args) => {
549
619
  /** `command -v name` (and bare `command name args` as a no-alias run). */
550
620
  const commandCmd = (args, ctx) => {
551
621
  let i = 0;
552
- let identify = false;
622
+ let identify = null;
553
623
  while (i < args.length) {
554
624
  const t = wordToString(args[i]);
555
- if (t === '-v' || t === '-V') {
556
- identify = true;
625
+ if (t === '-v') {
626
+ identify = 'path';
627
+ i++;
628
+ continue;
629
+ }
630
+ if (t === '-V') {
631
+ identify = 'verbose';
557
632
  i++;
558
633
  continue;
559
634
  }
@@ -561,14 +636,14 @@ const commandCmd = (args, ctx) => {
561
636
  i++;
562
637
  break;
563
638
  }
564
- if (t.startsWith('-')) {
639
+ if (t.startsWith('-') && t !== '-') {
565
640
  i++;
566
641
  continue;
567
642
  }
568
643
  break;
569
644
  }
570
645
  const rest = args.slice(i);
571
- if (identify) {
646
+ if (identify !== null) {
572
647
  if (rest.length === 0)
573
648
  return '';
574
649
  return [
@@ -576,11 +651,15 @@ const commandCmd = (args, ctx) => {
576
651
  '$fx_b = @(' + builtinNames().map(psStr).join(', ') + ')',
577
652
  '$fx_ns = ' + textArgs(rest),
578
653
  'foreach ($fx_n in $fx_ns) {',
579
- " if ($fx_b -contains $fx_n) { '/usr/bin/' + $fx_n }",
654
+ identify === 'verbose'
655
+ ? " if ($fx_b -contains $fx_n) { $fx_n + ' is /usr/bin/' + $fx_n }"
656
+ : " if ($fx_b -contains $fx_n) { '/usr/bin/' + $fx_n }",
580
657
  ' else {',
581
658
  ' $fx_w = fx-which $fx_n',
582
659
  " if ($fx_w -eq '') { $script:fx_exit = 1 }",
583
- " else { $fx_w.Replace('\\', '/') }",
660
+ identify === 'verbose'
661
+ ? " else { $fx_n + ' is ' + $fx_w.Replace('\\', '/') }"
662
+ : " else { $fx_w.Replace('\\', '/') }",
584
663
  ' }',
585
664
  '}',
586
665
  ].join('\n');
@@ -593,15 +672,28 @@ const commandCmd = (args, ctx) => {
593
672
  name: rest[0],
594
673
  args: rest.slice(1),
595
674
  redirects: [],
596
- }, ctx.position, ctx.hasStdin);
675
+ }, ctx.position, ctx.hasStdin, ctx.translationMode === 'pure' ? PURE_TRANSLATION : EXECUTE_TRANSLATION);
597
676
  };
598
677
  /* ------------------------------------------------------------------ */
599
678
  /* whoami / id / groups */
600
679
  /* ------------------------------------------------------------------ */
601
- const whoami = () => '$env:USERNAME.ToLower()';
680
+ const whoami = (args) => {
681
+ const { operandWords } = parseWords(args);
682
+ if (operandWords.length > 0)
683
+ return psErr('whoami', "extra operand '" + wordToString(operandWords[0]) + "'. Try 'whoami --help'.");
684
+ return '$env:USERNAME.ToLower()';
685
+ };
602
686
  const id = (args) => {
603
- const { flags } = parseWords(args);
604
- const wantNum = flags.has('u') || flags.has('g') || flags.has('G');
687
+ const { flags, operandWords } = parseWords(args);
688
+ if (operandWords.length > 0) {
689
+ return psErr('id', 'user operands are not supported; omit USER to inspect the current account');
690
+ }
691
+ const selectors = ['u', 'g', 'G'].filter((flag) => flags.has(flag));
692
+ if (selectors.length > 1)
693
+ return psErr('id', 'cannot print multiple ID selectors together');
694
+ if (flags.has('n') && selectors.length === 0)
695
+ return psErr('id', "option '-n' requires -u or -g");
696
+ const wantNum = selectors.length === 1;
605
697
  const wantName = flags.has('n');
606
698
  const uidPs = [
607
699
  '$fx_sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value',
@@ -616,7 +708,11 @@ const id = (args) => {
616
708
  "'uid=' + $fx_uid + '(' + $env:USERNAME + ') gid=' + $fx_uid + '(' + $env:USERNAME + ') groups=' + $fx_uid + '(' + $env:USERNAME + ')'",
617
709
  ].join('\n');
618
710
  };
619
- const groups = () => {
711
+ const groups = (args) => {
712
+ const { operandWords } = parseWords(args);
713
+ if (operandWords.length > 0) {
714
+ return psErr('groups', 'user operands are not supported; omit USER to inspect the current account');
715
+ }
620
716
  return [
621
717
  '$fx_id = [System.Security.Principal.WindowsIdentity]::GetCurrent()',
622
718
  '$fx_gs = @()',
@@ -699,8 +795,8 @@ const DATE_FNS = [
699
795
  '}',
700
796
  ].join('\n');
701
797
  const date = (args) => {
702
- const { flags, operandWords } = parseWords(args, ['d']);
703
- const utc = flags.has('u');
798
+ const { flags, longs, operandWords } = parseWords(args, ['d'], ['--date']);
799
+ const utc = flags.has('u') || longs.has('--utc');
704
800
  // find the -d value as a Word (so dynamic values stay PS expressions)
705
801
  const raw = args.map(wordToString);
706
802
  let dWord = null;
@@ -709,15 +805,34 @@ const date = (args) => {
709
805
  dWord = args[k + 1] ?? null;
710
806
  break;
711
807
  }
808
+ if (raw[k].startsWith('--date=')) {
809
+ dWord = wordAfterPrefix(args[k], '--date=');
810
+ break;
811
+ }
812
+ if (raw[k].startsWith('-') && !raw[k].startsWith('--')) {
813
+ const body = raw[k].slice(1);
814
+ const dIndex = body.indexOf('d');
815
+ if (dIndex >= 0) {
816
+ const prefix = '-' + body.slice(0, dIndex + 1);
817
+ const attached = wordAfterPrefix(args[k], prefix);
818
+ dWord = attached && attached.length > 0 ? attached : (args[k + 1] ?? null);
819
+ break;
820
+ }
821
+ }
822
+ }
823
+ const fmtWords = operandWords.filter((w) => wordToString(w).startsWith('+'));
824
+ const badOperand = operandWords.find((w) => !wordToString(w).startsWith('+'));
825
+ if (badOperand || fmtWords.length > 1) {
826
+ return psErr('date', "extra operand '" + wordToString(badOperand ?? fmtWords[1]) + "'. Try 'date --help'.");
712
827
  }
713
- const fmtWord = operandWords.find((w) => wordToString(w).startsWith('+'));
828
+ const fmtWord = fmtWords[0];
714
829
  const lines = [DATE_FNS];
715
830
  lines.push('$fx_d = ' + (utc ? '[DateTime]::UtcNow' : 'Get-Date'));
716
831
  lines.push('$fx_ok = $true');
717
832
  if (dWord !== null) {
718
833
  lines.push('$fx_ds = ' + exprOfWord(dWord));
719
834
  lines.push("if ($fx_ds -like '@*') {");
720
- lines.push(" try { $fx_n = [long]($fx_ds.Substring(1)); $fx_d = ([datetime]'1970-01-01').AddSeconds($fx_n)" +
835
+ lines.push(" try { $fx_n = [long]($fx_ds.Substring(1)); $fx_epoch = [DateTime]::SpecifyKind([datetime]'1970-01-01', [DateTimeKind]::Utc); $fx_d = $fx_epoch.AddSeconds($fx_n)" +
721
836
  (utc ? ' }' : '.ToLocalTime() }'));
722
837
  lines.push(' catch { $fx_ok = $false }');
723
838
  lines.push('} else { $fx_ok = $false }');
@@ -740,31 +855,43 @@ const date = (args) => {
740
855
  /* ------------------------------------------------------------------ */
741
856
  const ARCH_PS = "($(if ($env:PROCESSOR_ARCHITECTURE -like 'ARM*') { 'ARM64' } else { 'x86_64' }))";
742
857
  const uname = (args) => {
743
- const { flags } = parseWords(args);
744
- if (flags.has('a')) {
858
+ const { flags, longs, operandWords } = parseWords(args);
859
+ if (operandWords.length > 0)
860
+ return psErr('uname', "extra operand '" + wordToString(operandWords[0]) + "'. Try 'uname --help'.");
861
+ const has = (short, long) => flags.has(short) || longs.has(long);
862
+ if (has('a', '--all')) {
745
863
  return ("('Linux ' + $env:COMPUTERNAME + ' 6.8.0-fauxnix #1 SMP PREEMPT_DYNAMIC ' + " +
746
864
  ARCH_PS +
747
865
  " + ' GNU/Linux')");
748
866
  }
749
867
  const parts = [];
750
- if (flags.has('s') || flags.size === 0)
868
+ if (has('s', '--kernel-name') || (flags.size === 0 && longs.size === 0))
751
869
  parts.push("'Linux'");
752
- if (flags.has('n'))
870
+ if (has('n', '--nodename'))
753
871
  parts.push('$env:COMPUTERNAME');
754
- if (flags.has('r'))
872
+ if (has('r', '--kernel-release'))
755
873
  parts.push("'6.8.0-fauxnix'");
756
- if (flags.has('v'))
874
+ if (has('v', '--kernel-version'))
757
875
  parts.push("'#1 SMP PREEMPT_DYNAMIC'");
758
- if (flags.has('m') || flags.has('p'))
876
+ if (has('m', '--machine') || has('p', '--processor'))
759
877
  parts.push(ARCH_PS);
760
- if (flags.has('o'))
878
+ if (has('o', '--operating-system'))
761
879
  parts.push("'GNU/Linux'");
762
880
  if (parts.length === 0)
763
881
  parts.push("'Linux'");
764
882
  return '(@(' + parts.join(', ') + ") -join ' ')";
765
883
  };
766
- const hostname = () => '$env:COMPUTERNAME';
767
- const uptime = () => {
884
+ const hostname = (args) => {
885
+ const { operandWords } = parseWords(args);
886
+ if (operandWords.length > 0) {
887
+ return psErr('hostname', 'setting or selecting a host is not supported; omit operands to print the current hostname');
888
+ }
889
+ return '$env:COMPUTERNAME';
890
+ };
891
+ const uptime = (args) => {
892
+ const { operandWords } = parseWords(args);
893
+ if (operandWords.length > 0)
894
+ return psErr('uptime', "extra operand '" + wordToString(operandWords[0]) + "'. Try 'uptime --help'.");
768
895
  return [
769
896
  '$fx_now = Get-Date',
770
897
  '$fx_os = Get-CimInstance Win32_OperatingSystem',
@@ -777,8 +904,49 @@ const uptime = () => {
777
904
  ].join('\n');
778
905
  };
779
906
  const free = (args) => {
780
- const { flags } = parseWords(args);
781
- const unit = flags.has('h') ? 'h' : flags.has('g') ? 'g' : flags.has('m') ? 'm' : 'k';
907
+ const { flags, longs, operandWords } = parseWords(args);
908
+ if (operandWords.length > 0)
909
+ return psErr('free', "extra operand '" + wordToString(operandWords[0]) + "'. Try 'free --help'.");
910
+ void flags;
911
+ void longs;
912
+ let unit = 'k';
913
+ let sawHuman = false;
914
+ let nonHumanUnits = 0;
915
+ for (const raw of args.map(wordToString)) {
916
+ if (raw === '--human') {
917
+ unit = 'h';
918
+ sawHuman = true;
919
+ }
920
+ else if (raw === '--gibi') {
921
+ unit = 'g';
922
+ nonHumanUnits++;
923
+ }
924
+ else if (raw === '--mebi') {
925
+ unit = 'm';
926
+ nonHumanUnits++;
927
+ }
928
+ else if (raw === '--kibi') {
929
+ unit = 'k';
930
+ nonHumanUnits++;
931
+ }
932
+ else if (raw.startsWith('-') && !raw.startsWith('--')) {
933
+ for (const flag of raw.slice(1)) {
934
+ if (flag === 'h') {
935
+ unit = 'h';
936
+ sawHuman = true;
937
+ }
938
+ else if (flag === 'g' || flag === 'm' || flag === 'k') {
939
+ unit = flag;
940
+ nonHumanUnits++;
941
+ }
942
+ }
943
+ }
944
+ }
945
+ if (!sawHuman && nonHumanUnits > 1) {
946
+ return psErr('free', 'multiple unit options are not supported together; choose one of -k, -m, or -g');
947
+ }
948
+ if (sawHuman)
949
+ unit = 'h';
782
950
  // value converter for KB-based memory values
783
951
  let conv;
784
952
  if (unit === 'k')
@@ -819,11 +987,20 @@ const free = (args) => {
819
987
  ").TrimEnd()");
820
988
  return lines.join('\n');
821
989
  };
822
- const nproc = () => '[string][Environment]::ProcessorCount';
990
+ const nproc = (args) => {
991
+ const { operandWords } = parseWords(args);
992
+ if (operandWords.length > 0)
993
+ return psErr('nproc', "extra operand '" + wordToString(operandWords[0]) + "'. Try 'nproc --help'.");
994
+ return '[string][Environment]::ProcessorCount';
995
+ };
823
996
  /* ------------------------------------------------------------------ */
824
997
  /* clear / true / false / : */
825
998
  /* ------------------------------------------------------------------ */
826
- const clear = () => "([char]27 + '[2J' + [char]27 + '[H')";
999
+ const clear = (args) => {
1000
+ if (args.length > 0)
1001
+ return psErr('clear', "extra operand '" + wordToString(args[0]) + "'. Try 'clear --help'.");
1002
+ return "([char]27 + '[2J' + [char]27 + '[H')";
1003
+ };
827
1004
  const trueCmd = () => '';
828
1005
  const falseCmd = () => '$script:fx_exit = 1';
829
1006
  const colon = () => '';
@@ -1398,6 +1575,10 @@ function kshExprOfWord(w) {
1398
1575
  return arithExpr(expanded[0].parts);
1399
1576
  }
1400
1577
  if (!tilde && expanded.length === 1 && expanded[0].kind === 'Var') {
1578
+ const extra = varExtraOf(expanded[0]);
1579
+ if (extra && (extra.replace || extra.slice)) {
1580
+ return varExpr(expanded[0].name, expanded[0].index, expanded[0].param, expanded[0].length === true, extra);
1581
+ }
1401
1582
  if (expanded[0].param) {
1402
1583
  return paramExpr(expanded[0].name, expanded[0].param.op, expanded[0].param.word);
1403
1584
  }
@@ -1407,6 +1588,8 @@ function kshExprOfWord(w) {
1407
1588
  if (expanded[0].index !== undefined) {
1408
1589
  return '(fx-subget ' + psStr(expanded[0].name) + ' ' + psStr(expanded[0].index) + ')';
1409
1590
  }
1591
+ if (isSpecialShellVar(expanded[0].name))
1592
+ return varExpr(expanded[0].name);
1410
1593
  return '(fx-envget ' + psStr(expanded[0].name) + ')';
1411
1594
  }
1412
1595
  const literal = !tilde && expanded.every((p) => p.kind === 'Text' || p.kind === 'SingleQuoted');
@@ -1427,14 +1610,28 @@ function kshExprOfWord(w) {
1427
1610
  for (const q of p.parts)
1428
1611
  emitPart(q);
1429
1612
  break;
1430
- case 'Var':
1431
- out +=
1432
- p.param
1433
- ? '$(' + paramExpr(p.name, p.param.op, p.param.word) + ')'
1434
- : p.index !== undefined
1435
- ? '$(fx-subget ' + psStr(p.name) + ' ' + psStr(p.index) + ')'
1436
- : '$(fx-envget ' + psStr(p.name) + ')';
1613
+ case 'Var': {
1614
+ const extra = varExtraOf(p);
1615
+ if (extra && (extra.replace || extra.slice)) {
1616
+ out += '$(' + varExpr(p.name, p.index, p.param, p.length === true, extra) + ')';
1617
+ }
1618
+ else if (p.param) {
1619
+ out += '$(' + paramExpr(p.name, p.param.op, p.param.word) + ')';
1620
+ }
1621
+ else if (p.length) {
1622
+ out += '$(' + varExpr(p.name, p.index, undefined, true) + ')';
1623
+ }
1624
+ else if (p.index !== undefined) {
1625
+ out += '$(fx-subget ' + psStr(p.name) + ' ' + psStr(p.index) + ')';
1626
+ }
1627
+ else if (isSpecialShellVar(p.name)) {
1628
+ out += '$(' + varExpr(p.name) + ')';
1629
+ }
1630
+ else {
1631
+ out += '$(fx-envget ' + psStr(p.name) + ')';
1632
+ }
1437
1633
  break;
1634
+ }
1438
1635
  case 'CmdSub':
1439
1636
  // [[ ]] does not IFS-split, so keep the newline contract.
1440
1637
  out += '$(' + translateCmdSub(p.cmd, true) + ')';
@@ -2309,7 +2506,7 @@ const timeout = (args, ctx) => {
2309
2506
  return "[Console]::Error.WriteLine('timeout: missing operand'); $script:fx_exit = 125";
2310
2507
  }
2311
2508
  // NOTE: stdin is not forwarded into the job — documented limitation.
2312
- const inner = translateSimple({ kind: 'SimpleCommand', assignments: [], name: cmdWords[0], args: cmdWords.slice(1), redirects: [] }, ctx.position, false);
2509
+ const inner = translateSimple({ kind: 'SimpleCommand', assignments: [], name: cmdWords[0], args: cmdWords.slice(1), redirects: [] }, ctx.position, false, ctx.translationMode === 'pure' ? PURE_TRANSLATION : EXECUTE_TRANSLATION);
2313
2510
  const innerLines = inner.split('\n').map((l) => (l ? ' ' + l : l));
2314
2511
  return [
2315
2512
  '$fx_tn = ' + exprOfWord(nWord),
@@ -2478,7 +2675,7 @@ const source = (args) => {
2478
2675
  ].join('\n');
2479
2676
  };
2480
2677
  const evalCmd = () => {
2481
- return ("[Console]::Error.WriteLine('fauxnix: eval is not supported; pass the command itself'); $script:fx_exit = 1");
2678
+ return ("[Console]::Error.WriteLine('fauxnix: eval is not supported; pass the command itself instead'); $script:fx_exit = 1");
2482
2679
  };
2483
2680
  const exitCmd = (args) => {
2484
2681
  const w = args[0];
@@ -2510,9 +2707,15 @@ const alias = (args) => {
2510
2707
  });
2511
2708
  if (!has)
2512
2709
  return '';
2513
- return "[Console]::Error.WriteLine('fauxnix: alias is not supported'); $script:fx_exit = 1";
2710
+ return "[Console]::Error.WriteLine('fauxnix: alias is not supported. Invoke the real command instead.'); $script:fx_exit = 1";
2514
2711
  };
2515
2712
  const set = (args) => {
2713
+ if (args.length > 0 && wordToString(args[0]) === '--') {
2714
+ return [
2715
+ '$fx_pv = [object[]](' + argListExpr(args.slice(1)) + ')',
2716
+ 'fx-posset $fx_pv',
2717
+ ].join('\n');
2718
+ }
2516
2719
  const raw = args.map(wordToString);
2517
2720
  const unsupported = raw.filter((t) => t === '-e' ||
2518
2721
  t === '-u' ||
@@ -2529,12 +2732,336 @@ const set = (args) => {
2529
2732
  /^-.*[eux]/.test(t));
2530
2733
  if (unsupported.length > 0) {
2531
2734
  return ('[Console]::Error.WriteLine(' +
2532
- psStr('fauxnix: set -e/-u/-x is not supported (would silently lie); use explicit || exit') +
2735
+ psStr('fauxnix: set -e/-u/-x is not supported (would silently lie); use explicit || exit instead') +
2533
2736
  '); $script:fx_exit = 2');
2534
2737
  }
2535
2738
  return '';
2536
2739
  };
2740
+ const shiftCmd = (args) => {
2741
+ if (args.length === 0)
2742
+ return 'fx-posshift 1';
2743
+ return [
2744
+ '$fx_sn = [string](' + exprOfWord(args[0]) + ')',
2745
+ "if ($fx_sn -notmatch '^-?[0-9]+$') { [Console]::Error.WriteLine('bash: shift: ' + $fx_sn + ': numeric argument required'); $script:fx_exit = 1 }",
2746
+ 'else { fx-posshift $fx_sn }',
2747
+ ].join('\n');
2748
+ };
2537
2749
  /* ------------------------------------------------------------------ */
2750
+ export const specs = [
2751
+ {
2752
+ names: ['cd'],
2753
+ options: [
2754
+ { short: 'L', support: 'implemented' },
2755
+ { short: 'P', support: 'unsupported', reason: 'physical symlink/junction resolution' },
2756
+ { short: 'e', support: 'unsupported', reason: 'physical-resolution failure mode' },
2757
+ { short: '@', support: 'unsupported', reason: 'extended-attribute directory view' },
2758
+ ],
2759
+ effects: ['read'],
2760
+ platform: 'windows-ps51',
2761
+ dispatch: 'translated',
2762
+ usageExit: 2,
2763
+ handler: cd,
2764
+ },
2765
+ {
2766
+ names: ['pwd'],
2767
+ options: [
2768
+ { short: 'L', support: 'implemented' },
2769
+ { short: 'P', support: 'unsupported', reason: 'physical symlink/junction resolution' },
2770
+ { short: 'W', support: 'unsupported', reason: 'MSYS Windows-path output' },
2771
+ ],
2772
+ effects: ['read'],
2773
+ platform: 'windows-ps51',
2774
+ dispatch: 'translated',
2775
+ usageExit: 2,
2776
+ handler: pwd,
2777
+ },
2778
+ {
2779
+ names: ['export'],
2780
+ options: [
2781
+ { short: 'f', support: 'unsupported', reason: 'shell functions are not supported' },
2782
+ { short: 'n', support: 'unsupported', reason: 'all fauxnix variables live in the session environment' },
2783
+ { short: 'p', support: 'unsupported', reason: 'declare-style shell output' },
2784
+ ],
2785
+ effects: ['read', 'write'],
2786
+ platform: 'windows-ps51',
2787
+ dispatch: 'translated',
2788
+ usageExit: 2,
2789
+ handler: exportCmd,
2790
+ },
2791
+ {
2792
+ names: ['unset'],
2793
+ options: [
2794
+ { short: 'v', support: 'implemented' },
2795
+ { short: 'f', support: 'unsupported', reason: 'shell functions are not supported' },
2796
+ { short: 'n', support: 'unsupported', reason: 'nameref variables are not supported' },
2797
+ ],
2798
+ effects: ['write'],
2799
+ platform: 'windows-ps51',
2800
+ dispatch: 'translated',
2801
+ usageExit: 2,
2802
+ handler: unset,
2803
+ },
2804
+ {
2805
+ names: ['env'],
2806
+ options: [
2807
+ {
2808
+ short: 'u',
2809
+ long: '--unset',
2810
+ takesValue: true,
2811
+ support: 'implemented',
2812
+ reason: 'literal variable names only',
2813
+ },
2814
+ {
2815
+ short: 'i',
2816
+ long: '--ignore-environment',
2817
+ support: 'unsupported',
2818
+ reason: 'would silently keep inherited secrets; use env -u NAME or unset first',
2819
+ },
2820
+ { short: '0', long: '--null', support: 'unsupported', reason: 'NUL-terminated output' },
2821
+ { short: 'C', long: '--chdir', takesValue: true, support: 'unsupported', reason: 'temporary working directory' },
2822
+ { short: 'S', long: '--split-string', takesValue: true, support: 'unsupported', reason: 'shell-like string splitting' },
2823
+ ],
2824
+ effects: ['process'],
2825
+ platform: 'windows-ps51',
2826
+ dispatch: 'dynamic',
2827
+ usageExit: 125,
2828
+ leadingOptions: true,
2829
+ handler: env,
2830
+ },
2831
+ {
2832
+ names: ['printenv'],
2833
+ options: [
2834
+ { short: '0', long: '--null', support: 'unsupported', reason: 'NUL-terminated output' },
2835
+ ],
2836
+ effects: ['read'],
2837
+ platform: 'windows-ps51',
2838
+ dispatch: 'translated',
2839
+ usageExit: 2,
2840
+ handler: printenv,
2841
+ },
2842
+ {
2843
+ names: ['ps'],
2844
+ options: [
2845
+ { short: 'e', long: '--everyone', support: 'implemented' },
2846
+ { short: 'A', long: '--all', support: 'implemented' },
2847
+ { short: 'f', support: 'implemented' },
2848
+ { short: 'a', support: 'unsupported', reason: 'terminal-based process selection' },
2849
+ { short: 'x', support: 'unsupported', reason: 'terminal-based process selection' },
2850
+ { short: 'u', support: 'unsupported', reason: 'BSD user-oriented format' },
2851
+ { long: '--user', takesValue: true, support: 'unsupported', reason: 'user filtering' },
2852
+ { short: 'p', long: '--pid', takesValue: true, support: 'unsupported', reason: 'PID filtering' },
2853
+ { short: 'o', long: '--format', takesValue: true, support: 'unsupported', reason: 'custom output columns' },
2854
+ { long: '--sort', takesValue: true, support: 'unsupported', reason: 'custom process ordering' },
2855
+ ],
2856
+ effects: ['process'],
2857
+ platform: 'windows-ps51',
2858
+ dispatch: 'translated',
2859
+ handler: ps,
2860
+ },
2861
+ {
2862
+ names: ['sleep'],
2863
+ options: [],
2864
+ effects: ['process'],
2865
+ platform: 'windows-ps51',
2866
+ dispatch: 'translated',
2867
+ handler: sleep,
2868
+ },
2869
+ {
2870
+ names: ['which'],
2871
+ options: [
2872
+ { short: 'a', long: '--all', support: 'unsupported', reason: 'all matching PATH entries' },
2873
+ { short: 's', support: 'unsupported', reason: 'silent status-only mode' },
2874
+ ],
2875
+ effects: ['read'],
2876
+ platform: 'windows-ps51',
2877
+ dispatch: 'translated',
2878
+ handler: which,
2879
+ },
2880
+ {
2881
+ names: ['type'],
2882
+ options: [
2883
+ { short: 'a', support: 'unsupported', reason: 'all matching definitions' },
2884
+ { short: 'f', support: 'unsupported', reason: 'shell functions are not supported' },
2885
+ { short: 'P', support: 'unsupported', reason: 'forced PATH lookup' },
2886
+ { short: 'p', support: 'unsupported', reason: 'PATH-only lookup' },
2887
+ { short: 't', support: 'unsupported', reason: 'type-name-only output' },
2888
+ ],
2889
+ effects: ['read'],
2890
+ platform: 'windows-ps51',
2891
+ dispatch: 'translated',
2892
+ usageExit: 2,
2893
+ handler: type,
2894
+ },
2895
+ {
2896
+ names: ['command'],
2897
+ options: [
2898
+ { short: 'v', support: 'implemented' },
2899
+ { short: 'V', support: 'implemented' },
2900
+ { short: 'p', support: 'unsupported', reason: 'guaranteed default utility PATH' },
2901
+ ],
2902
+ effects: ['process'],
2903
+ platform: 'windows-ps51',
2904
+ dispatch: 'dynamic',
2905
+ usageExit: 2,
2906
+ leadingOptions: true,
2907
+ handler: commandCmd,
2908
+ },
2909
+ {
2910
+ names: ['whoami'],
2911
+ options: [],
2912
+ effects: ['read'],
2913
+ platform: 'windows-ps51',
2914
+ dispatch: 'translated',
2915
+ handler: whoami,
2916
+ },
2917
+ {
2918
+ names: ['id'],
2919
+ options: [
2920
+ { short: 'u', support: 'implemented' },
2921
+ { short: 'g', support: 'implemented' },
2922
+ { short: 'n', support: 'implemented' },
2923
+ { short: 'G', support: 'unsupported', reason: 'supplementary group ID mapping' },
2924
+ { short: 'r', support: 'unsupported', reason: 'real versus effective IDs' },
2925
+ { short: 'z', support: 'unsupported', reason: 'NUL-terminated output' },
2926
+ { short: 'Z', support: 'unsupported', reason: 'SELinux security context' },
2927
+ ],
2928
+ effects: ['read'],
2929
+ platform: 'windows-ps51',
2930
+ dispatch: 'translated',
2931
+ handler: id,
2932
+ },
2933
+ {
2934
+ names: ['groups'],
2935
+ options: [],
2936
+ effects: ['read'],
2937
+ platform: 'windows-ps51',
2938
+ dispatch: 'translated',
2939
+ handler: groups,
2940
+ },
2941
+ {
2942
+ names: ['date'],
2943
+ options: [
2944
+ { short: 'u', long: '--utc', support: 'implemented' },
2945
+ {
2946
+ short: 'd',
2947
+ long: '--date',
2948
+ takesValue: true,
2949
+ support: 'implemented',
2950
+ reason: '@SECONDS input only',
2951
+ },
2952
+ { short: 'I', long: '--iso-8601', support: 'unsupported', reason: 'ISO precision selection' },
2953
+ { short: 'R', long: '--rfc-email', support: 'unsupported', reason: 'RFC email formatting' },
2954
+ { long: '--rfc-3339', takesValue: true, support: 'unsupported', reason: 'RFC 3339 precision selection' },
2955
+ { short: 'r', long: '--reference', takesValue: true, support: 'unsupported', reason: 'file timestamp lookup' },
2956
+ { short: 's', long: '--set', takesValue: true, support: 'unsupported', reason: 'changing the system clock' },
2957
+ ],
2958
+ effects: ['read'],
2959
+ platform: 'windows-ps51',
2960
+ dispatch: 'translated',
2961
+ handler: date,
2962
+ },
2963
+ {
2964
+ names: ['uname'],
2965
+ options: [
2966
+ { short: 'a', long: '--all', support: 'implemented' },
2967
+ { short: 's', long: '--kernel-name', support: 'implemented' },
2968
+ { short: 'n', long: '--nodename', support: 'implemented' },
2969
+ { short: 'r', long: '--kernel-release', support: 'implemented' },
2970
+ { short: 'v', long: '--kernel-version', support: 'implemented' },
2971
+ { short: 'm', long: '--machine', support: 'implemented' },
2972
+ { short: 'p', long: '--processor', support: 'implemented' },
2973
+ { short: 'o', long: '--operating-system', support: 'implemented' },
2974
+ { short: 'i', long: '--hardware-platform', support: 'unsupported', reason: 'hardware-platform distinction' },
2975
+ ],
2976
+ effects: ['read'],
2977
+ platform: 'windows-ps51',
2978
+ dispatch: 'translated',
2979
+ handler: uname,
2980
+ },
2981
+ {
2982
+ names: ['hostname'],
2983
+ options: [
2984
+ { short: 's', long: '--short', support: 'unsupported', reason: 'short-name selection' },
2985
+ { short: 'f', long: '--fqdn', support: 'unsupported', reason: 'FQDN lookup' },
2986
+ { short: 'd', long: '--domain', support: 'unsupported', reason: 'DNS domain lookup' },
2987
+ { short: 'i', long: '--ip-address', support: 'unsupported', reason: 'address lookup' },
2988
+ { short: 'I', long: '--all-ip-addresses', support: 'unsupported', reason: 'all-address lookup' },
2989
+ { short: 'F', long: '--file', takesValue: true, support: 'unsupported', reason: 'changing the hostname from a file' },
2990
+ ],
2991
+ effects: ['read'],
2992
+ platform: 'windows-ps51',
2993
+ dispatch: 'translated',
2994
+ handler: hostname,
2995
+ },
2996
+ {
2997
+ names: ['uptime'],
2998
+ options: [
2999
+ { short: 'p', long: '--pretty', support: 'unsupported', reason: 'pretty duration output' },
3000
+ { short: 's', long: '--since', support: 'unsupported', reason: 'boot timestamp output' },
3001
+ ],
3002
+ effects: ['read'],
3003
+ platform: 'windows-ps51',
3004
+ dispatch: 'translated',
3005
+ handler: uptime,
3006
+ },
3007
+ {
3008
+ names: ['free'],
3009
+ options: [
3010
+ { short: 'h', long: '--human', support: 'implemented' },
3011
+ { short: 'k', long: '--kibi', support: 'implemented' },
3012
+ { short: 'm', long: '--mebi', support: 'implemented' },
3013
+ { short: 'g', long: '--gibi', support: 'implemented' },
3014
+ { short: 'b', long: '--bytes', support: 'unsupported', reason: 'byte-unit output' },
3015
+ { long: '--si', support: 'unsupported', reason: 'powers-of-1000 units' },
3016
+ { short: 't', long: '--total', support: 'unsupported', reason: 'total row' },
3017
+ { short: 'w', long: '--wide', support: 'unsupported', reason: 'wide buffer/cache columns' },
3018
+ { short: 's', long: '--seconds', takesValue: true, support: 'unsupported', reason: 'repeating output' },
3019
+ { short: 'c', long: '--count', takesValue: true, support: 'unsupported', reason: 'repeating output' },
3020
+ ],
3021
+ effects: ['read'],
3022
+ platform: 'windows-ps51',
3023
+ dispatch: 'translated',
3024
+ handler: free,
3025
+ },
3026
+ {
3027
+ names: ['nproc'],
3028
+ options: [
3029
+ { long: '--all', support: 'unsupported', reason: 'installed versus available processor distinction' },
3030
+ { long: '--ignore', takesValue: true, support: 'unsupported', reason: 'processor-count subtraction' },
3031
+ ],
3032
+ effects: ['read'],
3033
+ platform: 'windows-ps51',
3034
+ dispatch: 'translated',
3035
+ handler: nproc,
3036
+ },
3037
+ {
3038
+ names: ['clear'],
3039
+ options: [
3040
+ { short: 'x', support: 'unsupported', reason: 'scrollback-preserving terminal control' },
3041
+ { short: 'T', takesValue: true, support: 'unsupported', reason: 'alternate terminal type' },
3042
+ ],
3043
+ effects: [],
3044
+ platform: 'windows-ps51',
3045
+ dispatch: 'translated',
3046
+ handler: clear,
3047
+ },
3048
+ {
3049
+ names: ['timeout'],
3050
+ options: [
3051
+ { short: 's', long: '--signal', takesValue: true, support: 'unsupported', reason: 'signal selection' },
3052
+ { short: 'k', long: '--kill-after', takesValue: true, support: 'unsupported', reason: 'two-phase termination' },
3053
+ { long: '--preserve-status', support: 'unsupported', reason: 'child-status preservation after timeout' },
3054
+ { long: '--foreground', support: 'unsupported', reason: 'interactive foreground process groups' },
3055
+ { short: 'v', long: '--verbose', support: 'unsupported', reason: 'signal diagnostics' },
3056
+ ],
3057
+ effects: ['process'],
3058
+ platform: 'windows-ps51',
3059
+ dispatch: 'dynamic',
3060
+ usageExit: 125,
3061
+ leadingOptions: true,
3062
+ handler: timeout,
3063
+ },
3064
+ ];
2538
3065
  export const handlers = {
2539
3066
  cd,
2540
3067
  pwd,
@@ -2582,4 +3109,5 @@ export const handlers = {
2582
3109
  exit: exitCmd,
2583
3110
  alias,
2584
3111
  set,
3112
+ shift: shiftCmd,
2585
3113
  };