* 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>
160 lines
5.9 KiB
JavaScript
160 lines
5.9 KiB
JavaScript
'use strict';
|
|
/**
|
|
* One-shot script + library: bidirectional GSD slash-command namespace normalizer.
|
|
*
|
|
* - Default direction (transformContent): retired /gsd-<cmd> → /gsd:<cmd>
|
|
* (keeps monorepo sources, docs, and workflows in the active colon form).
|
|
* - Reverse direction (transformContentToHyphen): /gsd:<cmd> / gsd:<cmd> → gsd-<cmd>
|
|
* (used during skill installation for runtimes that register skills under the
|
|
* canonical hyphen form established in #2808).
|
|
*
|
|
* Both directions only rewrite known commands from `commands/gsd/*.md` (longest-first
|
|
* matching + word-boundary safety). Non-commands (gsd-sdk, gsd-tools, etc.) are
|
|
* intentionally left untouched.
|
|
*
|
|
* The transforms are pure and exported for use by the installer and tests.
|
|
*/
|
|
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
|
|
const SEARCH_DIRS = [
|
|
path.join(__dirname, '..', 'gsd-core', 'bin', 'lib'),
|
|
path.join(__dirname, '..', 'gsd-core', 'workflows'),
|
|
path.join(__dirname, '..', 'gsd-core', 'references'),
|
|
path.join(__dirname, '..', 'gsd-core', 'templates'),
|
|
path.join(__dirname, '..', 'gsd-core', 'contexts'),
|
|
path.join(__dirname, '..', 'commands', 'gsd'),
|
|
path.join(__dirname, '..', 'agents'),
|
|
path.join(__dirname, '..', 'hooks'),
|
|
];
|
|
|
|
const TOP_LEVEL_FILES = [
|
|
path.join(__dirname, '..', '.clinerules'),
|
|
];
|
|
|
|
const SKIP_DIRS = new Set(['node_modules', 'dist', '.turbo']);
|
|
const EXTENSIONS = new Set(['.md', '.cjs', '.js', '.ts', '.tsx']);
|
|
|
|
// Test files contain intentional fixture strings (e.g. inputs the sanitizer
|
|
// is expected to strip). Rewriting them changes test semantics.
|
|
function isTestFile(name) {
|
|
return /\.test\.(c?js|tsx?)$/.test(name);
|
|
}
|
|
|
|
function buildPattern(cmdNames) {
|
|
// Empty input would compile `/gsd-()(?=[^a-zA-Z0-9_-]|$)/g`, which the regex
|
|
// engine still matches at any `/gsd-` token followed by a non-word boundary
|
|
// (e.g. EOL, whitespace, punctuation) — rewriting it to a stray `/gsd:`.
|
|
// Short-circuit so the caller can no-op on a missing/empty registry rather
|
|
// than perform an unintended broad rewrite.
|
|
if (!Array.isArray(cmdNames) || cmdNames.length === 0) return null;
|
|
const sorted = [...cmdNames].sort((a, b) => b.length - a.length); // longest first to avoid partial matches
|
|
return new RegExp(`/gsd-(${sorted.join('|')})(?=[^a-zA-Z0-9_-]|$)`, 'g');
|
|
}
|
|
|
|
/**
|
|
* Pure transform: rewrite retired `/gsd-<cmd>` to `/gsd:<cmd>` for the given command names.
|
|
* Returns the rewritten string. Identifiers not in `cmdNames` (e.g. `/gsd-sdk`,
|
|
* `/gsd-tools`) are left untouched.
|
|
*/
|
|
function transformContent(src, cmdNames) {
|
|
const pattern = buildPattern(cmdNames);
|
|
if (!pattern) return src;
|
|
return src.replace(pattern, (_, cmd) => `/gsd:${cmd}`);
|
|
}
|
|
|
|
/**
|
|
* Build regex for the reverse direction (colon form → hyphen form).
|
|
* Matches both "gsd:cmd" and "/gsd:cmd" (the leading / is preserved automatically
|
|
* because it is not part of the match). Uses longest-first ordering plus
|
|
* bidirectional word-boundary safety (negative lookbehind on the left, lookahead
|
|
* on the right) so matches only occur at token boundaries.
|
|
*/
|
|
function buildColonPattern(cmdNames) {
|
|
if (!Array.isArray(cmdNames) || cmdNames.length === 0) return null;
|
|
const sorted = [...cmdNames].sort((a, b) => b.length - a.length);
|
|
return new RegExp(`(?<![a-zA-Z0-9_-])gsd:(${sorted.join('|')})(?=[^a-zA-Z0-9_-]|$)`, 'g');
|
|
}
|
|
|
|
/**
|
|
* Pure transform (reverse): rewrite `/gsd:<cmd>` / `gsd:<cmd>` to hyphen form
|
|
* for known GSD commands.
|
|
*
|
|
* Non-command identifiers (e.g. gsd-sdk, gsd-tools) are left untouched, matching
|
|
* the safety contract of the forward transform.
|
|
*/
|
|
function transformContentToHyphen(src, cmdNames) {
|
|
const pattern = buildColonPattern(cmdNames);
|
|
if (!pattern) return src;
|
|
return src.replace(pattern, (_, cmd) => `gsd-${cmd}`);
|
|
}
|
|
|
|
function readCmdNames() {
|
|
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) {
|
|
const pattern = buildPattern(cmdNames);
|
|
if (!pattern) return;
|
|
let src;
|
|
try { src = fs.readFileSync(file, 'utf-8'); } catch { return; }
|
|
const replaced = transformContent(src, cmdNames);
|
|
if (replaced !== src) {
|
|
fs.writeFileSync(file, replaced, 'utf-8');
|
|
const count = (src.match(pattern) || []).length;
|
|
console.log(` ${count} replacements: ${path.relative(path.join(__dirname, '..'), file)}`);
|
|
}
|
|
}
|
|
|
|
function processDir(dir, cmdNames) {
|
|
const pattern = buildPattern(cmdNames);
|
|
if (!pattern) return;
|
|
let entries;
|
|
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
for (const e of entries) {
|
|
const full = path.join(dir, e.name);
|
|
if (e.isDirectory()) {
|
|
if (SKIP_DIRS.has(e.name)) continue;
|
|
processDir(full, cmdNames);
|
|
} else if (EXTENSIONS.has(path.extname(e.name)) && !isTestFile(e.name)) {
|
|
processFile(full, cmdNames);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (require.main === module) {
|
|
const cmdNames = readCmdNames();
|
|
for (const dir of SEARCH_DIRS) {
|
|
processDir(dir, cmdNames);
|
|
}
|
|
for (const file of TOP_LEVEL_FILES) {
|
|
processFile(file, cmdNames);
|
|
}
|
|
console.log('Done.');
|
|
}
|
|
|
|
module.exports = {
|
|
transformContent,
|
|
transformContentToHyphen,
|
|
buildPattern,
|
|
buildColonPattern,
|
|
readCmdNames,
|
|
SKIP_DIRS
|
|
};
|