From dcb0d8a28d41c93af65bea8c6de972ab2a4bdd44 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Tue, 9 Jun 2026 12:37:26 -0400 Subject: [PATCH] fix(#935): install changeset CLI so /gsd-update changelog preview works (#938) - bin/install.js now copies scripts/changeset/ and scripts/lib/ into /scripts/ so $GSD_DIR/scripts/changeset/cli.cjs resolves at runtime; aborts install with an explicit failure if the source directory is missing from the package. - gsd-core/workflows/update.md: corrected path from gsd-core/scripts/changeset/cli.cjs to scripts/changeset/cli.cjs; added an explicit [ ! -f ] guard so a missing CLI surfaces a clear message rather than silently swallowing the error; stderr captured via 2>&1 sentinel so node errors are visible in the preview output. - release.yml's changeset-CLI invocations (node scripts/changeset/cli.cjs) remain at the repo-root path and are unaffected by this change. Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 --- .changeset/935-changeset-cli-install.md | 5 + bin/install.js | 122 +++++++++++++++++++++++ gsd-core/workflows/update.md | 35 ++++--- scripts/changeset/cli.cjs | 2 +- tests/changeset-cli.test.cjs | 43 ++++++++- tests/install.test.cjs | 123 ++++++++++++++++++++++++ 6 files changed, 311 insertions(+), 19 deletions(-) create mode 100644 .changeset/935-changeset-cli-install.md diff --git a/.changeset/935-changeset-cli-install.md b/.changeset/935-changeset-cli-install.md new file mode 100644 index 000000000..d18850c8a --- /dev/null +++ b/.changeset/935-changeset-cli-install.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 935 +--- +**`/gsd-update` changelog preview no longer silently fails** — the installer now copies `scripts/changeset/` and `scripts/lib/` into the runtime config dir so `$GSD_DIR/scripts/changeset/cli.cjs` resolves at runtime; `update.md` was updated to use the correct installed path and to surface an explicit error if the CLI is missing rather than swallowing it. diff --git a/bin/install.js b/bin/install.js index 37e26aebd..20865d067 100755 --- a/bin/install.js +++ b/bin/install.js @@ -8894,6 +8894,52 @@ function uninstall(isGlobal, runtime = 'claude') { } } + // 4a. Remove scripts/changeset/ and scripts/lib/ (#935) + // GSD-managed files only: enumerate the exact set the installer writes. + // Any file NOT in this set is user-owned and must survive uninstall. + // After removing GSD files, attempt to rmdir — if the directory is still + // non-empty (user has custom helpers) it stays; otherwise it goes cleanly. + const GSD_CHANGESET_FILES = [ + 'cli.cjs', 'parse.cjs', 'render.cjs', 'serialize.cjs', + 'github-release-notes.cjs', 'lint.cjs', 'new.cjs', + 'README.md', // documentation only — not user-authored + ]; + const GSD_SCRIPTS_LIB_FILES = ['cli-exit.cjs', 'allowlist-ratchet.cjs']; + + const changesetUninstallDir = path.join(targetDir, 'scripts', 'changeset'); + if (fs.existsSync(changesetUninstallDir)) { + let removedChangeset = 0; + for (const file of GSD_CHANGESET_FILES) { + const fp = path.join(changesetUninstallDir, file); + try { fs.unlinkSync(fp); removedChangeset++; } catch (_) { /* best-effort */ } + } + // Remove directory if empty after our cleanup + try { fs.rmdirSync(changesetUninstallDir); } catch (_) { /* Not empty — user content present */ } + if (removedChangeset > 0) { + removedCount++; + console.log(` ${green}✓${reset} Removed scripts/changeset/ GSD files`); + } + } + const scriptsLibUninstallDir = path.join(targetDir, 'scripts', 'lib'); + if (fs.existsSync(scriptsLibUninstallDir)) { + let removedScriptsLib = 0; + for (const file of GSD_SCRIPTS_LIB_FILES) { + const fp = path.join(scriptsLibUninstallDir, file); + try { fs.unlinkSync(fp); removedScriptsLib++; } catch (_) { /* best-effort */ } + } + // Remove directory if empty after our cleanup + try { fs.rmdirSync(scriptsLibUninstallDir); } catch (_) { /* Not empty — user content present */ } + if (removedScriptsLib > 0) { + removedCount++; + console.log(` ${green}✓${reset} Removed scripts/lib/ GSD files`); + } + } + // If scripts/ dir is now empty, remove it too + const scriptsUninstallDir = path.join(targetDir, 'scripts'); + if (fs.existsSync(scriptsUninstallDir)) { + try { fs.rmdirSync(scriptsUninstallDir); } catch (_) { /* Not empty — leave it */ } + } + // 5. Remove GSD package.json (CommonJS mode marker) const pkgJsonPath = path.join(targetDir, 'package.json'); if (fs.existsSync(pkgJsonPath)) { @@ -9577,6 +9623,24 @@ function writeManifest(configDir, runtime = 'claude', options = {}) { } } + // Track scripts/changeset/ and scripts/lib/ so saveLocalPatches() can detect drift + const changesetInstallDir = path.join(configDir, 'scripts', 'changeset'); + if (fs.existsSync(changesetInstallDir)) { + for (const file of fs.readdirSync(changesetInstallDir)) { + if (file.endsWith('.cjs')) { + manifest.files['scripts/changeset/' + file] = fileHash(path.join(changesetInstallDir, file)); + } + } + } + const scriptsLibInstallDir = path.join(configDir, 'scripts', 'lib'); + if (fs.existsSync(scriptsLibInstallDir)) { + for (const file of fs.readdirSync(scriptsLibInstallDir)) { + if (file.endsWith('.cjs')) { + manifest.files['scripts/lib/' + file] = fileHash(path.join(scriptsLibInstallDir, file)); + } + } + } + fs.writeFileSync(path.join(configDir, MANIFEST_NAME), JSON.stringify(manifest, null, 2)); return manifest; } @@ -10898,6 +10962,64 @@ function install(isGlobal, runtime = 'claude', options = {}) { console.log(` ${green}✓${reset} Installed hooks/lib/ helpers (git-cmd, graphify-rebuild, ...)`); } + // Install scripts/changeset/ and scripts/lib/ into /scripts/ + // so that `node "$GSD_DIR/scripts/changeset/cli.cjs"` resolves at runtime. + // + // The changeset CLI (scripts/changeset/cli.cjs) is invoked by the update + // workflow (gsd-core/workflows/update.md) to extract changelog ranges for + // the /gsd-update preview step. It was previously only present in the npm + // tarball root but never copied to the runtime config dir, causing the + // preview to always silently fail (#935). + // + // cli.cjs requires: + // - sibling files in scripts/changeset/ (parse/render/serialize/github-release-notes) + // - ../lib/cli-exit.cjs → scripts/lib/cli-exit.cjs + // - ../../gsd-core/bin/lib/semver-compare.cjs (already installed under gsd-core/) + // - ../../gsd-core/bin/lib/package-identity.cjs (already installed under gsd-core/) + // + // All runtimes that use the update workflow need this, so we copy unconditionally + // (same scope as gsd-core/ itself — every runtime that installs workflows gets it). + const changesetSrc = path.join(src, 'scripts', 'changeset'); + const scriptsLibSrc = path.join(src, 'scripts', 'lib'); + if (!fs.existsSync(changesetSrc)) { + // The changeset CLI source is missing from the package — mark as a hard failure + // so the user knows the changelog preview will not work rather than silently degrading. + failures.push('scripts/changeset/ (source missing from package — reinstall from npm)'); + } else { + const changesetDest = path.join(targetDir, 'scripts', 'changeset'); + const scriptsLibDest = path.join(targetDir, 'scripts', 'lib'); + fs.mkdirSync(changesetDest, { recursive: true }); + fs.mkdirSync(scriptsLibDest, { recursive: true }); + // Copy scripts/changeset/ — all .cjs and .md files + for (const entry of fs.readdirSync(changesetSrc)) { + const srcFile = path.join(changesetSrc, entry); + if (fs.statSync(srcFile).isFile()) { + fs.copyFileSync(srcFile, path.join(changesetDest, entry)); + } + } + // Copy scripts/lib/ — cli-exit.cjs (required by cli.cjs) and any future lib helpers. + // Hard-fail if missing: without cli-exit.cjs the installed CLI throws MODULE_NOT_FOUND. + if (!fs.existsSync(scriptsLibSrc)) { + failures.push('scripts/lib/ (source missing from package — reinstall from npm)'); + } else { + for (const entry of fs.readdirSync(scriptsLibSrc)) { + const srcFile = path.join(scriptsLibSrc, entry); + if (fs.statSync(srcFile).isFile()) { + fs.copyFileSync(srcFile, path.join(scriptsLibDest, entry)); + } + } + // Verify the critical dep cli-exit.cjs landed + if (!verifyFileInstalled(path.join(scriptsLibDest, 'cli-exit.cjs'), 'scripts/lib/cli-exit.cjs')) { + failures.push('scripts/lib/cli-exit.cjs'); + } + } + if (verifyFileInstalled(path.join(changesetDest, 'cli.cjs'), 'scripts/changeset/cli.cjs')) { + console.log(` ${green}✓${reset} Installed scripts/changeset/ (changelog preview CLI)`); + } else { + failures.push('scripts/changeset/cli.cjs'); + } + } + // Remove legacy get-shit-done-cc artifacts and stale update caches (#607). // cleanupLegacyGsdCc handles both the legacy shared cache and the per-package // cache (formerly an inline unlinkSync here). A cleanup failure must never diff --git a/gsd-core/workflows/update.md b/gsd-core/workflows/update.md index 4ff98df1c..16ffb9be0 100644 --- a/gsd-core/workflows/update.md +++ b/gsd-core/workflows/update.md @@ -195,24 +195,29 @@ CHANGELOG_TMP="/tmp/gsd-changelog-$$.md" curl -fsSL "https://raw.githubusercontent.com/open-gsd/gsd-core/main/CHANGELOG.md" -o "$CHANGELOG_TMP" 2>/dev/null \ || wget -qO "$CHANGELOG_TMP" "https://raw.githubusercontent.com/open-gsd/gsd-core/main/CHANGELOG.md" 2>/dev/null -EXTRACT_JSON=$(node "$GSD_DIR/gsd-core/scripts/changeset/cli.cjs" extract \ - --from "$INSTALLED_VERSION" \ - --to "$LATEST_VERSION" \ - --changelog "$CHANGELOG_TMP" \ - --json 2>/dev/null) -EXTRACT_EXIT=$? - -if [ "$EXTRACT_EXIT" -eq 2 ]; then - # Exit 2 = no releases in range (e.g. versions are equal or changelog is sparse) - CHANGELOG_PREVIEW="No changelog updates between v${INSTALLED_VERSION} and v${LATEST_VERSION}." -elif [ "$EXTRACT_EXIT" -ne 0 ] || [ -z "$EXTRACT_JSON" ]; then - CHANGELOG_PREVIEW="(Could not extract changelog — update will still proceed)" +GSD_CHANGESET_CLI="$GSD_DIR/scripts/changeset/cli.cjs" +if [ ! -f "$GSD_CHANGESET_CLI" ]; then + CHANGELOG_PREVIEW="(Changelog CLI not found at $GSD_CHANGESET_CLI — reinstall GSD to restore preview. Update will still proceed.)" else - # Re-run without --json to get the human-readable markdown for display - CHANGELOG_PREVIEW=$(node "$GSD_DIR/gsd-core/scripts/changeset/cli.cjs" extract \ + EXTRACT_JSON=$(node "$GSD_CHANGESET_CLI" extract \ --from "$INSTALLED_VERSION" \ --to "$LATEST_VERSION" \ - --changelog "$CHANGELOG_TMP" 2>/dev/null || echo "(changelog unavailable)") + --changelog "$CHANGELOG_TMP" \ + --json 2>&1) + EXTRACT_EXIT=$? + + if [ "$EXTRACT_EXIT" -eq 2 ]; then + # Exit 2 = no releases in range (e.g. versions are equal or changelog is sparse) + CHANGELOG_PREVIEW="No changelog updates between v${INSTALLED_VERSION} and v${LATEST_VERSION}." + elif [ "$EXTRACT_EXIT" -ne 0 ] || [ -z "$EXTRACT_JSON" ]; then + CHANGELOG_PREVIEW="(Could not extract changelog — update will still proceed)" + else + # Re-run without --json to get the human-readable markdown for display + CHANGELOG_PREVIEW=$(node "$GSD_CHANGESET_CLI" extract \ + --from "$INSTALLED_VERSION" \ + --to "$LATEST_VERSION" \ + --changelog "$CHANGELOG_TMP" 2>/dev/null || echo "(changelog unavailable)") + fi fi # Clean up temp changelog now that both extract runs are done rm -f "$CHANGELOG_TMP" diff --git a/scripts/changeset/cli.cjs b/scripts/changeset/cli.cjs index 92fc65af8..9ddcb667c 100755 --- a/scripts/changeset/cli.cjs +++ b/scripts/changeset/cli.cjs @@ -324,7 +324,7 @@ function resolveChangelogPath(opts) { * 1 — I/O error or missing required flags. * * Fix for #3496: provides a deterministic range-aware helper so the - * `/gsd:update` show_changes_and_confirm step no longer relies on + * `/gsd-update` show_changes_and_confirm step no longer relies on * vague/manual extraction that can silently skip intermediate versions. */ function cmdExtract(opts) { diff --git a/tests/changeset-cli.test.cjs b/tests/changeset-cli.test.cjs index 72b80c91e..831857c13 100644 --- a/tests/changeset-cli.test.cjs +++ b/tests/changeset-cli.test.cjs @@ -332,10 +332,13 @@ describe('changeset cli extract: version-range changelog extraction (#3496)', () test('F1: workflows/update.md contains concrete extract subcommand invocation', (_t) => { const workflowPath = path.join(ROOT, 'gsd-core', 'workflows', 'update.md'); const workflowText = fs.readFileSync(workflowPath, 'utf8'); - // The invocation is: node "$GSD_DIR/gsd-core/scripts/changeset/cli.cjs" extract - // so the literal substring is 'cli.cjs" extract' (quote between script path and subcommand) + // The invocation uses either a direct path or an intermediate variable: + // node "$GSD_DIR/scripts/changeset/cli.cjs" extract + // node "$GSD_CHANGESET_CLI" extract + // Accept either form so future refactors don't immediately trip this anchor. assert.ok( - workflowText.includes('cli.cjs" extract') || workflowText.includes('cli.cjs extract'), + workflowText.includes('cli.cjs" extract') || workflowText.includes('cli.cjs extract') || + (workflowText.includes('GSD_CHANGESET_CLI') && workflowText.includes('" extract')), 'update.md must invoke cli.cjs extract (fix for #3496 BLOCKER 1)', ); assert.ok( @@ -351,6 +354,40 @@ describe('changeset cli extract: version-range changelog extraction (#3496)', () 'update.md must capture exit code or JSON output from extract', ); }); + + // F2: update.md must use the INSTALLED path ($GSD_DIR/scripts/changeset/cli.cjs), + // NOT the old broken path ($GSD_DIR/gsd-core/scripts/changeset/cli.cjs). + // The installer copies scripts/changeset/ into /scripts/changeset/, + // so the runtime path is $GSD_DIR/scripts/changeset/cli.cjs (#935). + // allow-test-rule: reads a product workflow .md file (not CJS source) to verify + // the runtime install path contract; there is no behavioural runtime to invoke. + test('F2: update.md CLI path is $GSD_DIR/scripts/changeset/cli.cjs (not gsd-core/scripts/…) (#935)', (_t) => { + const workflowPath = path.join(ROOT, 'gsd-core', 'workflows', 'update.md'); + const workflowText = fs.readFileSync(workflowPath, 'utf8'); + // The correct installed path must appear somewhere in the update workflow + assert.ok( + workflowText.includes('scripts/changeset/cli.cjs'), + 'update.md must reference scripts/changeset/cli.cjs', + ); + // The old broken path ($GSD_DIR/gsd-core/scripts/changeset/cli.cjs) must not appear + assert.ok( + !workflowText.includes('gsd-core/scripts/changeset/cli.cjs'), + 'update.md must NOT reference the old gsd-core/scripts/changeset/cli.cjs path (fix for #935)', + ); + }); + + // F3: update.md must guard against the CLI being missing (not pure silent-swallow) + // allow-test-rule: reads a product workflow .md file (not CJS source) to verify + // the guard is present; there is no behavioural runtime to invoke. + test('F3: update.md has an explicit guard when changeset CLI is missing (#935)', (_t) => { + const workflowPath = path.join(ROOT, 'gsd-core', 'workflows', 'update.md'); + const workflowText = fs.readFileSync(workflowPath, 'utf8'); + // The workflow must check for CLI existence before invoking it + assert.ok( + workflowText.includes('GSD_CHANGESET_CLI') && workflowText.includes('! -f'), + 'update.md must guard against a missing changeset CLI with [ ! -f "$GSD_CHANGESET_CLI" ] (#935)', + ); + }); }); describe('changeset cli render: file-I/O wrapper (#2975)', () => { diff --git a/tests/install.test.cjs b/tests/install.test.cjs index 733baa6c7..7ecc72884 100644 --- a/tests/install.test.cjs +++ b/tests/install.test.cjs @@ -707,3 +707,126 @@ describe('Kilo source integration assertions', () => { assert.ok(updateContextSrc.includes('KILO_CONFIG')); }); }); + +// ─── Section N: changeset CLI install regression (#935) ────────────────────── + +describe('install — changeset CLI lands at scripts/changeset/cli.cjs (#935)', () => { + // Regression guard: the changeset CLI must be copied into the runtime config dir + // by the installer so $GSD_DIR/scripts/changeset/cli.cjs resolves at runtime. + // Before this fix, scripts/ was never copied and /gsd-update changelog preview + // silently failed on every real install. + let tmpDir; + let previousCwd; + + beforeEach(() => { + tmpDir = createTempDir('gsd-changeset-install-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('install() copies scripts/changeset/cli.cjs to /scripts/changeset/cli.cjs', () => { + install(false, 'claude'); + const claudeDir = path.join(tmpDir, '.claude'); + const cliPath = path.join(claudeDir, 'scripts', 'changeset', 'cli.cjs'); + assert.ok( + fs.existsSync(cliPath), + `scripts/changeset/cli.cjs must exist at ${path.relative(tmpDir, cliPath)} after install (#935)`, + ); + }); + + test('install() copies scripts/lib/cli-exit.cjs to /scripts/lib/cli-exit.cjs', () => { + install(false, 'claude'); + const claudeDir = path.join(tmpDir, '.claude'); + const cliExitPath = path.join(claudeDir, 'scripts', 'lib', 'cli-exit.cjs'); + assert.ok( + fs.existsSync(cliExitPath), + `scripts/lib/cli-exit.cjs must exist at ${path.relative(tmpDir, cliExitPath)} after install (#935)`, + ); + }); + + test('installed cli.cjs executes without module-resolution errors', () => { + // Smoke test: node can load the installed changeset CLI without crashing. + // This catches path mismatches in require('../lib/cli-exit.cjs') etc. + install(false, 'claude'); + const claudeDir = path.join(tmpDir, '.claude'); + const cliPath = path.join(claudeDir, 'scripts', 'changeset', 'cli.cjs'); + const { spawnSync } = require('node:child_process'); + const result = spawnSync(process.execPath, [cliPath, '--help'], { encoding: 'utf8' }); + // --help exits with code 1 (usage shown), but must NOT throw a MODULE_NOT_FOUND error + assert.ok( + !result.stderr.includes('MODULE_NOT_FOUND'), + `cli.cjs must not produce MODULE_NOT_FOUND errors; stderr=${result.stderr}`, + ); + assert.ok( + !result.stderr.includes('Cannot find module'), + `cli.cjs must resolve all modules; stderr=${result.stderr}`, + ); + }); + + test('installed cli.cjs can run extract subcommand end-to-end (#935)', () => { + // Integration smoke test: the installed CLI's extract path (invoked by update.md) + // must actually work — this catches require() path issues that --help wouldn't surface. + install(false, 'claude'); + const claudeDir = path.join(tmpDir, '.claude'); + const cliPath = path.join(claudeDir, 'scripts', 'changeset', 'cli.cjs'); + // Use the CHANGELOG.md that was installed into gsd-core/ (installed by the installer) + const changelogPath = path.join(claudeDir, 'gsd-core', 'CHANGELOG.md'); + assert.ok(fs.existsSync(changelogPath), 'CHANGELOG.md must be installed under gsd-core/'); + const { spawnSync } = require('node:child_process'); + const result = spawnSync( + process.execPath, + [cliPath, 'extract', '--from', '0.0.0', '--to', '9999.0.0', '--changelog', changelogPath, '--json'], + { encoding: 'utf8' }, + ); + // extract must NOT throw a MODULE_NOT_FOUND or Cannot find module error + assert.ok( + !result.stderr.includes('MODULE_NOT_FOUND') && !result.stderr.includes('Cannot find module'), + `installed cli.cjs extract must resolve all modules; stderr=${result.stderr}`, + ); + // extract exit code 0 (found entries) or 2 (no entries in range) are both valid; + // any other exit code is an error + assert.ok( + result.status === 0 || result.status === 2, + `installed cli.cjs extract must exit 0 or 2; got ${result.status}; stderr=${result.stderr}`, + ); + }); + + test('writeManifest() tracks scripts/changeset/ and scripts/lib/ files', () => { + install(false, 'claude'); + const claudeDir = path.join(tmpDir, '.claude'); + const manifest = writeManifest(claudeDir, 'claude'); + const changesetKeys = Object.keys(manifest.files).filter(k => k.startsWith('scripts/changeset/')); + const libKeys = Object.keys(manifest.files).filter(k => k.startsWith('scripts/lib/')); + assert.ok(changesetKeys.length > 0, 'manifest must track scripts/changeset/ files'); + assert.ok(libKeys.length > 0, 'manifest must track scripts/lib/ files'); + assert.ok( + changesetKeys.includes('scripts/changeset/cli.cjs'), + 'manifest must include scripts/changeset/cli.cjs', + ); + assert.ok( + libKeys.includes('scripts/lib/cli-exit.cjs'), + 'manifest must include scripts/lib/cli-exit.cjs', + ); + }); + + test('uninstall() removes scripts/changeset/ and scripts/lib/', () => { + install(false, 'claude'); + const claudeDir = path.join(tmpDir, '.claude'); + assert.ok(fs.existsSync(path.join(claudeDir, 'scripts', 'changeset', 'cli.cjs')), + 'pre-condition: cli.cjs must be installed before uninstall'); + uninstall(false, 'claude'); + assert.ok( + !fs.existsSync(path.join(claudeDir, 'scripts', 'changeset')), + 'scripts/changeset/ must be removed on uninstall', + ); + assert.ok( + !fs.existsSync(path.join(claudeDir, 'scripts', 'lib')), + 'scripts/lib/ must be removed on uninstall', + ); + }); +});