fauxnix-cli 0.7.0 → 0.8.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 +8 -1
- package/dist/cli.js +8 -2
- package/dist/commands/files.d.ts +4 -1
- package/dist/commands/files.js +277 -54
- package/dist/commands/install-all.js +5 -3
- package/dist/commands/sysinfo.js +3 -2
- package/dist/commands/text-filters.js +93 -13
- package/dist/commands/text-io.d.ts +2 -1
- package/dist/commands/text-io.js +14 -3
- package/dist/executor.d.ts +13 -1
- package/dist/executor.js +114 -14
- package/dist/mcp.d.ts +19 -0
- package/dist/mcp.js +81 -24
- package/dist/parser.js +55 -24
- package/dist/ps-host.d.ts +7 -1
- package/dist/ps-host.js +75 -8
- package/dist/registry.d.ts +50 -0
- package/dist/registry.js +142 -0
- package/dist/translator.js +38 -3
- package/dist/version.d.ts +1 -0
- package/dist/version.js +8 -0
- package/package.json +3 -1
|
@@ -77,7 +77,7 @@ function textExpr(w) {
|
|
|
77
77
|
return exprOfWord(w);
|
|
78
78
|
}
|
|
79
79
|
/** Collect EVERY value of a short option (-kN, -k N) — parseWords keeps only the last. */
|
|
80
|
-
function collectShortValues(args, letter
|
|
80
|
+
function collectShortValues(args, letter) {
|
|
81
81
|
const out = [];
|
|
82
82
|
let onlyOps = false;
|
|
83
83
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -88,9 +88,7 @@ function collectShortValues(args, letter, longName) {
|
|
|
88
88
|
}
|
|
89
89
|
if (onlyOps)
|
|
90
90
|
continue;
|
|
91
|
-
if (
|
|
92
|
-
out.push(t.slice(longName.length + 1));
|
|
93
|
-
else if (t === '-' + letter) {
|
|
91
|
+
if (t === '-' + letter) {
|
|
94
92
|
if (i + 1 < args.length) {
|
|
95
93
|
out.push(wordToString(args[i + 1]));
|
|
96
94
|
i++;
|
|
@@ -102,6 +100,32 @@ function collectShortValues(args, letter, longName) {
|
|
|
102
100
|
}
|
|
103
101
|
return out;
|
|
104
102
|
}
|
|
103
|
+
/** Collect repeated value-taking long options without mistaking short bundles for values. */
|
|
104
|
+
function collectLongValues(args, names) {
|
|
105
|
+
const out = [];
|
|
106
|
+
let onlyOps = false;
|
|
107
|
+
for (let i = 0; i < args.length; i++) {
|
|
108
|
+
const t = wordToString(args[i]);
|
|
109
|
+
if (t === '--') {
|
|
110
|
+
onlyOps = true;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (onlyOps || !t.startsWith('--'))
|
|
114
|
+
continue;
|
|
115
|
+
const eq = t.indexOf('=');
|
|
116
|
+
const name = eq >= 0 ? t.slice(0, eq) : t;
|
|
117
|
+
if (!names.includes(name))
|
|
118
|
+
continue;
|
|
119
|
+
if (eq >= 0) {
|
|
120
|
+
out.push({ name, value: t.slice(eq + 1) });
|
|
121
|
+
}
|
|
122
|
+
else if (i + 1 < args.length) {
|
|
123
|
+
out.push({ name, value: wordToString(args[i + 1]) });
|
|
124
|
+
i++;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
105
129
|
/** Build the "collect file operands through fx-glob" PS prologue. */
|
|
106
130
|
function psCollectSources(filesExpr, cmdErr, leafOnly) {
|
|
107
131
|
const test = leafOnly
|
|
@@ -238,8 +262,19 @@ function ereToDotNet(re) {
|
|
|
238
262
|
/* grep */
|
|
239
263
|
/* ------------------------------------------------------------------ */
|
|
240
264
|
const grep = (args) => {
|
|
241
|
-
const
|
|
242
|
-
const
|
|
265
|
+
const filterOptionNames = ['--include', '--exclude', '--exclude-dir'];
|
|
266
|
+
const filterOptions = collectLongValues(args, filterOptionNames);
|
|
267
|
+
const fileFilterOptions = filterOptions.filter((o) => o.name !== '--exclude-dir');
|
|
268
|
+
const excludeDirGlobs = filterOptions
|
|
269
|
+
.filter((o) => o.name === '--exclude-dir')
|
|
270
|
+
.map((o) => o.value.replace(/[\\/]+$/, ''));
|
|
271
|
+
const { flags, operandWords, values, missingValue } = parseWords(args, ['A', 'B', 'C'], filterOptionNames);
|
|
272
|
+
const missingFilterOption = missingValue.find((o) => filterOptionNames.includes(o));
|
|
273
|
+
if (missingFilterOption) {
|
|
274
|
+
return ('[Console]::Error.WriteLine(' +
|
|
275
|
+
psStr("grep: option '" + missingFilterOption + "' requires an argument") +
|
|
276
|
+
'); $script:fx_exit = 2');
|
|
277
|
+
}
|
|
243
278
|
const ci = flags.has('i');
|
|
244
279
|
const inv = flags.has('v');
|
|
245
280
|
const num = flags.has('n');
|
|
@@ -320,23 +355,68 @@ const grep = (args) => {
|
|
|
320
355
|
// --- source collection -------------------------------------------------
|
|
321
356
|
if (fileWords.length > 0) {
|
|
322
357
|
lines.push(PS_GLOB_FN);
|
|
323
|
-
lines.push('$
|
|
358
|
+
lines.push('$fx_fsel = @(' +
|
|
359
|
+
fileFilterOptions
|
|
360
|
+
.map((o) => '[pscustomobject]@{ Keep = ' +
|
|
361
|
+
pb(o.name === '--include') +
|
|
362
|
+
'; Glob = ' +
|
|
363
|
+
psStr(o.value) +
|
|
364
|
+
' }')
|
|
365
|
+
.join(', ') +
|
|
366
|
+
')', '$fx_excd = @(' + excludeDirGlobs.map((g) => psStr(g)).join(', ') + ')', '$fx_srcs = @()', '$fx_err = $false', '$fx_recd = $false');
|
|
367
|
+
lines.push('function fx-globmatch($fx_name, $fx_glob, $fx_suffix) {');
|
|
368
|
+
lines.push(" $fx_n = ([string]$fx_name).Replace('\\', '/')");
|
|
369
|
+
lines.push(" $fx_p = ([string]$fx_glob).Replace('\\', '/')");
|
|
370
|
+
lines.push(' if ($fx_n -like $fx_p) { return $true }');
|
|
371
|
+
lines.push(' if ($fx_suffix) {');
|
|
372
|
+
lines.push(" $fx_slash = $fx_n.IndexOf('/')");
|
|
373
|
+
lines.push(' while ($fx_slash -ge 0 -and $fx_slash + 1 -lt $fx_n.Length) {');
|
|
374
|
+
lines.push(' $fx_n = $fx_n.Substring($fx_slash + 1)');
|
|
375
|
+
lines.push(' if ($fx_n -like $fx_p) { return $true }');
|
|
376
|
+
lines.push(" $fx_slash = $fx_n.IndexOf('/')");
|
|
377
|
+
lines.push(' }');
|
|
378
|
+
lines.push(' }');
|
|
379
|
+
lines.push(' return $false');
|
|
380
|
+
lines.push('}');
|
|
381
|
+
lines.push('function fx-anyglob($fx_name, $fx_globs, $fx_suffix) {');
|
|
382
|
+
lines.push(' foreach ($fx_glob in $fx_globs) { if (fx-globmatch $fx_name $fx_glob $fx_suffix) { return $true } }');
|
|
383
|
+
lines.push(' return $false');
|
|
384
|
+
lines.push('}');
|
|
385
|
+
lines.push('function fx-filewanted($fx_path, $fx_suffix) {');
|
|
386
|
+
lines.push(' if ($fx_fsel.Count -eq 0) { return $true }');
|
|
387
|
+
lines.push(' $fx_name = if ($fx_suffix) { [string]$fx_path } else { [IO.Path]::GetFileName([string]$fx_path) }');
|
|
388
|
+
lines.push(' $fx_keep = -not [bool]$fx_fsel[0].Keep');
|
|
389
|
+
lines.push(' foreach ($fx_rule in $fx_fsel) { if (fx-globmatch $fx_name $fx_rule.Glob $fx_suffix) { $fx_keep = [bool]$fx_rule.Keep } }');
|
|
390
|
+
lines.push(' return $fx_keep');
|
|
391
|
+
lines.push('}');
|
|
392
|
+
if (rec) {
|
|
393
|
+
lines.push('function fx-walkfiles($fx_root) {');
|
|
394
|
+
lines.push(' $fx_dirs = New-Object System.Collections.Stack');
|
|
395
|
+
lines.push(' $fx_dirs.Push([string]$fx_root)');
|
|
396
|
+
lines.push(' while ($fx_dirs.Count -gt 0) {');
|
|
397
|
+
lines.push(' $fx_cur = [string]$fx_dirs.Pop()');
|
|
398
|
+
lines.push(' foreach ($fx_item in @(Get-ChildItem -LiteralPath $fx_cur -Force -ErrorAction SilentlyContinue)) {');
|
|
399
|
+
lines.push(' if ($fx_item.PSIsContainer) {');
|
|
400
|
+
lines.push(' if (($fx_item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0 -and -not (fx-anyglob $fx_item.Name $fx_excd $false)) { $fx_dirs.Push($fx_item.FullName) }');
|
|
401
|
+
lines.push(' } elseif (fx-filewanted $fx_item.FullName $false) { $fx_item.FullName }');
|
|
402
|
+
lines.push(' }');
|
|
403
|
+
lines.push(' }');
|
|
404
|
+
lines.push('}');
|
|
405
|
+
}
|
|
324
406
|
lines.push('foreach ($fx_o in ' + psArray(fileWords) + ') {');
|
|
325
407
|
lines.push(' foreach ($fx_g in (fx-glob $fx_o)) {');
|
|
326
408
|
lines.push(" if (-not (Test-Path -LiteralPath $fx_g)) { [Console]::Error.WriteLine('grep: ' + $fx_g + ': No such file or directory'); $fx_err = $true; continue }");
|
|
327
409
|
lines.push(' if (Test-Path -LiteralPath $fx_g -PathType Container) {');
|
|
328
410
|
if (rec) {
|
|
411
|
+
lines.push(' $fx_dir = Get-Item -LiteralPath $fx_g');
|
|
412
|
+
lines.push(' if (fx-anyglob $fx_g $fx_excd $true) { continue }');
|
|
329
413
|
lines.push(' $fx_recd = $true');
|
|
330
|
-
lines.push(' $
|
|
331
|
-
lines.push(' if ($fx_inc.Count -gt 0) {');
|
|
332
|
-
lines.push(" $fx_subs = @($fx_subs | Where-Object { $fx_ok = $false; foreach ($fx_gi in $fx_inc) { if ($_.Name -like $fx_gi) { $fx_ok = $true; break } }; $fx_ok })");
|
|
333
|
-
lines.push(' }');
|
|
334
|
-
lines.push(' foreach ($fx_s in $fx_subs) { $fx_srcs += $fx_s.FullName }');
|
|
414
|
+
lines.push(' $fx_srcs += @(fx-walkfiles $fx_dir.FullName)');
|
|
335
415
|
}
|
|
336
416
|
else {
|
|
337
417
|
lines.push(" [Console]::Error.WriteLine('grep: ' + $fx_g + ': Is a directory'); $fx_err = $true");
|
|
338
418
|
}
|
|
339
|
-
lines.push(' }
|
|
419
|
+
lines.push(' } elseif (fx-filewanted $fx_g $true) { $fx_srcs += $fx_g }');
|
|
340
420
|
lines.push(' }');
|
|
341
421
|
lines.push('}');
|
|
342
422
|
lines.push('$fx_pre = $false');
|
package/dist/commands/text-io.js
CHANGED
|
@@ -691,8 +691,8 @@ const wc = (args) => {
|
|
|
691
691
|
/* tee */
|
|
692
692
|
/* ------------------------------------------------------------------ */
|
|
693
693
|
const tee = (args, ctx) => {
|
|
694
|
-
const { flags, operandWords } = parseWords(args);
|
|
695
|
-
const append = flags.has('a') ||
|
|
694
|
+
const { flags, longs, operandWords } = parseWords(args);
|
|
695
|
+
const append = flags.has('a') || longs.has('--append');
|
|
696
696
|
return [
|
|
697
697
|
PS_WRITE_FN,
|
|
698
698
|
fxTermLine(ctx.position),
|
|
@@ -1142,6 +1142,18 @@ const xargs = (args) => {
|
|
|
1142
1142
|
].join('\n');
|
|
1143
1143
|
};
|
|
1144
1144
|
/* ------------------------------------------------------------------ */
|
|
1145
|
+
export const specs = [
|
|
1146
|
+
{
|
|
1147
|
+
names: ['tee'],
|
|
1148
|
+
options: [
|
|
1149
|
+
{ short: 'a', long: '--append', support: 'implemented' },
|
|
1150
|
+
],
|
|
1151
|
+
effects: ['read', 'write'],
|
|
1152
|
+
platform: 'windows-ps51',
|
|
1153
|
+
dispatch: 'translated',
|
|
1154
|
+
handler: tee,
|
|
1155
|
+
},
|
|
1156
|
+
];
|
|
1145
1157
|
export const handlers = {
|
|
1146
1158
|
echo,
|
|
1147
1159
|
printf,
|
|
@@ -1149,7 +1161,6 @@ export const handlers = {
|
|
|
1149
1161
|
head,
|
|
1150
1162
|
tail,
|
|
1151
1163
|
wc,
|
|
1152
|
-
tee,
|
|
1153
1164
|
nl,
|
|
1154
1165
|
tac,
|
|
1155
1166
|
md5sum,
|
package/dist/executor.d.ts
CHANGED
|
@@ -3,14 +3,22 @@ export interface ExecResult {
|
|
|
3
3
|
stdout: string;
|
|
4
4
|
stderr: string;
|
|
5
5
|
exitCode: number;
|
|
6
|
+
timedOut: boolean;
|
|
7
|
+
cancelled: boolean;
|
|
8
|
+
truncated: boolean;
|
|
9
|
+
spawnError?: 'ENOENT' | 'START';
|
|
6
10
|
}
|
|
7
11
|
export interface ExecOptions {
|
|
8
12
|
timeoutMs?: number;
|
|
9
13
|
/** Extra environment layered over the session (used by MCP per-call cwd). */
|
|
10
14
|
cwd?: string;
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
stdoutLimit?: number;
|
|
17
|
+
stderrLimit?: number;
|
|
11
18
|
}
|
|
12
19
|
/** Session persists cwd and env across segments, like a real shell. */
|
|
13
20
|
export declare class FauxnixSession {
|
|
21
|
+
id: string;
|
|
14
22
|
cwd: string | null;
|
|
15
23
|
env: Record<string, string>;
|
|
16
24
|
/** Exit code of the previous segment — powers bash's `$?`. */
|
|
@@ -20,14 +28,18 @@ export declare class FauxnixSession {
|
|
|
20
28
|
private scriptFile;
|
|
21
29
|
private hostFile;
|
|
22
30
|
private host;
|
|
23
|
-
private
|
|
31
|
+
private lifecycleLock;
|
|
24
32
|
constructor();
|
|
25
33
|
private bindFiles;
|
|
34
|
+
private withLock;
|
|
26
35
|
private syncFromDisk;
|
|
27
36
|
private ensureHost;
|
|
28
37
|
/** Boot powershell.exe now so the first run() is not the 1.1s cold start. */
|
|
29
38
|
prewarm(): Promise<void>;
|
|
30
39
|
dispose(): Promise<void>;
|
|
40
|
+
/** Kill the host and re-prewarm the same session object (no second FauxnixSession). */
|
|
41
|
+
reset(): Promise<void>;
|
|
42
|
+
private disposeUnlocked;
|
|
31
43
|
/** env for the child powershell process. */
|
|
32
44
|
childEnv(cwdOverride?: string, stdinFile?: string | null): NodeJS.ProcessEnv;
|
|
33
45
|
run(plans: SegmentPlan[], opts?: ExecOptions): Promise<ExecResult>;
|
package/dist/executor.js
CHANGED
|
@@ -5,8 +5,13 @@ import path from 'node:path';
|
|
|
5
5
|
import { normalizeLiteralPath, wrapScript } from './translator.js';
|
|
6
6
|
import { decodeOutput, normalizeHostNewlines, resolveNativePref } from './encoding.js';
|
|
7
7
|
import { normalizeStderr } from './errors.js';
|
|
8
|
-
import { PowerShellHost, PS_MISSING_MESSAGE } from './ps-host.js';
|
|
8
|
+
import { DEFAULT_STDERR_LIMIT, DEFAULT_STDOUT_LIMIT, PowerShellHost, PS_MISSING_MESSAGE, } from './ps-host.js';
|
|
9
9
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
10
|
+
const DEFAULT_WINDOWS_PATHEXT = '.COM;.EXE;.BAT;.CMD';
|
|
11
|
+
function hasEnvKey(env, name) {
|
|
12
|
+
const normalized = name.toUpperCase();
|
|
13
|
+
return Object.keys(env).some((key) => key.toUpperCase() === normalized);
|
|
14
|
+
}
|
|
10
15
|
/** Resolve /dev/null and POSIX-ish literal targets to real Windows paths. */
|
|
11
16
|
function winTarget(target) {
|
|
12
17
|
const p = normalizeLiteralPath(target);
|
|
@@ -148,6 +153,7 @@ function planRedirects(redirects) {
|
|
|
148
153
|
}
|
|
149
154
|
/** Session persists cwd and env across segments, like a real shell. */
|
|
150
155
|
export class FauxnixSession {
|
|
156
|
+
id;
|
|
151
157
|
cwd = null;
|
|
152
158
|
env = {};
|
|
153
159
|
/** Exit code of the previous segment — powers bash's `$?`. */
|
|
@@ -157,16 +163,23 @@ export class FauxnixSession {
|
|
|
157
163
|
scriptFile;
|
|
158
164
|
hostFile;
|
|
159
165
|
host = null;
|
|
160
|
-
|
|
166
|
+
lifecycleLock = Promise.resolve();
|
|
161
167
|
constructor() {
|
|
162
|
-
this.
|
|
168
|
+
this.id = randomUUID().slice(0, 8);
|
|
169
|
+
this.bindFiles(this.id);
|
|
163
170
|
}
|
|
164
171
|
bindFiles(id) {
|
|
172
|
+
this.id = id;
|
|
165
173
|
this.cwdFile = path.join(os.tmpdir(), 'fauxnix-' + id + '-cwd.txt');
|
|
166
174
|
this.envFile = path.join(os.tmpdir(), 'fauxnix-' + id + '-env.json');
|
|
167
175
|
this.scriptFile = path.join(os.tmpdir(), 'fauxnix-' + id + '-script.ps1');
|
|
168
176
|
this.hostFile = path.join(os.tmpdir(), 'fauxnix-' + id + '-host.ps1');
|
|
169
177
|
}
|
|
178
|
+
withLock(fn) {
|
|
179
|
+
const done = this.lifecycleLock.then(fn, fn);
|
|
180
|
+
this.lifecycleLock = done.then(() => undefined, () => undefined);
|
|
181
|
+
return done;
|
|
182
|
+
}
|
|
170
183
|
syncFromDisk() {
|
|
171
184
|
try {
|
|
172
185
|
if (existsSync(this.cwdFile)) {
|
|
@@ -196,10 +209,22 @@ export class FauxnixSession {
|
|
|
196
209
|
return this.host;
|
|
197
210
|
}
|
|
198
211
|
/** Boot powershell.exe now so the first run() is not the 1.1s cold start. */
|
|
199
|
-
|
|
200
|
-
|
|
212
|
+
prewarm() {
|
|
213
|
+
return this.withLock(async () => {
|
|
214
|
+
await this.ensureHost().ready();
|
|
215
|
+
});
|
|
201
216
|
}
|
|
202
|
-
|
|
217
|
+
dispose() {
|
|
218
|
+
return this.withLock(() => this.disposeUnlocked());
|
|
219
|
+
}
|
|
220
|
+
/** Kill the host and re-prewarm the same session object (no second FauxnixSession). */
|
|
221
|
+
reset() {
|
|
222
|
+
return this.withLock(async () => {
|
|
223
|
+
await this.disposeUnlocked();
|
|
224
|
+
await this.ensureHost().ready();
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
async disposeUnlocked() {
|
|
203
228
|
if (this.host) {
|
|
204
229
|
await this.host.stop();
|
|
205
230
|
this.host = null;
|
|
@@ -224,6 +249,14 @@ export class FauxnixSession {
|
|
|
224
249
|
else
|
|
225
250
|
env[k] = v;
|
|
226
251
|
}
|
|
252
|
+
// The MCP SDK's safe Windows stdio environment omits PATHEXT. Without it,
|
|
253
|
+
// PowerShell cannot resolve extensionless native commands such as `node`.
|
|
254
|
+
// Windows environment names are case-insensitive, so preserve any explicit
|
|
255
|
+
// spelling/value supplied by the caller and only restore the OS default
|
|
256
|
+
// when no variant is present at all.
|
|
257
|
+
if (process.platform === 'win32' && !hasEnvKey(env, 'PATHEXT')) {
|
|
258
|
+
env.PATHEXT = DEFAULT_WINDOWS_PATHEXT;
|
|
259
|
+
}
|
|
227
260
|
env.FAUXNIX_CWD_FILE = this.cwdFile;
|
|
228
261
|
env.FAUXNIX_ENV_FILE = this.envFile;
|
|
229
262
|
if (stdinFile)
|
|
@@ -242,16 +275,22 @@ export class FauxnixSession {
|
|
|
242
275
|
return env;
|
|
243
276
|
}
|
|
244
277
|
run(plans, opts = {}) {
|
|
245
|
-
|
|
246
|
-
this.runLock = done.then(() => undefined, () => undefined);
|
|
247
|
-
return done;
|
|
278
|
+
return this.withLock(() => runPlans(plans, this, opts, () => this.syncFromDisk(), () => this.ensureHost()));
|
|
248
279
|
}
|
|
249
280
|
}
|
|
250
281
|
async function runPlans(plans, session, opts, afterSegment, ensureHost) {
|
|
251
282
|
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
283
|
+
const deadline = Date.now() + timeoutMs;
|
|
284
|
+
const stdoutLimit = opts.stdoutLimit ?? DEFAULT_STDOUT_LIMIT;
|
|
285
|
+
const stderrLimit = opts.stderrLimit ?? DEFAULT_STDERR_LIMIT;
|
|
286
|
+
const timeoutMessage = '\nbash: command timed out after ' + Math.round(timeoutMs / 1000) + 's';
|
|
252
287
|
let stdout = '';
|
|
253
288
|
let stderr = '';
|
|
254
289
|
let exitCode = 0;
|
|
290
|
+
let timedOut = false;
|
|
291
|
+
let cancelled = false;
|
|
292
|
+
let truncated = false;
|
|
293
|
+
let spawnError;
|
|
255
294
|
// bash list semantics: `a && b ; c` runs c regardless of a; `a && b && c`
|
|
256
295
|
// skips b AND c when a fails. chainOk models the value of the current
|
|
257
296
|
// &&/|| chain; `;` segments always run and restart the chain.
|
|
@@ -267,6 +306,19 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
|
|
|
267
306
|
continue;
|
|
268
307
|
if (plan.op === '||' && chainOk)
|
|
269
308
|
continue;
|
|
309
|
+
if (opts.signal?.aborted) {
|
|
310
|
+
cancelled = true;
|
|
311
|
+
exitCode = 130;
|
|
312
|
+
session.prevExit = exitCode;
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
if (Date.now() >= deadline) {
|
|
316
|
+
stderr += timeoutMessage;
|
|
317
|
+
exitCode = 124;
|
|
318
|
+
timedOut = true;
|
|
319
|
+
session.prevExit = exitCode;
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
270
322
|
const red = planRedirects(plan.redirects);
|
|
271
323
|
red.stdinFile = red.stdinFile ? resolveTarget(red.stdinFile) : null;
|
|
272
324
|
red.stdoutFile = red.stdoutFile ? resolveTarget(red.stdoutFile) : null;
|
|
@@ -339,19 +391,41 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
|
|
|
339
391
|
chainOk = false;
|
|
340
392
|
continue;
|
|
341
393
|
}
|
|
394
|
+
const remainingMs = deadline - Date.now();
|
|
395
|
+
if (opts.signal?.aborted) {
|
|
396
|
+
cancelled = true;
|
|
397
|
+
exitCode = 130;
|
|
398
|
+
session.prevExit = exitCode;
|
|
399
|
+
break;
|
|
400
|
+
}
|
|
401
|
+
if (remainingMs <= 0) {
|
|
402
|
+
stderr += timeoutMessage;
|
|
403
|
+
exitCode = 124;
|
|
404
|
+
timedOut = true;
|
|
405
|
+
session.prevExit = exitCode;
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
342
408
|
const encoded = wrapScript(plan.body, { mode: 'host' });
|
|
343
409
|
const inv = await ensureHost().invoke(encoded, {
|
|
344
410
|
FAUXNIX_CWD: currentDir,
|
|
345
411
|
FAUXNIX_PREV_EXIT: session.prevExit === null ? '' : String(session.prevExit),
|
|
346
412
|
FAUXNIX_STDIN_FILE: red.stdinFile || '',
|
|
347
|
-
},
|
|
348
|
-
if (inv.spawnError === 'ENOENT') {
|
|
413
|
+
}, remainingMs, opts.signal);
|
|
414
|
+
if (inv.spawnError === 'ENOENT' || inv.spawnError === 'START') {
|
|
349
415
|
stderr += inv.stderr.toString('utf8') || PS_MISSING_MESSAGE;
|
|
350
416
|
exitCode = 127;
|
|
417
|
+
spawnError = inv.spawnError;
|
|
351
418
|
session.prevExit = exitCode;
|
|
352
419
|
chainOk = false;
|
|
353
420
|
continue;
|
|
354
421
|
}
|
|
422
|
+
if (inv.cancelled) {
|
|
423
|
+
cancelled = true;
|
|
424
|
+
exitCode = 130;
|
|
425
|
+
session.prevExit = exitCode;
|
|
426
|
+
chainOk = false;
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
355
429
|
afterSegment();
|
|
356
430
|
const decodePref = resolveNativePref();
|
|
357
431
|
// GNU line discipline: the PS host terminates Write-Output lines with
|
|
@@ -360,7 +434,7 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
|
|
|
360
434
|
let segOut = normalizeHostNewlines(decodeOutput(inv.stdout, decodePref));
|
|
361
435
|
let segErr = normalizeHostNewlines(normalizeStderr(decodeOutput(inv.stderr, decodePref)));
|
|
362
436
|
if (inv.timedOut) {
|
|
363
|
-
segErr +=
|
|
437
|
+
segErr += timeoutMessage;
|
|
364
438
|
}
|
|
365
439
|
if (red.mergeStderr) {
|
|
366
440
|
segOut += (segOut && !segOut.endsWith('\n') && segErr ? '\n' : '') + segErr;
|
|
@@ -402,7 +476,11 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
|
|
|
402
476
|
}
|
|
403
477
|
stdout += segOut;
|
|
404
478
|
stderr += segErr;
|
|
405
|
-
|
|
479
|
+
if (inv.truncated)
|
|
480
|
+
truncated = true;
|
|
481
|
+
exitCode = inv.timedOut ? 124 : inv.cancelled ? 130 : inv.exitCode;
|
|
482
|
+
if (inv.timedOut)
|
|
483
|
+
timedOut = true;
|
|
406
484
|
session.prevExit = exitCode;
|
|
407
485
|
chainOk = exitCode === 0;
|
|
408
486
|
// Only inherit cwd from a segment that actually ran and whose
|
|
@@ -410,10 +488,32 @@ async function runPlans(plans, session, opts, afterSegment, ensureHost) {
|
|
|
410
488
|
// not move later relative redirects.
|
|
411
489
|
if (redirectOk && session.cwd)
|
|
412
490
|
currentDir = session.cwd;
|
|
491
|
+
if (inv.timedOut)
|
|
492
|
+
break;
|
|
413
493
|
}
|
|
414
494
|
finally {
|
|
415
495
|
closePrepFds(prepFds);
|
|
416
496
|
}
|
|
417
497
|
}
|
|
418
|
-
|
|
498
|
+
const clippedOut = clipUtf8(stdout, stdoutLimit);
|
|
499
|
+
const clippedErr = clipUtf8(stderr, stderrLimit);
|
|
500
|
+
if (clippedOut.truncated || clippedErr.truncated)
|
|
501
|
+
truncated = true;
|
|
502
|
+
return {
|
|
503
|
+
stdout: clippedOut.text,
|
|
504
|
+
stderr: clippedErr.text,
|
|
505
|
+
exitCode,
|
|
506
|
+
timedOut,
|
|
507
|
+
cancelled,
|
|
508
|
+
truncated,
|
|
509
|
+
spawnError,
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
function clipUtf8(text, limit) {
|
|
513
|
+
if (Buffer.byteLength(text, 'utf8') <= limit)
|
|
514
|
+
return { text, truncated: false };
|
|
515
|
+
let end = text.length;
|
|
516
|
+
while (end > 0 && Buffer.byteLength(text.slice(0, end), 'utf8') > limit)
|
|
517
|
+
end--;
|
|
518
|
+
return { text: text.slice(0, end), truncated: true };
|
|
419
519
|
}
|
package/dist/mcp.d.ts
CHANGED
|
@@ -1,4 +1,23 @@
|
|
|
1
|
+
import { ExecResult } from './executor.js';
|
|
1
2
|
import { translatePipelineBody } from './translator.js';
|
|
2
3
|
import './commands/install-all.js';
|
|
4
|
+
export declare function formatBashText(r: Pick<ExecResult, 'stdout' | 'stderr' | 'exitCode' | 'timedOut' | 'cancelled'>): string;
|
|
5
|
+
export declare function bashToolResult(r: ExecResult, sessionId: string, infra: boolean): {
|
|
6
|
+
isError?: true | undefined;
|
|
7
|
+
content: {
|
|
8
|
+
type: "text";
|
|
9
|
+
text: string;
|
|
10
|
+
}[];
|
|
11
|
+
structuredContent: {
|
|
12
|
+
schemaVersion: 1;
|
|
13
|
+
stdout: string;
|
|
14
|
+
stderr: string;
|
|
15
|
+
exitCode: number;
|
|
16
|
+
timedOut: boolean;
|
|
17
|
+
cancelled: boolean;
|
|
18
|
+
truncated: boolean;
|
|
19
|
+
sessionId: string;
|
|
20
|
+
};
|
|
21
|
+
};
|
|
3
22
|
export declare function startMcpServer(): Promise<void>;
|
|
4
23
|
export { translatePipelineBody };
|
package/dist/mcp.js
CHANGED
|
@@ -1,16 +1,12 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
-
import { readFileSync } from 'node:fs';
|
|
4
|
-
import { fileURLToPath } from 'node:url';
|
|
5
3
|
import { z } from 'zod';
|
|
6
4
|
import { FauxnixSession } from './executor.js';
|
|
7
5
|
import { parseCommand } from './parser.js';
|
|
8
6
|
import { translateCommandList, wrapScript, translatePipelineBody } from './translator.js';
|
|
9
7
|
import { registeredNames } from './registry.js';
|
|
8
|
+
import { packageVersion } from './version.js';
|
|
10
9
|
import './commands/install-all.js';
|
|
11
|
-
// single source of truth: the npm package version in package.json
|
|
12
|
-
// (src/ and dist/ sit one level below the root, so the relative path holds in both)
|
|
13
|
-
const pkgVersion = JSON.parse(readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8')).version;
|
|
14
10
|
const TOOL_NAME = process.env.FAUXNIX_TOOL_NAME || 'bash';
|
|
15
11
|
const EXEC_ANNOTATIONS = {
|
|
16
12
|
readOnlyHint: false,
|
|
@@ -37,15 +33,46 @@ Output is formatted to look like GNU/Linux tooling (ls -l, ps aux, df -h ...), e
|
|
|
37
33
|
|
|
38
34
|
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
35
|
Unknown commands (git, node, npm, python, cargo...) are passed through and executed natively with argv-style quoting.
|
|
40
|
-
Not supported: heredocs, while/until/case, background jobs. if/then/elif/else/fi, for-in loops, and word-level \$((...)) arithmetic expansion are supported.
|
|
36
|
+
Not supported: heredocs, while/until/case, env -i/--ignore-environment, background jobs. if/then/elif/else/fi, for-in loops, and word-level \$((...)) arithmetic expansion are supported.
|
|
41
37
|
|
|
42
38
|
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
|
-
Exit codes follow bash conventions (0 ok, 1 fail, 2 usage/serious, 127 command not found, 124 timeout).
|
|
39
|
+
Exit codes follow bash conventions (0 ok, 1 fail, 2 usage/serious, 127 command not found, 124 timeout, 130 cancelled). The tool also returns structuredContent (schemaVersion 1) with stdout/stderr/exitCode/timedOut/cancelled/truncated/sessionId.
|
|
44
40
|
|
|
45
41
|
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.`;
|
|
42
|
+
export function formatBashText(r) {
|
|
43
|
+
const parts = [];
|
|
44
|
+
if (r.stdout.length)
|
|
45
|
+
parts.push(r.stdout.replace(/\n$/, ''));
|
|
46
|
+
if (r.stderr.length)
|
|
47
|
+
parts.push(r.stderr.replace(/\n$/, ''));
|
|
48
|
+
if (r.cancelled)
|
|
49
|
+
parts.push('Cancelled');
|
|
50
|
+
else if (r.timedOut)
|
|
51
|
+
parts.push('Exit code: 124');
|
|
52
|
+
else if (r.exitCode !== 0)
|
|
53
|
+
parts.push('Exit code: ' + r.exitCode);
|
|
54
|
+
return parts.length ? parts.join('\n') : '(no output)';
|
|
55
|
+
}
|
|
56
|
+
export function bashToolResult(r, sessionId, infra) {
|
|
57
|
+
const structuredContent = {
|
|
58
|
+
schemaVersion: 1,
|
|
59
|
+
stdout: r.stdout,
|
|
60
|
+
stderr: r.stderr,
|
|
61
|
+
exitCode: r.exitCode,
|
|
62
|
+
timedOut: r.timedOut,
|
|
63
|
+
cancelled: r.cancelled,
|
|
64
|
+
truncated: r.truncated,
|
|
65
|
+
sessionId,
|
|
66
|
+
};
|
|
67
|
+
return {
|
|
68
|
+
content: [{ type: 'text', text: formatBashText(r) }],
|
|
69
|
+
structuredContent,
|
|
70
|
+
...(infra ? { isError: true } : {}),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
46
73
|
export async function startMcpServer() {
|
|
47
|
-
const server = new McpServer({ name: 'fauxnix', version:
|
|
48
|
-
|
|
74
|
+
const server = new McpServer({ name: 'fauxnix', version: packageVersion }, { capabilities: { tools: {} } });
|
|
75
|
+
const session = new FauxnixSession();
|
|
49
76
|
await session.prewarm();
|
|
50
77
|
server.tool(TOOL_NAME, TOOL_DESCRIPTION, {
|
|
51
78
|
command: z.string().describe('The bash-style command line to run'),
|
|
@@ -56,23 +83,26 @@ export async function startMcpServer() {
|
|
|
56
83
|
.max(600_000)
|
|
57
84
|
.optional()
|
|
58
85
|
.describe('Timeout in milliseconds (default 120000)'),
|
|
59
|
-
}, EXEC_ANNOTATIONS, async ({ command, timeout_ms }) => {
|
|
86
|
+
}, EXEC_ANNOTATIONS, async ({ command, timeout_ms }, extra) => {
|
|
60
87
|
try {
|
|
61
88
|
const plans = translateCommandList(parseCommand(command));
|
|
62
|
-
const result = await session.run(plans, {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if (result.exitCode !== 0)
|
|
69
|
-
parts.push('Exit code: ' + result.exitCode);
|
|
70
|
-
const text = parts.length ? parts.join('\n') : '(no output)';
|
|
71
|
-
return { content: [{ type: 'text', text }] };
|
|
89
|
+
const result = await session.run(plans, {
|
|
90
|
+
timeoutMs: timeout_ms,
|
|
91
|
+
signal: extra.signal,
|
|
92
|
+
});
|
|
93
|
+
const infra = result.spawnError === 'ENOENT' || result.spawnError === 'START';
|
|
94
|
+
return bashToolResult(result, session.id, infra);
|
|
72
95
|
}
|
|
73
96
|
catch (e) {
|
|
74
97
|
const msg = e instanceof Error ? e.message : String(e);
|
|
75
|
-
return {
|
|
98
|
+
return bashToolResult({
|
|
99
|
+
stdout: '',
|
|
100
|
+
stderr: msg,
|
|
101
|
+
exitCode: 2,
|
|
102
|
+
timedOut: false,
|
|
103
|
+
cancelled: false,
|
|
104
|
+
truncated: false,
|
|
105
|
+
}, session.id, true);
|
|
76
106
|
}
|
|
77
107
|
});
|
|
78
108
|
server.tool('fauxnix_translate', 'Translate a bash-style command into the equivalent PowerShell script WITHOUT executing it. Useful for learning/debugging what fauxnix does under the hood.', { command: z.string().describe('The bash-style command line to translate (never executed)') }, TRANSLATE_ANNOTATIONS, async ({ command }) => {
|
|
@@ -94,18 +124,45 @@ export async function startMcpServer() {
|
|
|
94
124
|
.describe('"status" shows the session state (cwd, tracked env keys); "reset" clears it back to a fresh shell'),
|
|
95
125
|
}, SESSION_ANNOTATIONS, async ({ action }) => {
|
|
96
126
|
if (action === 'reset') {
|
|
97
|
-
await session.
|
|
98
|
-
session = new FauxnixSession();
|
|
99
|
-
await session.prewarm();
|
|
127
|
+
await session.reset();
|
|
100
128
|
return { content: [{ type: 'text', text: 'fauxnix: session reset' }] };
|
|
101
129
|
}
|
|
102
130
|
const envKeys = Object.keys(session.env).sort();
|
|
103
131
|
const text = 'cwd: ' + (session.cwd ?? '(inherit from server start)') +
|
|
104
132
|
'\nenv keys: ' + (envKeys.length ? envKeys.join(', ') : '(none tracked)') +
|
|
133
|
+
'\nsession: ' + session.id +
|
|
105
134
|
'\ncommands registered: ' + registeredNames().length;
|
|
106
135
|
return { content: [{ type: 'text', text }] };
|
|
107
136
|
});
|
|
108
137
|
const transport = new StdioServerTransport();
|
|
138
|
+
let shuttingDown = false;
|
|
139
|
+
const shutdown = async () => {
|
|
140
|
+
if (shuttingDown)
|
|
141
|
+
return;
|
|
142
|
+
shuttingDown = true;
|
|
143
|
+
await session.dispose();
|
|
144
|
+
try {
|
|
145
|
+
await server.close();
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
/* ignore */
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
process.stdin.on('end', () => {
|
|
152
|
+
void shutdown();
|
|
153
|
+
});
|
|
154
|
+
process.stdin.on('close', () => {
|
|
155
|
+
void shutdown();
|
|
156
|
+
});
|
|
157
|
+
process.on('SIGINT', () => {
|
|
158
|
+
void shutdown();
|
|
159
|
+
});
|
|
160
|
+
process.on('SIGTERM', () => {
|
|
161
|
+
void shutdown();
|
|
162
|
+
});
|
|
163
|
+
transport.onclose = () => {
|
|
164
|
+
void shutdown();
|
|
165
|
+
};
|
|
109
166
|
await server.connect(transport);
|
|
110
167
|
}
|
|
111
168
|
// keep referenced for tree-shaking clarity
|