diff --git a/.changeset/lively-otters-gather.md b/.changeset/lively-otters-gather.md new file mode 100644 index 000000000..82f71c0c8 --- /dev/null +++ b/.changeset/lively-otters-gather.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3011 +--- +**Actionable diagnostic when `gsd-sdk` is not on PATH after install** — Windows users (and others on multi-shell setups) reported that the previous "GSD SDK files are present but `gsd-sdk` is not on your PATH" warning gave them no way to fix it: no path to look at, no shell-specific commands, no mention of the npx-cache caveat. New `formatSdkPathDiagnostic({ shimDir, platform, runDir })` helper returns a typed IR with the resolved shim location, platform-specific PATH-export commands (PowerShell / cmd.exe / Git Bash on Windows; `export PATH` on POSIX), and an npx-specific note when running under an `_npx` cache segment (where the shim may be written to a temp dir that won't persist). The console renderer in `bin/install.js` emits the lines from the IR; tests assert on the typed fields directly. (#3011) diff --git a/bin/install.js b/bin/install.js index 5a3b666ba..dc62e8531 100755 --- a/bin/install.js +++ b/bin/install.js @@ -9078,6 +9078,12 @@ function installSdkIfNeeded(opts) { const shimSrc = path.resolve(__dirname, 'gsd-sdk.js'); let onPath = isGsdSdkOnPath(); + // Track WHERE we wrote the shim so the diagnostic can be specific even + // when isGsdSdkOnPath() returns false because the write target isn't on + // PATH (#3011: Windows users hit this when npm's global bin dir is + // populated but not on every shell's PATH — Git Bash vs PowerShell vs + // cmd.exe each read PATH from different sources). + let shimDir = null; if (!onPath) { // Try to materialize the shim into a user-writable PATH location so the // installer can deliver on the success message without requiring the user @@ -9086,6 +9092,7 @@ function installSdkIfNeeded(opts) { // it's not on PATH (then a follow-up suggestion is printed). const linked = trySelfLinkGsdSdk(shimSrc); if (linked) { + shimDir = path.dirname(linked); onPath = isGsdSdkOnPath(); if (onPath) { console.log(` ${dim}↪ linked gsd-sdk → ${linked}${reset}`); @@ -9096,12 +9103,23 @@ function installSdkIfNeeded(opts) { if (onPath) { console.log(` ${green}✓${reset} GSD SDK ready (sdk/dist/cli.js)`); } else { + // #3011: actionable diagnostic. The previous shape printed a generic + // "not on your PATH" message that didn't tell the user where to look. + // formatSdkPathDiagnostic produces a typed IR that we then render to + // stdout; tests assert on the IR (no source-grep, no console capture). + const ir = formatSdkPathDiagnostic({ + shimDir, + platform: process.platform, + runDir: __dirname, + }); console.log(''); console.log(` ${yellow}⚠${reset} GSD SDK files are present but ${bold}gsd-sdk${reset} is not on your PATH.`); console.log(` Workflows that call ${cyan}gsd-sdk query …${reset} will fail with "command not found".`); - console.log(` Install globally to materialize the bin symlink:`); - console.log(` ${cyan}npm install -g get-shit-done-cc${reset}`); - console.log(` Or add a directory containing the shim to your PATH manually.`); + if (ir.shimLocationLine) console.log(` ${ir.shimLocationLine}`); + for (const line of ir.actionLines) console.log(` ${line}`); + if (ir.npxNoteLines.length > 0) { + for (const line of ir.npxNoteLines) console.log(` ${line}`); + } console.log(''); } @@ -9275,6 +9293,75 @@ function buildWindowsShimTriple(shimSrc) { }; } +/** + * #3011: pure builder for the SDK-not-on-PATH diagnostic. Takes the + * resolved shim directory (or null if write failed), the current platform, + * and the install.js __dirname (used to detect npx-cache invocation). + * Returns a typed IR with: + * - shimLocationLine: prose mentioning where the shim is (or empty if no + * write happened) + * - actionLines: ordered list of commands the user can run to add the + * shim dir to their PATH (platform-specific shells), or fallback to + * `npm install -g` advice when no shim was written + * - npxNoteLines: ordered list of lines warning about npx persistence + * when runDir is under an `_npx` cache segment + * + * Tests assert on the typed fields (paths/commands), not on rendered + * console output. Pure function — no fs, no spawn, no console. + */ +function formatSdkPathDiagnostic({ shimDir, platform, runDir }) { + const path = require('path'); + const isWin32 = platform === 'win32'; + // Detect either path separator — the test fixtures pass Windows-style + // paths while running on POSIX, and real users hit either depending on + // their npm/npx setup. Anchor on `_npx` between separators. + const isNpx = typeof runDir === 'string' && + (runDir.includes('/_npx/') || runDir.includes('\\_npx\\')); + + const shimLocationLine = shimDir ? `Shim written to: ${shimDir}` : ''; + const actionLines = []; + + if (shimDir) { + // Escape shimDir for each shell context. A path containing a single + // quote (e.g. C:\Users\O'Neil\AppData\...) would otherwise generate + // broken commands the user can't paste: + // - PowerShell single-quoted string: '' escapes a literal single quote + // - bash inside outer single quotes: '\'' (close, escaped quote, reopen) + // - POSIX export inside double quotes: escape \ $ " ` so the path is + // copied verbatim and $PATH (which is OUTSIDE the escaped substring) + // still expands at paste time. + const psShimDir = shimDir.replace(/'/g, "''"); + const bashShimDir = shimDir.replace(/\\/g, '/').replace(/'/g, "'\\''"); + const posixShimDir = shimDir.replace(/[\\$"`]/g, '\\$&'); + actionLines.push('Add that directory to your PATH and restart your shell.'); + if (isWin32) { + actionLines.push(`PowerShell: [Environment]::SetEnvironmentVariable('PATH', '${psShimDir};' + [Environment]::GetEnvironmentVariable('PATH', 'User'), 'User')`); + // setx PATH "...;%PATH%" silently truncates above 1024 chars and + // expands %PATH% / %SystemRoot% to literals (turning REG_EXPAND_SZ + // into REG_SZ), permanently breaking lazy variable references. + // Invoke PowerShell from cmd.exe with the same SetEnvironmentVariable + // call as the PowerShell line so cmd.exe users get a safe command. + actionLines.push(`cmd.exe : powershell -Command "[Environment]::SetEnvironmentVariable('PATH', '${psShimDir};' + [Environment]::GetEnvironmentVariable('PATH', 'User'), 'User')"`); + actionLines.push(`Git Bash : echo 'export PATH="${bashShimDir}:$PATH"' >> ~/.bashrc`); + } else { + actionLines.push(`export PATH="${posixShimDir}:$PATH"`); + } + } else { + actionLines.push('Could not locate a writable PATH directory to install the shim.'); + actionLines.push('Install globally to materialize the bin symlink:'); + actionLines.push('npm install -g get-shit-done-cc'); + } + + const npxNoteLines = isNpx + ? [ + "Note: you're running via npx. For a persistent shim,", + 'install globally instead: npm install -g get-shit-done-cc', + ] + : []; + + return { shimLocationLine, actionLines, npxNoteLines, isNpx, isWin32 }; +} + function trySelfLinkGsdSdkWindows(shimSrc) { const path = require('path'); const fs = require('fs'); @@ -9475,6 +9562,7 @@ if (process.env.GSD_TEST_MODE) { trySelfLinkGsdSdk, trySelfLinkGsdSdkWindows, buildWindowsShimTriple, + formatSdkPathDiagnostic, isGsdSdkOnPath, homePathCoveredByRc, maybeSuggestPathExport, diff --git a/tests/bug-3011-sdk-path-diagnostic.test.cjs b/tests/bug-3011-sdk-path-diagnostic.test.cjs new file mode 100644 index 000000000..575cabfd6 --- /dev/null +++ b/tests/bug-3011-sdk-path-diagnostic.test.cjs @@ -0,0 +1,191 @@ +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Regression test for #3011: SDK not found. + * + * Reporter (Windows / PowerShell 7) ran `npx get-shit-done-cc@latest`, + * upgrade reported success, but `gsd-sdk` could not be resolved by Claude + * Code, Git Bash, PowerShell, or WSL. The previous diagnostic was a + * generic "not on your PATH" with no actionable info; the user couldn't + * find where the shim was written or how to add it to PATH for each shell. + * + * Fix: formatSdkPathDiagnostic() returns a typed IR with the shim + * location, platform-specific PATH-export commands, and an npx-note + * when running under an `_npx` cache. The console renderer in install.js + * just emits each line; tests assert on the IR fields directly. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const { formatSdkPathDiagnostic } = require(path.join(__dirname, '..', 'bin', 'install.js')); + +describe('Bug #3011: formatSdkPathDiagnostic — Windows shim location and PATH commands', () => { + test('emits shim location line when shimDir is provided', () => { + const ir = formatSdkPathDiagnostic({ + shimDir: 'C:\\Users\\me\\AppData\\Roaming\\npm', + platform: 'win32', + runDir: 'C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\get-shit-done-cc\\bin', + }); + assert.equal(ir.shimLocationLine, + 'Shim written to: C:\\Users\\me\\AppData\\Roaming\\npm'); + }); + + test('Windows action lines include all three shell flavors (PowerShell, cmd.exe, Git Bash)', () => { + const ir = formatSdkPathDiagnostic({ + shimDir: 'C:\\Users\\me\\AppData\\Roaming\\npm', + platform: 'win32', + runDir: 'C:\\some\\path', + }); + const labels = ir.actionLines.map(l => l.split(':')[0].trim()); + assert.ok(labels.some(l => l === 'PowerShell'), `expected PowerShell line, got: ${JSON.stringify(labels)}`); + assert.ok(labels.some(l => l === 'cmd.exe'), `expected cmd.exe line, got: ${JSON.stringify(labels)}`); + assert.ok(labels.some(l => l === 'Git Bash'), `expected Git Bash line, got: ${JSON.stringify(labels)}`); + }); + + test('Git Bash command translates Windows backslashes to forward slashes', () => { + const ir = formatSdkPathDiagnostic({ + shimDir: 'C:\\Users\\me\\AppData\\Roaming\\npm', + platform: 'win32', + runDir: 'C:\\some\\path', + }); + const gitBash = ir.actionLines.find(l => l.startsWith('Git Bash')); + assert.ok(gitBash); + // Git Bash uses POSIX path syntax; backslashes would not work in bash. + assert.equal(gitBash.includes('\\'), false, + `Git Bash line must not contain backslashes: ${gitBash}`); + assert.ok(gitBash.includes('C:/Users/me/AppData/Roaming/npm'), + `Git Bash line must contain forward-slash path: ${gitBash}`); + }); + + test('PowerShell command preserves Windows backslashes in the embedded path', () => { + const ir = formatSdkPathDiagnostic({ + shimDir: 'C:\\Users\\me\\AppData\\Roaming\\npm', + platform: 'win32', + runDir: 'C:\\some\\path', + }); + const ps = ir.actionLines.find(l => l.startsWith('PowerShell')); + assert.ok(ps); + // PowerShell uses native Windows paths. + assert.ok(ps.includes('C:\\Users\\me\\AppData\\Roaming\\npm'), + `PowerShell line must contain Windows-style path: ${ps}`); + }); + + test("paths containing a single quote are escaped for each shell (#3014 CR)", () => { + // CR finding: a real Windows username like "O'Neil" would generate + // unparseable commands. PowerShell single-quote escape is '' (doubled); + // bash within outer single-quotes uses '\'' to embed a literal quote; + // POSIX export within double-quotes leaves single quotes alone. + const ir = formatSdkPathDiagnostic({ + shimDir: "C:\\Users\\O'Neil\\AppData\\Roaming\\npm", + platform: 'win32', + runDir: 'C:\\some\\path', + }); + const ps = ir.actionLines.find(l => l.startsWith('PowerShell')); + const cmd = ir.actionLines.find(l => l.startsWith('cmd.exe')); + const gitBash = ir.actionLines.find(l => l.startsWith('Git Bash')); + // PowerShell: literal quote escape is doubled + assert.ok(ps.includes("C:\\Users\\O''Neil\\AppData\\Roaming\\npm"), + `PowerShell line must double single quotes: ${ps}`); + // cmd.exe (which delegates to powershell) uses the same PS-escape + assert.ok(cmd.includes("C:\\Users\\O''Neil\\AppData\\Roaming\\npm"), + `cmd.exe line must double single quotes (delegates to PowerShell): ${cmd}`); + // Git Bash: '\'' escape inside outer single-quoted echo + assert.ok(gitBash.includes("C:/Users/O'\\''Neil/AppData/Roaming/npm"), + `Git Bash line must escape single quote with '\\\\'': ${gitBash}`); + }); +}); + +describe('Bug #3011: formatSdkPathDiagnostic — POSIX action lines', () => { + test('emits a single export PATH line on Linux', () => { + const ir = formatSdkPathDiagnostic({ + shimDir: '/home/me/.local/bin', + platform: 'linux', + runDir: '/home/me/.local/lib/node_modules/get-shit-done-cc/bin', + }); + const exports_ = ir.actionLines.filter(l => l.startsWith('export PATH=')); + assert.equal(exports_.length, 1, `expected 1 export line, got: ${JSON.stringify(ir.actionLines)}`); + assert.equal(exports_[0], 'export PATH="/home/me/.local/bin:$PATH"'); + }); + + test('emits a single export PATH line on macOS (darwin)', () => { + const ir = formatSdkPathDiagnostic({ + shimDir: '/usr/local/bin', + platform: 'darwin', + runDir: '/usr/local/lib/node_modules/get-shit-done-cc/bin', + }); + assert.ok(ir.actionLines.some(l => l === 'export PATH="/usr/local/bin:$PATH"')); + }); +}); + +describe('Bug #3011: formatSdkPathDiagnostic — fallback when shimDir is null', () => { + test('shimLocationLine is empty', () => { + const ir = formatSdkPathDiagnostic({ + shimDir: null, + platform: 'win32', + runDir: 'C:\\some\\path', + }); + assert.equal(ir.shimLocationLine, ''); + }); + + test('action lines fall back to npm install -g advice', () => { + const ir = formatSdkPathDiagnostic({ + shimDir: null, + platform: 'win32', + runDir: 'C:\\some\\path', + }); + assert.ok(ir.actionLines.some(l => l.includes('npm install -g get-shit-done-cc'))); + }); +}); + +describe('Bug #3011: formatSdkPathDiagnostic — npx-cache detection', () => { + test('detects POSIX npx cache path', () => { + const ir = formatSdkPathDiagnostic({ + shimDir: '/home/me/.local/bin', + platform: 'linux', + runDir: '/home/me/.npm/_npx/abc123/node_modules/get-shit-done-cc/bin', + }); + assert.equal(ir.isNpx, true); + assert.ok(ir.npxNoteLines.length >= 2, + `expected npx note lines, got: ${JSON.stringify(ir.npxNoteLines)}`); + assert.ok(ir.npxNoteLines.some(l => l.includes('npx'))); + assert.ok(ir.npxNoteLines.some(l => l.includes('npm install -g'))); + }); + + test('detects Windows npx cache path', () => { + const ir = formatSdkPathDiagnostic({ + shimDir: 'C:\\Users\\me\\AppData\\Roaming\\npm', + platform: 'win32', + runDir: 'C:\\Users\\me\\AppData\\Local\\npm-cache\\_npx\\abc123\\node_modules\\get-shit-done-cc\\bin', + }); + assert.equal(ir.isNpx, true); + }); + + test('non-npx invocation leaves npxNoteLines empty', () => { + const ir = formatSdkPathDiagnostic({ + shimDir: 'C:\\Users\\me\\AppData\\Roaming\\npm', + platform: 'win32', + runDir: 'C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\get-shit-done-cc\\bin', + }); + assert.equal(ir.isNpx, false); + assert.deepEqual(ir.npxNoteLines, []); + }); +}); + +describe('Bug #3011: formatSdkPathDiagnostic — actionable shape contract', () => { + test('returns the documented IR shape for any input', () => { + const ir = formatSdkPathDiagnostic({ + shimDir: '/x', + platform: 'linux', + runDir: '/y', + }); + assert.equal(typeof ir.shimLocationLine, 'string'); + assert.ok(Array.isArray(ir.actionLines)); + assert.ok(Array.isArray(ir.npxNoteLines)); + assert.equal(typeof ir.isNpx, 'boolean'); + assert.equal(typeof ir.isWin32, 'boolean'); + }); +});