fauxnix-cli 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -85,6 +85,20 @@ npm install -g .
85
85
 
86
86
  Requires: Windows with PowerShell 5.1+ (built-in) and Node.js ≥ 18.
87
87
 
88
+ PowerShell 7 is an opt-in, CI-tested tier:
89
+
90
+ ```powershell
91
+ $env:FAUXNIX_PS = 'pwsh'
92
+ fauxnix check # edition: Core
93
+ ```
94
+
95
+ Set the variable before starting `fauxnix` or its MCP harness; restart the
96
+ harness after changing it. Windows PowerShell 5.1 remains the default. Invalid
97
+ values and a missing selected executable fail loudly rather than falling back.
98
+ The default is pinned below `SystemRoot`; `pwsh.exe` is resolved once from
99
+ absolute `PATH` directories, excluding the current working directory.
100
+ See [PowerShell 7 support](docs/powershell-7.md).
101
+
88
102
  ## Quick start
89
103
 
90
104
  ```bash
@@ -107,8 +121,14 @@ fauxnix install --claude
107
121
  fauxnix mcp
108
122
  ```
109
123
 
124
+ `translate` only renders a script and does not read command operands. Because
125
+ `sed -f` needs a script file, translate-only mode asks you to use `-e` with
126
+ inline script text; normal command execution continues to support `sed -f`.
127
+
110
128
  Unknown commands (git, node, npm, python, cargo, gh, docker, ...) are **passed through natively**
111
- with argv-style quoting no string re-parsing, no quoting bugs.
129
+ with argv-style quoting. Windows `.cmd`/`.bat` shims necessarily pass through `cmd.exe`; fauxnix
130
+ preserves its supported punctuation and fails loudly for `%`, embedded double quotes, NUL, and
131
+ line breaks rather than passing a different argument.
112
132
 
113
133
  ## Use with your agent harness
114
134
 
@@ -154,13 +174,13 @@ TOML config: `~/.kimi-code/mcp.json`
154
174
  ```
155
175
 
156
176
  **Qwen Code** (`~/.qwen/settings.json`)
157
- ```json
158
- {
159
- "mcpServers": {
160
- "fauxnix": { "command": "fauxnix", "args": ["mcp"] }
161
- }
162
- }
177
+ ```bash
178
+ fauxnix install --qwen
163
179
  ```
180
+ The installer preserves the rest of `settings.json` and writes an absolute
181
+ Node + package-entry launcher so Qwen startup does not depend on its working
182
+ directory or `PATH` order. See [the Qwen example](docs/examples/qwen.md) for
183
+ the generated JSON shape.
164
184
 
165
185
  **Any MCP client** — stdio server: `fauxnix mcp`. The tool name is `bash` (override with
166
186
  `FAUXNIX_TOOL_NAME`). Tool description already teaches the model the supported subset, so no
@@ -177,19 +197,19 @@ Windows path.
177
197
  development:
178
198
 
179
199
  - **files**: `ls cp mv rm mkdir rmdir touch mktemp ln readlink realpath basename dirname stat file du df find chmod chown diff`
180
- - **text filters**: `grep egrep sed awk sort uniq cut tr` — sed/awk scripts are parsed at
181
- translate time (unsupported constructs throw named errors, never silently misbehave)
200
+ - **text filters**: `grep egrep sed awk sort uniq cut tr` — sed/awk scripts are parsed while
201
+ preparing an executable plan (unsupported constructs throw named errors, never silently
202
+ misbehave); inspect-only `translate` keeps `sed -f` file reads out of that path
182
203
  - **text I/O**: `echo printf cat head tail wc tee nl tac md5sum sha1sum sha256sum base64 seq yes xargs`
183
204
 
184
- `cp` / `mv` / `rm` / `touch` / `du` / `ls` / `ll` / `mkdir` / `rmdir` / `mktemp` / `ln` /
185
- `readlink` / `realpath` / `basename` / `dirname` / `stat` / `file` / `df` / `chmod` / `chown` /
186
- `diff` / `tee` / `grep` / `head` / `echo` / `printf` / `cat` / `tail` / `wc` / `sort` / `uniq` /
187
- `cut` / `tr` / `gzip` / `gunzip` / `zcat` / `zip` / `unzip` carry a `CommandSpec`: unknown options fail with a GNU-style
188
- usage error instead of being ignored (`find` stays unspec'd so predicates like `-name` still
189
- compile; `sed`/`awk`/`egrep` stay unspec'd; `tar` stays unspec'd because it is fx-native to
190
- `tar.exe` and unknown GNU flags must reach bsdtar). Implemented GNU holes: `cp -n` / `mv -n` / `touch -c` / `tee --append` / `grep -m` /
191
- `head --lines` / `du --max-depth`. `fauxnix list --json` and `docs/command-specs.md` dump the
192
- same metadata.
205
+ The curated **agent-daily 60** carry a `CommandSpec`: unknown options fail with a GNU-style
206
+ usage error instead of being ignored. The generated [`docs/command-specs.md`](docs/command-specs.md)
207
+ is the exact list, coverage count, option table, and exclusion rationale; `fauxnix list --json`
208
+ exposes the same per-command metadata. `find` stays unspec'd so predicates like `-name` still
209
+ compile; `sed`/`awk`/`egrep` keep their command-specific parsers; `tar` remains native to
210
+ `tar.exe` so supported bsdtar options reach the executable. Implemented GNU holes include
211
+ `cp -n` / `mv -n` / `touch -c` / `tee --append` / `grep -m` / `head --lines` /
212
+ `du --max-depth` / `env -u` / `ps -f` / `command -V` / `date --date=@SECONDS`.
193
213
  - **shell/system**: `cd pwd export unset env printenv ps kill pkill pgrep sleep which type whoami
194
214
  id groups date uname hostname uptime free nproc clear true false test [ [[ : pushd popd dirs sudo
195
215
  timeout man history less more source . eval exit alias set shift`
@@ -208,9 +228,9 @@ Exit codes follow bash conventions: 0 ok, 1 fail, 2 usage/serious, 127 command n
208
228
  ## How it works
209
229
 
210
230
  ```
211
- bash command ──parser──▶ AST ──translator──▶ PowerShell script ──executor──▶ powershell.exe
231
+ bash command ──parser──▶ AST ──translator──▶ PowerShell script ──executor──▶ selected PowerShell
212
232
 
213
- agent ◀── GNU-style output, bash-style errors ◀── decoder (UTF-8 GBK fallback) ◀┘
233
+ agent ◀── GNU-style output, bash-style errors ◀── UTF-8 framed host protocol ◀┘
214
234
  ```
215
235
 
216
236
  - **Deterministic translation, zero LLM calls** at runtime.
@@ -218,9 +238,10 @@ agent ◀── GNU-style output, bash-style errors ◀── decoder (UTF-8 →
218
238
  "Fauxnix contract": string-per-line stdout, `[Console]::Error.WriteLine` for bash-style
219
239
  stderr, `$script:fx_exit` for exit codes, `$input` for stdin.
220
240
  - The executor wraps every script with UTF-8 enforcement (`[Console]::OutputEncoding`,
221
- `$OutputEncoding`, `chcp 65001`), decodes output as strict-UTF-8 with a GBK(936) fallback for
222
- legacy native tools, strips CLIXML serialization and PowerShell noise from stderr, and rewrites
223
- common PowerShell errors (including zh-CN locale messages) into bash phrasing.
241
+ `$OutputEncoding`, `chcp 65001`), decodes native output at the process boundary (UTF-8 by
242
+ default or GBK(936) in `ansi` mode), keeps host frames UTF-8, strips CLIXML serialization and
243
+ PowerShell noise from stderr, and rewrites common PowerShell errors (including zh-CN locale
244
+ messages) into bash phrasing.
224
245
  - Scripts run via `-EncodedCommand` (UTF-16LE) and transparently fall back to a temp `.ps1` file
225
246
  when the 32 KB command-line limit would be exceeded.
226
247
 
@@ -236,9 +257,11 @@ fauxnix optimizes for the commands agents actually run. Documented deviations:
236
257
  an unbounded `yes | head` would hang.
237
258
  - `tail -f`, `eval`, `alias`, heredocs,
238
259
  `env -i`/`--ignore-environment`,
239
- background `&`, and stdout redirects (`>` `>>` `&>` `&>>`) on a non-last
240
- pipeline stage (`echo hi >f | cat`) are rejected with actionable error
241
- messages instead of misbehaving.
260
+ background `&`, and output/fd redirects (`>` `>>` `2>` `2>>` `&>` `&>>`
261
+ `2>&1` `1>&2`) on a non-last pipeline stage (`echo hi >f | cat`) are
262
+ rejected with operation-specific, actionable error messages instead of
263
+ misbehaving. Per-stage `<` remains supported; fully routed per-stage output
264
+ fds are still tracked by #157.
242
265
  (`if/then/elif/else/fi`, `for x in ...`, `while`/`until`, `case ... esac` (`;;` only; `;&`/`;;&` fail loud),
243
266
  backtick substitution, `command -v`, pipeline `read`,
244
267
  dotenv-style `source`, word-level `$((...))` arithmetic expansion, `A=(x y z)` arrays, and
@@ -265,14 +288,15 @@ fauxnix optimizes for the commands agents actually run. Documented deviations:
265
288
 
266
289
  ## Development
267
290
 
268
- ```bash
291
+ ```powershell
269
292
  npm install
270
293
  npm test # unit + real-PowerShell integration suite (Windows only, auto-skipped elsewhere)
294
+ $env:FAUXNIX_PS = 'pwsh'; npm test # same suite through PowerShell 7
271
295
  npm run build
272
296
  npx tsx scratch/run.mjs "any bash command" # quick live check
273
297
  ```
274
298
 
275
- Differential vs Git Bash is opt-in (`FAUXNIX_DIFF_ORACLE=1`; skips if unset or `bash.exe` is missing — Git Bash is not required). See [`test/differential/README.md`](test/differential/README.md). This is grown from the 40-case RFC C-7 scaffold, not the 200-case 1.0 gate. The weekly oracle is opt-in via the scheduled workflow (`.github/workflows/differential.yml`) when Git Bash is on the runner.
299
+ Differential vs Git Bash is opt-in (`FAUXNIX_DIFF_ORACLE=1`; skips if unset or `bash.exe` is missing — Git Bash is not required). See [`test/differential/README.md`](test/differential/README.md). The 253-case corpus enforces the RFC C-7 minimum of 200 cases and a 95% identity gate. The weekly oracle runs from `.github/workflows/differential.yml`; two consecutive green **scheduled** runs are still required release evidence after this gate lands.
276
300
 
277
301
  Architecture map: `src/parser.ts` (bash subset → AST) · `src/translator.ts` (AST → PowerShell +
278
302
  executor wrapper) · `src/executor.ts` (spawn, redirects, session persistence) ·
package/dist/cli.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  import './commands/install-all.js';
2
- export declare const USAGE = "fauxnix \u2014 run Linux-style commands on Windows via PowerShell translation\n\nUsage:\n fauxnix \"ls -la | head -5\" translate + execute a bash-style command\n fauxnix -c \"cmd\" same as above\n fauxnix translate \"cmd\" show the PowerShell translation only\n fauxnix mcp start the MCP stdio server (for agent harnesses)\n fauxnix list list translated commands\n fauxnix list --json same list as machine-readable capability metadata\n fauxnix list --markdown CommandSpec tables (same text as docs/command-specs.md)\n fauxnix check verify the local PowerShell environment\n fauxnix doctor check + encoding, harness config, MCP readiness\n fauxnix install --claude|--codex|--opencode|--kimi|--qwen\n fauxnix --version\n\nNotes:\n Unknown commands (git, node, npm, python, cargo, ...) pass through and run natively.";
2
+ export declare const USAGE = "fauxnix \u2014 run Linux-style commands on Windows via PowerShell translation\n\nUsage:\n fauxnix \"ls -la | head -5\" translate + execute a bash-style command\n fauxnix -c \"cmd\" same as above\n fauxnix translate \"cmd\" show the PowerShell translation only\n fauxnix mcp start the MCP stdio server (for agent harnesses)\n fauxnix list list translated commands\n fauxnix list --json same list as machine-readable capability metadata\n fauxnix list --markdown CommandSpec tables (same text as docs/command-specs.md)\n fauxnix check verify the local PowerShell environment\n fauxnix doctor check + encoding, harness config, MCP readiness\n fauxnix install --claude|--codex|--opencode|--kimi|--qwen\n fauxnix --version\n\nNotes:\n FAUXNIX_PS=pwsh selects the opt-in PowerShell 7 host (5.1 is the default).\n Unknown commands (git, node, npm, python, cargo, ...) pass through and run natively.";
3
3
  export declare function runCli(argv: string[]): Promise<void>;
4
+ export declare function runCheck(): Promise<boolean>;
package/dist/cli.js CHANGED
@@ -1,13 +1,14 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { FauxnixSession } from './executor.js';
3
3
  import { parseCommand } from './parser.js';
4
- import { translateCommandList } from './translator.js';
4
+ import { EXECUTE_TRANSLATION, PURE_TRANSLATION, translateCommandList, } from './translator.js';
5
5
  import { listCommandsJson, registeredNames, specsMarkdown } from './registry.js';
6
6
  import { encodeCommand } from './encoding.js';
7
7
  import { startMcpServer } from './mcp.js';
8
8
  import { collectDoctorReport } from './doctor.js';
9
9
  import { runInstall } from './install.js';
10
10
  import { packageVersion } from './version.js';
11
+ import { POWERSHELL_ARGS, powerShellDisplay, powerShellMissingMessage, resolvePowerShell, } from './powershell.js';
11
12
  import './commands/install-all.js';
12
13
  export const USAGE = `fauxnix — run Linux-style commands on Windows via PowerShell translation
13
14
 
@@ -25,6 +26,7 @@ Usage:
25
26
  fauxnix --version
26
27
 
27
28
  Notes:
29
+ FAUXNIX_PS=pwsh selects the opt-in PowerShell 7 host (5.1 is the default).
28
30
  Unknown commands (git, node, npm, python, cargo, ...) pass through and run natively.`;
29
31
  export async function runCli(argv) {
30
32
  if (argv.length === 0) {
@@ -75,7 +77,7 @@ export async function runCli(argv) {
75
77
  if (verb === 'translate') {
76
78
  const cmd = rest.join(' ');
77
79
  const list = parseCommand(cmd);
78
- const plans = translateCommandList(list);
80
+ const plans = translateCommandList(list, PURE_TRANSLATION);
79
81
  console.log(plans.map((p) => p.script).join('\n# ---- next segment ----\n'));
80
82
  return;
81
83
  }
@@ -85,7 +87,7 @@ export async function runCli(argv) {
85
87
  return;
86
88
  }
87
89
  const list = parseCommand(cmd);
88
- const plans = translateCommandList(list);
90
+ const plans = translateCommandList(list, EXECUTE_TRANSLATION);
89
91
  const session = new FauxnixSession();
90
92
  const result = await session.run(plans);
91
93
  if (result.stdout)
@@ -95,36 +97,74 @@ export async function runCli(argv) {
95
97
  await session.dispose();
96
98
  process.exit(result.exitCode);
97
99
  }
98
- const PS_ARGS = ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass'];
99
100
  async function runDoctor() {
100
- await runCheck();
101
+ const checkOk = await runCheck();
101
102
  const report = await collectDoctorReport();
102
103
  for (const line of report.lines)
103
104
  console.log(line);
104
- if (!report.ok)
105
+ if (!checkOk || !report.ok)
105
106
  process.exitCode = 1;
106
107
  }
107
- async function runCheck() {
108
- console.log('powershell : powershell.exe (Windows built-in)');
109
- const probeCmd = '$PSVersionTable.PSVersion.ToString()';
110
- const probe = spawn('powershell.exe', [...PS_ARGS, '-EncodedCommand', encodeCommand(probeCmd)], {
108
+ export async function runCheck() {
109
+ const selection = resolvePowerShell();
110
+ console.log('powershell : ' + powerShellDisplay(selection));
111
+ if (selection.error) {
112
+ console.error('status : FAILED');
113
+ console.error(selection.error);
114
+ process.exitCode = 1;
115
+ return false;
116
+ }
117
+ const probeCmd = '[Console]::Out.WriteLine($PSVersionTable.PSVersion.ToString()); ' +
118
+ '[Console]::Out.WriteLine([string]$PSVersionTable.PSEdition)';
119
+ const probe = spawn(selection.executable, [...POWERSHELL_ARGS, '-EncodedCommand', encodeCommand(probeCmd)], {
111
120
  stdio: ['ignore', 'pipe', 'pipe'],
112
121
  windowsHide: true,
113
122
  });
114
- probe.on('error', (e) => {
115
- console.error('status : FAILED to run powershell.exe: ' + e.message);
116
- process.exit(1);
117
- });
118
123
  let out = '';
124
+ let err = '';
119
125
  probe.stdout.on('data', (d) => (out += d.toString('utf8')));
120
- const code = await new Promise((resolve) => probe.on('close', (c) => resolve(c ?? 1)));
121
- if (code === 0) {
122
- console.log('version : ' + out.trim());
123
- console.log('commands : ' + registeredNames().length + ' translated, others pass through');
124
- console.log('status : OK');
126
+ probe.stderr.on('data', (d) => (err += d.toString('utf8')));
127
+ const outcome = await new Promise((resolve) => {
128
+ let settled = false;
129
+ const done = (value) => {
130
+ if (settled)
131
+ return;
132
+ settled = true;
133
+ resolve(value);
134
+ };
135
+ probe.once('error', (error) => done({ code: 127, error }));
136
+ probe.once('close', (code) => done({ code: code ?? 1 }));
137
+ });
138
+ if (outcome.error) {
139
+ console.error('status : FAILED');
140
+ if (outcome.error.code === 'ENOENT') {
141
+ console.error(powerShellMissingMessage(selection).trimEnd());
142
+ }
143
+ else {
144
+ console.error(`fauxnix: failed to start ${selection.executable}: ${outcome.error.message}`);
145
+ }
146
+ process.exitCode = 1;
147
+ return false;
148
+ }
149
+ if (outcome.code !== 0) {
150
+ console.error(`status : FAILED to run ${selection.executable}`);
151
+ if (err.trim())
152
+ console.error(err.trim());
153
+ process.exitCode = 1;
154
+ return false;
125
155
  }
126
- else {
127
- console.error('status : FAILED to run powershell.exe');
156
+ const lines = out.trim().split(/\r?\n/);
157
+ const version = lines[0] ?? '';
158
+ const edition = lines[1] ?? '';
159
+ console.log('version : ' + version);
160
+ console.log('edition : ' + edition);
161
+ if (edition !== selection.expectedEdition) {
162
+ console.error(`status : FAILED: ${selection.executable} reported ${edition || 'no edition'}; ` +
163
+ `expected ${selection.expectedEdition}`);
128
164
  process.exitCode = 1;
165
+ return false;
129
166
  }
167
+ console.log('commands : ' + registeredNames().length + ' translated, others pass through');
168
+ console.log('status : OK');
169
+ return true;
130
170
  }
@@ -28,19 +28,89 @@ const PS_GZ_FNS = [
28
28
  ' $fx_gz.Close()',
29
29
  ' return ,$fx_ms.ToArray()',
30
30
  '}',
31
- 'function fx-gz-dbytes($src) {',
32
- ' $fx_fs = [IO.File]::OpenRead($src)',
33
- ' $fx_gz = New-Object System.IO.Compression.GZipStream($fx_fs, [System.IO.Compression.CompressionMode]::Decompress)',
34
- ' $fx_ms = New-Object System.IO.MemoryStream',
31
+ 'function fx-gz-open($src, $isBytes) {',
32
+ ' if ($isBytes) { $fx_raw = New-Object System.IO.MemoryStream(,$src) }',
33
+ ' else { $fx_raw = [IO.File]::OpenRead($src) }',
34
+ ' try {',
35
+ ' return (New-Object System.IO.Compression.GZipStream($fx_raw, [System.IO.Compression.CompressionMode]::Decompress))',
36
+ ' } catch {',
37
+ ' $fx_raw.Dispose()',
38
+ ' throw',
39
+ ' }',
40
+ '}',
41
+ // Drain the complete stream before producing stdout. Besides keeping -t
42
+ // bounded, this preserves the old all-or-nothing behavior for malformed
43
+ // inputs while remembering which text decoder the second pass should use.
44
+ 'function fx-gz-validate($src, $isBytes) {',
45
+ ' $fx_gz = fx-gz-open $src $isBytes',
35
46
  ' $fx_buf = New-Object byte[] 65536',
36
- ' while ($true) {',
37
- ' $fx_n = $fx_gz.Read($fx_buf, 0, $fx_buf.Length)',
38
- ' if ($fx_n -le 0) { break }',
39
- ' $fx_ms.Write($fx_buf, 0, $fx_n)',
47
+ ' $fx_dec = (New-Object System.Text.UTF8Encoding($false, $true)).GetDecoder()',
48
+ ' $fx_utf8 = $true',
49
+ ' try {',
50
+ ' while ($true) {',
51
+ ' $fx_n = $fx_gz.Read($fx_buf, 0, $fx_buf.Length)',
52
+ ' if ($fx_n -le 0) { break }',
53
+ ' if ($fx_utf8) {',
54
+ ' try { [void]$fx_dec.GetCharCount($fx_buf, 0, $fx_n, $false) }',
55
+ ' catch [System.Text.DecoderFallbackException] { $fx_utf8 = $false }',
56
+ ' }',
57
+ ' }',
58
+ ' if ($fx_utf8) {',
59
+ ' try { [void]$fx_dec.GetCharCount($fx_buf, 0, 0, $true) }',
60
+ ' catch [System.Text.DecoderFallbackException] { $fx_utf8 = $false }',
61
+ ' }',
62
+ ' return $fx_utf8',
63
+ ' } finally {',
64
+ ' $fx_gz.Dispose()',
65
+ ' }',
66
+ '}',
67
+ 'function fx-gz-stream-text($src, $isBytes) {',
68
+ ' $fx_utf8 = fx-gz-validate $src $isBytes',
69
+ ' if ($fx_utf8) { $fx_enc = New-Object System.Text.UTF8Encoding($false, $true) }',
70
+ ' else { try { $fx_enc = [System.Text.Encoding]::GetEncoding(936) } catch { $fx_enc = [System.Text.Encoding]::ASCII } }',
71
+ ' $fx_gz = fx-gz-open $src $isBytes',
72
+ ' $fx_sr = New-Object System.IO.StreamReader($fx_gz, $fx_enc, $false, 65536)',
73
+ ' try {',
74
+ ' while ($true) {',
75
+ ' $fx_line = $fx_sr.ReadLine()',
76
+ ' if ($null -eq $fx_line) { break }',
77
+ ' Write-Output -NoEnumerate $fx_line',
78
+ ' }',
79
+ ' } finally {',
80
+ ' $fx_sr.Dispose()',
81
+ ' }',
82
+ '}',
83
+ // File output is streamed into a sibling temporary file. The destination
84
+ // changes only after a complete decompression, and the temporary is removed
85
+ // on every failed path.
86
+ 'function fx-gz-stream-file($src, $dst) {',
87
+ ' $fx_full = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($dst)',
88
+ ' $fx_parent = [IO.Path]::GetDirectoryName($fx_full)',
89
+ " $fx_tmp = [IO.Path]::Combine($fx_parent, '.fauxnix-gzip-' + [Guid]::NewGuid().ToString('N') + '.tmp')",
90
+ ' $fx_gz = $null',
91
+ ' $fx_os = $null',
92
+ ' try {',
93
+ ' $fx_gz = fx-gz-open $src $false',
94
+ " $fx_os = New-Object System.IO.FileStream($fx_tmp, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)",
95
+ ' $fx_buf = New-Object byte[] 65536',
96
+ ' while ($true) {',
97
+ ' $fx_n = $fx_gz.Read($fx_buf, 0, $fx_buf.Length)',
98
+ ' if ($fx_n -le 0) { break }',
99
+ ' $fx_os.Write($fx_buf, 0, $fx_n)',
100
+ ' }',
101
+ ' $fx_os.Dispose(); $fx_os = $null',
102
+ ' $fx_gz.Dispose(); $fx_gz = $null',
103
+ ' if ([IO.File]::Exists($fx_full)) {',
104
+ ' $fx_null = [System.Management.Automation.Language.NullString]::Value',
105
+ ' [IO.File]::Replace($fx_tmp, $fx_full, $fx_null)',
106
+ ' }',
107
+ ' else { [IO.File]::Move($fx_tmp, $fx_full) }',
108
+ ' } catch {',
109
+ ' if ($null -ne $fx_os) { $fx_os.Dispose() }',
110
+ ' if ($null -ne $fx_gz) { $fx_gz.Dispose() }',
111
+ ' if ([IO.File]::Exists($fx_tmp)) { [IO.File]::Delete($fx_tmp) }',
112
+ ' throw',
40
113
  ' }',
41
- ' $fx_gz.Close()',
42
- ' $fx_fs.Close()',
43
- ' return ,$fx_ms.ToArray()',
44
114
  '}',
45
115
  ].join('\n');
46
116
  /** Parse GNU gzip-style argv (works for gzip/gunzip/zcat). */
@@ -121,7 +191,7 @@ function gzBlock(args, ctx, forced) {
121
191
  : " [Console]::Error.WriteLine('gzip: compressed data not written to a terminal. Use -f to force compression.')",
122
192
  ' $script:fx_exit = 1',
123
193
  ].join('\n')
124
- : p.decompress
194
+ : p.decompress || p.test
125
195
  ? [
126
196
  // NOTE stdin fidelity limitation: pipeline stdin arrives as decoded
127
197
  // text lines, so only gzip streams that survived the text pipeline
@@ -130,13 +200,7 @@ function gzBlock(args, ctx, forced) {
130
200
  " if ($fx_txt -ne '') { $fx_txt = $fx_txt + \"`n\" }",
131
201
  ' $fx_in = [System.Text.Encoding]::UTF8.GetBytes($fx_txt)',
132
202
  ' try {',
133
- ' $fx_ms = New-Object System.IO.MemoryStream(,$fx_in)',
134
- ' $fx_gz = New-Object System.IO.Compression.GZipStream($fx_ms, [System.IO.Compression.CompressionMode]::Decompress)',
135
- ' $fx_mo = New-Object System.IO.MemoryStream',
136
- ' $fx_buf = New-Object byte[] 65536',
137
- ' while ($true) { $fx_n = $fx_gz.Read($fx_buf, 0, $fx_buf.Length); if ($fx_n -le 0) { break }; $fx_mo.Write($fx_buf, 0, $fx_n) }',
138
- ' $fx_gz.Close()',
139
- ' fx-lines (fx-dec $fx_mo.ToArray())',
203
+ p.test ? ' [void](fx-gz-validate $fx_in $true)' : ' fx-gz-stream-text $fx_in $true',
140
204
  ' } catch {',
141
205
  " [Console]::Error.WriteLine('gzip: stdin: not in gzip format')",
142
206
  ' $script:fx_exit = 1',
@@ -163,14 +227,13 @@ function gzBlock(args, ctx, forced) {
163
227
  ' }',
164
228
  ];
165
229
  if (p.test) {
166
- fileLoop.push(' try { $fx_tb = fx-gz-dbytes $fx_f }', " catch { [Console]::Error.WriteLine('gzip: ' + $fx_f + ': not in gzip format'); $script:fx_exit = 1 }", ' continue');
230
+ fileLoop.push(' try { [void](fx-gz-validate $fx_f $false) }', " catch { [Console]::Error.WriteLine('gzip: ' + $fx_f + ': not in gzip format'); $script:fx_exit = 1 }", ' continue');
167
231
  }
168
232
  else if (p.decompress) {
169
233
  fileLoop.push(' $fx_low = $fx_f.ToLower()', " if (-not ($fx_low.EndsWith('.gz') -or $fx_low.EndsWith('.tgz'))) {", " [Console]::Error.WriteLine('gzip: ' + $fx_f + ': unknown suffix -- ignored')", ' $script:fx_exit = 2', ' continue', ' }', ' $fx_out = $fx_f.Substring(0, $fx_f.Length - 3)', " if ($fx_low.EndsWith('.tgz')) { $fx_out = $fx_f.Substring(0, $fx_f.Length - 4) + '.tar' }", ' try {', p.stdout
170
- ? ' fx-lines (fx-dec (fx-gz-dbytes $fx_f))'
234
+ ? ' fx-gz-stream-text $fx_f $false'
171
235
  : [
172
- ' $fx_o = fx-gz-dbytes $fx_f',
173
- ' [IO.File]::WriteAllBytes($fx_out, $fx_o)',
236
+ ' fx-gz-stream-file $fx_f $fx_out',
174
237
  ' try { (Get-Item -LiteralPath $fx_out).LastWriteTime = (Get-Item -LiteralPath $fx_f).LastWriteTime } catch {}',
175
238
  ' if (-not ' + b(p.keep) + ') { Remove-Item -LiteralPath $fx_f -Force }',
176
239
  ].join('\n'), " } catch { [Console]::Error.WriteLine('gzip: ' + $fx_f + ': not in gzip format'); $script:fx_exit = 1 }");
@@ -278,7 +341,7 @@ const zip = (args) => {
278
341
  " $fx_dst = $fx_arc",
279
342
  ' $fx_rename = $false',
280
343
  " if (-not $fx_arc.ToLower().EndsWith('.zip')) { $fx_dst = $fx_arc + '.zip'; $fx_rename = $true }",
281
- ' Compress-Archive -Path $fx_valid -DestinationPath $fx_dst -Force -ErrorAction Stop',
344
+ ' Compress-Archive -LiteralPath $fx_valid -DestinationPath $fx_dst -Force -ErrorAction Stop',
282
345
  // PS 5.1 Compress-Archive writes `\` entry separators; rewrite them to `/`
283
346
  // so GNU unzip/bsdtar see proper directory entries.
284
347
  ' Add-Type -AssemblyName System.IO.Compression.FileSystem',
@@ -2,7 +2,7 @@ import { registerAll, registerSpecs } from '../registry.js';
2
2
  import { handlers as files, specs as fileSpecs } from './files.js';
3
3
  import { handlers as textFilters, specs as textFilterSpecs } from './text-filters.js';
4
4
  import { handlers as textIo, specs as textIoSpecs } from './text-io.js';
5
- import { handlers as sysinfo } from './sysinfo.js';
5
+ import { handlers as sysinfo, specs as sysinfoSpecs } from './sysinfo.js';
6
6
  import { handlers as net } from './net.js';
7
7
  import { handlers as archive, specs as archiveSpecs } from './archive.js';
8
8
  /** Register every built-in Linux command translator. Side-effectful import. */
@@ -17,5 +17,6 @@ export function installAll() {
17
17
  registerSpecs(textIoSpecs);
18
18
  registerSpecs(textFilterSpecs);
19
19
  registerSpecs(archiveSpecs);
20
+ registerSpecs(sysinfoSpecs);
20
21
  }
21
22
  installAll();
@@ -1,2 +1,11 @@
1
1
  import { Handler } from '../registry.js';
2
+ /**
3
+ * GNU wget-style default output basename for the curl fallback.
4
+ *
5
+ * A valid UTF-8 percent sequence is decoded exactly once. Malformed sequences
6
+ * remain literal, while characters Windows cannot put in a filename are
7
+ * percent-encoded again. This keeps encoded separators inside one basename
8
+ * instead of accidentally turning them into local path components.
9
+ */
10
+ export declare function urlFileName(u: string): string;
2
11
  export declare const handlers: Record<string, Handler>;
@@ -105,21 +105,57 @@ const curl = (args) => {
105
105
  /* ------------------------------------------------------------------ */
106
106
  /* wget */
107
107
  /* ------------------------------------------------------------------ */
108
- /** GNU wget default output filename derived from a literal URL (index.html when bare). */
109
- function urlFileName(u) {
110
- // keep only the path component (drop scheme, host, query, fragment)
111
- let p = u.split('#')[0].split('?')[0];
112
- const sm = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.exec(p);
113
- if (sm)
114
- p = p.slice(sm[0].length);
115
- const slash = p.indexOf('/');
116
- if (slash >= 0)
117
- p = p.slice(slash + 1);
118
- else
119
- p = '';
120
- if (p === '')
121
- return 'index.html'; // no path (or trailing /) → GNU default
122
- return p;
108
+ const WINDOWS_DEVICE_NAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i;
109
+ function percentEncodeChar(char) {
110
+ return Array.from(Buffer.from(char, 'utf8'), (byte) => '%' + byte.toString(16).toUpperCase()).join('');
111
+ }
112
+ /**
113
+ * GNU wget-style default output basename for the curl fallback.
114
+ *
115
+ * A valid UTF-8 percent sequence is decoded exactly once. Malformed sequences
116
+ * remain literal, while characters Windows cannot put in a filename are
117
+ * percent-encoded again. This keeps encoded separators inside one basename
118
+ * instead of accidentally turning them into local path components.
119
+ */
120
+ export function urlFileName(u) {
121
+ let pathname;
122
+ try {
123
+ pathname = new URL(u).pathname;
124
+ }
125
+ catch {
126
+ return 'index.html';
127
+ }
128
+ // URL normalisation handles dot segments. A bare path or trailing slash uses
129
+ // wget's conventional index filename.
130
+ if (pathname === '' || pathname.endsWith('/'))
131
+ return 'index.html';
132
+ const rawName = pathname.slice(pathname.lastIndexOf('/') + 1);
133
+ let decoded = rawName;
134
+ try {
135
+ decoded = decodeURIComponent(rawName);
136
+ }
137
+ catch {
138
+ // Keep malformed percent escapes literal so naming remains deterministic.
139
+ }
140
+ let safe = '';
141
+ for (const char of decoded) {
142
+ const code = char.codePointAt(0) ?? 0;
143
+ if (code <= 0x1f || code === 0x7f || /[<>:"/\\|?*]/.test(char)) {
144
+ safe += percentEncodeChar(char);
145
+ }
146
+ else {
147
+ safe += char;
148
+ }
149
+ }
150
+ // Windows strips trailing dots/spaces and treats DOS device basenames as
151
+ // special even when an extension is present. Preserve the intended text in
152
+ // a stable, ordinary filename instead.
153
+ safe = safe.replace(/[. ]+$/, (suffix) => Array.from(suffix, (char) => percentEncodeChar(char)).join(''));
154
+ if (safe === '' || safe === '.' || safe === '..')
155
+ return 'index.html';
156
+ if (WINDOWS_DEVICE_NAME.test(safe))
157
+ safe = '_' + safe;
158
+ return safe;
123
159
  }
124
160
  /** Map GNU wget argv → curl.exe argv (only literal words are rewritten). */
125
161
  function mapWgetArgs(args) {
@@ -1,2 +1,3 @@
1
- import { Handler } from '../registry.js';
1
+ import { CommandSpec, Handler } from '../registry.js';
2
+ export declare const specs: CommandSpec[];
2
3
  export declare const handlers: Record<string, Handler>;