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 <configDir>/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 <noreply@anthropic.com>

* chore(#1223): backfill changeset PR number (#1240)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-14 16:03:26 -04:00
committed by GitHub
parent 0b3a2e5f9c
commit 00acbc8868
5 changed files with 208 additions and 3 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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 <configDir>/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
// <tmpRoot>/scripts/ — making COMMANDS_DIR = path.join(__dirname,'..','commands','gsd')
// resolve to <tmpRoot>/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: <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') = <tmpRoot>/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