fauxnix-cli 0.5.1 → 0.6.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.
@@ -18,8 +18,13 @@ 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;
23
28
  dispose(): Promise<void>;
24
29
  /** env for the child powershell process. */
25
30
  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, writeFileSync, existsSync, openSync, closeSync, writeSync } from 'node:fs';
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, encodeCommand, normalizeHostNewlines, resolveNativePref } from './encoding.js';
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
- const id = randomUUID().slice(0, 8);
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,27 @@ 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
+ }
187
198
  async dispose() {
199
+ if (this.host) {
200
+ await this.host.stop();
201
+ this.host = null;
202
+ }
203
+ this.cwd = null;
204
+ this.env = {};
205
+ this.prevExit = null;
188
206
  await Promise.allSettled([
189
207
  fs.rm(this.cwdFile, { force: true }),
190
208
  fs.rm(this.envFile, { force: true }),
191
209
  fs.rm(this.scriptFile, { force: true }),
210
+ fs.rm(this.hostFile, { force: true }),
192
211
  ]);
212
+ this.bindFiles(randomUUID().slice(0, 8));
193
213
  }
194
214
  /** env for the child powershell process. */
195
215
  childEnv(cwdOverride, stdinFile) {
@@ -218,10 +238,12 @@ export class FauxnixSession {
218
238
  return env;
219
239
  }
220
240
  run(plans, opts = {}) {
221
- return runPlans(plans, this, opts, () => this.syncFromDisk(), this.scriptFile);
241
+ const done = this.runLock.then(() => runPlans(plans, this, opts, () => this.syncFromDisk(), () => this.ensureHost()));
242
+ this.runLock = done.then(() => undefined, () => undefined);
243
+ return done;
222
244
  }
223
245
  }
224
- async function runPlans(plans, session, opts, afterSegment, scriptFile) {
246
+ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
225
247
  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
226
248
  let stdout = '';
227
249
  let stderr = '';
@@ -313,65 +335,27 @@ async function runPlans(plans, session, opts, afterSegment, scriptFile) {
313
335
  chainOk = false;
314
336
  continue;
315
337
  }
316
- const encoded = encodeCommand(plan.script);
317
- // -EncodedCommand is capped by the ~32K command-line limit; heavy
318
- // pipelines fall back to a UTF-8 BOM temp script (PS 5.1 honors the BOM
319
- // regardless of the console codepage, so non-ASCII stays intact).
320
- let psArgs;
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
- }
338
+ const encoded = wrapScript(plan.body, { mode: 'host' });
339
+ const inv = await ensureHost().invoke(encoded, {
340
+ FAUXNIX_CWD: currentDir,
341
+ FAUXNIX_PREV_EXIT: session.prevExit === null ? '' : String(session.prevExit),
342
+ FAUXNIX_STDIN_FILE: red.stdinFile || '',
350
343
  }, timeoutMs);
351
- const code = await new Promise((resolve) => {
352
- child.on('error', (e) => {
353
- if (e.code === 'ENOENT') {
354
- stderr +=
355
- 'fauxnix: powershell.exe not found — fauxnix executes bash via native Windows PowerShell 5.1+.\n' +
356
- 'This host has no PowerShell on PATH (typical for Linux containers/sandboxes).\n' +
357
- 'Run fauxnix on Windows, or install PowerShell and make powershell.exe reachable on PATH.\n';
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);
344
+ if (inv.spawnError === 'ENOENT') {
345
+ stderr += inv.stderr.toString('utf8') || PS_MISSING_MESSAGE;
346
+ exitCode = 127;
347
+ session.prevExit = exitCode;
348
+ chainOk = false;
349
+ continue;
350
+ }
367
351
  afterSegment();
368
352
  const decodePref = resolveNativePref();
369
353
  // GNU line discipline: the PS host terminates Write-Output lines with
370
354
  // CRLF. Exact writers (fx-write / printf / echo -n) must keep embedded
371
355
  // CR so `printf 'a\r\nb' > out` stays 4 bytes.
372
- let segOut = normalizeHostNewlines(decodeOutput(Buffer.concat(outBufs), decodePref));
373
- let segErr = normalizeHostNewlines(normalizeStderr(decodeOutput(Buffer.concat(errBufs), decodePref)));
374
- if (running.killed) {
356
+ let segOut = normalizeHostNewlines(decodeOutput(inv.stdout, decodePref));
357
+ let segErr = normalizeHostNewlines(normalizeStderr(decodeOutput(inv.stderr, decodePref)));
358
+ if (inv.timedOut) {
375
359
  segErr += '\nbash: command timed out after ' + Math.round(timeoutMs / 1000) + 's';
376
360
  }
377
361
  if (red.mergeStderr) {
@@ -414,7 +398,7 @@ async function runPlans(plans, session, opts, afterSegment, scriptFile) {
414
398
  }
415
399
  stdout += segOut;
416
400
  stderr += segErr;
417
- exitCode = code ?? 0;
401
+ exitCode = inv.timedOut ? 124 : inv.exitCode;
418
402
  session.prevExit = exitCode;
419
403
  chainOk = exitCode === 0;
420
404
  // Only inherit cwd from a segment that actually ran and whose
package/dist/mcp.js CHANGED
@@ -39,13 +39,13 @@ Supported: pipes (|), && / || / ;, redirections (> >> 2> 2>&1 < /dev/null), vari
39
39
  Unknown commands (git, node, npm, python, cargo...) are passed through and executed natively with argv-style quoting.
40
40
  Not supported: heredocs, while/until/case, word-level \$((...)) arithmetic expansion, background jobs. if/then/else/fi and for-in loops are supported.
41
41
 
42
- CWD, environment variables, export/unset and cd persist across calls within this session — but prefer COMBINING related commands in one call with ; or && (e.g. 'cd src && ls | wc -l'); each call is a fresh translation+process, so batching is faster than many tiny calls.
42
+ CWD, environment variables, export/unset and cd persist across calls within this session — a resident PowerShell 5.1 host is reused, so batching with ; or && is still nicer but many tiny calls no longer each pay a powershell.exe spawn.
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
- const session = new FauxnixSession();
48
+ let session = new FauxnixSession();
49
49
  server.tool(TOOL_NAME, TOOL_DESCRIPTION, {
50
50
  command: z.string().describe('The bash-style command line to run'),
51
51
  timeout_ms: z
@@ -94,6 +94,7 @@ export async function startMcpServer() {
94
94
  }, SESSION_ANNOTATIONS, async ({ action }) => {
95
95
  if (action === 'reset') {
96
96
  await session.dispose();
97
+ session = new FauxnixSession();
97
98
  return { content: [{ type: 'text', text: 'fauxnix: session reset' }] };
98
99
  }
99
100
  const envKeys = Object.keys(session.env).sort();
@@ -0,0 +1,51 @@
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
+ invoke(script: string, env: HostRequestEnv, timeoutMs: number): Promise<HostInvokeResult>;
41
+ stop(): Promise<void>;
42
+ private invokeSerial;
43
+ private ensureStarted;
44
+ private deadRestart;
45
+ private start;
46
+ private onStdout;
47
+ private nextLine;
48
+ private nextReadyLine;
49
+ private nextJsonLine;
50
+ private failWaiters;
51
+ }
@@ -0,0 +1,310 @@
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
+ async invoke(script, env, timeoutMs) {
50
+ const run = this.invokeLock.then(() => this.invokeSerial(script, env, timeoutMs));
51
+ this.invokeLock = run.then(() => undefined, () => undefined);
52
+ return run;
53
+ }
54
+ async stop() {
55
+ const proc = this.proc;
56
+ this.proc = null;
57
+ this.closed = true;
58
+ this.failWaiters(new Error('fauxnix: powershell host stopped'));
59
+ if (!proc)
60
+ return;
61
+ try {
62
+ proc.stdin?.end();
63
+ }
64
+ catch {
65
+ /* ignore */
66
+ }
67
+ try {
68
+ proc.kill();
69
+ }
70
+ catch {
71
+ /* ignore */
72
+ }
73
+ await new Promise((resolve) => {
74
+ const t = setTimeout(resolve, 2000);
75
+ proc.once('close', () => {
76
+ clearTimeout(t);
77
+ resolve();
78
+ });
79
+ });
80
+ }
81
+ async invokeSerial(script, env, timeoutMs) {
82
+ const started = await this.ensureStarted();
83
+ if (started)
84
+ return started;
85
+ const id = 'f' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
86
+ const line = encodeHostRequest(id, script, env);
87
+ try {
88
+ this.proc.stdin.write(line + '\n');
89
+ }
90
+ catch (e) {
91
+ await this.deadRestart();
92
+ return {
93
+ stdout: Buffer.alloc(0),
94
+ stderr: Buffer.from('fauxnix: powershell host exited unexpectedly\n', 'utf8'),
95
+ exitCode: 1,
96
+ timedOut: false,
97
+ spawnMessage: e.message,
98
+ };
99
+ }
100
+ try {
101
+ const raw = await this.nextJsonLine(timeoutMs, id);
102
+ const msg = decodeHostResponse(raw);
103
+ return {
104
+ stdout: msg.stdout,
105
+ stderr: msg.stderr,
106
+ exitCode: msg.exitCode,
107
+ timedOut: false,
108
+ };
109
+ }
110
+ catch (e) {
111
+ const timedOut = e.timedOut === true;
112
+ await this.stop();
113
+ if (timedOut) {
114
+ return { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode: 124, timedOut: true };
115
+ }
116
+ if (this.closeErr && this.closeErr.code === 'ENOENT') {
117
+ return {
118
+ stdout: Buffer.alloc(0),
119
+ stderr: Buffer.from(PS_MISSING_MESSAGE, 'utf8'),
120
+ exitCode: 127,
121
+ timedOut: false,
122
+ spawnError: 'ENOENT',
123
+ };
124
+ }
125
+ const code = this.closeCode ?? 1;
126
+ return {
127
+ stdout: Buffer.alloc(0),
128
+ stderr: Buffer.from('fauxnix: powershell host exited unexpectedly' +
129
+ (code !== 1 ? ' (exit ' + String(code) + ')' : '') +
130
+ '\n', 'utf8'),
131
+ exitCode: code === 0 ? 1 : code,
132
+ timedOut: false,
133
+ };
134
+ }
135
+ }
136
+ async ensureStarted() {
137
+ if (this.proc && !this.closed)
138
+ return null;
139
+ if (!this.startLock)
140
+ this.startLock = this.start();
141
+ try {
142
+ await this.startLock;
143
+ return null;
144
+ }
145
+ catch (e) {
146
+ const err = e;
147
+ if (err.code === 'ENOENT') {
148
+ return {
149
+ stdout: Buffer.alloc(0),
150
+ stderr: Buffer.from(PS_MISSING_MESSAGE, 'utf8'),
151
+ exitCode: 127,
152
+ timedOut: false,
153
+ spawnError: 'ENOENT',
154
+ };
155
+ }
156
+ return {
157
+ stdout: Buffer.alloc(0),
158
+ stderr: Buffer.from('fauxnix: failed to start powershell.exe: ' + err.message + '\n', 'utf8'),
159
+ exitCode: 127,
160
+ timedOut: false,
161
+ spawnError: 'START',
162
+ spawnMessage: err.message,
163
+ };
164
+ }
165
+ finally {
166
+ this.startLock = null;
167
+ }
168
+ }
169
+ async deadRestart() {
170
+ await this.stop();
171
+ this.closed = false;
172
+ this.closeCode = undefined;
173
+ this.closeErr = null;
174
+ this.stdoutBuf = Buffer.alloc(0);
175
+ this.queuedLines = [];
176
+ this.stderrChunks = [];
177
+ }
178
+ async start() {
179
+ await this.deadRestart();
180
+ this.closed = false;
181
+ writeFileSync(this.hostFile, '\ufeff' + hostBootstrapScript(), 'utf8');
182
+ const child = spawn('powershell.exe', [...PS_ARGS, '-File', this.hostFile], {
183
+ env: this.envFn(),
184
+ stdio: ['pipe', 'pipe', 'pipe'],
185
+ windowsHide: true,
186
+ });
187
+ this.proc = child;
188
+ child.stdout.on('data', (d) => this.onStdout(d));
189
+ child.stderr.on('data', (d) => this.stderrChunks.push(d));
190
+ child.on('error', (e) => {
191
+ this.closeErr = e;
192
+ this.closed = true;
193
+ this.failWaiters(e);
194
+ });
195
+ child.on('close', (c) => {
196
+ this.closeCode = c;
197
+ this.closed = true;
198
+ this.proc = null;
199
+ this.failWaiters(new Error('fauxnix: powershell host closed'));
200
+ });
201
+ try {
202
+ const readyLine = await this.nextReadyLine(READY_TIMEOUT_MS);
203
+ const msg = decodeHostResponse(readyLine);
204
+ if (!msg.ready) {
205
+ throw new Error('fauxnix: powershell host handshake failed');
206
+ }
207
+ }
208
+ catch (e) {
209
+ await this.stop();
210
+ throw e;
211
+ }
212
+ }
213
+ onStdout(chunk) {
214
+ this.stdoutBuf = Buffer.concat([this.stdoutBuf, chunk]);
215
+ while (true) {
216
+ const i = this.stdoutBuf.indexOf(0x0a);
217
+ if (i < 0)
218
+ break;
219
+ let line = this.stdoutBuf.subarray(0, i);
220
+ this.stdoutBuf = this.stdoutBuf.subarray(i + 1);
221
+ if (line.length && line[line.length - 1] === 0x0d)
222
+ line = line.subarray(0, line.length - 1);
223
+ const s = line.toString('utf8').replace(/^\uFEFF/, '');
224
+ const w = this.waiters.shift();
225
+ if (w)
226
+ w.resolve(s);
227
+ else
228
+ this.queuedLines.push(s);
229
+ }
230
+ }
231
+ nextLine(timeoutMs) {
232
+ if (this.queuedLines.length)
233
+ return Promise.resolve(this.queuedLines.shift());
234
+ if (this.closed) {
235
+ return Promise.reject(this.closeErr ?? new Error('fauxnix: powershell host closed'));
236
+ }
237
+ return new Promise((resolve, reject) => {
238
+ const waiter = {
239
+ resolve: (line) => {
240
+ clearTimeout(timer);
241
+ resolve(line);
242
+ },
243
+ reject: (err) => {
244
+ clearTimeout(timer);
245
+ reject(err);
246
+ },
247
+ };
248
+ const timer = setTimeout(() => {
249
+ const idx = this.waiters.indexOf(waiter);
250
+ if (idx >= 0)
251
+ this.waiters.splice(idx, 1);
252
+ const err = new Error('fauxnix: powershell host timed out');
253
+ err.timedOut = true;
254
+ reject(err);
255
+ }, timeoutMs);
256
+ this.waiters.push(waiter);
257
+ });
258
+ }
259
+ async nextReadyLine(timeoutMs) {
260
+ const deadline = Date.now() + timeoutMs;
261
+ while (Date.now() < deadline) {
262
+ const line = await this.nextLine(Math.max(1, deadline - Date.now()));
263
+ if (!line.trim())
264
+ continue;
265
+ try {
266
+ const msg = decodeHostResponse(line);
267
+ if (msg.ready)
268
+ return line;
269
+ }
270
+ catch {
271
+ /* skip PS boot noise */
272
+ }
273
+ }
274
+ const err = new Error('fauxnix: powershell host handshake timed out');
275
+ err.timedOut = true;
276
+ throw err;
277
+ }
278
+ async nextJsonLine(timeoutMs, id) {
279
+ const deadline = Date.now() + timeoutMs;
280
+ while (Date.now() < deadline) {
281
+ const line = await this.nextLine(Math.max(1, deadline - Date.now()));
282
+ if (!line.trim())
283
+ continue;
284
+ try {
285
+ const msg = decodeHostResponse(line);
286
+ if (msg.ready)
287
+ continue;
288
+ if (msg.id === id)
289
+ return line;
290
+ }
291
+ catch {
292
+ /* skip noise */
293
+ }
294
+ }
295
+ const err = new Error('fauxnix: powershell host timed out');
296
+ err.timedOut = true;
297
+ throw err;
298
+ }
299
+ failWaiters(err) {
300
+ const ws = this.waiters.splice(0);
301
+ for (const w of ws) {
302
+ try {
303
+ w.reject(err);
304
+ }
305
+ catch {
306
+ /* ignore */
307
+ }
308
+ }
309
+ }
310
+ }
@@ -76,16 +76,28 @@ export declare function translatePipelineBody(p: {
76
76
  }): PipelineParts;
77
77
  export interface SegmentPlan {
78
78
  op: ';' | '&&' | '||';
79
- /** Complete PowerShell script for one powershell.exe invocation. */
79
+ /** Spawn-mode wrapScript (CLI/MCP `translate`, one-shot powershell.exe). */
80
80
  script: string;
81
+ /** Pipeline body before wrapScript — executor host mode re-wraps this. */
82
+ body: string;
81
83
  /** All redirects collected from this segment (executor handles them). */
82
84
  redirects: Redirect[];
83
85
  }
84
86
  export declare function translateCommandList(list: CommandList): SegmentPlan[];
87
+ export type WrapMode = 'spawn' | 'host';
88
+ export interface WrapScriptOptions {
89
+ /** spawn (default): one-shot process, `exit` at the end. host: no `exit`, no helper re-emit. */
90
+ mode?: WrapMode;
91
+ }
85
92
  /**
86
93
  * Wrap a pipeline body with the Fauxnix executor contract:
87
94
  * UTF-8 everywhere, bash-style exit codes, cwd/env persistence channels.
88
- * Only the fx- helpers the body actually calls are emitted — the full
89
- * catalog is ~170 lines and was paid on every `echo hi`.
95
+ * Spawn mode emits only the fx- helpers the body actually calls. Host mode
96
+ * assumes the resident process already loaded the catalog and must not `exit`.
97
+ */
98
+ export declare function wrapScript(body: string, opts?: WrapScriptOptions): string;
99
+ /**
100
+ * Resident-host bootstrap: encoding + full fx-* catalog + JSON-line RPC loop.
101
+ * Loaded once via `powershell.exe -File`. Must never `exit` a successful frame.
90
102
  */
91
- export declare function wrapScript(body: string): string;
103
+ export declare function hostBootstrapScript(): string;
@@ -771,7 +771,7 @@ export function translateCommandList(list) {
771
771
  call +
772
772
  ' }';
773
773
  }
774
- plans.push({ op: seg.op, script: wrapScript(body), redirects });
774
+ plans.push({ op: seg.op, script: wrapScript(body), body, redirects });
775
775
  }
776
776
  return plans;
777
777
  }
@@ -833,20 +833,10 @@ function wrapHelpersNeeded(body) {
833
833
  }
834
834
  return needed;
835
835
  }
836
- /**
837
- * Wrap a pipeline body with the Fauxnix executor contract:
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 = [
836
+ function wrapEncodingPreamble() {
837
+ return [
845
838
  '$ErrorActionPreference = "Continue"',
846
839
  "$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
840
  // single console-encoding knob in PS 5.1: ansi mode decodes GBK-native
851
841
  // admin tools correctly, utf8 mode decodes UTF-8-native dev tools
852
842
  // (see encoding.ts — file reads sniff per file and are always right)
@@ -857,6 +847,13 @@ export function wrapScript(body) {
857
847
  ' try { chcp 65001 > $null } catch {}',
858
848
  '}',
859
849
  '$OutputEncoding = [System.Text.Encoding]::UTF8',
850
+ ];
851
+ }
852
+ function wrapCwdPreamble() {
853
+ return [
854
+ '$script:fx_exit = 0',
855
+ '$fx_prev = 0',
856
+ 'if ($env:FAUXNIX_PREV_EXIT) { try { $fx_prev = [int]$env:FAUXNIX_PREV_EXIT } catch { $fx_prev = 0 } }',
860
857
  'if ($env:FAUXNIX_CWD) { try { Set-Location -LiteralPath $env:FAUXNIX_CWD } catch {} }',
861
858
  // capture AFTER the session cwd is applied — OLDPWD must refer to the
862
859
  // shell's previous directory, not the host process' startup directory
@@ -865,6 +862,43 @@ export function wrapScript(body) {
865
862
  // process working directory, NOT the PS location — keep them in sync.
866
863
  'try { [Environment]::CurrentDirectory = (Get-Location).ProviderPath } catch {}',
867
864
  ];
865
+ }
866
+ function wrapBodyAndPersist(body, exitProcess) {
867
+ const lines = [
868
+ 'try {',
869
+ ...body.split('\n').map((l) => ' ' + l),
870
+ '} catch [System.Management.Automation.CommandNotFoundException] {',
871
+ " [Console]::Error.WriteLine('bash: ' + $_.Exception.TargetName + ': command not found')",
872
+ ' $script:fx_exit = 127',
873
+ '} catch {',
874
+ ' [Console]::Error.WriteLine(($_.Exception.Message).Split("`n")[0])',
875
+ ' $script:fx_exit = 1',
876
+ '}',
877
+ '# persist session cwd and environment for the next segment',
878
+ 'try { [IO.File]::WriteAllText($env:FAUXNIX_CWD_FILE, (Get-Location).Path) } catch {}',
879
+ 'if ((Get-Location).Path -ne $fx_oldcwd) { $env:FAUXNIX_OLDPWD = $fx_oldcwd }',
880
+ 'try {',
881
+ ' $envObj = @{}',
882
+ ' Get-ChildItem Env: | ForEach-Object { $envObj[$_.Name] = $_.Value }',
883
+ ' [IO.File]::WriteAllText($env:FAUXNIX_ENV_FILE, (ConvertTo-Json $envObj -Compress))',
884
+ '} catch {}',
885
+ ];
886
+ if (exitProcess)
887
+ lines.push('exit $script:fx_exit');
888
+ return lines;
889
+ }
890
+ /**
891
+ * Wrap a pipeline body with the Fauxnix executor contract:
892
+ * UTF-8 everywhere, bash-style exit codes, cwd/env persistence channels.
893
+ * Spawn mode emits only the fx- helpers the body actually calls. Host mode
894
+ * assumes the resident process already loaded the catalog and must not `exit`.
895
+ */
896
+ export function wrapScript(body, opts = {}) {
897
+ const mode = opts.mode ?? 'spawn';
898
+ const needed = mode === 'host' ? new Set() : wrapHelpersNeeded(body);
899
+ const lines = mode === 'host'
900
+ ? wrapCwdPreamble()
901
+ : [...wrapEncodingPreamble(), ...wrapCwdPreamble()];
868
902
  const helpers = {
869
903
  'fx-readlines': [
870
904
  'function fx-readlines($p) {',
@@ -1045,10 +1079,104 @@ export function wrapScript(body) {
1045
1079
  '}',
1046
1080
  ],
1047
1081
  };
1082
+ cachedWrapHelpers = helpers;
1048
1083
  for (const name of WRAP_HELPER_ORDER) {
1049
1084
  if (needed.has(name))
1050
1085
  lines.push(...helpers[name]);
1051
1086
  }
1052
- lines.push('try {', ...body.split('\n').map((l) => ' ' + l), '} catch [System.Management.Automation.CommandNotFoundException] {', " [Console]::Error.WriteLine('bash: ' + $_.Exception.TargetName + ': command not found')", ' $script:fx_exit = 127', '} catch {', ' [Console]::Error.WriteLine(($_.Exception.Message).Split("`n")[0])', ' $script:fx_exit = 1', '}', '# persist session cwd and environment for the next segment', 'try { [IO.File]::WriteAllText($env:FAUXNIX_CWD_FILE, (Get-Location).Path) } catch {}', 'if ((Get-Location).Path -ne $fx_oldcwd) { $env:FAUXNIX_OLDPWD = $fx_oldcwd }', 'try {', ' $envObj = @{}', ' Get-ChildItem Env: | ForEach-Object { $envObj[$_.Name] = $_.Value }', ' [IO.File]::WriteAllText($env:FAUXNIX_ENV_FILE, (ConvertTo-Json $envObj -Compress))', '} catch {}', 'exit $script:fx_exit');
1087
+ lines.push(...wrapBodyAndPersist(body, mode === 'spawn'));
1053
1088
  return lines.join('\n');
1054
1089
  }
1090
+ let cachedWrapHelpers = null;
1091
+ function wrapHelperCatalog() {
1092
+ if (!cachedWrapHelpers)
1093
+ wrapScript('');
1094
+ return cachedWrapHelpers;
1095
+ }
1096
+ /**
1097
+ * Resident-host bootstrap: encoding + full fx-* catalog + JSON-line RPC loop.
1098
+ * Loaded once via `powershell.exe -File`. Must never `exit` a successful frame.
1099
+ */
1100
+ export function hostBootstrapScript() {
1101
+ const helpers = wrapHelperCatalog();
1102
+ const helperLines = [];
1103
+ for (const name of WRAP_HELPER_ORDER)
1104
+ helperLines.push(...helpers[name]);
1105
+ return [
1106
+ ...wrapEncodingPreamble(),
1107
+ '$script:fx_exit = 0',
1108
+ ...helperLines,
1109
+ HOST_RPC_LOOP,
1110
+ ].join('\n');
1111
+ }
1112
+ /** Raw UTF-8 JSON lines on stdin/stdout; command streams captured per frame. */
1113
+ const HOST_RPC_LOOP = `
1114
+ $fx_utf8 = New-Object System.Text.UTF8Encoding $false
1115
+ $fx_in = [Console]::OpenStandardInput()
1116
+ $fx_out = [Console]::OpenStandardOutput()
1117
+ $fx_reader = New-Object System.IO.StreamReader($fx_in, $fx_utf8, $true, 8192, $true)
1118
+ $fx_proto = New-Object System.IO.StreamWriter($fx_out, $fx_utf8, 8192, $true)
1119
+ $fx_proto.NewLine = [string][char]10
1120
+ $fx_proto.AutoFlush = $true
1121
+ $fx_proto.WriteLine('{"ready":true}')
1122
+ while ($true) {
1123
+ $fx_line = $fx_reader.ReadLine()
1124
+ if ($null -eq $fx_line) { break }
1125
+ if ($fx_line -eq '') { continue }
1126
+ $fx_id = ''
1127
+ $fx_msOut = $null
1128
+ $fx_msErr = $null
1129
+ $fx_outW = $null
1130
+ $fx_errW = $null
1131
+ $fx_oldOut = [Console]::Out
1132
+ $fx_oldErr = [Console]::Error
1133
+ try {
1134
+ $fx_req = $fx_line | ConvertFrom-Json
1135
+ $fx_id = [string]$fx_req.id
1136
+ if ($fx_req.env) {
1137
+ foreach ($fx_p in $fx_req.env.PSObject.Properties) {
1138
+ $fx_en = [string]$fx_p.Name
1139
+ $fx_ev = [string]$fx_p.Value
1140
+ if ($fx_ev -eq '') {
1141
+ Remove-Item -LiteralPath ('Env:\\' + $fx_en) -ErrorAction SilentlyContinue
1142
+ } else {
1143
+ Set-Item -LiteralPath ('Env:\\' + $fx_en) -Value $fx_ev
1144
+ }
1145
+ }
1146
+ }
1147
+ $fx_script = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String([string]$fx_req.scriptB64))
1148
+ $fx_msOut = New-Object System.IO.MemoryStream
1149
+ $fx_msErr = New-Object System.IO.MemoryStream
1150
+ $fx_outW = New-Object System.IO.StreamWriter($fx_msOut, $fx_utf8, 1024, $true)
1151
+ $fx_errW = New-Object System.IO.StreamWriter($fx_msErr, $fx_utf8, 1024, $true)
1152
+ $fx_outW.NewLine = [string][char]13 + [string][char]10
1153
+ $fx_errW.NewLine = [string][char]13 + [string][char]10
1154
+ $fx_outW.AutoFlush = $true
1155
+ $fx_errW.AutoFlush = $true
1156
+ [Console]::SetOut($fx_outW)
1157
+ [Console]::SetError($fx_errW)
1158
+ $script:fx_exit = 0
1159
+ $fx_sb = [scriptblock]::Create($fx_script)
1160
+ & $fx_sb | ForEach-Object { [Console]::Out.WriteLine([string]$_) }
1161
+ } catch [System.Management.Automation.CommandNotFoundException] {
1162
+ [Console]::Error.WriteLine('bash: ' + $_.Exception.TargetName + ': command not found')
1163
+ $script:fx_exit = 127
1164
+ } catch {
1165
+ [Console]::Error.WriteLine(($_.Exception.Message).Split([string][char]10)[0])
1166
+ $script:fx_exit = 1
1167
+ } finally {
1168
+ try { if ($null -ne $fx_outW) { $fx_outW.Flush() } } catch {}
1169
+ try { if ($null -ne $fx_errW) { $fx_errW.Flush() } } catch {}
1170
+ try { [Console]::SetOut($fx_oldOut) } catch {}
1171
+ try { [Console]::SetError($fx_oldErr) } catch {}
1172
+ }
1173
+ $fx_outB64 = ''
1174
+ $fx_errB64 = ''
1175
+ if ($null -ne $fx_msOut) { $fx_outB64 = [Convert]::ToBase64String($fx_msOut.ToArray()) }
1176
+ if ($null -ne $fx_msErr) { $fx_errB64 = [Convert]::ToBase64String($fx_msErr.ToArray()) }
1177
+ $fx_code = 0
1178
+ try { $fx_code = [int]$script:fx_exit } catch { $fx_code = 1 }
1179
+ $fx_res = @{ id = $fx_id; stdoutB64 = $fx_outB64; stderrB64 = $fx_errB64; exitCode = $fx_code }
1180
+ $fx_proto.WriteLine(($fx_res | ConvertTo-Json -Compress))
1181
+ }
1182
+ `.trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fauxnix-cli",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "Fauxnix — run Linux-style commands on Windows via deterministic PowerShell translation. No VM, no WSL. MCP server + CLI for AI agents.",
5
5
  "type": "module",
6
6
  "bin": {