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 +52 -28
- package/dist/cli.d.ts +2 -1
- package/dist/cli.js +61 -21
- package/dist/commands/archive.js +87 -24
- package/dist/commands/install-all.js +2 -1
- package/dist/commands/net.d.ts +9 -0
- package/dist/commands/net.js +51 -15
- package/dist/commands/sysinfo.d.ts +2 -1
- package/dist/commands/sysinfo.js +564 -72
- package/dist/commands/text-filters.js +36 -11
- package/dist/commands/text-io.js +72 -43
- package/dist/doctor.d.ts +1 -0
- package/dist/doctor.js +44 -3
- package/dist/errors.js +14 -4
- package/dist/executor.d.ts +4 -1
- package/dist/executor.js +194 -51
- package/dist/install.js +43 -2
- package/dist/mcp.d.ts +13 -0
- package/dist/mcp.js +15 -14
- package/dist/parser.js +11 -3
- package/dist/powershell.d.ts +22 -0
- package/dist/powershell.js +129 -0
- package/dist/ps-host.d.ts +41 -13
- package/dist/ps-host.js +302 -40
- package/dist/qwen-launch.d.ts +12 -0
- package/dist/qwen-launch.js +29 -0
- package/dist/registry.d.ts +22 -0
- package/dist/registry.js +109 -1
- package/dist/translator.d.ts +13 -6
- package/dist/translator.js +411 -86
- package/package.json +3 -1
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
|
|
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
|
-
```
|
|
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
|
|
181
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
`
|
|
191
|
-
`
|
|
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──▶
|
|
231
|
+
bash command ──parser──▶ AST ──translator──▶ PowerShell script ──executor──▶ selected PowerShell
|
|
212
232
|
│
|
|
213
|
-
agent ◀── GNU-style output, bash-style errors ◀──
|
|
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
|
|
222
|
-
|
|
223
|
-
common PowerShell errors (including zh-CN locale
|
|
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
|
|
240
|
-
pipeline stage (`echo hi >f | cat`) are
|
|
241
|
-
messages instead of
|
|
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
|
-
```
|
|
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).
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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
|
}
|
package/dist/commands/archive.js
CHANGED
|
@@ -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-
|
|
32
|
-
' $
|
|
33
|
-
' $
|
|
34
|
-
'
|
|
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
|
-
'
|
|
37
|
-
'
|
|
38
|
-
'
|
|
39
|
-
'
|
|
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
|
-
' $
|
|
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 {
|
|
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-
|
|
234
|
+
? ' fx-gz-stream-text $fx_f $false'
|
|
171
235
|
: [
|
|
172
|
-
'
|
|
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 -
|
|
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();
|
package/dist/commands/net.d.ts
CHANGED
|
@@ -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>;
|
package/dist/commands/net.js
CHANGED
|
@@ -105,21 +105,57 @@ const curl = (args) => {
|
|
|
105
105
|
/* ------------------------------------------------------------------ */
|
|
106
106
|
/* wget */
|
|
107
107
|
/* ------------------------------------------------------------------ */
|
|
108
|
-
|
|
109
|
-
function
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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) {
|