Files
msd-core/tests/bug-3011-sdk-path-diagnostic.test.cjs
Tom Boucher a4e5cc7c24 fix(#3011): actionable SDK-not-on-PATH diagnostic with shim location and shell-specific commands (#3014)
* fix(#3011): actionable SDK-not-on-PATH diagnostic with shim location and shell-specific commands

The previous diagnostic was a generic 'GSD SDK files are present but
gsd-sdk is not on your PATH' message with no concrete path or
shell-specific PATH-export command. Windows users reported that they
couldn't find where the shim was written and didn't know how to add
it to PATH for each shell (PowerShell vs cmd.exe vs Git Bash vs WSL
all read PATH from different sources).

New formatSdkPathDiagnostic({ shimDir, platform, runDir }) helper
returns a typed IR:
- shimLocationLine: explicit 'Shim written to: <path>'
- actionLines: platform-specific PATH-export commands
  - Windows: 3 lines (PowerShell, cmd.exe, Git Bash with backslash->/
    translation for bash compatibility)
  - POSIX: 1 line (export PATH=...)
- npxNoteLines: 'you're running via npx ... npm install -g instead'
  when runDir is under an _npx cache segment (where the shim may be
  written to a temp dir that won't persist for the user's interactive
  shell)
- isNpx, isWin32: structured booleans for assertions

Renderer in install.js just emits each line. Tests assert on the
typed IR fields directly (no source-grep, no console-output parsing).

Tests: 12 cases across 5 suites covering Windows shell flavors
(PowerShell preserves backslashes, Git Bash translates to forward),
POSIX exports, null-shimDir fallback to npm install -g advice, npx
detection on both path-separator conventions, and IR shape contract.

Closes #3011

* fix(#3011): cmd.exe guidance uses powershell -Command, not setx

CodeRabbit flagged the cmd.exe action line as a Major Windows
correctness bug:

  setx PATH "${shimDir}; %PATH%"

Two failure modes:
1. setx silently truncates the registry value above 1024 chars,
   permanently storing the truncated PATH and breaking applications
   until restored from the registry backup or fixed manually.
2. %PATH% expands to its current literal value at the moment setx
   runs, and the result is written as REG_SZ instead of REG_EXPAND_SZ.
   Lazy references like %SystemRoot% are baked in as literals, so
   future changes to those variables stop propagating.

Replace with the same SetEnvironmentVariable call already used for
the PowerShell line, invoked through `powershell -Command` so cmd.exe
users get a safe command without us recommending two different APIs.

* fix(#3011): escape shimDir for PowerShell, bash, and POSIX export

CodeRabbit (Minor): a Windows username with a single quote (e.g.
"C:\Users\O'Neil\AppData\Roaming\npm") would interpolate raw into the
suggested commands, producing unparseable shell input the user can't
fix without understanding the bug.

Each shell context needs a different escape:

- PowerShell single-quoted strings: '' is the literal-quote escape.
  Apply to both the PowerShell line and the cmd.exe line (which
  delegates to PowerShell).

- Git Bash, where the path lives inside an outer single-quoted echo:
  '\'' (close-quote, escaped-quote, reopen-quote) embeds a literal
  single quote. The slash-conversion (\\ → /) still applies first.

- POSIX export (Linux/macOS) inside double quotes: escape \, $, ",
  and backtick so the path is copied verbatim. $PATH lives outside
  the escape and still expands at paste time.

Regression test: bug-3011-sdk-path-diagnostic.test.cjs locks in the
expected escape sequence for all three shell flavors.
2026-05-02 09:30:58 -04:00

192 lines
7.9 KiB
JavaScript

'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');
});
});