fix(3406): detect + warn on stale @gsd-build/sdk@0.1.0 global shadow (#3641)
* fix(3406): detect + warn on stale @gsd-build/sdk@0.1.0 global shadow
`@gsd-build/sdk@0.1.0` is the only published version of the standalone
SDK package (the SDK now ships embedded in get-shit-done-cc). When a
user has the stale 0.1.0 globally installed, its `gsd-sdk` bin shadows
the shim get-shit-done-cc wires up — and the 0.1.0 binary only knows
`run | auto | init` (no `query`), so every `gsd-sdk query <cmd>` call
from skills and hooks fails silently until the user runs
`npm uninstall -g @gsd-build/sdk`.
Per maintainer triage decision (option 2): detect at install time and
surface the remediation, instead of waiting for the user to discover
the failure through a broken workflow.
Changes:
- New helper `detectStaleStandaloneSdk(runNpmLs)` (pure function,
accepts an injected executor). Returns `{stale: true, version}` when
`@gsd-build/sdk` is in the top-level dependency tree; returns
`{stale: false}` for every other input including executor throws,
malformed JSON, missing keys, and null/undefined returns.
- New helper `formatStaleStandaloneSdkWarning(info)` — message names
the package, version, the exact `npm uninstall -g @gsd-build/sdk`
remediation command, and references the issue.
- Call site in `install()` for `isGlobal` runs. Spawns
`npm ls -g @gsd-build/sdk --json --depth=0`, recovers the JSON
attached to the non-zero-exit error (npm's "absent" signal),
forwards to detectStaleStandaloneSdk, prints the warning if stale.
Best-effort: any failure is swallowed so detection never blocks
install.
- `GSD_SKIP_STALE_SDK_CHECK=1` opt-out for CI/test environments that
need silence (also used by the install-side test below).
Regression test `tests/bug-3406-stale-sdk-shadow-detect.test.cjs`:
- 8 unit tests pinning every detectStaleStandaloneSdk path (exported,
absent, present, executor-throws, malformed-JSON, no-deps-field,
null/undefined, format).
- 1 install-side end-to-end test confirming that when the package is
absent, the install run does NOT mention `@gsd-build/sdk` or `#3406`
in stdout. Uses a per-test `npm_config_prefix` so the test never
depends on the host's npm dependency tree.
Fixes #3406
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(3406): correct changeset pr field — 3406 was the issue number, not the PR
CodeRabbit caught that .changeset/fix-3406-detect-stale-sdk-shadow.md
referenced `pr: 3406` (the issue number) instead of `pr: 3641` (the
PR number). Per CONTEXT.md PRED.k329 changeset frontmatter pr: must
reference the pull request number.
Local: docker gsd-test-summary 11214/0 on plex2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(3406): two CR follow-ups on bin/install.js stale-shadow check
Round-2 CodeRabbit findings on PR #3641:
1. bin/install.js:7769 — GSD_SKIP_STALE_SDK_CHECK opt-out now matches
only explicit "1" / "true" / "yes". The previous any-truthy check
silently disabled the warning for `GSD_SKIP_STALE_SDK_CHECK=0` and
`GSD_SKIP_STALE_SDK_CHECK=false`.
2. bin/install.js:10568 — detectStaleStandaloneSdk now gates stale=true
on version === '0.1.0' (the known-bad shadow). Any newer published
version is intentional and must not flag a "stale shadow" warning
on every install. Added a regression test for non-0.1.0 versions
(1.50.0-canary.0 and 2.0.0) returning stale:false.
Local: 11/11 in the bug-3406 test file + docker gsd-test-summary 11215/0
on plex2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
115
bin/install.js
115
bin/install.js
@@ -7855,6 +7855,45 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
||||
? targetDir.replace(os.homedir(), '~')
|
||||
: targetDir.replace(process.cwd(), '.');
|
||||
|
||||
// #3406: warn if a stale standalone `@gsd-build/sdk` is globally installed
|
||||
// and shadows the `gsd-sdk` shim this installer wires up. Only meaningful
|
||||
// for global installs (the shim collision lives in the global node_modules
|
||||
// bin dir). Guarded by GSD_SKIP_STALE_SDK_CHECK so CI/tests can silence it.
|
||||
// #3406 CR: opt-out only on explicit "1" / "true" / "yes" rather than any
|
||||
// non-empty value. Without this guard `GSD_SKIP_STALE_SDK_CHECK=0` and
|
||||
// `GSD_SKIP_STALE_SDK_CHECK=false` would silently disable the check.
|
||||
const skipRaw = process.env.GSD_SKIP_STALE_SDK_CHECK;
|
||||
const skipStaleCheck = skipRaw === '1' || skipRaw === 'true' || skipRaw === 'yes';
|
||||
if (isGlobal && !skipStaleCheck) {
|
||||
try {
|
||||
const { execFileSync } = require('child_process');
|
||||
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||
const staleInfo = detectStaleStandaloneSdk(() => {
|
||||
try {
|
||||
return execFileSync(
|
||||
npmCmd,
|
||||
['ls', '-g', '@gsd-build/sdk', '--json', '--depth=0'],
|
||||
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000 }
|
||||
);
|
||||
} catch (e) {
|
||||
// `npm ls -g <missing>` exits 1 with the JSON still on stdout when
|
||||
// the package is absent. execFileSync throws on non-zero exit but
|
||||
// attaches stdout to the error. Recover the JSON in that case so
|
||||
// the detector classifies "absent" correctly.
|
||||
if (e && typeof e.stdout !== 'undefined') {
|
||||
return Buffer.isBuffer(e.stdout) ? e.stdout.toString('utf-8') : String(e.stdout);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
if (staleInfo.stale) {
|
||||
console.warn(`\n${yellow}${formatStaleStandaloneSdkWarning(staleInfo)}${reset}\n`);
|
||||
}
|
||||
} catch {
|
||||
// Detection is best-effort; never block install on its failure.
|
||||
}
|
||||
}
|
||||
|
||||
// Path prefix for file references in markdown content (e.g. gsd-tools.cjs).
|
||||
// Replaces $HOME/.claude/ or ~/.claude/ so the result is <pathPrefix>get-shit-done/bin/...
|
||||
// For global installs: use $HOME/ so paths expand correctly inside double-quoted
|
||||
@@ -10597,6 +10636,80 @@ function installSdkIfNeeded(opts) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #3406 helper: detect a stale globally-installed `@gsd-build/sdk` package
|
||||
* shadowing the `gsd-sdk` shim that `get-shit-done-cc` installs.
|
||||
*
|
||||
* Background: `@gsd-build/sdk@0.1.0` was published once and never updated
|
||||
* (the SDK now ships embedded in `get-shit-done-cc`). When a user has the
|
||||
* 0.1.0 standalone package installed globally, its `gsd-sdk` bin shadows
|
||||
* the one `get-shit-done-cc` provides — and the 0.1.0 binary only knows
|
||||
* `run | auto | init` (no `query`), so every `gsd-sdk query <command>`
|
||||
* call from skills/hooks fails until the user runs
|
||||
* `npm uninstall -g @gsd-build/sdk`.
|
||||
*
|
||||
* Pure function: takes an injected `runNpmLs` executor that returns
|
||||
* `npm ls -g @gsd-build/sdk --json --depth=0` stdout. Returns:
|
||||
* `{ stale: true, version }` when the package is present.
|
||||
* `{ stale: false }` for every other input — including:
|
||||
* - executor throws (npm missing / EACCES / network),
|
||||
* - executor returns null/undefined/non-string,
|
||||
* - stdout is not parseable JSON,
|
||||
* - the JSON has no `.dependencies['@gsd-build/sdk']` field.
|
||||
*
|
||||
* Fail-closed conservative: we'd rather miss a detection than fire a
|
||||
* false-positive warning that confuses users who have a fine install.
|
||||
*/
|
||||
function detectStaleStandaloneSdk(runNpmLs) {
|
||||
if (typeof runNpmLs !== 'function') return { stale: false };
|
||||
let out;
|
||||
try {
|
||||
out = runNpmLs();
|
||||
} catch {
|
||||
return { stale: false };
|
||||
}
|
||||
if (typeof out !== 'string' || out.length === 0) return { stale: false };
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(out);
|
||||
} catch {
|
||||
return { stale: false };
|
||||
}
|
||||
const deps = parsed && typeof parsed === 'object' ? parsed.dependencies : null;
|
||||
if (!deps || typeof deps !== 'object') return { stale: false };
|
||||
const entry = deps['@gsd-build/sdk'];
|
||||
if (!entry || typeof entry !== 'object') return { stale: false };
|
||||
const version = typeof entry.version === 'string' ? entry.version : '(unknown)';
|
||||
// #3406 CR: scope stale detection to the known-bad version (0.1.0). Any
|
||||
// newer @gsd-build/sdk version is an intentional install (or a future
|
||||
// republish) and should not be flagged as a shim shadow. Without this
|
||||
// narrowing, a maintainer's local-link or a legitimate future publish
|
||||
// would trigger a misleading "stale shadow" warning on every install.
|
||||
if (version !== '0.1.0') return { stale: false };
|
||||
return { stale: true, version };
|
||||
}
|
||||
|
||||
/**
|
||||
* #3406 helper: format the install-time warning emitted when
|
||||
* `detectStaleStandaloneSdk` reports a stale shadow. Separated from the
|
||||
* detection so the message contract is testable independently of npm.
|
||||
*/
|
||||
function formatStaleStandaloneSdkWarning(info) {
|
||||
const version = info && info.version ? info.version : '(unknown)';
|
||||
return [
|
||||
'⚠ A stale globally-installed @gsd-build/sdk@' + version + ' is shadowing the',
|
||||
' `gsd-sdk` shim that get-shit-done-cc provides. The standalone package',
|
||||
' only knows `run | auto | init` — every `gsd-sdk query <cmd>` call from',
|
||||
' skills and hooks will fail until you remove it.',
|
||||
'',
|
||||
' Remediation:',
|
||||
' npm uninstall -g @gsd-build/sdk',
|
||||
' npx -y get-shit-done-cc@latest --<runtime> --global',
|
||||
'',
|
||||
' Tracking: #3406 — https://github.com/gsd-build/get-shit-done/issues/3406',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* #3231 helper: detect whether a `gsd-sdk` binary is the legacy deprecated
|
||||
* shim pointing at `gsd-tools.cjs`.
|
||||
@@ -11215,6 +11328,8 @@ if (process.env.GSD_TEST_MODE) {
|
||||
installAllRuntimes,
|
||||
uninstall,
|
||||
installSdkIfNeeded,
|
||||
detectStaleStandaloneSdk,
|
||||
formatStaleStandaloneSdkWarning,
|
||||
buildSdkFailFastReport,
|
||||
renderSdkFailFastReport,
|
||||
classifySdkInstall,
|
||||
|
||||
Reference in New Issue
Block a user