* fix(#2992): deterministic latest-version check — package name is a constant, not LLM choice
The /gsd-update workflow's check_latest_version step was prescribed in
LLM-driven prose: "run `npm view get-shit-done-cc version`". The
executing model could and did shortcut the prescription and invent
npm queries against name-shaped guesses — `@get-shit-done/cli`,
`get-shit-done-cli`, `gsd` — all of which 404 or, worse, return an
unrelated typosquat (the 2016 `get-shit-done` timer package). Same
architectural anti-pattern as #2969 (Hunk Verification Gate where
the LLM filled `verified: yes` without checking).
Implementation built TDD per #2992:
get-shit-done/bin/check-latest-version.cjs
- PACKAGE_NAME = 'get-shit-done-cc' as a module constant; not
parameterised, not exposed for override.
- checkLatestVersion({ spawn? }) returns
{ ok: bool, version?: string, reason: CHECK_REASON.X, detail? }
via a frozen enum: OK / FAIL_NPM_FAILED / FAIL_INVALID_OUTPUT.
- --json mode emits the structured record on stdout for the
workflow to parse via jq.
- Windows-aware: uses { shell: process.platform === 'win32' }
since npm is npm.cmd on Windows (same lesson as #2962).
- Stored under get-shit-done/bin/ (not top-level scripts/) because
that path IS in the user's installed config dir; top-level
scripts/ ships in the npm tarball but is not copied into
~/.claude/ at install time.
tests/bug-2992-check-latest-version.test.cjs
- 7 tests, all assertions on the typed CHECK_REASON enum + the
structured record. Injectable spawn function so no real npm
process is invoked. Covers OK, npm-non-zero, invalid-output,
empty-output, pre-release semver, PACKAGE_NAME constant lock,
enum-shape lock.
get-shit-done/workflows/update.md
- check_latest_version step rewritten to call the script via
`node "${GSD_HOME}/get-shit-done/bin/check-latest-version.cjs"
--json` and parse the structured response with jq. Explicit
"Do NOT run `npm view` or `npm search` directly" guidance
cites #2992 so future contributors understand why.
Closes#2992
* fix(#2992): trailing slash on GSD_HOME default to satisfy bare-path lint
The bug-2470 regression test scans update.md for bare `$HOME/.claude`
references (no trailing slash). The PR added one in the new
check_latest_version step. Fix: trailing slash on the default value
(`${GSD_HOME:-$HOME/.claude/}`). Bash POSIX collapses the resulting
double slash; the lint pattern's negative lookahead is now satisfied.
* fix(#2992): emit GSD_DIR from get_installed_version, use it in check_latest_version
Addresses CodeRabbit feedback: the previous `${GSD_HOME:-$HOME/.claude/}`
fallback hardcoded the Claude runtime path, which silently breaks for
non-Claude runtimes (gemini, codex, opencode, kilo).
Fix:
- get_installed_version now emits a 4th line with the resolved config
dir ($LOCAL_DIR or $GLOBAL_DIR), captured by callers as GSD_DIR.
- check_latest_version uses $GSD_DIR/get-shit-done/bin/check-latest-version.cjs.
Empty GSD_DIR (UNKNOWN scope) skips the version check and falls
through to fresh-install path.
This keeps the package name deterministic (#2992) AND respects the
detected runtime, instead of assuming Claude.
* chore(#2992): add changeset fragment for PR #2993
* chore(#2992): add changeset fragment for PR #2993
* fix(#2992): consolidate LATEST_RESULT parsing inside the GSD_DIR guard
CodeRabbit on PR #2993: the previous structure separated the GSD_DIR
guard from the jq parsing, so when GSD_DIR was empty the parsing block
ran against an unset LATEST_RESULT and produced misleading 'couldn't
check for updates' diagnostics instead of clean 'no_install_detected'.
Move all field assignments inside the conditional so the skip path
seeds LATEST_OK=false, LATEST_VERSION='', LATEST_REASON='no_install_detected',
and LATEST_STATUS=0 atomically.
* fix(#2992): emit GSD_DIR in early-return; add code-block lang and spawnSync timeout (CR)
CodeRabbit on PR #2993 caught three issues:
1. (Major) The early-return path in get_installed_version (PREFERRED_CONFIG_DIR
fast path) only echoed 3 lines, but PR #2993 changed the contract to 4
(GSD_DIR is now line 4). Downstream check_latest_version misread valid
installs as UNKNOWN. Added `echo "$PREFERRED_CONFIG_DIR"` before exit 0.
2. (Minor) Markdown MD040: fenced code block at line 310 was missing a
language identifier. Added ```text.
3. (Quick win) spawnSync('npm view ...') had no timeout, so a hung network
could block /gsd-update indefinitely. Added 15s timeout; on timeout
spawnSync returns with signal !== null and the existing failure path
emits FAIL_NPM_FAILED.
* fix(#3008): kill cross-process race in install-minimal:307 mid-copy test
Old shape compared listTmpStageDirs() snapshots before/after the
mid-copy throw. Under scripts/run-tests.cjs --test-concurrency=4,
tests/install-minimal-all-runtimes.test.cjs runs in a parallel
subprocess and also creates gsd-minimal-skills-* dirs in shared
os.tmpdir(). The parallel process's create/remove activity between
this test's two snapshots caused deterministic failure when timing
aligned -- presented as 'flaky' but is a real race.
CI failure data (PR #2993 run 25238555786):
expected (before): ['gsd-minimal-skills-km1O1O']
actual (after): []
Both processes behaved correctly in isolation. The test was wrong:
it observed a shared filesystem state across processes.
Fix: stub fs.mkdtempSync inside this test to record THIS call's
stage dir path. After the throw, assert fs.existsSync(stagedDir)
=== false. Direct observation of the function's own behavior; no
global tmpdir scan; no parallel-process interference.
Closes#3008
* fix(#2992): distinguish timeout from npm failure; guard empty LATEST_RESULT (CR)
CodeRabbit on PR #2993 (post-fix-up review) caught two improvements:
1. (Low value) check-latest-version.cjs:55-61 — when spawnSync times
out, r.status is null and r.signal is set (e.g. 'SIGTERM'), but
r.stderr is empty. Without the signal-first branch, both timeouts
and genuine npm failures shaped as 'npm exited non-zero' in detail,
making logs ambiguous. Added explicit signal-first branch:
'npm timed out (signal: SIGTERM)'.
2. (Quick win) update.md:284-315 — when node is missing or the script
doesn't exist, LATEST_RESULT is empty. Piping empty to jq parses
without error but leaves LATEST_OK / LATEST_REASON as empty
strings, producing the user-visible diagnostic
'Couldn\'t check for updates (reason: , exit: N)' with a blank
reason. Added an explicit guard that sets LATEST_REASON to
'script_not_found_or_node_unavailable' when LATEST_RESULT is empty,
so operators see a meaningful failure message.
Tests: bug-2992 grows by 2 cases (timeout signal detail + empty
stderr fallback).