fauxnix-cli 0.5.1 → 0.7.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 +6 -4
- package/dist/ast.d.ts +5 -2
- package/dist/ast.js +4 -2
- package/dist/commands/sysinfo.js +8 -1
- package/dist/executor.d.ts +7 -0
- package/dist/executor.js +50 -62
- package/dist/mcp.js +6 -3
- package/dist/parser.js +73 -8
- package/dist/ps-host.d.ts +53 -0
- package/dist/ps-host.js +314 -0
- package/dist/translator.d.ts +21 -5
- package/dist/translator.js +205 -14
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -193,11 +193,10 @@ fauxnix optimizes for the commands agents actually run. Documented deviations:
|
|
|
193
193
|
word expansion precedes the temporary environment).
|
|
194
194
|
- `yes` is capped at 65,536 lines — PS 5.1 pipelines cannot signal upstream producers to stop, so
|
|
195
195
|
an unbounded `yes | head` would hang.
|
|
196
|
-
- `tail -f`, `eval`, `alias`, heredocs, `while`/`until`/`case`,
|
|
197
|
-
`$((...))` arithmetic expansion
|
|
196
|
+
- `tail -f`, `eval`, `alias`, heredocs, `while`/`until`/`case`,
|
|
198
197
|
and background `&` are rejected with actionable error messages instead of misbehaving.
|
|
199
|
-
(`if/then/else/fi`, `for x in ...`, backtick substitution, `command -v`, pipeline `read`,
|
|
200
|
-
|
|
198
|
+
(`if/then/elif/else/fi`, `for x in ...`, backtick substitution, `command -v`, pipeline `read`,
|
|
199
|
+
dotenv-style `source`, and word-level `$((...))` arithmetic expansion are supported.)
|
|
201
200
|
- `command -v <builtin>` prints `/usr/bin/<name>` where bash prints the bare builtin name;
|
|
202
201
|
exit codes and empty-result semantics match.
|
|
203
202
|
- `chmod` maps only the read-only bit; exec bits are no-ops on Windows. `chown` is a silent no-op
|
|
@@ -231,6 +230,9 @@ Architecture map: `src/parser.ts` (bash subset → AST) · `src/translator.ts` (
|
|
|
231
230
|
executor wrapper) · `src/executor.ts` (spawn, redirects, session persistence) ·
|
|
232
231
|
`src/commands/*.ts` (per-command generators) · `src/mcp.ts` (MCP server) · `src/cli.ts`.
|
|
233
232
|
|
|
233
|
+
Roadmap: [docs/rfc-roadmap-to-1.0.md](docs/rfc-roadmap-to-1.0.md) — tracks, milestones,
|
|
234
|
+
and the RFC process for proposing waves.
|
|
235
|
+
|
|
234
236
|
## License
|
|
235
237
|
|
|
236
238
|
MIT © 20000419
|
package/dist/ast.d.ts
CHANGED
|
@@ -8,12 +8,12 @@
|
|
|
8
8
|
* - quoting: 'literal' "interp $VAR $(cmd)"
|
|
9
9
|
* - variables: $VAR ${VAR} ${VAR[n]} plus special cases ($HOME $USER $PATH ...)
|
|
10
10
|
* - command substitution: $(...) and `...` (recursively translated)
|
|
11
|
+
* - arithmetic expansion: $((...)) (existing fx-arith engine)
|
|
11
12
|
* - env assignment prefix: VAR=value cmd
|
|
12
13
|
*
|
|
13
14
|
* Explicitly unsupported (parser throws a helpful FauxnixError):
|
|
14
15
|
* heredocs, subshells (...), background &, while/until/case,
|
|
15
|
-
*
|
|
16
|
-
* process substitution <(...).
|
|
16
|
+
* globs inside quotes, process substitution <(...).
|
|
17
17
|
*/
|
|
18
18
|
export interface CommandList {
|
|
19
19
|
kind: 'CommandList';
|
|
@@ -89,6 +89,9 @@ export type WordPart = {
|
|
|
89
89
|
} | {
|
|
90
90
|
kind: 'CmdSub';
|
|
91
91
|
cmd: string;
|
|
92
|
+
} | {
|
|
93
|
+
kind: 'Arith';
|
|
94
|
+
parts: WordPart[];
|
|
92
95
|
};
|
|
93
96
|
export declare function wordToString(w: Word): string;
|
|
94
97
|
/** True when every part is unquoted Text and the concatenation equals `tok`. */
|
package/dist/ast.js
CHANGED
|
@@ -8,12 +8,12 @@
|
|
|
8
8
|
* - quoting: 'literal' "interp $VAR $(cmd)"
|
|
9
9
|
* - variables: $VAR ${VAR} ${VAR[n]} plus special cases ($HOME $USER $PATH ...)
|
|
10
10
|
* - command substitution: $(...) and `...` (recursively translated)
|
|
11
|
+
* - arithmetic expansion: $((...)) (existing fx-arith engine)
|
|
11
12
|
* - env assignment prefix: VAR=value cmd
|
|
12
13
|
*
|
|
13
14
|
* Explicitly unsupported (parser throws a helpful FauxnixError):
|
|
14
15
|
* heredocs, subshells (...), background &, while/until/case,
|
|
15
|
-
*
|
|
16
|
-
* process substitution <(...).
|
|
16
|
+
* globs inside quotes, process substitution <(...).
|
|
17
17
|
*/
|
|
18
18
|
export function wordToString(w) {
|
|
19
19
|
return w.map(partToString).join('');
|
|
@@ -40,6 +40,8 @@ function partToString(p) {
|
|
|
40
40
|
return p.index !== undefined ? `\${${p.name}[${p.index}]}` : `$${p.name}`;
|
|
41
41
|
case 'CmdSub':
|
|
42
42
|
return '$(' + p.cmd + ')';
|
|
43
|
+
case 'Arith':
|
|
44
|
+
return '$((' + p.parts.map(partToString).join('') + '))';
|
|
43
45
|
}
|
|
44
46
|
}
|
|
45
47
|
/** Best-effort "raw literal" view: is this word free of interpolation? */
|
package/dist/commands/sysinfo.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { FauxnixParseError, isUnquotedLiteral, wordToString } from '../ast.js';
|
|
2
2
|
import { lookup, parseWords, psStr, registeredNames } from '../registry.js';
|
|
3
|
-
import { argListExpr, exprOfWord, operandExpr, translateSimple, wrapTempEnv, encodeSetValExpr, escapeDq, translateCmdSub, normalizeLiteralPath, pathExpr, paramExpr, varExpr, } from '../translator.js';
|
|
3
|
+
import { argListExpr, exprOfWord, operandExpr, translateSimple, wrapTempEnv, encodeSetValExpr, escapeDq, translateCmdSub, normalizeLiteralPath, pathExpr, paramExpr, varExpr, arithExpr, setArithHelperPreamble, } from '../translator.js';
|
|
4
4
|
import { handlers as textIoHandlers } from './text-io.js';
|
|
5
5
|
/* ------------------------------------------------------------------ */
|
|
6
6
|
/* Shared TS helpers */
|
|
@@ -1373,6 +1373,7 @@ const FX_ENVGET_FN = [
|
|
|
1373
1373
|
' return [string]$fx_ev.Value',
|
|
1374
1374
|
'}',
|
|
1375
1375
|
].join('\n');
|
|
1376
|
+
setArithHelperPreamble(FX_TNK_FN + '\n' + FX_ENVGET_FN);
|
|
1376
1377
|
/** Like exprOfWord, but $var is an exact-case env lookup (bash, not $env:). */
|
|
1377
1378
|
function kshExprOfWord(w) {
|
|
1378
1379
|
const expanded = [];
|
|
@@ -1392,6 +1393,9 @@ function kshExprOfWord(w) {
|
|
|
1392
1393
|
}
|
|
1393
1394
|
if (tilde && expanded.length === 0)
|
|
1394
1395
|
return '(fx-home)';
|
|
1396
|
+
if (!tilde && expanded.length === 1 && expanded[0].kind === 'Arith') {
|
|
1397
|
+
return arithExpr(expanded[0].parts);
|
|
1398
|
+
}
|
|
1395
1399
|
if (!tilde && expanded.length === 1 && expanded[0].kind === 'Var') {
|
|
1396
1400
|
if (expanded[0].param) {
|
|
1397
1401
|
return paramExpr(expanded[0].name, expanded[0].param.op, expanded[0].param.word);
|
|
@@ -1434,6 +1438,9 @@ function kshExprOfWord(w) {
|
|
|
1434
1438
|
// [[ ]] does not IFS-split, so keep the newline contract.
|
|
1435
1439
|
out += '$(' + translateCmdSub(p.cmd, true) + ')';
|
|
1436
1440
|
break;
|
|
1441
|
+
case 'Arith':
|
|
1442
|
+
out += arithExpr(p.parts);
|
|
1443
|
+
break;
|
|
1437
1444
|
}
|
|
1438
1445
|
};
|
|
1439
1446
|
for (const p of expanded)
|
package/dist/executor.d.ts
CHANGED
|
@@ -18,8 +18,15 @@ export declare class FauxnixSession {
|
|
|
18
18
|
private cwdFile;
|
|
19
19
|
private envFile;
|
|
20
20
|
private scriptFile;
|
|
21
|
+
private hostFile;
|
|
22
|
+
private host;
|
|
23
|
+
private runLock;
|
|
21
24
|
constructor();
|
|
25
|
+
private bindFiles;
|
|
22
26
|
private syncFromDisk;
|
|
27
|
+
private ensureHost;
|
|
28
|
+
/** Boot powershell.exe now so the first run() is not the 1.1s cold start. */
|
|
29
|
+
prewarm(): Promise<void>;
|
|
23
30
|
dispose(): Promise<void>;
|
|
24
31
|
/** env for the child powershell process. */
|
|
25
32
|
childEnv(cwdOverride?: string, stdinFile?: string | null): NodeJS.ProcessEnv;
|
package/dist/executor.js
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
1
|
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import { promises as fs, readFileSync,
|
|
2
|
+
import { promises as fs, readFileSync, existsSync, openSync, closeSync, writeSync } from 'node:fs';
|
|
4
3
|
import os from 'node:os';
|
|
5
4
|
import path from 'node:path';
|
|
6
|
-
import { normalizeLiteralPath } from './translator.js';
|
|
7
|
-
import { decodeOutput,
|
|
5
|
+
import { normalizeLiteralPath, wrapScript } from './translator.js';
|
|
6
|
+
import { decodeOutput, normalizeHostNewlines, resolveNativePref } from './encoding.js';
|
|
8
7
|
import { normalizeStderr } from './errors.js';
|
|
8
|
+
import { PowerShellHost, PS_MISSING_MESSAGE } from './ps-host.js';
|
|
9
9
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
10
|
-
const PS_ARGS = ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass'];
|
|
11
10
|
/** Resolve /dev/null and POSIX-ish literal targets to real Windows paths. */
|
|
12
11
|
function winTarget(target) {
|
|
13
12
|
const p = normalizeLiteralPath(target);
|
|
@@ -156,11 +155,17 @@ export class FauxnixSession {
|
|
|
156
155
|
cwdFile;
|
|
157
156
|
envFile;
|
|
158
157
|
scriptFile;
|
|
158
|
+
hostFile;
|
|
159
|
+
host = null;
|
|
160
|
+
runLock = Promise.resolve();
|
|
159
161
|
constructor() {
|
|
160
|
-
|
|
162
|
+
this.bindFiles(randomUUID().slice(0, 8));
|
|
163
|
+
}
|
|
164
|
+
bindFiles(id) {
|
|
161
165
|
this.cwdFile = path.join(os.tmpdir(), 'fauxnix-' + id + '-cwd.txt');
|
|
162
166
|
this.envFile = path.join(os.tmpdir(), 'fauxnix-' + id + '-env.json');
|
|
163
167
|
this.scriptFile = path.join(os.tmpdir(), 'fauxnix-' + id + '-script.ps1');
|
|
168
|
+
this.hostFile = path.join(os.tmpdir(), 'fauxnix-' + id + '-host.ps1');
|
|
164
169
|
}
|
|
165
170
|
syncFromDisk() {
|
|
166
171
|
try {
|
|
@@ -184,12 +189,31 @@ export class FauxnixSession {
|
|
|
184
189
|
/* ignore */
|
|
185
190
|
}
|
|
186
191
|
}
|
|
192
|
+
ensureHost() {
|
|
193
|
+
if (!this.host) {
|
|
194
|
+
this.host = new PowerShellHost(this.hostFile, () => this.childEnv());
|
|
195
|
+
}
|
|
196
|
+
return this.host;
|
|
197
|
+
}
|
|
198
|
+
/** Boot powershell.exe now so the first run() is not the 1.1s cold start. */
|
|
199
|
+
async prewarm() {
|
|
200
|
+
await this.ensureHost().ready();
|
|
201
|
+
}
|
|
187
202
|
async dispose() {
|
|
203
|
+
if (this.host) {
|
|
204
|
+
await this.host.stop();
|
|
205
|
+
this.host = null;
|
|
206
|
+
}
|
|
207
|
+
this.cwd = null;
|
|
208
|
+
this.env = {};
|
|
209
|
+
this.prevExit = null;
|
|
188
210
|
await Promise.allSettled([
|
|
189
211
|
fs.rm(this.cwdFile, { force: true }),
|
|
190
212
|
fs.rm(this.envFile, { force: true }),
|
|
191
213
|
fs.rm(this.scriptFile, { force: true }),
|
|
214
|
+
fs.rm(this.hostFile, { force: true }),
|
|
192
215
|
]);
|
|
216
|
+
this.bindFiles(randomUUID().slice(0, 8));
|
|
193
217
|
}
|
|
194
218
|
/** env for the child powershell process. */
|
|
195
219
|
childEnv(cwdOverride, stdinFile) {
|
|
@@ -218,10 +242,12 @@ export class FauxnixSession {
|
|
|
218
242
|
return env;
|
|
219
243
|
}
|
|
220
244
|
run(plans, opts = {}) {
|
|
221
|
-
|
|
245
|
+
const done = this.runLock.then(() => runPlans(plans, this, opts, () => this.syncFromDisk(), () => this.ensureHost()));
|
|
246
|
+
this.runLock = done.then(() => undefined, () => undefined);
|
|
247
|
+
return done;
|
|
222
248
|
}
|
|
223
249
|
}
|
|
224
|
-
async function runPlans(plans, session, opts, afterSegment,
|
|
250
|
+
async function runPlans(plans, session, opts, afterSegment, ensureHost) {
|
|
225
251
|
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
226
252
|
let stdout = '';
|
|
227
253
|
let stderr = '';
|
|
@@ -313,65 +339,27 @@ async function runPlans(plans, session, opts, afterSegment, scriptFile) {
|
|
|
313
339
|
chainOk = false;
|
|
314
340
|
continue;
|
|
315
341
|
}
|
|
316
|
-
const encoded =
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
if (encoded.length > 28000) {
|
|
322
|
-
writeFileSync(scriptFile, '\ufeff' + plan.script, 'utf8');
|
|
323
|
-
psArgs = [...PS_ARGS, '-File', scriptFile];
|
|
324
|
-
}
|
|
325
|
-
else {
|
|
326
|
-
psArgs = [...PS_ARGS, '-EncodedCommand', encoded];
|
|
327
|
-
}
|
|
328
|
-
const child = spawn('powershell.exe', psArgs, {
|
|
329
|
-
env: session.childEnv(currentDir, red.stdinFile),
|
|
330
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
331
|
-
windowsHide: true,
|
|
332
|
-
});
|
|
333
|
-
const running = { proc: child, killed: false };
|
|
334
|
-
const outBufs = [];
|
|
335
|
-
const errBufs = [];
|
|
336
|
-
child.stdout.on('data', (d) => outBufs.push(d));
|
|
337
|
-
child.stderr.on('data', (d) => errBufs.push(d));
|
|
338
|
-
child.stdin.end();
|
|
339
|
-
const timer = setTimeout(() => {
|
|
340
|
-
running.killed = true;
|
|
341
|
-
// Node-native termination — no external kill process, nothing injectable.
|
|
342
|
-
// Grandchildren of a timed-out script may survive; the `kill -9`/`pkill`
|
|
343
|
-
// builtins remain available for explicit Windows tree kills.
|
|
344
|
-
try {
|
|
345
|
-
child.kill();
|
|
346
|
-
}
|
|
347
|
-
catch {
|
|
348
|
-
/* best effort */
|
|
349
|
-
}
|
|
342
|
+
const encoded = wrapScript(plan.body, { mode: 'host' });
|
|
343
|
+
const inv = await ensureHost().invoke(encoded, {
|
|
344
|
+
FAUXNIX_CWD: currentDir,
|
|
345
|
+
FAUXNIX_PREV_EXIT: session.prevExit === null ? '' : String(session.prevExit),
|
|
346
|
+
FAUXNIX_STDIN_FILE: red.stdinFile || '',
|
|
350
347
|
}, timeoutMs);
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
}
|
|
359
|
-
else {
|
|
360
|
-
stderr += 'fauxnix: failed to start powershell.exe: ' + e.message + '\n';
|
|
361
|
-
}
|
|
362
|
-
resolve(127);
|
|
363
|
-
});
|
|
364
|
-
child.on('close', (c) => resolve(running.killed ? 124 : (c ?? 0)));
|
|
365
|
-
});
|
|
366
|
-
clearTimeout(timer);
|
|
348
|
+
if (inv.spawnError === 'ENOENT') {
|
|
349
|
+
stderr += inv.stderr.toString('utf8') || PS_MISSING_MESSAGE;
|
|
350
|
+
exitCode = 127;
|
|
351
|
+
session.prevExit = exitCode;
|
|
352
|
+
chainOk = false;
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
367
355
|
afterSegment();
|
|
368
356
|
const decodePref = resolveNativePref();
|
|
369
357
|
// GNU line discipline: the PS host terminates Write-Output lines with
|
|
370
358
|
// CRLF. Exact writers (fx-write / printf / echo -n) must keep embedded
|
|
371
359
|
// CR so `printf 'a\r\nb' > out` stays 4 bytes.
|
|
372
|
-
let segOut = normalizeHostNewlines(decodeOutput(
|
|
373
|
-
let segErr = normalizeHostNewlines(normalizeStderr(decodeOutput(
|
|
374
|
-
if (
|
|
360
|
+
let segOut = normalizeHostNewlines(decodeOutput(inv.stdout, decodePref));
|
|
361
|
+
let segErr = normalizeHostNewlines(normalizeStderr(decodeOutput(inv.stderr, decodePref)));
|
|
362
|
+
if (inv.timedOut) {
|
|
375
363
|
segErr += '\nbash: command timed out after ' + Math.round(timeoutMs / 1000) + 's';
|
|
376
364
|
}
|
|
377
365
|
if (red.mergeStderr) {
|
|
@@ -414,7 +402,7 @@ async function runPlans(plans, session, opts, afterSegment, scriptFile) {
|
|
|
414
402
|
}
|
|
415
403
|
stdout += segOut;
|
|
416
404
|
stderr += segErr;
|
|
417
|
-
exitCode =
|
|
405
|
+
exitCode = inv.timedOut ? 124 : inv.exitCode;
|
|
418
406
|
session.prevExit = exitCode;
|
|
419
407
|
chainOk = exitCode === 0;
|
|
420
408
|
// Only inherit cwd from a segment that actually ran and whose
|
package/dist/mcp.js
CHANGED
|
@@ -37,15 +37,16 @@ Output is formatted to look like GNU/Linux tooling (ls -l, ps aux, df -h ...), e
|
|
|
37
37
|
|
|
38
38
|
Supported: pipes (|), && / || / ;, redirections (> >> 2> 2>&1 < /dev/null), variables ($VAR $HOME ~), command substitution $(...), and ${registeredNames().length}+ coreutils-style commands (${registeredNames().slice(0, 18).join(', ')}...).
|
|
39
39
|
Unknown commands (git, node, npm, python, cargo...) are passed through and executed natively with argv-style quoting.
|
|
40
|
-
Not supported: heredocs, while/until/case,
|
|
40
|
+
Not supported: heredocs, while/until/case, background jobs. if/then/elif/else/fi, for-in loops, and word-level \$((...)) arithmetic expansion are supported.
|
|
41
41
|
|
|
42
|
-
CWD, environment variables, export/unset and cd persist across calls within this session —
|
|
42
|
+
CWD, environment variables, export/unset and cd persist across calls within this session — a resident PowerShell 5.1 host is started when the MCP session begins (and after reset), so the first bash tool call is already warm.
|
|
43
43
|
Exit codes follow bash conventions (0 ok, 1 fail, 2 usage/serious, 127 command not found, 124 timeout).
|
|
44
44
|
|
|
45
45
|
Platform requirement: the execution backend is native Windows PowerShell 5.1+. On hosts without PowerShell on PATH (e.g. Linux containers/sandboxes), the bash tool returns exit code 127 with an actionable error instead of running the command.`;
|
|
46
46
|
export async function startMcpServer() {
|
|
47
47
|
const server = new McpServer({ name: 'fauxnix', version: pkgVersion }, { capabilities: { tools: {} } });
|
|
48
|
-
|
|
48
|
+
let session = new FauxnixSession();
|
|
49
|
+
await session.prewarm();
|
|
49
50
|
server.tool(TOOL_NAME, TOOL_DESCRIPTION, {
|
|
50
51
|
command: z.string().describe('The bash-style command line to run'),
|
|
51
52
|
timeout_ms: z
|
|
@@ -94,6 +95,8 @@ export async function startMcpServer() {
|
|
|
94
95
|
}, SESSION_ANNOTATIONS, async ({ action }) => {
|
|
95
96
|
if (action === 'reset') {
|
|
96
97
|
await session.dispose();
|
|
98
|
+
session = new FauxnixSession();
|
|
99
|
+
await session.prewarm();
|
|
97
100
|
return { content: [{ type: 'text', text: 'fauxnix: session reset' }] };
|
|
98
101
|
}
|
|
99
102
|
const envKeys = Object.keys(session.env).sort();
|
package/dist/parser.js
CHANGED
|
@@ -217,7 +217,32 @@ function readBacktick(input, i) {
|
|
|
217
217
|
}
|
|
218
218
|
throw new FauxnixParseError('fauxnix: unclosed backtick');
|
|
219
219
|
}
|
|
220
|
-
/**
|
|
220
|
+
/** Expand `$…` constructs that appear inside `$((…))`. */
|
|
221
|
+
function readArithParts(input) {
|
|
222
|
+
const parts = [];
|
|
223
|
+
let buf = '';
|
|
224
|
+
let i = 0;
|
|
225
|
+
while (i < input.length) {
|
|
226
|
+
if (input[i] === '$') {
|
|
227
|
+
const v = readDollar(input, i);
|
|
228
|
+
if (v) {
|
|
229
|
+
if (buf) {
|
|
230
|
+
parts.push({ kind: 'Text', text: buf });
|
|
231
|
+
buf = '';
|
|
232
|
+
}
|
|
233
|
+
parts.push(v.part);
|
|
234
|
+
i = v.next;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
buf += input[i];
|
|
239
|
+
i++;
|
|
240
|
+
}
|
|
241
|
+
if (buf)
|
|
242
|
+
parts.push({ kind: 'Text', text: buf });
|
|
243
|
+
return parts;
|
|
244
|
+
}
|
|
245
|
+
/** Parse $VAR, ${VAR}, $(cmd substitution), $((arith)). Returns null when not a valid dollar construct. */
|
|
221
246
|
function readDollar(input, i) {
|
|
222
247
|
const n = input.length;
|
|
223
248
|
if (input[i] !== '$')
|
|
@@ -256,11 +281,41 @@ function readDollar(input, i) {
|
|
|
256
281
|
}
|
|
257
282
|
return { part: { kind: 'Var', name }, next: end + 1 };
|
|
258
283
|
}
|
|
259
|
-
// $((...))
|
|
260
|
-
//
|
|
261
|
-
// empty/confusing command. Reject loudly until word-level arith lands.
|
|
284
|
+
// $((...)) arithmetic expansion — distinct from `$( (cmd) )` (space after
|
|
285
|
+
// the first paren is command substitution of a grouped body).
|
|
262
286
|
if (input[j] === '(' && j + 1 < n && input[j + 1] === '(') {
|
|
263
|
-
|
|
287
|
+
let depth = 0;
|
|
288
|
+
let k = j;
|
|
289
|
+
while (k < n) {
|
|
290
|
+
const c = input[k];
|
|
291
|
+
if (c === "'" || c === '"') {
|
|
292
|
+
const q = c;
|
|
293
|
+
k++;
|
|
294
|
+
while (k < n && input[k] !== q) {
|
|
295
|
+
if (input[k] === '\\')
|
|
296
|
+
k++;
|
|
297
|
+
k++;
|
|
298
|
+
}
|
|
299
|
+
k++;
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
if (c === '(')
|
|
303
|
+
depth++;
|
|
304
|
+
else if (c === ')') {
|
|
305
|
+
depth--;
|
|
306
|
+
if (depth === 0) {
|
|
307
|
+
k++;
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
k++;
|
|
312
|
+
}
|
|
313
|
+
if (depth !== 0)
|
|
314
|
+
throw new FauxnixParseError('fauxnix: unclosed $(( ))');
|
|
315
|
+
return {
|
|
316
|
+
part: { kind: 'Arith', parts: readArithParts(input.slice(j + 2, k - 2)) },
|
|
317
|
+
next: k,
|
|
318
|
+
};
|
|
264
319
|
}
|
|
265
320
|
// $(cmd substitution) — captured with balanced parens; the translator
|
|
266
321
|
// recursively translates this text before embedding it.
|
|
@@ -642,14 +697,24 @@ export function parseCommand(input) {
|
|
|
642
697
|
}
|
|
643
698
|
return { kind: 'CommandList', segments };
|
|
644
699
|
};
|
|
645
|
-
const parseIf = () => {
|
|
646
|
-
expectKw(
|
|
700
|
+
const parseIf = (start = 'if') => {
|
|
701
|
+
expectKw(start);
|
|
647
702
|
const test = parseListUntil(['then']);
|
|
648
703
|
expectKw('then');
|
|
649
704
|
const thenL = parseListUntil(['else', 'elif', 'fi']);
|
|
650
705
|
let elseL;
|
|
651
706
|
if (peekKw() === 'elif') {
|
|
652
|
-
|
|
707
|
+
// bash `elif` is `else` + nested `if`; the innermost clause eats `fi`.
|
|
708
|
+
elseL = {
|
|
709
|
+
kind: 'CommandList',
|
|
710
|
+
segments: [
|
|
711
|
+
{
|
|
712
|
+
op: ';',
|
|
713
|
+
pipeline: { kind: 'Pipeline', commands: [parseIf('elif')] },
|
|
714
|
+
},
|
|
715
|
+
],
|
|
716
|
+
};
|
|
717
|
+
return { kind: 'If', test, then: thenL, else: elseL, redirects: [] };
|
|
653
718
|
}
|
|
654
719
|
if (peekKw() === 'else') {
|
|
655
720
|
next();
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export declare const PS_MISSING_MESSAGE: string;
|
|
2
|
+
export interface HostInvokeResult {
|
|
3
|
+
stdout: Buffer;
|
|
4
|
+
stderr: Buffer;
|
|
5
|
+
exitCode: number;
|
|
6
|
+
timedOut: boolean;
|
|
7
|
+
spawnError?: 'ENOENT' | 'START';
|
|
8
|
+
spawnMessage?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface HostRequestEnv {
|
|
11
|
+
[key: string]: string;
|
|
12
|
+
}
|
|
13
|
+
export declare function encodeHostRequest(id: string, script: string, env: HostRequestEnv): string;
|
|
14
|
+
export declare function decodeHostResponse(line: string): {
|
|
15
|
+
id: string;
|
|
16
|
+
stdout: Buffer;
|
|
17
|
+
stderr: Buffer;
|
|
18
|
+
exitCode: number;
|
|
19
|
+
ready?: boolean;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* One resident powershell.exe 5.1 process. Frames are UTF-8 JSON lines;
|
|
23
|
+
* command stdout/stderr come back as base64 so PS 5.1's UTF-16LE pipe
|
|
24
|
+
* encoding cannot scramble the payload.
|
|
25
|
+
*/
|
|
26
|
+
export declare class PowerShellHost {
|
|
27
|
+
private readonly hostFile;
|
|
28
|
+
private readonly envFn;
|
|
29
|
+
private proc;
|
|
30
|
+
private stdoutBuf;
|
|
31
|
+
private queuedLines;
|
|
32
|
+
private waiters;
|
|
33
|
+
private stderrChunks;
|
|
34
|
+
private closeCode;
|
|
35
|
+
private closeErr;
|
|
36
|
+
private closed;
|
|
37
|
+
private startLock;
|
|
38
|
+
private invokeLock;
|
|
39
|
+
constructor(hostFile: string, envFn: () => NodeJS.ProcessEnv);
|
|
40
|
+
/** Start the resident process and wait for the ready handshake (B1 prewarm). */
|
|
41
|
+
ready(): Promise<HostInvokeResult | null>;
|
|
42
|
+
invoke(script: string, env: HostRequestEnv, timeoutMs: number): Promise<HostInvokeResult>;
|
|
43
|
+
stop(): Promise<void>;
|
|
44
|
+
private invokeSerial;
|
|
45
|
+
private ensureStarted;
|
|
46
|
+
private deadRestart;
|
|
47
|
+
private start;
|
|
48
|
+
private onStdout;
|
|
49
|
+
private nextLine;
|
|
50
|
+
private nextReadyLine;
|
|
51
|
+
private nextJsonLine;
|
|
52
|
+
private failWaiters;
|
|
53
|
+
}
|
package/dist/ps-host.js
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { writeFileSync } from 'node:fs';
|
|
3
|
+
import { hostBootstrapScript } from './translator.js';
|
|
4
|
+
const PS_ARGS = ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass'];
|
|
5
|
+
const READY_TIMEOUT_MS = 30_000;
|
|
6
|
+
export const PS_MISSING_MESSAGE = 'fauxnix: powershell.exe not found — fauxnix executes bash via native Windows PowerShell 5.1+.\n' +
|
|
7
|
+
'This host has no PowerShell on PATH (typical for Linux containers/sandboxes).\n' +
|
|
8
|
+
'Run fauxnix on Windows, or install PowerShell and make powershell.exe reachable on PATH.\n';
|
|
9
|
+
export function encodeHostRequest(id, script, env) {
|
|
10
|
+
return JSON.stringify({
|
|
11
|
+
id,
|
|
12
|
+
scriptB64: Buffer.from(script, 'utf8').toString('base64'),
|
|
13
|
+
env,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
export function decodeHostResponse(line) {
|
|
17
|
+
const j = JSON.parse(line);
|
|
18
|
+
const n = Number(j.exitCode);
|
|
19
|
+
return {
|
|
20
|
+
id: j.id ?? '',
|
|
21
|
+
stdout: Buffer.from(j.stdoutB64 ?? '', 'base64'),
|
|
22
|
+
stderr: Buffer.from(j.stderrB64 ?? '', 'base64'),
|
|
23
|
+
exitCode: Number.isFinite(n) ? n : 0,
|
|
24
|
+
ready: j.ready === true,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* One resident powershell.exe 5.1 process. Frames are UTF-8 JSON lines;
|
|
29
|
+
* command stdout/stderr come back as base64 so PS 5.1's UTF-16LE pipe
|
|
30
|
+
* encoding cannot scramble the payload.
|
|
31
|
+
*/
|
|
32
|
+
export class PowerShellHost {
|
|
33
|
+
hostFile;
|
|
34
|
+
envFn;
|
|
35
|
+
proc = null;
|
|
36
|
+
stdoutBuf = Buffer.alloc(0);
|
|
37
|
+
queuedLines = [];
|
|
38
|
+
waiters = [];
|
|
39
|
+
stderrChunks = [];
|
|
40
|
+
closeCode;
|
|
41
|
+
closeErr = null;
|
|
42
|
+
closed = false;
|
|
43
|
+
startLock = null;
|
|
44
|
+
invokeLock = Promise.resolve();
|
|
45
|
+
constructor(hostFile, envFn) {
|
|
46
|
+
this.hostFile = hostFile;
|
|
47
|
+
this.envFn = envFn;
|
|
48
|
+
}
|
|
49
|
+
/** Start the resident process and wait for the ready handshake (B1 prewarm). */
|
|
50
|
+
async ready() {
|
|
51
|
+
return this.ensureStarted();
|
|
52
|
+
}
|
|
53
|
+
async invoke(script, env, timeoutMs) {
|
|
54
|
+
const run = this.invokeLock.then(() => this.invokeSerial(script, env, timeoutMs));
|
|
55
|
+
this.invokeLock = run.then(() => undefined, () => undefined);
|
|
56
|
+
return run;
|
|
57
|
+
}
|
|
58
|
+
async stop() {
|
|
59
|
+
const proc = this.proc;
|
|
60
|
+
this.proc = null;
|
|
61
|
+
this.closed = true;
|
|
62
|
+
this.failWaiters(new Error('fauxnix: powershell host stopped'));
|
|
63
|
+
if (!proc)
|
|
64
|
+
return;
|
|
65
|
+
try {
|
|
66
|
+
proc.stdin?.end();
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
/* ignore */
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
proc.kill();
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
/* ignore */
|
|
76
|
+
}
|
|
77
|
+
await new Promise((resolve) => {
|
|
78
|
+
const t = setTimeout(resolve, 2000);
|
|
79
|
+
proc.once('close', () => {
|
|
80
|
+
clearTimeout(t);
|
|
81
|
+
resolve();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
async invokeSerial(script, env, timeoutMs) {
|
|
86
|
+
const started = await this.ensureStarted();
|
|
87
|
+
if (started)
|
|
88
|
+
return started;
|
|
89
|
+
const id = 'f' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
|
90
|
+
const line = encodeHostRequest(id, script, env);
|
|
91
|
+
try {
|
|
92
|
+
this.proc.stdin.write(line + '\n');
|
|
93
|
+
}
|
|
94
|
+
catch (e) {
|
|
95
|
+
await this.deadRestart();
|
|
96
|
+
return {
|
|
97
|
+
stdout: Buffer.alloc(0),
|
|
98
|
+
stderr: Buffer.from('fauxnix: powershell host exited unexpectedly\n', 'utf8'),
|
|
99
|
+
exitCode: 1,
|
|
100
|
+
timedOut: false,
|
|
101
|
+
spawnMessage: e.message,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const raw = await this.nextJsonLine(timeoutMs, id);
|
|
106
|
+
const msg = decodeHostResponse(raw);
|
|
107
|
+
return {
|
|
108
|
+
stdout: msg.stdout,
|
|
109
|
+
stderr: msg.stderr,
|
|
110
|
+
exitCode: msg.exitCode,
|
|
111
|
+
timedOut: false,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
catch (e) {
|
|
115
|
+
const timedOut = e.timedOut === true;
|
|
116
|
+
await this.stop();
|
|
117
|
+
if (timedOut) {
|
|
118
|
+
return { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode: 124, timedOut: true };
|
|
119
|
+
}
|
|
120
|
+
if (this.closeErr && this.closeErr.code === 'ENOENT') {
|
|
121
|
+
return {
|
|
122
|
+
stdout: Buffer.alloc(0),
|
|
123
|
+
stderr: Buffer.from(PS_MISSING_MESSAGE, 'utf8'),
|
|
124
|
+
exitCode: 127,
|
|
125
|
+
timedOut: false,
|
|
126
|
+
spawnError: 'ENOENT',
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const code = this.closeCode ?? 1;
|
|
130
|
+
return {
|
|
131
|
+
stdout: Buffer.alloc(0),
|
|
132
|
+
stderr: Buffer.from('fauxnix: powershell host exited unexpectedly' +
|
|
133
|
+
(code !== 1 ? ' (exit ' + String(code) + ')' : '') +
|
|
134
|
+
'\n', 'utf8'),
|
|
135
|
+
exitCode: code === 0 ? 1 : code,
|
|
136
|
+
timedOut: false,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
async ensureStarted() {
|
|
141
|
+
if (this.proc && !this.closed)
|
|
142
|
+
return null;
|
|
143
|
+
if (!this.startLock)
|
|
144
|
+
this.startLock = this.start();
|
|
145
|
+
try {
|
|
146
|
+
await this.startLock;
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
catch (e) {
|
|
150
|
+
const err = e;
|
|
151
|
+
if (err.code === 'ENOENT') {
|
|
152
|
+
return {
|
|
153
|
+
stdout: Buffer.alloc(0),
|
|
154
|
+
stderr: Buffer.from(PS_MISSING_MESSAGE, 'utf8'),
|
|
155
|
+
exitCode: 127,
|
|
156
|
+
timedOut: false,
|
|
157
|
+
spawnError: 'ENOENT',
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
stdout: Buffer.alloc(0),
|
|
162
|
+
stderr: Buffer.from('fauxnix: failed to start powershell.exe: ' + err.message + '\n', 'utf8'),
|
|
163
|
+
exitCode: 127,
|
|
164
|
+
timedOut: false,
|
|
165
|
+
spawnError: 'START',
|
|
166
|
+
spawnMessage: err.message,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
this.startLock = null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
async deadRestart() {
|
|
174
|
+
await this.stop();
|
|
175
|
+
this.closed = false;
|
|
176
|
+
this.closeCode = undefined;
|
|
177
|
+
this.closeErr = null;
|
|
178
|
+
this.stdoutBuf = Buffer.alloc(0);
|
|
179
|
+
this.queuedLines = [];
|
|
180
|
+
this.stderrChunks = [];
|
|
181
|
+
}
|
|
182
|
+
async start() {
|
|
183
|
+
await this.deadRestart();
|
|
184
|
+
this.closed = false;
|
|
185
|
+
writeFileSync(this.hostFile, '\ufeff' + hostBootstrapScript(), 'utf8');
|
|
186
|
+
const child = spawn('powershell.exe', [...PS_ARGS, '-File', this.hostFile], {
|
|
187
|
+
env: this.envFn(),
|
|
188
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
189
|
+
windowsHide: true,
|
|
190
|
+
});
|
|
191
|
+
this.proc = child;
|
|
192
|
+
child.stdout.on('data', (d) => this.onStdout(d));
|
|
193
|
+
child.stderr.on('data', (d) => this.stderrChunks.push(d));
|
|
194
|
+
child.on('error', (e) => {
|
|
195
|
+
this.closeErr = e;
|
|
196
|
+
this.closed = true;
|
|
197
|
+
this.failWaiters(e);
|
|
198
|
+
});
|
|
199
|
+
child.on('close', (c) => {
|
|
200
|
+
this.closeCode = c;
|
|
201
|
+
this.closed = true;
|
|
202
|
+
this.proc = null;
|
|
203
|
+
this.failWaiters(new Error('fauxnix: powershell host closed'));
|
|
204
|
+
});
|
|
205
|
+
try {
|
|
206
|
+
const readyLine = await this.nextReadyLine(READY_TIMEOUT_MS);
|
|
207
|
+
const msg = decodeHostResponse(readyLine);
|
|
208
|
+
if (!msg.ready) {
|
|
209
|
+
throw new Error('fauxnix: powershell host handshake failed');
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
catch (e) {
|
|
213
|
+
await this.stop();
|
|
214
|
+
throw e;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
onStdout(chunk) {
|
|
218
|
+
this.stdoutBuf = Buffer.concat([this.stdoutBuf, chunk]);
|
|
219
|
+
while (true) {
|
|
220
|
+
const i = this.stdoutBuf.indexOf(0x0a);
|
|
221
|
+
if (i < 0)
|
|
222
|
+
break;
|
|
223
|
+
let line = this.stdoutBuf.subarray(0, i);
|
|
224
|
+
this.stdoutBuf = this.stdoutBuf.subarray(i + 1);
|
|
225
|
+
if (line.length && line[line.length - 1] === 0x0d)
|
|
226
|
+
line = line.subarray(0, line.length - 1);
|
|
227
|
+
const s = line.toString('utf8').replace(/^\uFEFF/, '');
|
|
228
|
+
const w = this.waiters.shift();
|
|
229
|
+
if (w)
|
|
230
|
+
w.resolve(s);
|
|
231
|
+
else
|
|
232
|
+
this.queuedLines.push(s);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
nextLine(timeoutMs) {
|
|
236
|
+
if (this.queuedLines.length)
|
|
237
|
+
return Promise.resolve(this.queuedLines.shift());
|
|
238
|
+
if (this.closed) {
|
|
239
|
+
return Promise.reject(this.closeErr ?? new Error('fauxnix: powershell host closed'));
|
|
240
|
+
}
|
|
241
|
+
return new Promise((resolve, reject) => {
|
|
242
|
+
const waiter = {
|
|
243
|
+
resolve: (line) => {
|
|
244
|
+
clearTimeout(timer);
|
|
245
|
+
resolve(line);
|
|
246
|
+
},
|
|
247
|
+
reject: (err) => {
|
|
248
|
+
clearTimeout(timer);
|
|
249
|
+
reject(err);
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
const timer = setTimeout(() => {
|
|
253
|
+
const idx = this.waiters.indexOf(waiter);
|
|
254
|
+
if (idx >= 0)
|
|
255
|
+
this.waiters.splice(idx, 1);
|
|
256
|
+
const err = new Error('fauxnix: powershell host timed out');
|
|
257
|
+
err.timedOut = true;
|
|
258
|
+
reject(err);
|
|
259
|
+
}, timeoutMs);
|
|
260
|
+
this.waiters.push(waiter);
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
async nextReadyLine(timeoutMs) {
|
|
264
|
+
const deadline = Date.now() + timeoutMs;
|
|
265
|
+
while (Date.now() < deadline) {
|
|
266
|
+
const line = await this.nextLine(Math.max(1, deadline - Date.now()));
|
|
267
|
+
if (!line.trim())
|
|
268
|
+
continue;
|
|
269
|
+
try {
|
|
270
|
+
const msg = decodeHostResponse(line);
|
|
271
|
+
if (msg.ready)
|
|
272
|
+
return line;
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
/* skip PS boot noise */
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
const err = new Error('fauxnix: powershell host handshake timed out');
|
|
279
|
+
err.timedOut = true;
|
|
280
|
+
throw err;
|
|
281
|
+
}
|
|
282
|
+
async nextJsonLine(timeoutMs, id) {
|
|
283
|
+
const deadline = Date.now() + timeoutMs;
|
|
284
|
+
while (Date.now() < deadline) {
|
|
285
|
+
const line = await this.nextLine(Math.max(1, deadline - Date.now()));
|
|
286
|
+
if (!line.trim())
|
|
287
|
+
continue;
|
|
288
|
+
try {
|
|
289
|
+
const msg = decodeHostResponse(line);
|
|
290
|
+
if (msg.ready)
|
|
291
|
+
continue;
|
|
292
|
+
if (msg.id === id)
|
|
293
|
+
return line;
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
/* skip noise */
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const err = new Error('fauxnix: powershell host timed out');
|
|
300
|
+
err.timedOut = true;
|
|
301
|
+
throw err;
|
|
302
|
+
}
|
|
303
|
+
failWaiters(err) {
|
|
304
|
+
const ws = this.waiters.splice(0);
|
|
305
|
+
for (const w of ws) {
|
|
306
|
+
try {
|
|
307
|
+
w.reject(err);
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
/* ignore */
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
package/dist/translator.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Assignment, CommandList, Redirect, SimpleCommand, IfCommand, ForCommand, Word } from './ast.js';
|
|
1
|
+
import { Assignment, CommandList, Redirect, SimpleCommand, IfCommand, ForCommand, Word, WordPart } from './ast.js';
|
|
2
2
|
import { PipelineCtx } from './registry.js';
|
|
3
3
|
/** `${name:-word}` and friends using case-exact fx-scalar0. */
|
|
4
4
|
export declare function paramExpr(name: string, op: ':-' | ':=' | ':+' | ':?' | '-' | '+' | '?', word: string): string;
|
|
@@ -25,6 +25,10 @@ export declare function pathExpr(s: string): string;
|
|
|
25
25
|
export declare function exprOfWord(w: Word, opts?: {
|
|
26
26
|
preserveCmdSub?: boolean;
|
|
27
27
|
}): string;
|
|
28
|
+
/** Registered by sysinfo so wrapScript can emit fx-arith without a circular import. */
|
|
29
|
+
export declare function setArithHelperPreamble(s: string): void;
|
|
30
|
+
/** PowerShell expression: evaluate `$((…))` via fx-arith; errors are loud, expansion empty. */
|
|
31
|
+
export declare function arithExpr(parts: WordPart[]): string;
|
|
28
32
|
/** Literal text of a word when it contains no interpolation, else null. */
|
|
29
33
|
export declare function literalOfWord(w: Word): string | null;
|
|
30
34
|
/**
|
|
@@ -76,16 +80,28 @@ export declare function translatePipelineBody(p: {
|
|
|
76
80
|
}): PipelineParts;
|
|
77
81
|
export interface SegmentPlan {
|
|
78
82
|
op: ';' | '&&' | '||';
|
|
79
|
-
/**
|
|
83
|
+
/** Spawn-mode wrapScript (CLI/MCP `translate`, one-shot powershell.exe). */
|
|
80
84
|
script: string;
|
|
85
|
+
/** Pipeline body before wrapScript — executor host mode re-wraps this. */
|
|
86
|
+
body: string;
|
|
81
87
|
/** All redirects collected from this segment (executor handles them). */
|
|
82
88
|
redirects: Redirect[];
|
|
83
89
|
}
|
|
84
90
|
export declare function translateCommandList(list: CommandList): SegmentPlan[];
|
|
91
|
+
export type WrapMode = 'spawn' | 'host';
|
|
92
|
+
export interface WrapScriptOptions {
|
|
93
|
+
/** spawn (default): one-shot process, `exit` at the end. host: no `exit`, no helper re-emit. */
|
|
94
|
+
mode?: WrapMode;
|
|
95
|
+
}
|
|
85
96
|
/**
|
|
86
97
|
* Wrap a pipeline body with the Fauxnix executor contract:
|
|
87
98
|
* UTF-8 everywhere, bash-style exit codes, cwd/env persistence channels.
|
|
88
|
-
*
|
|
89
|
-
*
|
|
99
|
+
* Spawn mode emits only the fx- helpers the body actually calls. Host mode
|
|
100
|
+
* assumes the resident process already loaded the catalog and must not `exit`.
|
|
101
|
+
*/
|
|
102
|
+
export declare function wrapScript(body: string, opts?: WrapScriptOptions): string;
|
|
103
|
+
/**
|
|
104
|
+
* Resident-host bootstrap: encoding + full fx-* catalog + JSON-line RPC loop.
|
|
105
|
+
* Loaded once via `powershell.exe -File`. Must never `exit` a successful frame.
|
|
90
106
|
*/
|
|
91
|
-
export declare function
|
|
107
|
+
export declare function hostBootstrapScript(): string;
|
package/dist/translator.js
CHANGED
|
@@ -161,6 +161,9 @@ export function exprOfWord(w, opts) {
|
|
|
161
161
|
if (expanded.length === 1 && expanded[0].kind === 'CmdSub') {
|
|
162
162
|
return '$(' + translateCmdSub(expanded[0].cmd, opts?.preserveCmdSub === true) + ')';
|
|
163
163
|
}
|
|
164
|
+
if (expanded.length === 1 && expanded[0].kind === 'Arith') {
|
|
165
|
+
return arithExpr(expanded[0].parts);
|
|
166
|
+
}
|
|
164
167
|
const literal = expanded.every((p) => p.kind === 'Text' || p.kind === 'SingleQuoted');
|
|
165
168
|
if (literal) {
|
|
166
169
|
const text = expanded.map((p) => p.text).join('');
|
|
@@ -186,6 +189,9 @@ export function exprOfWord(w, opts) {
|
|
|
186
189
|
case 'CmdSub':
|
|
187
190
|
out += '$(' + translateCmdSub(p.cmd, quoted || opts?.preserveCmdSub === true) + ')';
|
|
188
191
|
break;
|
|
192
|
+
case 'Arith':
|
|
193
|
+
out += arithExpr(p.parts);
|
|
194
|
+
break;
|
|
189
195
|
}
|
|
190
196
|
};
|
|
191
197
|
for (const p of expanded)
|
|
@@ -193,6 +199,62 @@ export function exprOfWord(w, opts) {
|
|
|
193
199
|
out += '"';
|
|
194
200
|
return out;
|
|
195
201
|
}
|
|
202
|
+
let arithHelperPreamble = '';
|
|
203
|
+
/** Registered by sysinfo so wrapScript can emit fx-arith without a circular import. */
|
|
204
|
+
export function setArithHelperPreamble(s) {
|
|
205
|
+
arithHelperPreamble = s;
|
|
206
|
+
}
|
|
207
|
+
function injectArithHelpers(body) {
|
|
208
|
+
if (!arithHelperPreamble)
|
|
209
|
+
return body;
|
|
210
|
+
if (!/\bfx-arith\b/.test(body))
|
|
211
|
+
return body;
|
|
212
|
+
if (/function\s+fx-arith\b/.test(body))
|
|
213
|
+
return body;
|
|
214
|
+
return arithHelperPreamble + '\n' + body;
|
|
215
|
+
}
|
|
216
|
+
/** PowerShell expression: evaluate `$((…))` via fx-arith; errors are loud, expansion empty. */
|
|
217
|
+
export function arithExpr(parts) {
|
|
218
|
+
const src = arithSourceExpr(parts);
|
|
219
|
+
return ('$(try { [string](fx-arith (' +
|
|
220
|
+
src +
|
|
221
|
+
')) } catch { [Console]::Error.WriteLine((\'bash: \' + [string](' +
|
|
222
|
+
src +
|
|
223
|
+
') + \': integer expression expected\')); $script:fx_exit = 1; \'\' })');
|
|
224
|
+
}
|
|
225
|
+
function arithSourceExpr(parts) {
|
|
226
|
+
if (parts.length === 0)
|
|
227
|
+
return "''";
|
|
228
|
+
if (parts.every((p) => p.kind === 'Text')) {
|
|
229
|
+
return psStr(parts.map((p) => p.text).join(''));
|
|
230
|
+
}
|
|
231
|
+
let out = '"';
|
|
232
|
+
const emit = (p) => {
|
|
233
|
+
switch (p.kind) {
|
|
234
|
+
case 'Text':
|
|
235
|
+
case 'SingleQuoted':
|
|
236
|
+
out += escapeDq(p.text);
|
|
237
|
+
break;
|
|
238
|
+
case 'DoubleQuoted':
|
|
239
|
+
for (const q of p.parts)
|
|
240
|
+
emit(q);
|
|
241
|
+
break;
|
|
242
|
+
case 'Var':
|
|
243
|
+
out += '$(' + varExpr(p.name, p.index, p.param, p.length === true) + ')';
|
|
244
|
+
break;
|
|
245
|
+
case 'CmdSub':
|
|
246
|
+
out += '$(' + translateCmdSub(p.cmd, true) + ')';
|
|
247
|
+
break;
|
|
248
|
+
case 'Arith':
|
|
249
|
+
out += arithExpr(p.parts);
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
for (const p of parts)
|
|
254
|
+
emit(p);
|
|
255
|
+
out += '"';
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
196
258
|
/** Literal text of a word when it contains no interpolation, else null. */
|
|
197
259
|
export function literalOfWord(w) {
|
|
198
260
|
if (!w.every((p) => p.kind === 'Text' || p.kind === 'SingleQuoted'))
|
|
@@ -771,7 +833,7 @@ export function translateCommandList(list) {
|
|
|
771
833
|
call +
|
|
772
834
|
' }';
|
|
773
835
|
}
|
|
774
|
-
plans.push({ op: seg.op, script: wrapScript(body), redirects });
|
|
836
|
+
plans.push({ op: seg.op, script: wrapScript(body), body, redirects });
|
|
775
837
|
}
|
|
776
838
|
return plans;
|
|
777
839
|
}
|
|
@@ -833,20 +895,10 @@ function wrapHelpersNeeded(body) {
|
|
|
833
895
|
}
|
|
834
896
|
return needed;
|
|
835
897
|
}
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
* UTF-8 everywhere, bash-style exit codes, cwd/env persistence channels.
|
|
839
|
-
* Only the fx- helpers the body actually calls are emitted — the full
|
|
840
|
-
* catalog is ~170 lines and was paid on every `echo hi`.
|
|
841
|
-
*/
|
|
842
|
-
export function wrapScript(body) {
|
|
843
|
-
const needed = wrapHelpersNeeded(body);
|
|
844
|
-
const lines = [
|
|
898
|
+
function wrapEncodingPreamble() {
|
|
899
|
+
return [
|
|
845
900
|
'$ErrorActionPreference = "Continue"',
|
|
846
901
|
"$ProgressPreference = 'SilentlyContinue'",
|
|
847
|
-
'$fx_exit = 0',
|
|
848
|
-
'$fx_prev = 0',
|
|
849
|
-
'if ($env:FAUXNIX_PREV_EXIT) { try { $fx_prev = [int]$env:FAUXNIX_PREV_EXIT } catch { $fx_prev = 0 } }',
|
|
850
902
|
// single console-encoding knob in PS 5.1: ansi mode decodes GBK-native
|
|
851
903
|
// admin tools correctly, utf8 mode decodes UTF-8-native dev tools
|
|
852
904
|
// (see encoding.ts — file reads sniff per file and are always right)
|
|
@@ -857,6 +909,13 @@ export function wrapScript(body) {
|
|
|
857
909
|
' try { chcp 65001 > $null } catch {}',
|
|
858
910
|
'}',
|
|
859
911
|
'$OutputEncoding = [System.Text.Encoding]::UTF8',
|
|
912
|
+
];
|
|
913
|
+
}
|
|
914
|
+
function wrapCwdPreamble() {
|
|
915
|
+
return [
|
|
916
|
+
'$script:fx_exit = 0',
|
|
917
|
+
'$fx_prev = 0',
|
|
918
|
+
'if ($env:FAUXNIX_PREV_EXIT) { try { $fx_prev = [int]$env:FAUXNIX_PREV_EXIT } catch { $fx_prev = 0 } }',
|
|
860
919
|
'if ($env:FAUXNIX_CWD) { try { Set-Location -LiteralPath $env:FAUXNIX_CWD } catch {} }',
|
|
861
920
|
// capture AFTER the session cwd is applied — OLDPWD must refer to the
|
|
862
921
|
// shell's previous directory, not the host process' startup directory
|
|
@@ -865,6 +924,44 @@ export function wrapScript(body) {
|
|
|
865
924
|
// process working directory, NOT the PS location — keep them in sync.
|
|
866
925
|
'try { [Environment]::CurrentDirectory = (Get-Location).ProviderPath } catch {}',
|
|
867
926
|
];
|
|
927
|
+
}
|
|
928
|
+
function wrapBodyAndPersist(body, exitProcess) {
|
|
929
|
+
const lines = [
|
|
930
|
+
'try {',
|
|
931
|
+
...body.split('\n').map((l) => ' ' + l),
|
|
932
|
+
'} catch [System.Management.Automation.CommandNotFoundException] {',
|
|
933
|
+
" [Console]::Error.WriteLine('bash: ' + $_.Exception.TargetName + ': command not found')",
|
|
934
|
+
' $script:fx_exit = 127',
|
|
935
|
+
'} catch {',
|
|
936
|
+
' [Console]::Error.WriteLine(($_.Exception.Message).Split("`n")[0])',
|
|
937
|
+
' $script:fx_exit = 1',
|
|
938
|
+
'}',
|
|
939
|
+
'# persist session cwd and environment for the next segment',
|
|
940
|
+
'try { [IO.File]::WriteAllText($env:FAUXNIX_CWD_FILE, (Get-Location).Path) } catch {}',
|
|
941
|
+
'if ((Get-Location).Path -ne $fx_oldcwd) { $env:FAUXNIX_OLDPWD = $fx_oldcwd }',
|
|
942
|
+
'try {',
|
|
943
|
+
' $envObj = @{}',
|
|
944
|
+
' Get-ChildItem Env: | ForEach-Object { $envObj[$_.Name] = $_.Value }',
|
|
945
|
+
' [IO.File]::WriteAllText($env:FAUXNIX_ENV_FILE, (ConvertTo-Json $envObj -Compress))',
|
|
946
|
+
'} catch {}',
|
|
947
|
+
];
|
|
948
|
+
if (exitProcess)
|
|
949
|
+
lines.push('exit $script:fx_exit');
|
|
950
|
+
return lines;
|
|
951
|
+
}
|
|
952
|
+
/**
|
|
953
|
+
* Wrap a pipeline body with the Fauxnix executor contract:
|
|
954
|
+
* UTF-8 everywhere, bash-style exit codes, cwd/env persistence channels.
|
|
955
|
+
* Spawn mode emits only the fx- helpers the body actually calls. Host mode
|
|
956
|
+
* assumes the resident process already loaded the catalog and must not `exit`.
|
|
957
|
+
*/
|
|
958
|
+
export function wrapScript(body, opts = {}) {
|
|
959
|
+
const mode = opts.mode ?? 'spawn';
|
|
960
|
+
body = injectArithHelpers(body);
|
|
961
|
+
const needed = mode === 'host' ? new Set() : wrapHelpersNeeded(body);
|
|
962
|
+
const lines = mode === 'host'
|
|
963
|
+
? wrapCwdPreamble()
|
|
964
|
+
: [...wrapEncodingPreamble(), ...wrapCwdPreamble()];
|
|
868
965
|
const helpers = {
|
|
869
966
|
'fx-readlines': [
|
|
870
967
|
'function fx-readlines($p) {',
|
|
@@ -1045,10 +1142,104 @@ export function wrapScript(body) {
|
|
|
1045
1142
|
'}',
|
|
1046
1143
|
],
|
|
1047
1144
|
};
|
|
1145
|
+
cachedWrapHelpers = helpers;
|
|
1048
1146
|
for (const name of WRAP_HELPER_ORDER) {
|
|
1049
1147
|
if (needed.has(name))
|
|
1050
1148
|
lines.push(...helpers[name]);
|
|
1051
1149
|
}
|
|
1052
|
-
lines.push(
|
|
1150
|
+
lines.push(...wrapBodyAndPersist(body, mode === 'spawn'));
|
|
1053
1151
|
return lines.join('\n');
|
|
1054
1152
|
}
|
|
1153
|
+
let cachedWrapHelpers = null;
|
|
1154
|
+
function wrapHelperCatalog() {
|
|
1155
|
+
if (!cachedWrapHelpers)
|
|
1156
|
+
wrapScript('');
|
|
1157
|
+
return cachedWrapHelpers;
|
|
1158
|
+
}
|
|
1159
|
+
/**
|
|
1160
|
+
* Resident-host bootstrap: encoding + full fx-* catalog + JSON-line RPC loop.
|
|
1161
|
+
* Loaded once via `powershell.exe -File`. Must never `exit` a successful frame.
|
|
1162
|
+
*/
|
|
1163
|
+
export function hostBootstrapScript() {
|
|
1164
|
+
const helpers = wrapHelperCatalog();
|
|
1165
|
+
const helperLines = [];
|
|
1166
|
+
for (const name of WRAP_HELPER_ORDER)
|
|
1167
|
+
helperLines.push(...helpers[name]);
|
|
1168
|
+
return [
|
|
1169
|
+
...wrapEncodingPreamble(),
|
|
1170
|
+
'$script:fx_exit = 0',
|
|
1171
|
+
...helperLines,
|
|
1172
|
+
HOST_RPC_LOOP,
|
|
1173
|
+
].join('\n');
|
|
1174
|
+
}
|
|
1175
|
+
/** Raw UTF-8 JSON lines on stdin/stdout; command streams captured per frame. */
|
|
1176
|
+
const HOST_RPC_LOOP = `
|
|
1177
|
+
$fx_utf8 = New-Object System.Text.UTF8Encoding $false
|
|
1178
|
+
$fx_in = [Console]::OpenStandardInput()
|
|
1179
|
+
$fx_out = [Console]::OpenStandardOutput()
|
|
1180
|
+
$fx_reader = New-Object System.IO.StreamReader($fx_in, $fx_utf8, $true, 8192, $true)
|
|
1181
|
+
$fx_proto = New-Object System.IO.StreamWriter($fx_out, $fx_utf8, 8192, $true)
|
|
1182
|
+
$fx_proto.NewLine = [string][char]10
|
|
1183
|
+
$fx_proto.AutoFlush = $true
|
|
1184
|
+
$fx_proto.WriteLine('{"ready":true}')
|
|
1185
|
+
while ($true) {
|
|
1186
|
+
$fx_line = $fx_reader.ReadLine()
|
|
1187
|
+
if ($null -eq $fx_line) { break }
|
|
1188
|
+
if ($fx_line -eq '') { continue }
|
|
1189
|
+
$fx_id = ''
|
|
1190
|
+
$fx_msOut = $null
|
|
1191
|
+
$fx_msErr = $null
|
|
1192
|
+
$fx_outW = $null
|
|
1193
|
+
$fx_errW = $null
|
|
1194
|
+
$fx_oldOut = [Console]::Out
|
|
1195
|
+
$fx_oldErr = [Console]::Error
|
|
1196
|
+
try {
|
|
1197
|
+
$fx_req = $fx_line | ConvertFrom-Json
|
|
1198
|
+
$fx_id = [string]$fx_req.id
|
|
1199
|
+
if ($fx_req.env) {
|
|
1200
|
+
foreach ($fx_p in $fx_req.env.PSObject.Properties) {
|
|
1201
|
+
$fx_en = [string]$fx_p.Name
|
|
1202
|
+
$fx_ev = [string]$fx_p.Value
|
|
1203
|
+
if ($fx_ev -eq '') {
|
|
1204
|
+
Remove-Item -LiteralPath ('Env:\\' + $fx_en) -ErrorAction SilentlyContinue
|
|
1205
|
+
} else {
|
|
1206
|
+
Set-Item -LiteralPath ('Env:\\' + $fx_en) -Value $fx_ev
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
$fx_script = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String([string]$fx_req.scriptB64))
|
|
1211
|
+
$fx_msOut = New-Object System.IO.MemoryStream
|
|
1212
|
+
$fx_msErr = New-Object System.IO.MemoryStream
|
|
1213
|
+
$fx_outW = New-Object System.IO.StreamWriter($fx_msOut, $fx_utf8, 1024, $true)
|
|
1214
|
+
$fx_errW = New-Object System.IO.StreamWriter($fx_msErr, $fx_utf8, 1024, $true)
|
|
1215
|
+
$fx_outW.NewLine = [string][char]13 + [string][char]10
|
|
1216
|
+
$fx_errW.NewLine = [string][char]13 + [string][char]10
|
|
1217
|
+
$fx_outW.AutoFlush = $true
|
|
1218
|
+
$fx_errW.AutoFlush = $true
|
|
1219
|
+
[Console]::SetOut($fx_outW)
|
|
1220
|
+
[Console]::SetError($fx_errW)
|
|
1221
|
+
$script:fx_exit = 0
|
|
1222
|
+
$fx_sb = [scriptblock]::Create($fx_script)
|
|
1223
|
+
& $fx_sb | ForEach-Object { [Console]::Out.WriteLine([string]$_) }
|
|
1224
|
+
} catch [System.Management.Automation.CommandNotFoundException] {
|
|
1225
|
+
[Console]::Error.WriteLine('bash: ' + $_.Exception.TargetName + ': command not found')
|
|
1226
|
+
$script:fx_exit = 127
|
|
1227
|
+
} catch {
|
|
1228
|
+
[Console]::Error.WriteLine(($_.Exception.Message).Split([string][char]10)[0])
|
|
1229
|
+
$script:fx_exit = 1
|
|
1230
|
+
} finally {
|
|
1231
|
+
try { if ($null -ne $fx_outW) { $fx_outW.Flush() } } catch {}
|
|
1232
|
+
try { if ($null -ne $fx_errW) { $fx_errW.Flush() } } catch {}
|
|
1233
|
+
try { [Console]::SetOut($fx_oldOut) } catch {}
|
|
1234
|
+
try { [Console]::SetError($fx_oldErr) } catch {}
|
|
1235
|
+
}
|
|
1236
|
+
$fx_outB64 = ''
|
|
1237
|
+
$fx_errB64 = ''
|
|
1238
|
+
if ($null -ne $fx_msOut) { $fx_outB64 = [Convert]::ToBase64String($fx_msOut.ToArray()) }
|
|
1239
|
+
if ($null -ne $fx_msErr) { $fx_errB64 = [Convert]::ToBase64String($fx_msErr.ToArray()) }
|
|
1240
|
+
$fx_code = 0
|
|
1241
|
+
try { $fx_code = [int]$script:fx_exit } catch { $fx_code = 1 }
|
|
1242
|
+
$fx_res = @{ id = $fx_id; stdoutB64 = $fx_outB64; stderrB64 = $fx_errB64; exitCode = $fx_code }
|
|
1243
|
+
$fx_proto.WriteLine(($fx_res | ConvertTo-Json -Compress))
|
|
1244
|
+
}
|
|
1245
|
+
`.trim();
|
package/package.json
CHANGED