* test: reproduce model-catalog MODULE_NOT_FOUND in install layout (#3288) Tests A/B/C exercise the install-layout regression introduced by #3230: - A: confirms the old 3-level __dirname path fails when sdk/shared/ is absent - B: confirms the new co-located bin/shared/ path resolves correctly (RED) - C: confirms install() copies model-catalog.json to co-located path (RED) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(install): copy sdk/shared/model-catalog.json + resolve chain in CJS (#3288) Two-part fix for the CRITICAL regression introduced by #3230: Install-side: bin/install.js now copies sdk/shared/model-catalog.json into get-shit-done/bin/shared/model-catalog.json immediately after the main get-shit-done/ copy step. Every runtime install (Claude Code, Codex, OpenCode, Gemini, etc.) now includes this file in the payload. CJS-side: model-catalog.cjs replaces the brittle single-path require with a resolve-chain that checks candidates in order: 1. get-shit-done/bin/shared/model-catalog.json (co-located, preferred post-install) 2. sdk/shared/model-catalog.json (source-repo dev path, legacy fallback) 3. GSD_MODEL_CATALOG env override (custom deployments / test harnesses) When no candidate resolves, throws with a diagnostic listing all tried paths (PRED.k301 — throw must include candidate paths for debuggability). REFACTOR audit: three other __dirname traversals in bin/lib/ were inspected: - core.cjs:1242 (3 levels up → agents/) — safe; agents/ IS copied to targetDir - profile-output.cjs:547,740 (2 levels up → templates/) — safe; templates/ is inside get-shit-done/ and IS copied Only model-catalog.cjs traversed outside the installed payload. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * changeset: pr=3293 for #3288 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(model-catalog): narrow catch to missing-file errors; clear env in test (#3288) Two CR findings from PR #3293 review: 1. model-catalog.cjs catch block swallowed ALL errors — malformed JSON, permission errors, and other real failures were silently absorbed into the fallback chain. Now only MODULE_NOT_FOUND (with matching path in message) and ENOENT are treated as recoverable; any other error is rethrown immediately. 2. test beforeEach saved GSD_EXPLICIT_CONFIG_DIR but didn't clear it — a CI-set value could leak into install() and redirect the install to an unexpected directory, making test C non-deterministic. Added `delete process.env.GSD_EXPLICIT_CONFIG_DIR` to beforeEach. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
5
.changeset/steady-jays-click.md
Normal file
5
.changeset/steady-jays-click.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 3293
|
||||
---
|
||||
**`gsd-tools.cjs` and CJS fallback bridge work again post-install** — the install manifest now copies `sdk/shared/model-catalog.json` into the get-shit-done payload at `get-shit-done/bin/shared/model-catalog.json`, and `model-catalog.cjs` uses a resolve chain (co-located install path → source-repo dev path → `GSD_MODEL_CATALOG` env override). Regression introduced by #3230.
|
||||
@@ -7951,6 +7951,28 @@ function install(isGlobal, runtime = 'claude') {
|
||||
failures.push('get-shit-done');
|
||||
}
|
||||
|
||||
// #3288 — Copy sdk/shared/model-catalog.json into the get-shit-done payload
|
||||
// at the co-located path that model-catalog.cjs resolves first:
|
||||
// get-shit-done/bin/shared/model-catalog.json
|
||||
//
|
||||
// The install copies get-shit-done/ but NOT sdk/ — the CJS module's legacy
|
||||
// path (3 levels up → sdk/shared/) therefore resolves to a non-existent
|
||||
// location in every post-install layout. Copying the catalog alongside the
|
||||
// CJS files ensures require() succeeds without needing sdk/ to exist.
|
||||
const modelCatalogSrc = path.join(src, 'sdk', 'shared', 'model-catalog.json');
|
||||
const modelCatalogDest = path.join(skillDest, 'bin', 'shared', 'model-catalog.json');
|
||||
if (fs.existsSync(modelCatalogSrc)) {
|
||||
fs.mkdirSync(path.dirname(modelCatalogDest), { recursive: true });
|
||||
fs.copyFileSync(modelCatalogSrc, modelCatalogDest);
|
||||
if (verifyFileInstalled(modelCatalogDest, 'get-shit-done/bin/shared/model-catalog.json')) {
|
||||
console.log(` ${green}✓${reset} Installed get-shit-done/bin/shared/model-catalog.json`);
|
||||
} else {
|
||||
failures.push('get-shit-done/bin/shared/model-catalog.json');
|
||||
}
|
||||
} else {
|
||||
failures.push('sdk/shared/model-catalog.json (source missing)');
|
||||
}
|
||||
|
||||
// Copy agents to agents directory.
|
||||
// Skipped under --minimal: gsd-* subagent descriptions are eagerly loaded
|
||||
// into the runtime's Agent tool schema, costing ~6k tokens per turn even
|
||||
|
||||
@@ -1,7 +1,51 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('node:path');
|
||||
const catalog = require(path.join(__dirname, '..', '..', '..', 'sdk', 'shared', 'model-catalog.json'));
|
||||
|
||||
// Resolve model-catalog.json via a prioritised candidate list so the module
|
||||
// works in every layout:
|
||||
//
|
||||
// 1. Co-located install path — get-shit-done/bin/shared/model-catalog.json
|
||||
// Written by bin/install.js (#3288 fix). This is the canonical post-install
|
||||
// location across all runtimes (Claude Code, Codex, OpenCode, etc.).
|
||||
//
|
||||
// 2. Source-repo dev path — sdk/shared/model-catalog.json
|
||||
// Three levels up from bin/lib/: works when running directly from the
|
||||
// gsd-build/get-shit-done clone (the original path introduced by #3230).
|
||||
//
|
||||
// 3. GSD_MODEL_CATALOG env override — allows test harnesses and custom
|
||||
// deployments to point at an arbitrary catalog file.
|
||||
//
|
||||
// Throws with a diagnostic message that lists all candidates when none resolve,
|
||||
// so MODULE_NOT_FOUND surfaces as a clear actionable error (PRED.k301).
|
||||
const _catalogCandidates = [
|
||||
path.resolve(__dirname, '..', 'shared', 'model-catalog.json'),
|
||||
path.resolve(__dirname, '..', '..', '..', 'sdk', 'shared', 'model-catalog.json'),
|
||||
process.env.GSD_MODEL_CATALOG ? path.resolve(process.env.GSD_MODEL_CATALOG) : null,
|
||||
].filter(Boolean);
|
||||
|
||||
let catalog = null;
|
||||
let _catalogLastErr = null;
|
||||
for (const _p of _catalogCandidates) {
|
||||
try {
|
||||
catalog = require(_p);
|
||||
break;
|
||||
} catch (e) {
|
||||
// Only treat missing-file errors as recoverable — rethrow parse errors,
|
||||
// permission errors, and any other real failures so they surface clearly
|
||||
// instead of being silently swallowed (CR finding, PR #3293).
|
||||
const isMissingCandidate =
|
||||
(e && e.code === 'MODULE_NOT_FOUND' && String(e.message || '').includes(_p)) ||
|
||||
(e && e.code === 'ENOENT');
|
||||
if (!isMissingCandidate) throw e;
|
||||
_catalogLastErr = e;
|
||||
}
|
||||
}
|
||||
if (!catalog) {
|
||||
throw new Error(
|
||||
`model-catalog.json not found. Tried:\n${_catalogCandidates.map((p) => ` ${p}`).join('\n')}\nLast error: ${_catalogLastErr?.message}`
|
||||
);
|
||||
}
|
||||
|
||||
const VALID_PROFILES = [...catalog.profiles];
|
||||
const VALID_PHASE_TYPES = new Set(catalog.phaseTypes);
|
||||
|
||||
245
tests/bug-3288-model-catalog-install-path.test.cjs
Normal file
245
tests/bug-3288-model-catalog-install-path.test.cjs
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Regression test for #3288: model-catalog.cjs uses brittle relative path
|
||||
* that breaks after install.
|
||||
*
|
||||
* Repro:
|
||||
* After `node bin/install.js --global --claude`, the installed
|
||||
* `~/.claude/get-shit-done/bin/lib/model-catalog.cjs` tries:
|
||||
* require(path.join(__dirname, '..', '..', '..', 'sdk', 'shared', 'model-catalog.json'))
|
||||
* which resolves to `~/.claude/sdk/shared/model-catalog.json`.
|
||||
* The installer copies `get-shit-done/` but never copies `sdk/shared/`,
|
||||
* so the require throws MODULE_NOT_FOUND.
|
||||
*
|
||||
* Fix contract:
|
||||
* 1. model-catalog.cjs must use a resolve-chain that checks a co-located
|
||||
* path first (bin/shared/model-catalog.json) before the legacy
|
||||
* source-repo path.
|
||||
* 2. bin/install.js must copy sdk/shared/model-catalog.json into
|
||||
* get-shit-done/bin/shared/model-catalog.json (co-located inside the
|
||||
* get-shit-done/ payload).
|
||||
*
|
||||
* Both halves must be true for the install layout to work.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..');
|
||||
const MODEL_CATALOG_CJS = path.join(REPO_ROOT, 'get-shit-done', 'bin', 'lib', 'model-catalog.cjs');
|
||||
const MODEL_CATALOG_JSON = path.join(REPO_ROOT, 'sdk', 'shared', 'model-catalog.json');
|
||||
|
||||
const { install } = require('../bin/install.js');
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeTmpDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function rmTmpDir(dir) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Silence console output during install to avoid noise in test output.
|
||||
*/
|
||||
function silenceConsole(fn) {
|
||||
const orig = {
|
||||
log: console.log,
|
||||
warn: console.warn,
|
||||
error: console.error,
|
||||
};
|
||||
console.log = () => {};
|
||||
console.warn = () => {};
|
||||
console.error = () => {};
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
console.log = orig.log;
|
||||
console.warn = orig.warn;
|
||||
console.error = orig.error;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── test 1: fake-install layout reproduces MODULE_NOT_FOUND ────────────────
|
||||
//
|
||||
// Build a fake post-install layout that mirrors what the OLD install did:
|
||||
// <tmp>/.claude/get-shit-done/bin/lib/model-catalog.cjs (copy of real file)
|
||||
// <tmp>/.claude/sdk/shared/model-catalog.json ABSENT
|
||||
//
|
||||
// Then attempt to require model-catalog.cjs from that layout.
|
||||
// Under the old path scheme (3 levels up → sdk/shared/) this should throw.
|
||||
// After the fix, if we DON'T also copy the json, it should still throw — this
|
||||
// confirms the co-located path IS required.
|
||||
|
||||
describe('bug #3288: model-catalog.cjs install-layout resolution', () => {
|
||||
let tmpRoot;
|
||||
let savedHome;
|
||||
let savedExplicitConfigDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = makeTmpDir('gsd-3288-');
|
||||
savedHome = process.env.HOME;
|
||||
// Stash and clear explicitConfigDir via env so install() picks up our tmp dir.
|
||||
// Must delete (not just save) so any CI-set value doesn't leak into install()
|
||||
// and target a different directory than tmpRoot (CR finding, PR #3293).
|
||||
savedExplicitConfigDir = process.env.GSD_EXPLICIT_CONFIG_DIR;
|
||||
delete process.env.GSD_EXPLICIT_CONFIG_DIR;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.HOME = savedHome;
|
||||
if (savedExplicitConfigDir === undefined) {
|
||||
delete process.env.GSD_EXPLICIT_CONFIG_DIR;
|
||||
} else {
|
||||
process.env.GSD_EXPLICIT_CONFIG_DIR = savedExplicitConfigDir;
|
||||
}
|
||||
rmTmpDir(tmpRoot);
|
||||
});
|
||||
|
||||
// ── test A ──────────────────────────────────────────────────────────────────
|
||||
test('OLD layout (3-level __dirname, no co-located json) fails to require', () => {
|
||||
// Build the old install layout manually:
|
||||
// <tmpRoot>/.claude/get-shit-done/bin/lib/model-catalog.cjs (copy of the real CJS)
|
||||
// sdk/shared/model-catalog.json ABSENT
|
||||
const gsdLibDir = path.join(tmpRoot, '.claude', 'get-shit-done', 'bin', 'lib');
|
||||
fs.mkdirSync(gsdLibDir, { recursive: true });
|
||||
|
||||
// Write a minimal model-catalog.cjs that uses ONLY the 3-level path (the old/broken path).
|
||||
const oldCjsContent = `'use strict';
|
||||
const path = require('node:path');
|
||||
// This is the BRITTLE path: 3 levels up from bin/lib → sdk/shared/
|
||||
const catalog = require(path.join(__dirname, '..', '..', '..', 'sdk', 'shared', 'model-catalog.json'));
|
||||
module.exports = { catalog };
|
||||
`;
|
||||
const catalogCjsPath = path.join(gsdLibDir, 'model-catalog.cjs');
|
||||
fs.writeFileSync(catalogCjsPath, oldCjsContent);
|
||||
|
||||
// Deliberately do NOT create sdk/shared/model-catalog.json (simulates missing file post-install).
|
||||
|
||||
// Require must fail with MODULE_NOT_FOUND.
|
||||
assert.throws(
|
||||
() => {
|
||||
// Delete from require cache to force a fresh load.
|
||||
delete require.cache[catalogCjsPath];
|
||||
require(catalogCjsPath);
|
||||
},
|
||||
(err) => {
|
||||
assert.ok(
|
||||
err.code === 'MODULE_NOT_FOUND' || err.message.includes('model-catalog.json'),
|
||||
`Expected MODULE_NOT_FOUND or model-catalog.json error, got: ${err.message}`,
|
||||
);
|
||||
return true;
|
||||
},
|
||||
'OLD 3-level path must fail when sdk/shared/model-catalog.json is not present (install layout)',
|
||||
);
|
||||
});
|
||||
|
||||
// ── test B ──────────────────────────────────────────────────────────────────
|
||||
test('NEW layout (co-located bin/shared/model-catalog.json) resolves correctly', () => {
|
||||
// Build the new install layout:
|
||||
// <tmpRoot>/.claude/get-shit-done/bin/lib/model-catalog.cjs (copy of real CJS)
|
||||
// <tmpRoot>/.claude/get-shit-done/bin/shared/model-catalog.json (co-located copy)
|
||||
const gsdBinDir = path.join(tmpRoot, '.claude', 'get-shit-done', 'bin');
|
||||
const gsdLibDir = path.join(gsdBinDir, 'lib');
|
||||
const gsdSharedDir = path.join(gsdBinDir, 'shared');
|
||||
fs.mkdirSync(gsdLibDir, { recursive: true });
|
||||
fs.mkdirSync(gsdSharedDir, { recursive: true });
|
||||
|
||||
// Copy the real model-catalog.cjs into the fake install.
|
||||
const catalogCjsPath = path.join(gsdLibDir, 'model-catalog.cjs');
|
||||
fs.copyFileSync(MODEL_CATALOG_CJS, catalogCjsPath);
|
||||
|
||||
// Copy the real model-catalog.json to the co-located path.
|
||||
fs.copyFileSync(MODEL_CATALOG_JSON, path.join(gsdSharedDir, 'model-catalog.json'));
|
||||
|
||||
// Require must succeed and expose catalog with expected shape.
|
||||
delete require.cache[catalogCjsPath];
|
||||
let mod;
|
||||
assert.doesNotThrow(() => {
|
||||
mod = require(catalogCjsPath);
|
||||
}, 'NEW co-located layout must not throw MODULE_NOT_FOUND');
|
||||
|
||||
assert.ok(mod.catalog, 'module must export catalog');
|
||||
assert.ok(Array.isArray(mod.VALID_PROFILES), 'module must export VALID_PROFILES');
|
||||
assert.ok(mod.VALID_PROFILES.length > 0, 'VALID_PROFILES must not be empty');
|
||||
});
|
||||
|
||||
// ── test C ──────────────────────────────────────────────────────────────────
|
||||
test('post-install: install() copies model-catalog.json to co-located path', () => {
|
||||
// Run the real installer against a tmp target dir, then assert the co-located
|
||||
// json is present and parseable.
|
||||
const claudeDir = path.join(tmpRoot, '.claude');
|
||||
fs.mkdirSync(claudeDir, { recursive: true });
|
||||
process.env.HOME = tmpRoot;
|
||||
|
||||
// Capture process.exit to prevent the test from being killed.
|
||||
const origExit = process.exit;
|
||||
let exitCalled = false;
|
||||
process.exit = (code) => {
|
||||
exitCalled = true;
|
||||
throw new Error(`process.exit(${code}) during install — should not happen`);
|
||||
};
|
||||
|
||||
try {
|
||||
silenceConsole(() => {
|
||||
install(true /* isGlobal */, 'claude');
|
||||
});
|
||||
} catch (e) {
|
||||
if (exitCalled) {
|
||||
assert.fail(`install() called process.exit — unexpected: ${e.message}`);
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
process.exit = origExit;
|
||||
}
|
||||
|
||||
// The co-located json must be present after install.
|
||||
const colocatedJson = path.join(
|
||||
claudeDir,
|
||||
'get-shit-done',
|
||||
'bin',
|
||||
'shared',
|
||||
'model-catalog.json',
|
||||
);
|
||||
assert.ok(
|
||||
fs.existsSync(colocatedJson),
|
||||
`model-catalog.json must be present at co-located path post-install: ${colocatedJson}`,
|
||||
);
|
||||
|
||||
// The json must be valid and have expected shape.
|
||||
let parsed;
|
||||
assert.doesNotThrow(() => {
|
||||
parsed = JSON.parse(fs.readFileSync(colocatedJson, 'utf8'));
|
||||
}, 'co-located model-catalog.json must be valid JSON');
|
||||
|
||||
assert.ok(Array.isArray(parsed.profiles), 'catalog.profiles must be an array');
|
||||
assert.ok(parsed.profiles.length > 0, 'catalog.profiles must not be empty');
|
||||
|
||||
// And the installed model-catalog.cjs must be requireable from its install location.
|
||||
const installedCjs = path.join(
|
||||
claudeDir,
|
||||
'get-shit-done',
|
||||
'bin',
|
||||
'lib',
|
||||
'model-catalog.cjs',
|
||||
);
|
||||
assert.ok(fs.existsSync(installedCjs), `model-catalog.cjs must be installed at: ${installedCjs}`);
|
||||
|
||||
delete require.cache[installedCjs];
|
||||
let installedMod;
|
||||
assert.doesNotThrow(() => {
|
||||
installedMod = require(installedCjs);
|
||||
}, 'installed model-catalog.cjs must not throw MODULE_NOT_FOUND after install');
|
||||
|
||||
assert.ok(installedMod.catalog, 'installed module must export catalog');
|
||||
assert.ok(installedMod.VALID_PROFILES.length > 0, 'installed module must have valid profiles');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user