fix(installer): warn on stale gsd-sdk path

This commit is contained in:
Tom Boucher
2026-05-10 17:40:39 -04:00
committed by GitHub
parent 52337a8861
commit cb27c18026
3 changed files with 248 additions and 10 deletions

View File

@@ -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)

View File

@@ -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,

View File

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