From cb27c18026b3c9ecbde9b8117ef6477cd466a696 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 10 May 2026 17:40:39 -0400 Subject: [PATCH] fix(installer): warn on stale gsd-sdk path --- .changeset/fix-3359-stale-sdk-path-version.md | 5 + bin/install.js | 84 +++++++-- ...g-3359-stale-gsd-sdk-path-version.test.cjs | 169 ++++++++++++++++++ 3 files changed, 248 insertions(+), 10 deletions(-) create mode 100644 .changeset/fix-3359-stale-sdk-path-version.md create mode 100644 tests/bug-3359-stale-gsd-sdk-path-version.test.cjs diff --git a/.changeset/fix-3359-stale-sdk-path-version.md b/.changeset/fix-3359-stale-sdk-path-version.md new file mode 100644 index 000000000..1ff99e6a9 --- /dev/null +++ b/.changeset/fix-3359-stale-sdk-path-version.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3363 +--- +**Installer SDK readiness now detects stale `gsd-sdk` executables earlier on PATH** — when the resolved `gsd-sdk --version` differs from the package/runtime version being installed, the installer withholds the ready message and prints the resolved path, detected version, expected version, and global update remediation. (#3359) diff --git a/bin/install.js b/bin/install.js index f08396e14..cf14f4c50 100755 --- a/bin/install.js +++ b/bin/install.js @@ -9933,7 +9933,8 @@ function installSdkIfNeeded(opts) { // shell. A gsd-sdk found there must NOT count as "on PATH". const shimSrc = path.resolve(__dirname, 'gsd-sdk.js'); const persistentPath = filterNpxFromPath(process.env.PATH || ''); - let onPath = isGsdSdkOnPath(persistentPath); + let resolvedSdkPath = findGsdSdkOnPath(persistentPath); + let onPath = !!resolvedSdkPath; // Track WHERE we wrote the shim so the diagnostic can be specific even // when isGsdSdkOnPath() returns false because the write target isn't on @@ -9950,7 +9951,8 @@ function installSdkIfNeeded(opts) { const linked = trySelfLinkGsdSdk(shimSrc); if (linked) { shimDir = path.dirname(linked); - onPath = isGsdSdkOnPath(persistentPath); + resolvedSdkPath = findGsdSdkOnPath(persistentPath); + onPath = !!resolvedSdkPath; if (onPath) { console.log(` ${dim}↪ linked gsd-sdk → ${linked}${reset}`); } @@ -9983,16 +9985,24 @@ function installSdkIfNeeded(opts) { const persistentUserShellPath = process.platform === 'win32' ? userShellPath // already filtered by getUserShellWindowsPersistentPath : filterNpxFromPath(userShellPath); - const userSees = isGsdSdkOnPath(persistentUserShellPath); - if (!userSees) { + const userSdkPath = findGsdSdkOnPath(persistentUserShellPath); + if (!userSdkPath) { onPath = false; + resolvedSdkPath = null; + } else { + resolvedSdkPath = userSdkPath; } } // If userShellPath is null (probe failed or unavailable), onPath reflects // the persistent-PATH check — that is the best available invariant. if (onPath) { - console.log(` ${green}✓${reset} GSD SDK ready (sdk/dist/cli.js)`); + const versionReport = buildGsdSdkVersionMismatchReport(resolvedSdkPath, pkg.version); + if (versionReport) { + renderGsdSdkVersionMismatchReport(versionReport); + } else { + 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. @@ -10099,7 +10109,7 @@ function filterNpxFromPath(pathString) { } /** - * #2775 helper: check whether a callable `gsd-sdk` exists on a PATH. + * #2775 helper: find a callable `gsd-sdk` on a PATH. * * Pure PATH walk (no spawn) — we look for a regular file or symlink named * `gsd-sdk` (or `gsd-sdk.cmd`/`.exe` on Windows) in any directory on PATH and @@ -10117,7 +10127,7 @@ function filterNpxFromPath(pathString) { * isLegacyGsdSdkShim — a symlink pointing at the deprecated gsd-tools.cjs * binary must NOT be treated as "on PATH" even if it is executable. */ -function isGsdSdkOnPath(pathString) { +function findGsdSdkOnPath(pathString) { const path = require('path'); const fs = require('fs'); // Type-guard the explicit input (#3028 CR): callers may pass null @@ -10133,13 +10143,13 @@ function isGsdSdkOnPath(pathString) { const st = fs.statSync(candidate); if (st.isFile()) { if (process.platform === 'win32') { - if (!isLegacyGsdSdkShim(candidate)) return true; + if (!isLegacyGsdSdkShim(candidate)) return candidate; } else if ((st.mode & 0o111) !== 0) { // #3231: resolve symlink before sniffing, so we detect legacy // through any level of indirection. let target = candidate; try { target = fs.realpathSync(candidate); } catch {} - if (!isLegacyGsdSdkShim(target)) return true; + if (!isLegacyGsdSdkShim(target)) return candidate; } } } catch { @@ -10147,7 +10157,60 @@ function isGsdSdkOnPath(pathString) { } } } - return false; + return null; +} + +function isGsdSdkOnPath(pathString) { + return !!findGsdSdkOnPath(pathString); +} + +function parseGsdSdkVersion(text) { + const match = String(text || '').match(/\bv?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/); + return match ? match[1] : null; +} + +function readGsdSdkVersion(sdkPath) { + if (!sdkPath) return null; + const cp = require('child_process'); + try { + const isWindowsCommandShim = process.platform === 'win32' && /\.(cmd|bat)$/i.test(String(sdkPath)); + const result = cp.spawnSync(isWindowsCommandShim ? 'cmd.exe' : sdkPath, isWindowsCommandShim ? ['/c', sdkPath, '--version'] : ['--version'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 2000, + env: process.env, + }); + if (result.error || result.status !== 0) return null; + return parseGsdSdkVersion(`${result.stdout || ''}\n${result.stderr || ''}`); + } catch { + return null; + } +} + +function buildGsdSdkVersionMismatchReport(sdkPath, expectedVersion) { + const actualVersion = readGsdSdkVersion(sdkPath); + if (!actualVersion || !expectedVersion) return null; + if (actualVersion === expectedVersion) return null; + return { + ok: false, + reason: 'gsd_sdk_version_mismatch', + sdk_path: sdkPath, + actual_version: actualVersion, + expected_version: expectedVersion, + fix_command: 'npm install -g get-shit-done-cc@latest', + }; +} + +function renderGsdSdkVersionMismatchReport(ir) { + console.log(''); + console.log(` ${yellow}⚠${reset} ${bold}gsd-sdk version mismatch${reset} — PATH resolves a stale SDK.`); + console.log(` Resolved gsd-sdk: ${ir.sdk_path}`); + console.log(` Resolved version: ${ir.actual_version}`); + console.log(` Installer version: ${ir.expected_version}`); + console.log(` Workflows that call ${cyan}gsd-sdk query …${reset} will use the stale executable first.`); + console.log(` Fix: ${cyan}${ir.fix_command}${reset}`); + console.log(` Or remove the stale global install / adjust PATH so the current shim is first.`); + console.log(''); } /** @@ -10637,6 +10700,7 @@ if (process.env.GSD_TEST_MODE) { buildSdkFailFastReport, renderSdkFailFastReport, classifySdkInstall, + readGsdSdkVersion, convertClaudeCommandToCodexSkill, convertClaudeToOpencodeFrontmatter, convertClaudeToKiloFrontmatter, diff --git a/tests/bug-3359-stale-gsd-sdk-path-version.test.cjs b/tests/bug-3359-stale-gsd-sdk-path-version.test.cjs new file mode 100644 index 000000000..4cb57bee7 --- /dev/null +++ b/tests/bug-3359-stale-gsd-sdk-path-version.test.cjs @@ -0,0 +1,169 @@ +/** + * Regression test for bug #3359. + * + * `npx get-shit-done-cc@latest` can refresh runtime files while an older + * global `gsd-sdk` earlier on PATH remains the executable workflows call. + * The installer must not report SDK readiness when the resolved `gsd-sdk` + * version differs from the package/runtime version being installed. + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const { installSdkIfNeeded, readGsdSdkVersion } = require('../bin/install.js'); +const cp = require('node:child_process'); +const pkg = require('../package.json'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +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'), + }; +} + +describe('bug #3359: installer detects stale gsd-sdk earlier on PATH', () => { + let tmpRoot; + let sdkDir; + let pathDir; + let homeDir; + let savedEnv; + + beforeEach(() => { + tmpRoot = createTempDir('gsd-3359-'); + sdkDir = path.join(tmpRoot, 'sdk'); + pathDir = path.join(tmpRoot, 'global-bin'); + homeDir = path.join(tmpRoot, 'home'); + fs.mkdirSync(path.join(sdkDir, 'dist'), { recursive: true }); + fs.writeFileSync( + path.join(sdkDir, 'dist', 'cli.js'), + '#!/usr/bin/env node\nconsole.log("sdk cli");\n', + { mode: 0o755 }, + ); + fs.mkdirSync(pathDir, { recursive: true }); + fs.mkdirSync(homeDir, { recursive: true }); + savedEnv = { + PATH: process.env.PATH, + HOME: process.env.HOME, + SHELL: process.env.SHELL, + }; + process.env.PATH = pathDir; + process.env.HOME = homeDir; + delete process.env.SHELL; + }); + + afterEach(() => { + 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; + cleanup(tmpRoot); + }); + + test('does not print ready when resolved gsd-sdk version differs from installer package version', () => { + const staleSdk = path.join(pathDir, 'gsd-sdk'); + fs.writeFileSync( + staleSdk, + '#!/bin/sh\nprintf "%s\\n" "gsd-sdk v0.0.1"\n', + { mode: 0o755 }, + ); + + const { stdout, stderr } = captureConsole(() => { + installSdkIfNeeded({ sdkDir }); + }); + const combined = `${stdout}\n${stderr}`; + + assert.ok( + /version mismatch|different version|stale/i.test(combined), + `installer must warn that resolved gsd-sdk is stale. Output:\n${combined}`, + ); + assert.ok( + combined.includes(staleSdk), + `warning must include resolved gsd-sdk path. Output:\n${combined}`, + ); + assert.ok( + combined.includes('0.0.1') && combined.includes(pkg.version), + `warning must include detected and installer versions. Output:\n${combined}`, + ); + assert.ok( + /npm install -g get-shit-done-cc@latest/.test(combined), + `warning must include global update remediation. Output:\n${combined}`, + ); + assert.ok( + !/GSD SDK ready/.test(combined), + `installer must not report ready while PATH resolves a stale gsd-sdk. Output:\n${combined}`, + ); + }); + + test('prints ready when no stale gsd-sdk is on PATH', () => { + const currentSdk = path.join(pathDir, 'gsd-sdk'); + fs.writeFileSync( + currentSdk, + `#!/bin/sh\nprintf "%s\\n" "gsd-sdk v${pkg.version}"\n`, + { mode: 0o755 }, + ); + + const { stdout, stderr } = captureConsole(() => { + installSdkIfNeeded({ sdkDir }); + }); + const combined = `${stdout}\n${stderr}`; + + assert.ok( + /GSD SDK ready/.test(combined), + `installer must report ready when no stale gsd-sdk exists. Output:\n${combined}`, + ); + }); + + test('reads Windows cmd shim versions through cmd.exe', () => { + const originalSpawnSync = cp.spawnSync; + const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + const calls = []; + cp.spawnSync = (command, args, options) => { + calls.push({ command, args, options }); + return { status: 0, stdout: `gsd-sdk v${pkg.version}\n`, stderr: '' }; + }; + Object.defineProperty(process, 'platform', { value: 'win32' }); + + try { + assert.equal(readGsdSdkVersion('C:\\tools\\gsd-sdk.cmd'), pkg.version); + } finally { + cp.spawnSync = originalSpawnSync; + Object.defineProperty(process, 'platform', platformDescriptor); + } + + assert.deepEqual(calls.map(({ command, args }) => ({ command, args })), [{ + command: 'cmd.exe', + args: ['/c', 'C:\\tools\\gsd-sdk.cmd', '--version'], + }]); + assert.equal(calls[0].options.encoding, 'utf8'); + assert.equal(calls[0].options.timeout, 2000); + }); + });