* 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:
5
.changeset/1223-installer-fix-slash-commands-missing.md
Normal file
5
.changeset/1223-installer-fix-slash-commands-missing.md
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user