fauxnix-cli 0.2.1 → 0.3.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.
@@ -1,4 +1,4 @@
1
- import { FauxnixParseError, } from './ast.js';
1
+ import { FauxnixParseError, isUnquotedLiteral, } from './ast.js';
2
2
  import { parseCommand } from './parser.js';
3
3
  import { lookup, psStr } from './registry.js';
4
4
  /* ------------------------------------------------------------------ */
@@ -36,8 +36,13 @@ export function varExpr(name) {
36
36
  /* Word → PowerShell expression */
37
37
  /* ------------------------------------------------------------------ */
38
38
  /** Escape text destined for the inside of a PS double-quoted string. */
39
- function escDq(s) {
40
- return s.replace(/`/g, '``').replace(/"/g, '\\"').replace(/\$/g, '`$');
39
+ export function escapeDq(s) {
40
+ return s
41
+ .replace(/`/g, '``')
42
+ .replace(/"/g, '`"')
43
+ .replace(/\$/g, '`$')
44
+ .replace(/\r/g, '`r')
45
+ .replace(/\n/g, '`n');
41
46
  }
42
47
  /** Normalize a literal POSIX-ish path to its Windows equivalent. */
43
48
  export function normalizeLiteralPath(s) {
@@ -107,10 +112,10 @@ export function exprOfWord(w) {
107
112
  const emitPart = (p) => {
108
113
  switch (p.kind) {
109
114
  case 'Text':
110
- out += escDq(p.text);
115
+ out += escapeDq(p.text);
111
116
  break;
112
117
  case 'SingleQuoted':
113
- out += escDq(p.text);
118
+ out += escapeDq(p.text);
114
119
  break;
115
120
  case 'DoubleQuoted':
116
121
  for (const q of p.parts)
@@ -164,11 +169,23 @@ export function translateCmdSub(cmdText) {
164
169
  /* Simple command translation */
165
170
  /* ------------------------------------------------------------------ */
166
171
  export function translateSimple(cmd, position, hasStdin) {
172
+ // assignment-only segment (`X=1; cmd`): bash semantics are "set for the
173
+ // rest of the shell". Reuse the export code path — persist + env shadow —
174
+ // so empty values (`X=`) and `[[ -v X ]]` behave like bash (documented
175
+ // deviation: shell var vs exported var are indistinguishable here).
176
+ if (cmd.name === null) {
177
+ const exportHandler = lookup('export');
178
+ const words = cmd.assignments.map((a) => [
179
+ { kind: 'Text', text: a.name + '=' },
180
+ ...a.value,
181
+ ]);
182
+ return exportHandler ? exportHandler(words, { position, hasStdin }) : '';
183
+ }
167
184
  const nameLit = literalOfWord(cmd.name);
168
185
  let body;
169
186
  if (nameLit !== null) {
170
187
  const handler = lookup(nameLit);
171
- if (handler) {
188
+ if (handler && !(nameLit === '[[' && !isUnquotedLiteral(cmd.name, '[['))) {
172
189
  body = handler(cmd.args, { position, hasStdin });
173
190
  }
174
191
  else {
@@ -197,15 +214,258 @@ export function translateSimple(cmd, position, hasStdin) {
197
214
  'if ($LASTEXITCODE -gt 0) { $script:fx_exit = $LASTEXITCODE } elseif ($LASTEXITCODE -lt 0) { $script:fx_exit = 1 }',
198
215
  ].join('\n');
199
216
  }
200
- // `VAR=value cmd ...` prefix set process env for the invocation.
217
+ // `VAR=value cmd` is command-scoped. Values are captured in the
218
+ // current environment, then applied, then restored — including when
219
+ // the command throws — so they never leak into later list segments
220
+ // or the persisted MCP session. `VAR=x export VAR` keeps VAR (bash).
201
221
  if (cmd.assignments.length > 0) {
202
- const sets = cmd.assignments
203
- .map((a) => '$env:' + a.name + ' = ' + exprOfWord(a.value))
204
- .join('; ');
205
- body = sets + '\n' + body;
222
+ const persistNames = new Set();
223
+ const persistWords = [];
224
+ if (nameLit === 'export') {
225
+ for (const w of cmd.args) {
226
+ const lit = literalExportName(w);
227
+ if (lit === '')
228
+ continue; // flag
229
+ if (lit)
230
+ persistNames.add(lit);
231
+ else
232
+ persistWords.push(w);
233
+ }
234
+ }
235
+ body = wrapTempEnv(cmd.assignments, body, { persistNames, persistWords });
206
236
  }
207
237
  return body;
208
238
  }
239
+ /** Literal `NAME` / `NAME=...` from an export argument. `''` = flag. */
240
+ function literalExportName(w) {
241
+ if (w.length === 0)
242
+ return null;
243
+ let s = '';
244
+ for (const p of w) {
245
+ if (p.kind !== 'Text' && p.kind !== 'SingleQuoted')
246
+ return null;
247
+ const eq = p.text.indexOf('=');
248
+ if (eq >= 0) {
249
+ const name = s + p.text.slice(0, eq);
250
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) ? name : null;
251
+ }
252
+ s += p.text;
253
+ }
254
+ if (s.startsWith('-'))
255
+ return '';
256
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(s) ? s : null;
257
+ }
258
+ let tempEnvSeq = 0;
259
+ /** PS expr: encode a string so SETVALS records can stay newline-delimited. */
260
+ export function encodeSetValExpr(srcExpr) {
261
+ return ('([string](' +
262
+ srcExpr +
263
+ ')).Replace([string][char]92, ([string][char]92 + [string][char]92)).Replace([string][char]13, ([string][char]92 + [char]114)).Replace([string][char]10, ([string][char]92 + [char]110))');
264
+ }
265
+ /**
266
+ * Apply env assignments (and optional unsets) only for `body`, then restore.
267
+ * All assignment *values* are evaluated before any name is mutated.
268
+ * `persistWords` are evaluated after the prefix is applied (so
269
+ * `export "$NAME"` sees the current env) and those names are not restored.
270
+ */
271
+ export function wrapTempEnv(sets, body, extra) {
272
+ const unsets = extra?.unsets ?? [];
273
+ const persistNames = extra?.persistNames ?? new Set();
274
+ const persistWords = extra?.persistWords;
275
+ const names = [];
276
+ const seen = new Set();
277
+ for (const s of sets) {
278
+ if (!seen.has(s.name)) {
279
+ seen.add(s.name);
280
+ names.push(s.name);
281
+ }
282
+ }
283
+ for (const u of unsets) {
284
+ if (!seen.has(u)) {
285
+ seen.add(u);
286
+ names.push(u);
287
+ }
288
+ }
289
+ if (names.length === 0)
290
+ return body;
291
+ const id = tempEnvSeq++;
292
+ const save = '$fx_es' + id;
293
+ const keep = persistWords && persistWords.length > 0 ? '$fx_ek' + id : '';
294
+ const lines = [
295
+ save + ' = @{}',
296
+ '$fx_sv0' + id + ' = $env:FAUXNIX_SETVARS',
297
+ '$fx_uv0' + id + ' = $env:FAUXNIX_UNSETVARS',
298
+ '$fx_xv0' + id + ' = $env:FAUXNIX_SETVALS',
299
+ ];
300
+ for (const n of names) {
301
+ const p = psStr('Env:\\' + n);
302
+ lines.push(save +
303
+ '[' +
304
+ psStr(n) +
305
+ '] = $(if (Test-Path -LiteralPath ' +
306
+ p +
307
+ ') { [string](Get-Item -LiteralPath ' +
308
+ p +
309
+ ').Value } else { $null })');
310
+ }
311
+ const valVars = [];
312
+ for (let i = 0; i < sets.length; i++) {
313
+ const vn = '$fx_ev' + id + '_' + i;
314
+ valVars.push(vn);
315
+ lines.push(vn + ' = ' + exprOfWord(sets[i].value));
316
+ }
317
+ lines.push('try {');
318
+ for (const u of unsets) {
319
+ const uq = u.replace(/'/g, "''");
320
+ lines.push(' Remove-Item -LiteralPath ' + psStr('Env:\\' + u) + ' -ErrorAction SilentlyContinue');
321
+ // `env -u NAME` must hide NAME from fx-envget / fx-isset for the
322
+ // wrapped body. Removing Env:\NAME is not enough: an earlier
323
+ // `export NAME=x` still lives in SETVARS/SETVALS, and special
324
+ // names (PATH, HOME, …) have hardcoded fallbacks.
325
+ lines.push(" $env:FAUXNIX_SETVARS = (@($env:FAUXNIX_SETVARS -split ';' | Where-Object { $_ -ne '' -and $_ -cne '" +
326
+ uq +
327
+ "' }) -join ';')");
328
+ lines.push(" $env:FAUXNIX_UNSETVARS = ((@($env:FAUXNIX_UNSETVARS -split ';' | Where-Object { $_ -ne '' -and $_ -cne '" +
329
+ uq +
330
+ "' }) + '" +
331
+ uq +
332
+ "') -join ';')");
333
+ lines.push(' $fx_sm = @(); foreach ($fx_pair in @($env:FAUXNIX_SETVALS -split [string][char]10)) { $fx_eq = $fx_pair.IndexOf([char]61); if ($fx_eq -lt 1) { continue }; if ($fx_pair.Substring(0, $fx_eq) -cne \'' +
334
+ uq +
335
+ '\') { $fx_sm += $fx_pair } }; $env:FAUXNIX_SETVALS = ($fx_sm -join [string][char]10)');
336
+ }
337
+ for (let i = 0; i < sets.length; i++) {
338
+ const n = sets[i].name;
339
+ const nq = n.replace(/'/g, "''");
340
+ lines.push(' $env:' + n + ' = ' + valVars[i]);
341
+ lines.push(" $env:FAUXNIX_SETVARS = ((@($env:FAUXNIX_SETVARS -split ';' | Where-Object { $_ -ne '' -and $_ -cne '" +
342
+ nq +
343
+ "' }) + '" +
344
+ nq +
345
+ "') -join ';')");
346
+ lines.push(" $env:FAUXNIX_UNSETVARS = (@($env:FAUXNIX_UNSETVARS -split ';' | Where-Object { $_ -ne '' -and $_ -cne '" +
347
+ nq +
348
+ "' }) -join ';')");
349
+ lines.push(' $fx_sm = @(); foreach ($fx_pair in @($env:FAUXNIX_SETVALS -split [string][char]10)) { $fx_eq = $fx_pair.IndexOf([char]61); if ($fx_eq -lt 1) { continue }; if ($fx_pair.Substring(0, $fx_eq) -cne \'' +
350
+ nq +
351
+ '\') { $fx_sm += $fx_pair } }; $fx_sm += (\'' +
352
+ nq +
353
+ "' + [string][char]61 + " +
354
+ encodeSetValExpr(valVars[i]) +
355
+ '); $env:FAUXNIX_SETVALS = ($fx_sm -join [string][char]10)');
356
+ }
357
+ if (keep) {
358
+ lines.push(' ' + keep + ' = @{}');
359
+ for (const w of persistWords) {
360
+ const ev = '$fx_en' + id + '_' + lines.length;
361
+ lines.push(' ' + ev + ' = [string](' + exprOfWord(w) + ')');
362
+ lines.push(' if (' +
363
+ ev +
364
+ " -notmatch '^-') { $fx_nm = if (" +
365
+ ev +
366
+ ".Contains([string][char]61)) { " +
367
+ ev +
368
+ '.Substring(0, ' +
369
+ ev +
370
+ ".IndexOf([string][char]61)) } else { " +
371
+ ev +
372
+ " }; if ($fx_nm -match '^[A-Za-z_][A-Za-z0-9_]*$') { " +
373
+ keep +
374
+ '[$fx_nm] = $true } }');
375
+ }
376
+ }
377
+ for (const l of body.split('\n'))
378
+ lines.push(l ? ' ' + l : l);
379
+ lines.push('} finally {');
380
+ lines.push(' $env:FAUXNIX_SETVARS = $fx_sv0' + id);
381
+ lines.push(' $env:FAUXNIX_UNSETVARS = $fx_uv0' + id);
382
+ lines.push(' $env:FAUXNIX_SETVALS = $fx_xv0' + id);
383
+ for (const n of names) {
384
+ if (persistNames.has(n))
385
+ continue;
386
+ const p = psStr('Env:\\' + n);
387
+ if (keep) {
388
+ lines.push(' $fx_skip = if (' + keep + '[' + psStr(n) + ']) { $true } else { $false }');
389
+ lines.push(' if (-not $fx_skip) { if ($null -eq ' +
390
+ save +
391
+ '[' +
392
+ psStr(n) +
393
+ ']) { Remove-Item -LiteralPath ' +
394
+ p +
395
+ ' -ErrorAction SilentlyContinue } else { Set-Item -LiteralPath ' +
396
+ p +
397
+ ' -Value ' +
398
+ save +
399
+ '[' +
400
+ psStr(n) +
401
+ '] } }');
402
+ }
403
+ else {
404
+ lines.push(' if ($null -eq ' +
405
+ save +
406
+ '[' +
407
+ psStr(n) +
408
+ ']) { Remove-Item -LiteralPath ' +
409
+ p +
410
+ ' -ErrorAction SilentlyContinue } else { Set-Item -LiteralPath ' +
411
+ p +
412
+ ' -Value ' +
413
+ save +
414
+ '[' +
415
+ psStr(n) +
416
+ '] }');
417
+ }
418
+ }
419
+ const assigned = new Set(sets.map((s) => s.name));
420
+ for (const n of persistNames) {
421
+ const nq = n.replace(/'/g, "''");
422
+ const ep = psStr('Env:\\' + n);
423
+ // Bare `export UNSET` only marks the name for export; it must stay
424
+ // unset (`[[ -v UNSET ]]` is false). Persist a record only when the
425
+ // prefix assigned the name (including empty) or it already exists.
426
+ const cond = assigned.has(n) ? '$true' : '(Test-Path -LiteralPath ' + ep + ')';
427
+ lines.push(' if (' + cond + ') {');
428
+ lines.push(" $env:FAUXNIX_SETVARS = ((@($env:FAUXNIX_SETVARS -split ';' | Where-Object { $_ -ne '' -and $_ -cne '" +
429
+ nq +
430
+ "' }) + '" +
431
+ nq +
432
+ "') -join ';')");
433
+ lines.push(" $env:FAUXNIX_UNSETVARS = (@($env:FAUXNIX_UNSETVARS -split ';' | Where-Object { $_ -ne '' -and $_ -cne '" +
434
+ nq +
435
+ "' }) -join ';')");
436
+ lines.push(' $fx_pv = $(if (Test-Path -LiteralPath ' +
437
+ ep +
438
+ ') { [string](Get-Item -LiteralPath ' +
439
+ ep +
440
+ ").Value } else { '' })");
441
+ lines.push(' $fx_sm = @(); foreach ($fx_pair in @($env:FAUXNIX_SETVALS -split [string][char]10)) { $fx_eq = $fx_pair.IndexOf([char]61); if ($fx_eq -lt 1) { continue }; if ($fx_pair.Substring(0, $fx_eq) -cne \'' +
442
+ nq +
443
+ '\') { $fx_sm += $fx_pair } }; $fx_sm += (\'' +
444
+ nq +
445
+ "' + [string][char]61 + " +
446
+ encodeSetValExpr('$fx_pv') +
447
+ '); $env:FAUXNIX_SETVALS = ($fx_sm -join [string][char]10)');
448
+ lines.push(' }');
449
+ }
450
+ if (keep) {
451
+ const had = '@(' +
452
+ [...assigned].map((n) => "'" + n.replace(/'/g, "''") + "'").join(',') +
453
+ ')';
454
+ lines.push(' foreach ($fx_pn in @(' + keep + '.Keys)) {');
455
+ lines.push(' if (-not ((Test-Path -LiteralPath (\'Env:\\\' + $fx_pn)) -or (' +
456
+ had +
457
+ ' -ccontains $fx_pn))) { continue }');
458
+ lines.push(" $env:FAUXNIX_SETVARS = ((@($env:FAUXNIX_SETVARS -split ';' | Where-Object { $_ -ne '' -and $_ -cne $fx_pn }) + $fx_pn) -join ';')");
459
+ lines.push(" $env:FAUXNIX_UNSETVARS = (@($env:FAUXNIX_UNSETVARS -split ';' | Where-Object { $_ -ne '' -and $_ -cne $fx_pn }) -join ';')");
460
+ lines.push(" $fx_pv = $(if (Test-Path -LiteralPath ('Env:\\' + $fx_pn)) { [string](Get-Item -LiteralPath ('Env:\\' + $fx_pn)).Value } else { '' })");
461
+ lines.push(' $fx_sm = @(); foreach ($fx_pair in @($env:FAUXNIX_SETVALS -split [string][char]10)) { $fx_eq = $fx_pair.IndexOf([char]61); if ($fx_eq -lt 1) { continue }; if ($fx_pair.Substring(0, $fx_eq) -cne $fx_pn) { $fx_sm += $fx_pair } }; $fx_sm += ($fx_pn + [string][char]61 + ' +
462
+ encodeSetValExpr('$fx_pv') +
463
+ '); $env:FAUXNIX_SETVALS = ($fx_sm -join [string][char]10)');
464
+ lines.push(' }');
465
+ }
466
+ lines.push('}');
467
+ return lines.join('\n');
468
+ }
209
469
  /** Unique suffix for generated stage functions (nested pipelines included). */
210
470
  let stageSeq = 0;
211
471
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fauxnix-cli",
3
- "version": "0.2.1",
3
+ "version": "0.3.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": {