* feat(#323): fish-shell support in post-install PATH suggestion Two additive changes to the post-install PATH-suggestion seam, both scoped to existing functions. A. Projection: add a fish entry to the persist-mode shell-action list in projectPathActionProjection() (src/shell-command-projection.cts). fish has no `export`/`$PATH`-list syntax, so the existing zsh/bash `export PATH=...` commands are inert when pasted. The new entry emits the fish-native `fish_add_path '<dir>'` (fish 3.2+, persists via the universal-variable store, de-duplicating). The directory is single-quoted with the same POSIX literal escaping as the zsh/bash siblings; verified round-tripping through real fish 3.7.0 for paths containing quotes, spaces, `$`, `*`, backticks and unicode. B. Detection: add homePathCoveredByFishConfig() in bin/install.js, called from maybeSuggestPathExport() alongside homePathCoveredByRc(). fish does not use sh-style `export PATH=` rc files, so a fish user whose fish_user_paths already covers the global bin would otherwise get a false-positive "not on your PATH" warning on every install. Two side-effect-free detection routes (no fish subprocess): 1. The universal-variable store (~/.config/fish/fish_variables). fish serializes this with `full_escape`: every byte outside [A-Za-z0-9/_] becomes `\xHH` (space -> \x20, `-` -> \x2d, `.` -> \x2e, `$` -> \x24, unicode -> \uXXXX) and list elements are joined by the literal 4-char token `\x1e` (NOT a raw 0x1e byte). The detector splits on `\x1e`, decodes the escapes, then compares each as an absolute literal — a decoded `$` is part of the directory name, not an unexpanded variable. Verified against real fish 3.7.0 output. 2. config.fish (`fish_add_path`, `set -gx PATH`, `set -Ux fish_user_paths`) — plain shell tokens: HOME forms ($HOME/${HOME}/~) are expanded and a token still holding `$` (e.g. `$PATH`, `$fish_user_paths`) is skipped. Honours $XDG_CONFIG_HOME and always also checks ~/.config/fish. No behaviour change for bash/zsh/PowerShell/cmd/Git-Bash users: their entries and command strings are unchanged; the fish entry is additive and the fish detector only narrows the set of cases that warn. Tests: update the projection length assertion (2 -> 3) and fish escaping in bug-3441; add fish detection + suppression cases in install-path-detection (uvar store with real fish escaping, dot/hyphen/space/$-literal decode regressions, config.fish routes, commented-out, relative-segment guard, unreadable-file fault injection, suppression and emission via maybeSuggestPathExport). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(changeset): add Changed fragment for #323 fish PATH support (#727) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#323): address review — action-only fish docs, decoder property test, win32 guard Addresses @trek-e's review on #727: - docs (blocker): keep the how-to action-only (Diátaxis). Drop the `# fish — persists via …` comment and the internal-mechanism clause naming fish_variables/config.fish; leave one command + the exec-fish directive. - tests (minor): extract decodeFishUniversalValue to a pure, exported module function and add fast-check round-trip properties (decode(fishEscape(p)) === p over arbitrary unicode, abs-path variant, totality). Consolidated into install-path-detection.test.cjs to respect the install test-file-count ratchet. - tests (follow-up): port #721's win32 negative-projection test (no fish action on win32; persist projection is PowerShell/cmd.exe/Git Bash). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(#323): address review — drop unused 'after' import, clarify escaping comment Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
137 lines
4.8 KiB
JavaScript
137 lines
4.8 KiB
JavaScript
'use strict';
|
|
|
|
process.env.GSD_TEST_MODE = '1';
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const path = require('node:path');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
|
|
const projection = require(path.join(
|
|
__dirname,
|
|
'..',
|
|
'gsd-core',
|
|
'bin',
|
|
'lib',
|
|
'shell-command-projection.cjs',
|
|
));
|
|
const install = require(path.join(__dirname, '..', 'bin', 'install.js'));
|
|
const { withIsolatedProcessState, cleanup } = require('./helpers.cjs');
|
|
|
|
function createTempHome() {
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-home-3441-'));
|
|
}
|
|
|
|
|
|
describe('bug #3441: PATH guidance is projected from typed shell action IR', () => {
|
|
test('projection module exports PATH action projection helper', () => {
|
|
assert.equal(typeof projection.projectPathActionProjection, 'function');
|
|
});
|
|
|
|
// (formatSdkPathDiagnostic removed with the gsd-sdk shim, #191 — the PATH
|
|
// action projection it wrapped is still covered by the tests below.)
|
|
|
|
test('persistent PATH export guidance is projected via the same seam', () => {
|
|
const posix = projection.projectPathActionProjection({
|
|
mode: 'persist',
|
|
targetDir: '/tmp/with quote',
|
|
platform: 'linux',
|
|
});
|
|
assert.ok(Array.isArray(posix.shellActions));
|
|
assert.equal(posix.shellActions.length, 3);
|
|
assert.equal(posix.shellActions[0].label, 'zsh');
|
|
assert.equal(posix.shellActions[1].label, 'bash');
|
|
assert.equal(posix.shellActions[2].label, 'fish');
|
|
assert.ok(posix.shellActions[0].command.includes('~/.zshrc'));
|
|
assert.ok(posix.shellActions[1].command.includes('~/.bashrc'));
|
|
// #323: fish gets a fish-native fish_add_path suggestion, not `export`.
|
|
assert.ok(posix.shellActions[2].command.startsWith('fish_add_path '));
|
|
assert.ok(!posix.shellActions[2].command.includes('export'));
|
|
});
|
|
|
|
// #323 (ported from the closed #721): the fish suggestion is POSIX-only.
|
|
// On win32 the persist branch projects PowerShell / cmd.exe / Git Bash —
|
|
// no fish action — locking the POSIX-only contract.
|
|
test('no fish action is projected on win32', () => {
|
|
const win = projection.projectPathActionProjection({
|
|
mode: 'persist',
|
|
targetDir: 'C:\\Users\\me\\AppData\\npm',
|
|
platform: 'win32',
|
|
});
|
|
assert.ok(Array.isArray(win.shellActions));
|
|
assert.equal(
|
|
win.shellActions.some((a) => a.shell === 'fish' || a.label === 'fish'),
|
|
false,
|
|
'win32 persist projection must not include a fish action',
|
|
);
|
|
assert.deepEqual(
|
|
win.shellActions.map((a) => a.label),
|
|
['PowerShell', 'cmd.exe', 'Git Bash'],
|
|
);
|
|
});
|
|
|
|
test('POSIX repair mode escapes double-quoted shell metacharacters', () => {
|
|
const projected = projection.projectPathActionProjection({
|
|
mode: 'repair',
|
|
targetDir: '/tmp/qa\\"$HOME`tick',
|
|
platform: 'linux',
|
|
});
|
|
assert.equal(projected.shellActions.length, 1);
|
|
assert.equal(
|
|
projected.shellActions[0].command,
|
|
'export PATH="/tmp/qa\\\\\\"\\$HOME\\`tick:$PATH"',
|
|
);
|
|
});
|
|
|
|
test('POSIX persist mode escapes single quotes for rc-file echo commands', () => {
|
|
const projected = projection.projectPathActionProjection({
|
|
mode: 'persist',
|
|
targetDir: "/tmp/O'Neil/bin",
|
|
platform: 'linux',
|
|
});
|
|
assert.equal(projected.shellActions[0].command.includes("/tmp/O'\\''Neil/bin"), true);
|
|
assert.equal(projected.shellActions[1].command.includes("/tmp/O'\\''Neil/bin"), true);
|
|
// #323: fish entry single-quotes the dir with the same POSIX literal
|
|
// escaping (`'\''` is also a valid escaped quote in fish unquoted context).
|
|
assert.equal(projected.shellActions[2].command, "fish_add_path '/tmp/O'\\''Neil/bin'");
|
|
});
|
|
|
|
test('maybeSuggestPathExport renders commands projected by path-action seam', () => {
|
|
const home = createTempHome();
|
|
try {
|
|
withIsolatedProcessState(() => {
|
|
const globalBin = path.join(home, '.npm-global', 'bin');
|
|
fs.mkdirSync(globalBin, { recursive: true });
|
|
fs.writeFileSync(path.join(home, '.zshrc'), 'export PATH="$HOME/.cargo/bin:$PATH"\n');
|
|
process.env.PATH = '';
|
|
|
|
const expected = projection.projectPathActionProjection({
|
|
mode: 'persist',
|
|
targetDir: globalBin,
|
|
platform: process.platform,
|
|
});
|
|
|
|
const logs = [];
|
|
const originalLog = console.log;
|
|
console.log = (...args) => logs.push(args.join(' '));
|
|
try {
|
|
install.maybeSuggestPathExport(globalBin, home);
|
|
} finally {
|
|
console.log = originalLog;
|
|
}
|
|
|
|
const joined = logs.join('\n');
|
|
for (const action of expected.shellActions) {
|
|
assert.ok(
|
|
joined.includes(action.command),
|
|
`expected installer output to include projected command: ${action.command}\nOutput:\n${joined}`,
|
|
);
|
|
}
|
|
});
|
|
} finally {
|
|
cleanup(home);
|
|
}
|
|
});
|
|
});
|