* 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).
97 lines
3.9 KiB
JavaScript
97 lines
3.9 KiB
JavaScript
'use strict';
|
|
process.env.GSD_TEST_MODE = '1';
|
|
|
|
const { test, describe, before, after } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const path = require('node:path');
|
|
const cp = require('node:child_process');
|
|
|
|
const ROOT = path.join(__dirname, '..');
|
|
const { checkLatestVersion, CHECK_REASON, PACKAGE_NAME } = require(
|
|
path.join(ROOT, 'get-shit-done', 'bin', 'check-latest-version.cjs'),
|
|
);
|
|
|
|
// checkLatestVersion is a pure-ish function: it spawns one fixed npm
|
|
// command, validates the output, and returns { ok, version | reason }.
|
|
// The package name is HARDCODED — not a free choice for the caller.
|
|
// Tests use a pluggable spawn so no real npm process is invoked.
|
|
|
|
describe('Bug #2992: deterministic latest-version check', () => {
|
|
test('PACKAGE_NAME is the constant get-shit-done-cc (no callers can override)', () => {
|
|
assert.equal(PACKAGE_NAME, 'get-shit-done-cc');
|
|
});
|
|
|
|
test('CHECK_REASON enum exposes the documented codes', () => {
|
|
assert.deepEqual(
|
|
Object.keys(CHECK_REASON).sort(),
|
|
['FAIL_INVALID_OUTPUT', 'FAIL_NPM_FAILED', 'OK'].sort(),
|
|
);
|
|
});
|
|
|
|
test('returns { ok: true, version } when npm prints a valid semver', () => {
|
|
const fakeSpawn = () => ({ status: 0, stdout: '1.39.1\n', stderr: '' });
|
|
const r = checkLatestVersion({ spawn: fakeSpawn });
|
|
assert.deepEqual(r, { ok: true, version: '1.39.1', reason: CHECK_REASON.OK });
|
|
});
|
|
});
|
|
|
|
describe('Bug #2992: error paths', () => {
|
|
const { checkLatestVersion, CHECK_REASON } = require(require('node:path').join(__dirname, '..', 'get-shit-done', 'bin', 'check-latest-version.cjs'));
|
|
|
|
test('FAIL_NPM_FAILED when npm exits non-zero (e.g. offline, 404)', () => {
|
|
const r = checkLatestVersion({
|
|
spawn: () => ({ status: 1, stdout: '', stderr: 'npm ERR! 404\n' }),
|
|
});
|
|
assert.equal(r.ok, false);
|
|
assert.equal(r.reason, CHECK_REASON.FAIL_NPM_FAILED);
|
|
assert.equal(r.detail, 'npm ERR! 404',
|
|
'detail should be the trimmed stderr when npm reports a real error');
|
|
});
|
|
|
|
// #2993 CR: distinguish timeout from genuine npm failure in `detail`.
|
|
// spawnSync sets status=null and signal='SIGTERM' on timeout; stderr is
|
|
// typically empty. Without the signal-first branch, both shape as
|
|
// 'npm exited non-zero' and the operator cannot tell timeout from failure.
|
|
test('FAIL_NPM_FAILED detail names the signal when spawn times out', () => {
|
|
const r = checkLatestVersion({
|
|
spawn: () => ({ status: null, signal: 'SIGTERM', stdout: '', stderr: '' }),
|
|
});
|
|
assert.equal(r.ok, false);
|
|
assert.equal(r.reason, CHECK_REASON.FAIL_NPM_FAILED);
|
|
assert.equal(r.detail, 'npm timed out (signal: SIGTERM)',
|
|
'detail should explicitly name the signal when status is null and signal is set');
|
|
});
|
|
|
|
test('FAIL_NPM_FAILED detail falls back to generic when neither stderr nor signal is present', () => {
|
|
const r = checkLatestVersion({
|
|
spawn: () => ({ status: 1, stdout: '', stderr: '' }),
|
|
});
|
|
assert.equal(r.detail, 'npm exited non-zero');
|
|
});
|
|
|
|
test('FAIL_INVALID_OUTPUT when npm prints something that is not a semver', () => {
|
|
// E.g. if a future npm version changes the output format, or if the
|
|
// network returns an HTML error page captured as stdout.
|
|
const r = checkLatestVersion({
|
|
spawn: () => ({ status: 0, stdout: '<html>not a version</html>\n', stderr: '' }),
|
|
});
|
|
assert.equal(r.ok, false);
|
|
assert.equal(r.reason, CHECK_REASON.FAIL_INVALID_OUTPUT);
|
|
});
|
|
|
|
test('FAIL_INVALID_OUTPUT when stdout is empty', () => {
|
|
const r = checkLatestVersion({
|
|
spawn: () => ({ status: 0, stdout: '', stderr: '' }),
|
|
});
|
|
assert.equal(r.ok, false);
|
|
assert.equal(r.reason, CHECK_REASON.FAIL_INVALID_OUTPUT);
|
|
});
|
|
|
|
test('accepts pre-release semver (e.g. 1.40.0-rc.1)', () => {
|
|
const r = checkLatestVersion({
|
|
spawn: () => ({ status: 0, stdout: '1.40.0-rc.1\n', stderr: '' }),
|
|
});
|
|
assert.deepEqual(r, { ok: true, version: '1.40.0-rc.1', reason: CHECK_REASON.OK });
|
|
});
|
|
});
|