From d24122712947f5a4646d43ebf7d6c4ad3569cc0d Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 9 May 2026 02:03:57 -0400 Subject: [PATCH] fix(install): Windows persistent SDK shim; replace legacy gsd-tools.cjs shim (#3211) (#3282) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: reproduce Windows SDK not found after fresh npx install (#3211) Co-Authored-By: Claude Sonnet 4.6 * fix(install): Windows persistent Path probe + npx-PATH filter on Windows (#3211) Add getUserShellWindowsPersistentPath() — the Windows counterpart to getUserShellPath(). Probes the user-level 'Path' registry key via powershell.exe so the installer can verify gsd-sdk is reachable from PowerShell/cmd.exe/Git Bash post-install, not just in the transient npx subprocess PATH. Wire it into installSdkIfNeeded: on Windows, use the registry-derived persistent Path (with npx dirs stripped) as the cross-shell reachability gate, instead of skipping the check entirely. This is the Windows sibling of the Linux fix in #3249/#3231. Co-Authored-By: Claude Sonnet 4.6 * changeset: pr=3282 for #3211 Co-Authored-By: Claude Sonnet 4.6 * fix(install): include Machine+User Path in Windows persistent probe (#3211) getUserShellWindowsPersistentPath now merges Machine-level and User-level registry Path entries (matching the effective PATH that PowerShell, cmd.exe, and Git Bash inherit), instead of reading only User-level. Reading User-only would produce a false warning when gsd-sdk is installed in a machine-level bin dir (e.g. C:\Program Files\nodejs). Addresses CodeRabbit finding on PR #3282. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .changeset/silly-foxes-sing.md | 5 + bin/install.js | 102 +++- tests/bug-3211-windows-sdk-not-found.test.cjs | 447 ++++++++++++++++++ 3 files changed, 540 insertions(+), 14 deletions(-) create mode 100644 .changeset/silly-foxes-sing.md create mode 100644 tests/bug-3211-windows-sdk-not-found.test.cjs diff --git a/.changeset/silly-foxes-sing.md b/.changeset/silly-foxes-sing.md new file mode 100644 index 000000000..4d468a7f6 --- /dev/null +++ b/.changeset/silly-foxes-sing.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3282 +--- +**`gsd-sdk` now installs reliably on Windows** — the Windows installer now probes the user-level registry Path via PowerShell (the same source PowerShell, cmd.exe, and Git Bash inherit) to verify persistent reachability, instead of skipping the cross-shell check entirely. Applies the same npx-PATH filter as the Linux fix from #3249, and replaces stale `gsd-sdk.cmd` shims pointing at the deprecated `gsd-tools.cjs`. Emits an actionable warning instead of a false-positive ready signal when the npm-prefix bin dir is not on the user's persistent Path. diff --git a/bin/install.js b/bin/install.js index 469fa83f1..30d000fc6 100755 --- a/bin/install.js +++ b/bin/install.js @@ -9803,24 +9803,32 @@ function installSdkIfNeeded(opts) { // mismatch, POSIX ~/.local/bin missing from login shell, or node- // version-manager PATH shims. Probe the user's login shell PATH and // require the shim to be reachable there too before claiming ✓. - // POSIX-only probe; on Windows getUserShellPath() returns null and - // we trust the existing check (Windows-specific fix is separate). // - // #3231: when getUserShellPath() returns null (e.g. $SHELL unset on - // Linux, rc-file timeout), we cannot confirm persistent reachability. - // In that case, do NOT preserve a true onPath — require the initial - // check (on persistentPath) to have found the shim in a persistent - // location. Since we already filtered npx dirs above, onPath=true here - // means a non-transient dir has the shim, which is sufficient. - const userShellPath = getUserShellPath(); + // #3211 (Windows): getUserShellWindowsPersistentPath() reads the user-level + // 'Path' registry key via PowerShell — the correct cross-shell source on + // Windows (Git Bash, PowerShell, and cmd.exe all inherit it). Returns null + // when PowerShell is unavailable or the probe times out. + // + // #3231: when getUserShellPath() / getUserShellWindowsPersistentPath() + // returns null (probe failed or unavailable), we cannot confirm persistent + // reachability. Since we already filtered npx dirs from persistentPath above, + // onPath=true means a non-transient dir has the shim — that is the best + // available invariant and is sufficient to claim ✓. + const userShellPath = process.platform === 'win32' + ? getUserShellWindowsPersistentPath() + : getUserShellPath(); if (onPath && userShellPath !== null) { - const persistentUserShellPath = filterNpxFromPath(userShellPath); + // filterNpxFromPath is applied inside getUserShellWindowsPersistentPath + // (Windows) and here for the POSIX case. + const persistentUserShellPath = process.platform === 'win32' + ? userShellPath // already filtered by getUserShellWindowsPersistentPath + : filterNpxFromPath(userShellPath); const userSees = isGsdSdkOnPath(persistentUserShellPath); if (!userSees) { onPath = false; } } - // If userShellPath is null (POSIX probe failed), onPath reflects + // If userShellPath is null (probe failed or unavailable), onPath reflects // the persistent-PATH check — that is the best available invariant. if (onPath) { @@ -9995,9 +10003,8 @@ function isGsdSdkOnPath(pathString) { * login shell. * * Uses `$SHELL -lc 'printf %s "$PATH"'` on POSIX. Returns null on Windows - * (cross-shell PATH probing requires a different strategy — Git Bash - * vs PowerShell vs cmd.exe each read PATH from different sources, and - * a future revision can build a Windows-aware probe). Returns null + * (the Windows counterpart is getUserShellWindowsPersistentPath, which reads + * the user-level 'Path' registry key via PowerShell). Returns null * when $SHELL is unset, when the spawn fails, or when the result is * empty — callers must fall back to process.env.PATH in those cases. * @@ -10030,6 +10037,72 @@ function getUserShellPath() { } } +/** + * #3211: Windows counterpart to getUserShellPath(). Probes the effective + * persistent Path from the Windows registry via PowerShell by merging + * Machine-level + User-level entries: + * + * $m=[Environment]::GetEnvironmentVariable('Path','Machine') + * $u=[Environment]::GetEnvironmentVariable('Path','User') + * ($m + ';' + $u).Trim(';') + * + * This is the correct primitive for Windows cross-shell PATH verification — + * Git Bash, PowerShell, and cmd.exe all inherit the effective (Machine;User) + * registry Path, while the install-subprocess process.env.PATH is polluted + * with transient npx entries and may not include directories added by the + * user post-install. Reading only User-level Path would produce a false + * warning when gsd-sdk is in a machine-level bin dir (e.g. C:\Program Files\nodejs). + * + * Returns the filtered persistent Path string (npx segments stripped) or null + * on any failure (non-Windows, PowerShell not available, spawn timeout, empty + * result). Callers must treat null as "check unavailable — trust install-time + * filtered PATH". + * + * Synchronous, 2-second timeout, best-effort — safe to call from + * installSdkIfNeeded without restructuring to async. + */ +function getUserShellWindowsPersistentPath() { + if (process.platform !== 'win32') return null; + const cp = require('child_process'); + // Use the same execFileSync form as getUserShellPath() above — static + // literal args, no user input, no injection vector. + const execFile = cp.execFileSync.bind(cp); + try { + // Read Machine + User Path and merge them — the effective PATH that + // PowerShell, cmd.exe, and Git Bash inherit is Machine;User (machine + // entries first). Reading only User-level Path would produce a false + // warning when gsd-sdk is installed in a machine-level bin dir + // (e.g. C:\Program Files\nodejs). + const out = execFile( + 'powershell.exe', + [ + '-NoProfile', + '-Command', + "$u=[Environment]::GetEnvironmentVariable('Path','User');" + + "$m=[Environment]::GetEnvironmentVariable('Path','Machine');" + + "[Console]::Out.Write(($m + ';' + $u).Trim(';'))", + ], + { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + // 2-second cap — a locked registry or slow profile can't hang the install. + timeout: 2000, + }, + ); + // Take the last non-empty line so any motd/banner noise before the output + // doesn't corrupt the result — same defensive pattern as getUserShellPath. + const lines = String(out || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean); + const candidate = lines.length > 0 ? lines[lines.length - 1] : ''; + if (!candidate) return null; + // Strip transient npx dirs from the persistent Path before returning — + // the registry can accumulate stale _npx entries from prior runs. + const filtered = filterNpxFromPath(candidate); + return filtered.length > 0 ? filtered : null; + } catch { + return null; + } +} + /** * #2775 helper: attempt to materialize the `gsd-sdk` shim at a user-writable * PATH location. Returns the absolute path created on success, or null if no @@ -10468,6 +10541,7 @@ if (process.env.GSD_TEST_MODE) { isLegacyGsdSdkShim, isGsdSdkOnPath, getUserShellPath, + getUserShellWindowsPersistentPath, homePathCoveredByRc, maybeSuggestPathExport, runtimeMap, diff --git a/tests/bug-3211-windows-sdk-not-found.test.cjs b/tests/bug-3211-windows-sdk-not-found.test.cjs new file mode 100644 index 000000000..cf014634e --- /dev/null +++ b/tests/bug-3211-windows-sdk-not-found.test.cjs @@ -0,0 +1,447 @@ +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Regression tests for bug #3211. + * + * Windows 11 + PowerShell 7 + Node v22.22.1, fresh + * `npx get-shit-done-cc@latest --global --claude`: + * gsd-sdk: The term 'gsd-sdk' is not recognized + * + * Root causes (Windows sibling of #3231): + * + * A. filterNpxFromPath must handle Windows-style backslash paths (e.g. + * C:\Users\user\AppData\Local\npm-cache\_npx\abc123\node_modules\.bin). + * After replace(/\\/g, '/') the norm contains /_npx/ and should be + * stripped. We verify this explicitly because the Linux tests only exercised + * POSIX-style paths. + * + * B. isGsdSdkOnPath (zero-arg fallback) reads `process.env.PATH || ''`. On + * Windows, Node.js normalises PATH case so `process.env.PATH` always + * returns the right value in production. But in a cross-platform test + * running on macOS/Linux that simulates Windows by writing to + * `process.env.PATH`, the filter must still strip `_npx` dirs expressed + * with Windows backslash separators so the helper returns false when only + * transient dirs are present. + * + * C. getUserShellWindowsPersistentPath() — new Windows equivalent of + * getUserShellPath(). Probes the user's persistent 'Path' from the Windows + * registry via: + * powershell.exe -NoProfile -Command + * "[Environment]::GetEnvironmentVariable('Path', 'User')" + * Returns the persistent Path string or null on failure. Must be exported + * and must apply filterNpxFromPath before returning. + * + * D. installSdkIfNeeded on Windows must invoke getUserShellWindowsPersistentPath + * (instead of the always-null getUserShellPath) for cross-shell verification + * — parallel to the POSIX userShellPath guard. + * + * All assertions use typed-IR / behavioral testing — no source-grep, no + * readFileSync on install.js source. + */ + +const { describe, test, beforeEach, afterEach, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const cp = require('node:child_process'); + +const ROOT = path.join(__dirname, '..'); +const installModule = require(path.join(ROOT, 'bin', 'install.js')); + +const { + filterNpxFromPath, + isLegacyGsdSdkShim, + isGsdSdkOnPath, + installSdkIfNeeded, + getUserShellWindowsPersistentPath, +} = installModule; + +// --------------------------------------------------------------------------- +// A. filterNpxFromPath — Windows backslash paths +// --------------------------------------------------------------------------- + +describe('bug #3211-A: filterNpxFromPath handles Windows backslash _npx paths', () => { + test('strips a Windows-style _npx dir expressed with backslashes', () => { + assert.equal(typeof filterNpxFromPath, 'function', 'filterNpxFromPath must be exported'); + + // Windows npm-cache path with backslash separators, semicolon delimiter + const winNpxDir = 'C:\\Users\\user\\AppData\\Local\\npm-cache\\_npx\\abc123\\node_modules\\.bin'; + const winPersistentDir = 'C:\\Users\\user\\AppData\\Roaming\\npm'; + const winSystemDir = 'C:\\Windows\\System32'; + + // On macOS path.delimiter is ':', not ';'. We pass an explicit string + // so the test validates the normalize logic, not the local path.delimiter. + const inputPath = [winNpxDir, winPersistentDir, winSystemDir].join(';'); + const result = filterNpxFromPath(inputPath); + + // Regardless of delimiter used, the _npx segment must be stripped + assert.ok( + !result.includes('_npx'), + 'filterNpxFromPath must strip Windows _npx dirs. Got: ' + result, + ); + assert.ok( + result.includes('Roaming\\npm') || result.includes('Roaming/npm'), + 'filterNpxFromPath must preserve the persistent npm dir. Got: ' + result, + ); + }); + + test('strips a mixed-separator Windows _npx path (forward + backward slashes)', () => { + const mixedNpxDir = 'C:/Users/user/AppData/Local/npm-cache/_npx/abc/node_modules/.bin'; + const persistentDir = 'C:/Users/user/AppData/Roaming/npm'; + const result = filterNpxFromPath([mixedNpxDir, persistentDir].join(';')); + assert.ok(!result.includes('_npx'), 'must strip mixed-separator _npx dir. Got: ' + result); + assert.ok(result.includes('Roaming/npm'), 'must keep persistent dir. Got: ' + result); + }); + + test('does NOT strip a Windows user dir that merely contains "npx" as a substring', () => { + // A user-named dir like C:\my-npx-tools\bin must NOT be filtered. + const userNpxLikeDir = 'C:\\Users\\user\\my-npx-tools\\bin'; + const realNpxDir = 'C:\\Users\\user\\AppData\\Local\\npm-cache\\_npx\\abc\\node_modules\\.bin'; + const result = filterNpxFromPath([userNpxLikeDir, realNpxDir].join(';')); + assert.ok( + result.includes('my-npx-tools'), + 'must not strip user dirs that merely contain "npx". Got: ' + result, + ); + assert.ok(!result.includes('_npx'), 'must strip the real _npx dir. Got: ' + result); + }); +}); + +// --------------------------------------------------------------------------- +// B. isGsdSdkOnPath — does not return true when only a Windows _npx dir has +// gsd-sdk.cmd (using filterNpxFromPath on the passed pathString) +// --------------------------------------------------------------------------- + +describe('bug #3211-B: isGsdSdkOnPath rejects Windows _npx-only transient PATH', () => { + let tmpRoot; + + before(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3211-b-')); + }); + + after(() => { + try { fs.rmSync(tmpRoot, { recursive: true, force: true }); } catch {} + }); + + test('returns false when a gsd-sdk.cmd exists only in an _npx-style transient dir', () => { + // On POSIX we name the dir with _npx/ to match the filter pattern. + // We can't set process.platform, but we CAN call isGsdSdkOnPath with an + // explicit pathString that contains an _npx segment — the fix must + // ensure callers pre-filter via filterNpxFromPath before calling + // isGsdSdkOnPath. We test filterNpxFromPath(pathString) produces an + // empty result, which means isGsdSdkOnPath of the filtered path returns false. + const npxBinDir = path.join(tmpRoot, '_npx', 'abc123', 'node_modules', '.bin'); + fs.mkdirSync(npxBinDir, { recursive: true }); + + // Write a gsd-sdk shim (named .cmd for the Windows scenario — on POSIX + // isGsdSdkOnPath won't find .cmd; we validate the filter, not the exec check). + const shimPath = path.join(npxBinDir, 'gsd-sdk.cmd'); + fs.writeFileSync( + shimPath, + ['@ECHO OFF', '@node "C:\\path\\to\\gsd-sdk.js" %*', ''].join('\r\n'), + ); + + // The raw pathString contains an _npx segment — it MUST be filtered. + const rawPath = npxBinDir; + const persistentPath = filterNpxFromPath(rawPath); + + // After filtering, the _npx dir must be gone so isGsdSdkOnPath returns false. + const onPath = isGsdSdkOnPath(persistentPath); + assert.equal( + onPath, + false, + 'isGsdSdkOnPath(filterNpxFromPath(path)) must return false when only _npx dir has gsd-sdk. persistentPath=' + persistentPath, + ); + }); +}); + +// --------------------------------------------------------------------------- +// C. getUserShellWindowsPersistentPath — new export +// --------------------------------------------------------------------------- + +describe('bug #3211-C: getUserShellWindowsPersistentPath export', () => { + test('is exported from install.js', () => { + assert.equal( + typeof getUserShellWindowsPersistentPath, + 'function', + 'getUserShellWindowsPersistentPath must be exported from install.js', + ); + }); + + test('returns a string or null — never throws', () => { + // We can call it on macOS/Linux; it must handle non-Windows gracefully + // (return null or a string). Must never throw. + let result; + let threw = null; + try { + result = getUserShellWindowsPersistentPath(); + } catch (e) { + threw = e; + } + assert.equal(threw, null, 'getUserShellWindowsPersistentPath must not throw. Error: ' + threw); + assert.ok( + result === null || typeof result === 'string', + 'must return string or null. Got: ' + typeof result, + ); + }); + + test('on non-Windows, returns null (Windows-only probe)', () => { + if (process.platform === 'win32') { + // On actual Windows, any non-null string is acceptable. + const result = getUserShellWindowsPersistentPath(); + assert.ok( + result === null || typeof result === 'string', + 'on Windows must return string or null', + ); + } else { + // On POSIX, the Windows probe is meaningless — must return null. + const result = getUserShellWindowsPersistentPath(); + assert.equal(result, null, 'on POSIX, getUserShellWindowsPersistentPath must return null'); + } + }); + + test('when the PowerShell probe is mocked to return a path, strips _npx dirs', () => { + // Mock execFileSync to return a merged Machine;User Windows Path that + // includes both persistent and transient _npx dirs. + const savedExecFileSync = cp.execFileSync; + const winPersistentDir = 'C:\\Users\\user\\AppData\\Roaming\\npm'; + const winNpxDir = 'C:\\Users\\user\\AppData\\Local\\npm-cache\\_npx\\abc\\node_modules\\.bin'; + // Merged Machine;User result (as the new probe emits) + const mockPath = [winPersistentDir, winNpxDir].join(';'); + + cp.execFileSync = (file, args, opts) => { + if (typeof file === 'string' && file.includes('powershell') && Array.isArray(args) && + args.some((a) => typeof a === 'string' && a.includes('GetEnvironmentVariable'))) { + return mockPath + '\n'; + } + return savedExecFileSync.call(cp, file, args, opts); + }; + + let result; + try { + result = getUserShellWindowsPersistentPath(); + } finally { + cp.execFileSync = savedExecFileSync; + } + + // On non-Windows this returns null (the function guards on process.platform). + // On Windows it returns the filtered path. + if (result !== null) { + assert.ok( + !result.includes('_npx'), + 'getUserShellWindowsPersistentPath must filter _npx dirs from the returned Path. Got: ' + result, + ); + assert.ok( + result.includes('Roaming\\npm') || result.includes('Roaming/npm'), + 'must keep persistent npm dir. Got: ' + result, + ); + } + }); + + test('probe command includes both Machine and User Path sources', () => { + // The PowerShell command that the function invokes must request BOTH + // Machine-level and User-level Path. Verify by inspecting what args + // the mock receives. This is a behavioral assertion on the call shape, + // not a source-grep. + const savedExecFileSync = cp.execFileSync; + let capturedArgs = null; + + cp.execFileSync = (file, args, opts) => { + if (typeof file === 'string' && file.includes('powershell')) { + capturedArgs = args; + return 'C:\\Windows\\System32\n'; + } + return savedExecFileSync.call(cp, file, args, opts); + }; + + try { + // On non-Windows this never calls execFileSync — skip assertion. + getUserShellWindowsPersistentPath(); + } finally { + cp.execFileSync = savedExecFileSync; + } + + if (process.platform === 'win32' && capturedArgs !== null) { + // The command string must reference both 'Machine' and 'User' so both + // registry hives contribute to the returned Path. + const cmdStr = capturedArgs.join(' '); + assert.ok( + cmdStr.includes('Machine'), + 'PowerShell command must read Machine-level Path. Got: ' + cmdStr, + ); + assert.ok( + cmdStr.includes('User'), + 'PowerShell command must read User-level Path. Got: ' + cmdStr, + ); + } + }); +}); + +// --------------------------------------------------------------------------- +// D. installSdkIfNeeded Windows false-positive: transient _npx + npm-prefix +// NOT on PATH → must NOT print "GSD SDK ready" +// --------------------------------------------------------------------------- + +describe('bug #3211-D: installSdkIfNeeded — Windows _npx false-positive', () => { + let tmpRoot; + let sdkDir; + let savedEnv; + let origExecSync; + + function captureConsole(fn) { + const stdout = []; + const stderr = []; + const origLog = console.log; + const origWarn = console.warn; + const origError = console.error; + console.log = (...a) => stdout.push(a.join(' ')); + console.warn = (...a) => stderr.push(a.join(' ')); + console.error = (...a) => stderr.push(a.join(' ')); + let threw = null; + try { fn(); } catch (e) { threw = e; } + finally { + console.log = origLog; + console.warn = origWarn; + console.error = origError; + } + if (threw) throw threw; + const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, ''); + return { stdout: stdout.map(strip).join('\n'), stderr: stderr.map(strip).join('\n') }; + } + + function makeSdkDir(root) { + const dir = path.join(root, 'sdk'); + fs.mkdirSync(path.join(dir, 'dist'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'dist', 'cli.js'), + ['#!/usr/bin/env node', "console.log('0.0.0-test');", ''].join('\n'), + { mode: 0o755 }, + ); + return dir; + } + + beforeEach(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3211-d-')); + sdkDir = makeSdkDir(tmpRoot); + + // Simulate: install-time PATH contains only a transient _npx dir + // with a gsd-sdk shim. The persistent npm prefix dir is separate and + // NOT in process.env.PATH during the npx run. + const npxBinDir = path.join(tmpRoot, '_npx', 'abc123', 'node_modules', '.bin'); + fs.mkdirSync(npxBinDir, { recursive: true }); + // Write a gsd-sdk shim in the transient dir (executable on POSIX) + fs.writeFileSync( + path.join(npxBinDir, 'gsd-sdk'), + ['#!/bin/sh', 'exec node /path/to/gsd-sdk.js "$@"', ''].join('\n'), + { mode: 0o755 }, + ); + + const homeDir = path.join(tmpRoot, 'home'); + fs.mkdirSync(homeDir, { recursive: true }); + + savedEnv = { + PATH: process.env.PATH, + HOME: process.env.HOME, + SHELL: process.env.SHELL, + }; + + // Only the transient _npx dir is on PATH — nothing persistent. + process.env.PATH = npxBinDir; + process.env.HOME = homeDir; + delete process.env.SHELL; + + // Mock cp.execSync for npm prefix -g — return a separate dir that is + // NOT in process.env.PATH (simulating Windows: npm prefix is on the + // user's registry Path but not on the npx-injected subprocess PATH). + const npmPrefixDir = path.join(tmpRoot, 'npm-global'); + fs.mkdirSync(npmPrefixDir, { recursive: true }); + + origExecSync = cp.execSync; + cp.execSync = (cmd, opts) => { + if (typeof cmd === 'string' && cmd.trim() === 'npm prefix -g') { + return npmPrefixDir + '\n'; + } + return origExecSync.call(cp, cmd, opts); + }; + }); + + afterEach(() => { + cp.execSync = origExecSync; + if (savedEnv.PATH == null) delete process.env.PATH; + else process.env.PATH = savedEnv.PATH; + if (savedEnv.HOME == null) delete process.env.HOME; + else process.env.HOME = savedEnv.HOME; + if (savedEnv.SHELL == null) delete process.env.SHELL; + else process.env.SHELL = savedEnv.SHELL; + try { fs.rmSync(tmpRoot, { recursive: true, force: true }); } catch {} + }); + + test('does NOT print "GSD SDK ready" when the only gsd-sdk entry is in a transient _npx dir and npm-prefix is off-PATH', () => { + const { stdout, stderr } = captureConsole(() => { + installSdkIfNeeded({ sdkDir }); + }); + const combined = `${stdout}\n${stderr}`; + + assert.ok( + !/GSD SDK ready/.test(combined), + 'installer must NOT print "GSD SDK ready" when gsd-sdk is only in a transient _npx dir. Got:\n' + combined, + ); + // Must emit SOME output (warning or diagnostic), not silently succeed. + assert.ok( + combined.trim().length > 0, + 'installer must emit a diagnostic when self-link fails or shim is in transient dir. Got empty output.', + ); + }); +}); + +// --------------------------------------------------------------------------- +// E. isLegacyGsdSdkShim — Windows .cmd shim detection +// --------------------------------------------------------------------------- + +describe('bug #3211-E: isLegacyGsdSdkShim detects legacy marker in .cmd files', () => { + let tmpRoot; + + before(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3211-e-')); + }); + + after(() => { + try { fs.rmSync(tmpRoot, { recursive: true, force: true }); } catch {} + }); + + test('detects @deprecated + gsd-tools.cjs in a .cmd shim', () => { + assert.equal(typeof isLegacyGsdSdkShim, 'function'); + + const legacyCmd = path.join(tmpRoot, 'gsd-sdk.cmd'); + fs.writeFileSync( + legacyCmd, + [ + '@ECHO OFF', + ':: @deprecated — use gsd-tools.cjs directly', + '@node "C:\\path\\to\\gsd-tools.cjs" %*', + '', + ].join('\r\n'), + ); + assert.equal(isLegacyGsdSdkShim(legacyCmd), true, 'must detect @deprecated gsd-tools.cjs in .cmd shim'); + }); + + test('returns false for a modern .cmd shim pointing at gsd-sdk.js', () => { + const modernCmd = path.join(tmpRoot, 'gsd-sdk-modern.cmd'); + fs.writeFileSync( + modernCmd, + [ + '@ECHO OFF', + '@SETLOCAL', + '@node "C:\\path\\to\\get-shit-done-cc\\bin\\gsd-sdk.js" %*', + '', + ].join('\r\n'), + ); + assert.equal(isLegacyGsdSdkShim(modernCmd), false, 'must not flag modern .cmd shim as legacy'); + }); + + test('returns false for a missing file', () => { + assert.equal(isLegacyGsdSdkShim(path.join(tmpRoot, 'no-such-file.cmd')), false); + }); +});