From 00acbc8868482658ef1ac4ae6fdc1365019add2b Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 14 Jun 2026 16:03:26 -0400 Subject: [PATCH] fix(#1223): install scripts/fix-slash-commands.cjs so gsd-tools loads (#1240) * fix(#1223): install scripts/fix-slash-commands.cjs so gsd-tools loads Before this fix, bin/install.js copied scripts/changeset/ and scripts/lib/ into the runtime config dir but omitted scripts/fix-slash-commands.cjs. gsd-core/bin/lib/command-roster.cjs requires this file at module load via require('../../../scripts/fix-slash-commands.cjs'), so every gsd-tools command crashed with MODULE_NOT_FOUND on every installed runtime. Four changes: - bin/install.js copy step: copy fix-slash-commands.cjs into /scripts/ with source-missing hard-fail and verifyFileInstalled smoke check - bin/install.js writeManifest: track scripts/fix-slash-commands.cjs (not covered by the changeset/lib subdir loops) - bin/install.js uninstall: best-effort unlinkSync before scripts/ rmdir - scripts/fix-slash-commands.cjs readCmdNames(): wrap readdirSync in try/catch returning [] so skill-based/global installs without a local commands/gsd/ directory do not throw ENOENT Tests added to tests/install.test.cjs (6 new tests): - smoke: install() copies fix-slash-commands.cjs - e2e: spawned gsd-tools.cjs does not crash with MODULE_NOT_FOUND - manifest: writeManifest() tracks the file - uninstall: uninstall() removes the file - readCmdNames unit: export returns an array - readCmdNames spawn: absent COMMANDS_DIR returns exit 0 (no throw) Co-Authored-By: Claude Opus 4.8 * chore(#1223): backfill changeset PR number (#1240) Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- ...23-installer-fix-slash-commands-missing.md | 5 + bin/install.js | 29 ++++ scripts/fix-slash-commands.cjs | 18 ++- ...g-376-claude-js-hook-gsd-rewriter.test.cjs | 13 ++ tests/install.test.cjs | 146 ++++++++++++++++++ 5 files changed, 208 insertions(+), 3 deletions(-) create mode 100644 .changeset/1223-installer-fix-slash-commands-missing.md diff --git a/.changeset/1223-installer-fix-slash-commands-missing.md b/.changeset/1223-installer-fix-slash-commands-missing.md new file mode 100644 index 000000000..9203eb428 --- /dev/null +++ b/.changeset/1223-installer-fix-slash-commands-missing.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 1240 +--- +**gsd-tools no longer crashes at load on a fresh install** — the installer omitted `scripts/fix-slash-commands.cjs`, which `command-roster` requires at module load, so every `gsd-tools` command failed with MODULE_NOT_FOUND. The installer now ships it (with a smoke assertion), and `readCmdNames()` tolerates a missing commands directory. (#1240) diff --git a/bin/install.js b/bin/install.js index 249df659c..c41360461 100755 --- a/bin/install.js +++ b/bin/install.js @@ -8314,6 +8314,10 @@ function uninstall(isGlobal, runtime = 'claude') { console.log(` ${green}✓${reset} Removed scripts/lib/ GSD files`); } } + // Remove scripts/fix-slash-commands.cjs (#1223) — must come before the scripts/ rmdir + const fixSlashUninstallPath = path.join(targetDir, 'scripts', 'fix-slash-commands.cjs'); + try { fs.unlinkSync(fixSlashUninstallPath); } catch (_) { /* best-effort */ } + // If scripts/ dir is now empty, remove it too const scriptsUninstallDir = path.join(targetDir, 'scripts'); if (fs.existsSync(scriptsUninstallDir)) { @@ -9029,6 +9033,12 @@ function writeManifest(configDir, runtime = 'claude', options = {}) { } } + // Track scripts/fix-slash-commands.cjs (top-level scripts/ file, not covered by changeset/lib loops) + const fixSlashInstallPath = path.join(configDir, 'scripts', 'fix-slash-commands.cjs'); + if (fs.existsSync(fixSlashInstallPath)) { + manifest.files['scripts/fix-slash-commands.cjs'] = fileHash(fixSlashInstallPath); + } + fs.writeFileSync(path.join(configDir, MANIFEST_NAME), JSON.stringify(manifest, null, 2)); return manifest; } @@ -10421,6 +10431,25 @@ function install(isGlobal, runtime = 'claude', options = {}) { } } + // Copy scripts/fix-slash-commands.cjs — required by gsd-core/bin/lib/command-roster.cjs + // at load time via require('../../../scripts/fix-slash-commands.cjs'). Without this file + // every gsd-tools command crashes with MODULE_NOT_FOUND (#1223). + // This copy is independent of scripts/changeset/ — it must land even when the + // changeset CLI source is absent. + { + const fixSlashSrc = path.join(src, 'scripts', 'fix-slash-commands.cjs'); + const fixSlashDest = path.join(targetDir, 'scripts', 'fix-slash-commands.cjs'); + fs.mkdirSync(path.join(targetDir, 'scripts'), { recursive: true }); + if (!fs.existsSync(fixSlashSrc)) { + failures.push('scripts/fix-slash-commands.cjs (source missing from package — reinstall from npm)'); + } else { + fs.copyFileSync(fixSlashSrc, fixSlashDest); + if (!verifyFileInstalled(fixSlashDest, 'scripts/fix-slash-commands.cjs')) { + failures.push('scripts/fix-slash-commands.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/scripts/fix-slash-commands.cjs b/scripts/fix-slash-commands.cjs index 64216a0fb..7f9bf15ac 100644 --- a/scripts/fix-slash-commands.cjs +++ b/scripts/fix-slash-commands.cjs @@ -92,9 +92,21 @@ function transformContentToHyphen(src, cmdNames) { } function readCmdNames() { - return fs.readdirSync(COMMANDS_DIR) - .filter(f => f.endsWith('.md')) - .map(f => f.replace(/\.md$/, '')); + try { + return fs.readdirSync(COMMANDS_DIR) + .filter(f => f.endsWith('.md')) + .map(f => f.replace(/\.md$/, '')); + } catch (err) { + // Only swallow the missing-directory case. Any other error (EACCES, ENOTDIR, + // etc.) indicates a real misconfiguration and must propagate so callers are + // not silently handed an empty registry while the real problem goes undetected. + if (err.code !== 'ENOENT') throw err; + // COMMANDS_DIR may not exist on installs that use skill-based runtimes or + // global Claude installs (no local commands/gsd/ directory). Return [] so + // callers that handle an empty array gracefully (buildPattern returns null, + // transformContent is a no-op) are not broken by a missing directory. + return []; + } } function processFile(file, cmdNames) { diff --git a/tests/bug-376-claude-js-hook-gsd-rewriter.test.cjs b/tests/bug-376-claude-js-hook-gsd-rewriter.test.cjs index 22ea002be..8c5d730f0 100644 --- a/tests/bug-376-claude-js-hook-gsd-rewriter.test.cjs +++ b/tests/bug-376-claude-js-hook-gsd-rewriter.test.cjs @@ -241,6 +241,17 @@ describe('bug #376 — Suite 2: Cursor install still rewrites /gsd: → /gsd- (r const cursorDir = path.join(tmpDir, '.cursor'); if (!fs.existsSync(cursorDir)) return; + // Infrastructure files whose /gsd: occurrences are intentional implementation + // details — NOT user-facing command references that Cursor would invoke. + // + // scripts/fix-slash-commands.cjs is the slash-command rewriter engine, required + // by gsd-core/bin/lib/command-roster.cjs on ALL runtimes (including Cursor). + // It must be installed verbatim and must NOT be content-rewritten: it needs to + // emit `/gsd:${cmd}` for non-Cursor runtimes, and its /gsd: strings are internal + // implementation/docs (transform patterns, regex literals, template literals), + // not commands a Cursor user would type. Rewriting it would corrupt the transformer. + const INFRA_BASENAMES = new Set(['fix-slash-commands.cjs']); + const jsFiles = findJsFiles(cursorDir); // Cursor may not install any .js files depending on what agent/skill content exists; // if none, skip gracefully. @@ -248,6 +259,8 @@ describe('bug #376 — Suite 2: Cursor install still rewrites /gsd: → /gsd- (r const offenders = []; for (const { rel, full } of jsFiles) { + // Skip infrastructure files whose /gsd: strings are intentional (see above). + if (INFRA_BASENAMES.has(path.basename(full))) continue; const content = fs.readFileSync(full, 'utf-8'); const badLines = colonRefs(content); if (badLines.length > 0) { diff --git a/tests/install.test.cjs b/tests/install.test.cjs index dbe522d5f..a2c84ad2c 100644 --- a/tests/install.test.cjs +++ b/tests/install.test.cjs @@ -947,6 +947,152 @@ describe('install — changeset CLI lands at scripts/changeset/cli.cjs (#935)', }); }); +// ─── Section N: fix-slash-commands.cjs install regression (#1223) ─────────────── + +describe('install — fix-slash-commands.cjs lands at scripts/fix-slash-commands.cjs (#1223)', () => { + // Regression guard: scripts/fix-slash-commands.cjs must be copied into the runtime + // config dir by the installer so gsd-core/bin/lib/command-roster.cjs can require it + // via '../../../scripts/fix-slash-commands.cjs'. Before this fix, the file was never + // installed and every gsd-tools command crashed with MODULE_NOT_FOUND (#1223). + let tmpDir; + let previousCwd; + + beforeEach(() => { + tmpDir = createTempDir('gsd-fix-slash-install-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('install() copies scripts/fix-slash-commands.cjs to /scripts/fix-slash-commands.cjs', () => { + install(false, 'claude'); + const claudeDir = path.join(tmpDir, '.claude'); + const fixSlashPath = path.join(claudeDir, 'scripts', 'fix-slash-commands.cjs'); + assert.ok( + fs.existsSync(fixSlashPath), + `scripts/fix-slash-commands.cjs must exist at ${path.relative(tmpDir, fixSlashPath)} after install (#1223)`, + ); + }); + + test('installed gsd-tools.cjs query loads without MODULE_NOT_FOUND (#1223)', () => { + // End-to-end smoke: spawning gsd-tools.cjs must not crash with MODULE_NOT_FOUND. + // This directly exercises the command-roster → fix-slash-commands require chain. + install(false, 'claude'); + const claudeDir = path.join(tmpDir, '.claude'); + const gsdToolsPath = path.join(claudeDir, 'gsd-core', 'bin', 'gsd-tools.cjs'); + assert.ok(fs.existsSync(gsdToolsPath), 'pre-condition: gsd-tools.cjs must be installed'); + const { spawnSync } = require('node:child_process'); + const result = spawnSync( + process.execPath, + [gsdToolsPath, 'query', 'init.new-project'], + { encoding: 'utf8', timeout: 15000 }, + ); + assert.ok( + !result.stderr.includes('MODULE_NOT_FOUND'), + `gsd-tools.cjs must not crash with MODULE_NOT_FOUND; stderr=${result.stderr}`, + ); + assert.ok( + !result.stderr.includes('Cannot find module'), + `gsd-tools.cjs must resolve all modules; stderr=${result.stderr}`, + ); + }); + + test('writeManifest() tracks scripts/fix-slash-commands.cjs', () => { + install(false, 'claude'); + const claudeDir = path.join(tmpDir, '.claude'); + const manifest = writeManifest(claudeDir, 'claude'); + assert.ok( + 'scripts/fix-slash-commands.cjs' in manifest.files, + 'manifest must track scripts/fix-slash-commands.cjs', + ); + }); + + test('uninstall() removes scripts/fix-slash-commands.cjs', () => { + install(false, 'claude'); + const claudeDir = path.join(tmpDir, '.claude'); + const fixSlashPath = path.join(claudeDir, 'scripts', 'fix-slash-commands.cjs'); + assert.ok(fs.existsSync(fixSlashPath), + 'pre-condition: fix-slash-commands.cjs must be installed before uninstall'); + uninstall(false, 'claude'); + assert.ok( + !fs.existsSync(fixSlashPath), + 'scripts/fix-slash-commands.cjs must be removed on uninstall', + ); + }); +}); + +// ─── Section N: readCmdNames() tolerates absent commands/gsd/ dir (#1223) ──────── + +describe('readCmdNames() — tolerates missing commands/gsd directory (#1223)', () => { + // Regression guard: on installs where commands/gsd/ does not exist (e.g. skill-based + // or global Claude installs) readCmdNames() must return [] rather than throwing ENOENT. + test('readCmdNames() returns an array (does not throw)', () => { + // Verify the guard contract: readCmdNames() must always return an array regardless + // of whether COMMANDS_DIR exists. The spawn-based test below covers the absent-dir + // scenario; this inline test asserts the basic export shape. + const fixSlashModule = require('../scripts/fix-slash-commands.cjs'); + const result = fixSlashModule.readCmdNames(); + assert.ok(Array.isArray(result), 'readCmdNames() must return an array'); + }); + + test('readCmdNames() returns [] from a context where commands/gsd/ is absent', () => { + // Genuine absent-dir test: copy fix-slash-commands.cjs into a fresh temp directory + // under a scripts/ subdirectory so that __dirname inside the copy points to + // /scripts/ — making COMMANDS_DIR = path.join(__dirname,'..','commands','gsd') + // resolve to /commands/gsd, which does NOT exist. Requiring the copy (not + // the repo original) exercises the real ENOENT guard rather than silently hitting + // the repo's live 69-command registry. + // + // This test MUST fail on a pre-fix build (unguarded readdirSync throws ENOENT) and + // pass after (ENOENT-specific catch returns []). + const { spawnSync } = require('node:child_process'); + const absScriptsSrc = path.resolve(__dirname, '..', 'scripts', 'fix-slash-commands.cjs'); + + // Build a clean tmpRoot: /scripts/fix-slash-commands.cjs + // No commands/gsd/ exists anywhere under or adjacent to tmpRoot. + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-readcmdnames-absentdir-')); + try { + const tmpScriptsDir = path.join(tmpRoot, 'scripts'); + fs.mkdirSync(tmpScriptsDir, { recursive: true }); + const tmpCopyPath = path.join(tmpScriptsDir, 'fix-slash-commands.cjs'); + fs.copyFileSync(absScriptsSrc, tmpCopyPath); + + // Script: require the COPY (not the repo original) so __dirname === tmpScriptsDir + // → COMMANDS_DIR = path.join(tmpScriptsDir, '..', 'commands', 'gsd') = /commands/gsd + // which does not exist → must return [] without throwing. + const script = [ + `'use strict';`, + `const mod = require(${JSON.stringify(tmpCopyPath)});`, + `let result;`, + `try { result = mod.readCmdNames(); } catch(e) { process.stderr.write('THREW:' + e.code + ':' + e.message); process.exit(2); }`, + `if (!Array.isArray(result)) { process.stderr.write('NOT_ARRAY:' + JSON.stringify(result)); process.exit(3); }`, + `if (result.length !== 0) { process.stderr.write('EXPECTED_EMPTY:got ' + result.length + ' entries'); process.exit(4); }`, + `// readCmdNames() returned [] as required — success`, + `process.exit(0);`, + ].join('\n'); + + const spawnResult = spawnSync(process.execPath, ['-e', script], { + encoding: 'utf8', + timeout: 10000, + env: { ...process.env, GSD_TEST_MODE: '1' }, + }); + assert.ok( + !spawnResult.stderr.includes('THREW:'), + `readCmdNames() must not throw when commands/gsd/ is absent; stderr=${spawnResult.stderr}`, + ); + assert.strictEqual(spawnResult.status, 0, + `readCmdNames() must return [] (exit 0) when commands/gsd/ is absent; ` + + `status=${spawnResult.status} stderr=${spawnResult.stderr}`); + } finally { + cleanup(tmpRoot); + } + }); +}); + // ─── Section N: Antigravity .agents canonical workspace dir (#791) ───────────── // allow-test-rule: runtime-contract-is-the-product // Reads deployed agent .md files whose text IS the product surface the