* 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>
This commit is contained in:
195
bin/install.js
195
bin/install.js
@@ -11793,6 +11793,189 @@ function homePathCoveredByRc(globalBin, homeDir, rcFileNames) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode fish's universal-variable value escaping (the inverse of fish's
|
||||
* `full_escape`). fish serializes every non-`[A-Za-z0-9/_]` byte in
|
||||
* `fish_variables` — e.g. space -> `\x20`, hyphen -> `\x2d`, dot -> `\x2e` —
|
||||
* and joins list elements with the literal 4-char token `\x1e` (NOT a raw
|
||||
* 0x1e byte). Callers split on `\x1e` first, then decode each element here.
|
||||
*
|
||||
* Pure and total: any unrecognised `\`-sequence is passed through verbatim,
|
||||
* so `decode(fishEscape(p)) === p` holds for every path string. Exported for
|
||||
* a fast-check round-trip property test (#323).
|
||||
*
|
||||
* @param {string} s A single (already `\x1e`-split) escaped value.
|
||||
* @returns {string} The decoded literal.
|
||||
*/
|
||||
function decodeFishUniversalValue(s) {
|
||||
let out = '';
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const c = s[i];
|
||||
if (c !== '\\') { out += c; continue; }
|
||||
const n = s[i + 1];
|
||||
if (n === 'n') { out += '\n'; i += 1; }
|
||||
else if (n === 'r') { out += '\r'; i += 1; }
|
||||
else if (n === 't') { out += '\t'; i += 1; }
|
||||
else if (n === '\\') { out += '\\'; i += 1; }
|
||||
else if (n === 'x' || n === 'X') {
|
||||
const hex = s.slice(i + 2, i + 4);
|
||||
if (/^[0-9a-fA-F]{2}$/.test(hex)) { out += String.fromCharCode(parseInt(hex, 16)); i += 3; }
|
||||
else { out += c; }
|
||||
} else if (n === 'u') {
|
||||
const hex = s.slice(i + 2, i + 6);
|
||||
if (/^[0-9a-fA-F]{4}$/.test(hex)) { out += String.fromCharCode(parseInt(hex, 16)); i += 5; }
|
||||
else { out += c; }
|
||||
} else if (n === 'U') {
|
||||
const hex = s.slice(i + 2, i + 10);
|
||||
if (/^[0-9a-fA-F]{8}$/.test(hex)) { out += String.fromCodePoint(parseInt(hex, 16)); i += 9; }
|
||||
else { out += c; }
|
||||
} else { out += c; }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether fish's configuration already places `globalBin` on PATH (#323).
|
||||
*
|
||||
* fish does not use the sh-style `export PATH=` rc files that
|
||||
* `homePathCoveredByRc()` parses, so a fish user whose `fish_user_paths`
|
||||
* already covers the global bin would otherwise see a false-positive
|
||||
* "not on your PATH" warning on every install. Two detection routes,
|
||||
* mirroring how `fish_add_path` actually persists:
|
||||
*
|
||||
* 1. The universal-variable store `fish_variables` — a
|
||||
* `SETUVAR fish_user_paths:<a>\x1e<b>…` line whose `\x1e`-separated
|
||||
* entries are absolute paths (fish does not HOME-expand them here).
|
||||
* 2. `config.fish` — explicit `fish_add_path …`, `set -gx PATH …`, or
|
||||
* `set -Ux fish_user_paths …` lines that name the directory after
|
||||
* HOME expansion.
|
||||
*
|
||||
* Best-effort and side-effect-free: any unreadable / missing file is ignored
|
||||
* (no fish subprocess is spawned). Honours `$XDG_CONFIG_HOME` and always also
|
||||
* checks `~/.config/fish`. Pass `fishConfigDir` to override the lookup
|
||||
* directory (tests).
|
||||
*
|
||||
* @param {string} globalBin Absolute path to npm's global bin directory.
|
||||
* @param {string} homeDir Absolute path used to substitute HOME / ~.
|
||||
* @param {string} [fishConfigDir] Override the fish config directory.
|
||||
* @returns {boolean} true iff fish config adds globalBin to PATH.
|
||||
*/
|
||||
function homePathCoveredByFishConfig(globalBin, homeDir, fishConfigDir) {
|
||||
if (!globalBin || !homeDir) return false;
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const normalise = (p) => {
|
||||
if (!p) return '';
|
||||
let n = p.replace(/[\\/]+$/g, '');
|
||||
if (n === '') n = p.startsWith('/') ? '/' : p;
|
||||
return n;
|
||||
};
|
||||
|
||||
const targetAbs = normalise(path.resolve(globalBin));
|
||||
const homeAbs = path.resolve(homeDir);
|
||||
|
||||
const baseDirs = [];
|
||||
if (fishConfigDir) {
|
||||
baseDirs.push(fishConfigDir);
|
||||
} else {
|
||||
if (process.env.XDG_CONFIG_HOME) {
|
||||
baseDirs.push(path.join(process.env.XDG_CONFIG_HOME, 'fish'));
|
||||
}
|
||||
baseDirs.push(path.join(homeAbs, '.config', 'fish'));
|
||||
}
|
||||
|
||||
const expandHome = (segment) => {
|
||||
let s = segment;
|
||||
s = s.replace(/\$\{HOME\}/g, homeAbs).replace(/\$HOME/g, homeAbs);
|
||||
if (s.startsWith('~/') || s === '~') {
|
||||
s = s === '~' ? homeAbs : path.join(homeAbs, s.slice(2));
|
||||
}
|
||||
return s;
|
||||
};
|
||||
|
||||
// Compare an already-resolved absolute literal (a decoded fish_user_paths
|
||||
// entry — fish stores these resolved, never as `$VAR`/`~`). A literal `$`
|
||||
// here is part of the directory name, so it must NOT be treated as an
|
||||
// unexpanded variable.
|
||||
const matchesLiteral = (segment) => {
|
||||
if (!segment || !path.isAbsolute(segment)) return false;
|
||||
try {
|
||||
return normalise(path.resolve(segment)) === targetAbs;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Compare a config.fish shell token: strip surrounding quotes, expand the
|
||||
// common HOME forms, and skip anything still holding a `$` (an unexpanded
|
||||
// variable such as `$PATH` / `$fish_user_paths`) or still relative.
|
||||
const matchesTarget = (rawSegment) => {
|
||||
if (!rawSegment) return false;
|
||||
let seg = rawSegment.trim();
|
||||
if ((seg.startsWith('"') && seg.endsWith('"')) ||
|
||||
(seg.startsWith("'") && seg.endsWith("'"))) {
|
||||
seg = seg.slice(1, -1);
|
||||
}
|
||||
const expanded = expandHome(seg);
|
||||
if (expanded.includes('$')) return false;
|
||||
return matchesLiteral(expanded);
|
||||
};
|
||||
|
||||
const readLines = (filePath) => {
|
||||
try {
|
||||
return fs.readFileSync(filePath, 'utf8').split(/\r?\n/);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
for (const baseDir of baseDirs) {
|
||||
// Route 1: universal variable store.
|
||||
const uvarLines = readLines(path.join(baseDir, 'fish_variables'));
|
||||
if (uvarLines) {
|
||||
for (const rawLine of uvarLines) {
|
||||
const m = /^SETUVAR(?:\s+--\S+)*\s+fish_user_paths:(.*)$/.exec(rawLine);
|
||||
if (!m) continue;
|
||||
// Elements are joined by the literal `\x1e` token; decode each. The
|
||||
// decoded entry is an absolute literal — compare it directly.
|
||||
for (const entry of m[1].split('\\x1e')) {
|
||||
if (matchesLiteral(decodeFishUniversalValue(entry))) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Route 2: config.fish explicit PATH mutations.
|
||||
const configLines = readLines(path.join(baseDir, 'config.fish'));
|
||||
if (configLines) {
|
||||
for (const rawLine of configLines) {
|
||||
const line = rawLine.replace(/^\s+/, '');
|
||||
if (line.startsWith('#')) continue;
|
||||
|
||||
let rest = null;
|
||||
let m;
|
||||
if ((m = /^fish_add_path\s+(.+)$/.exec(line))) {
|
||||
rest = m[1];
|
||||
} else if ((m = /^set\s+(?:-\S+\s+)*PATH\s+(.+)$/.exec(line))) {
|
||||
rest = m[1];
|
||||
} else if ((m = /^set\s+(?:-\S+\s+)*fish_user_paths\s+(.+)$/.exec(line))) {
|
||||
rest = m[1];
|
||||
}
|
||||
if (rest === null) continue;
|
||||
|
||||
// Tokens are whitespace-separated; flag tokens (`-g`, `--path`) and
|
||||
// variable references are skipped by matchesTarget / the `-` guard.
|
||||
for (const tok of rest.split(/\s+/)) {
|
||||
if (!tok || tok.startsWith('-')) continue;
|
||||
if (matchesTarget(tok)) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a PATH-export suggestion if globalBin is not already on PATH AND
|
||||
* the user's shell rc files do not already cover it via a HOME-relative
|
||||
@@ -11835,6 +12018,16 @@ function maybeSuggestPathExport(globalBin, homeDir) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Same idea for fish users: fish_user_paths / config.fish already covers the
|
||||
// dir, the current session just predates it. fish has no sh-style rc file so
|
||||
// homePathCoveredByRc never sees it — check the fish config explicitly (#323).
|
||||
if (homePathCoveredByFishConfig(globalBin, homeDir)) {
|
||||
console.log('');
|
||||
console.log(` ${yellow}⚠${reset} ${bold}${globalBin}${reset}'s directory is already on your PATH via fish's universal variables — open a new fish session (or run ${cyan}exec fish${reset}).`);
|
||||
console.log('');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(` ${yellow}⚠${reset} ${bold}${globalBin}${reset} is not on your PATH.`);
|
||||
console.log(` Add it with one of:`);
|
||||
@@ -12172,6 +12365,8 @@ module.exports = {
|
||||
USER_OWNED_ARTIFACTS,
|
||||
finishInstall,
|
||||
homePathCoveredByRc,
|
||||
homePathCoveredByFishConfig,
|
||||
decodeFishUniversalValue,
|
||||
maybeSuggestPathExport,
|
||||
runtimeMap,
|
||||
allRuntimes,
|
||||
|
||||
Reference in New Issue
Block a user