fix(#935): install changeset CLI so /gsd-update changelog preview works (#938)

- bin/install.js now copies scripts/changeset/ and scripts/lib/ into
  <configDir>/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 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-09 12:37:26 -04:00
committed by GitHub
parent 79254478fe
commit dcb0d8a28d
6 changed files with 311 additions and 19 deletions

View File

@@ -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 <configDir>/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)', () => {

View File

@@ -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 <configDir>/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 <configDir>/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',
);
});
});