Files
msd-core/tests/fix-1920-installer-ships-capability-generators.test.cjs
Tom Boucher d3d689a5e9 fix(#1920): resolve host version from gsd-core/VERSION + ship capability generators (#1938)
The flattened install layout broke the third-party capability ecosystem in two
ways. Both are fixed at the host-version / installer boundary.

Gap 1 — host version read as 0.0.0. The running GSD version was resolved via
require('../../../package.json') (loader/source) and require('../../package.json')
(the gsd-tools CLI), which in the installed layout is the versionless CommonJS
marker → the fail-closed fallback reported 0.0.0, so `capability install` rejected
any manifest with a real engines.gsd range as "incompatible with GSD 0.0.0". For
runtimes that get no marker, and for local installs, that walked-up package.json
could even be the USER's own project, reporting a wrong version. Fix:
readHostVersion() (capability-loader.cts, capability-source.cts) and capHostVersion()
(gsd-tools.cjs) now prefer the authoritative gsd-core/VERSION the installer already
writes for EVERY runtime (mirrors resolveVersionFrom(), #1383), falling back to the
runtime-root package.json for the dev/source tree, then fail-closing. This fixes
every runtime and the actual `capability install` CLI path without touching the
marker package.json, so uninstall is unchanged (no data-loss surface).

Gap 2 — scripts/gen-capability-registry.cjs (+ its sibling
gen-loop-host-contract.cjs) were never copied by the installer, so the loader's
never-crash invariant discarded every overlay and fell back to the frozen
first-party registry (installed capabilities silently inert). Now copied,
uninstalled, and manifest-tracked exactly like fix-slash-commands.cjs (#1223).

Regenerates the 16 golden-install-parity fixtures to capture the two newly shipped
generator scripts and the gsd-tools.cjs change.

Regression tests (RED→GREEN): readHostVersion VERSION-first / fallback / fail-closed
resolution; an end-to-end `capability install` against a REAL installed layout
proving the engines gate sees the real host version, not 0.0.0; and a real-install
check that both generators are shipped and manifest-tracked.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 20:34:30 -04:00

209 lines
9.3 KiB
JavaScript

'use strict';
/**
* Regression tests for #1920: the installer must produce a capability-ecosystem-
* complete flattened layout, and the capability loader must resolve the real host
* version in that layout.
*
* Two gaps broke third-party capabilities on installed (flattened) layouts:
*
* Gap 1 — host version read as 0.0.0. `readHostVersion()` resolved the running GSD
* version via require('../../../package.json'), which in the installed layout is the
* marker package.json ({"type":"commonjs"}, no version) → the fail-closed fallback
* reported 0.0.0, so `capability install` rejected any manifest with a real
* engines.gsd range as "incompatible with GSD 0.0.0". Worse, for runtimes that get
* no marker and for local installs, that walked-up package.json could be the USER's
* own project, reporting a wrong version. Fix: readHostVersion() prefers the
* authoritative gsd-core/VERSION the installer writes for EVERY runtime.
*
* Gap 2 — the registry generator was never shipped. The loader composes overlays via
* require('../../../scripts/gen-capability-registry.cjs'); the installer never copied
* it (nor its sibling gen-loop-host-contract.cjs), so the never-crash invariant
* discarded EVERY overlay and fell back to the frozen first-party registry —
* installed third-party capabilities were silently inert. Same class of gap as #1223
* (scripts/fix-slash-commands.cjs).
*
* These tests are RED before the fix (loader/install.js) and GREEN after.
*/
process.env.GSD_TEST_MODE = '1';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { cleanup } = require('./helpers.cjs');
const ROOT = path.join(__dirname, '..');
const INSTALL = path.join(ROOT, 'bin', 'install.js');
const MANIFEST_NAME = 'gsd-file-manifest.json';
// The generator scripts the capability loader requires by relative path.
const GENERATORS = ['gen-capability-registry.cjs', 'gen-loop-host-contract.cjs'];
// ---------------------------------------------------------------------------
// Gap 1 — readHostVersion() prefers gsd-core/VERSION (the installer-written,
// all-runtime authoritative source) over an ambient/absent package.json.
// ---------------------------------------------------------------------------
describe('Gap 1: readHostVersion resolves the real host version in an installed layout (#1920)', () => {
const { readHostVersion } = require('../gsd-core/bin/lib/capability-loader.cjs');
/** Build a fake installed tree and return its gsd-core/bin/lib dir (the module libDir). */
function fakeTree({ version, pkg }) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1920-ver-'));
const libDir = path.join(root, 'gsd-core', 'bin', 'lib');
fs.mkdirSync(libDir, { recursive: true });
if (version !== undefined) fs.writeFileSync(path.join(root, 'gsd-core', 'VERSION'), version);
if (pkg !== undefined) fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify(pkg));
return { root, libDir };
}
test('prefers gsd-core/VERSION over a wrong ambient package.json version', () => {
// The walked-up package.json belongs to the user's project (wrong version) — must be ignored.
const { root, libDir } = fakeTree({ version: '9.9.9\n', pkg: { name: 'user-app', version: '1.2.3', type: 'commonjs' } });
try {
assert.strictEqual(readHostVersion(libDir), '9.9.9');
} finally {
cleanup(root);
}
});
test('falls back to the runtime-root package.json when no VERSION file (dev/source tree)', () => {
const { root, libDir } = fakeTree({ pkg: { version: '2.3.4' } });
try {
assert.strictEqual(readHostVersion(libDir), '2.3.4');
} finally {
cleanup(root);
}
});
test('fail-closes to 0.0.0 when neither VERSION nor a package.json version resolves', () => {
const { root, libDir } = fakeTree({});
try {
assert.strictEqual(readHostVersion(libDir), '0.0.0');
} finally {
cleanup(root);
}
});
test('a real global install writes gsd-core/VERSION carrying the host version', () => {
const dir = realInstall();
try {
const vfile = path.join(dir, 'gsd-core', 'VERSION');
assert.ok(fs.existsSync(vfile), 'installer must write gsd-core/VERSION');
assert.strictEqual(
fs.readFileSync(vfile, 'utf8').trim(),
require('../package.json').version,
'gsd-core/VERSION must carry the real host version readHostVersion() reads',
);
} finally {
cleanup(dir);
}
});
});
// ---------------------------------------------------------------------------
// Gap 2 — the installer ships (and uninstalls / manifest-tracks) the capability
// registry generator scripts.
// ---------------------------------------------------------------------------
/** Run a real global install into a fresh temp config dir; return that dir. */
function realInstall() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1920-'));
// The module-level GSD_TEST_MODE=1 gates the installer's main() off entirely —
// strip it from the child env for the spawned REAL install.
const childEnv = { ...process.env };
delete childEnv.GSD_TEST_MODE;
const res = spawnSync(
process.execPath,
[INSTALL, '--claude', '--global', '--config-dir', dir],
{ encoding: 'utf8', timeout: 120000, env: childEnv },
);
assert.strictEqual(res.status, 0, `install --claude failed: ${res.stderr || res.stdout}`);
return dir;
}
// ---------------------------------------------------------------------------
// Gap 1 (end-to-end CLI) — the actual repro: `gsd-tools capability install` on an
// INSTALLED layout must resolve the real host version for the engines.gsd gate, not
// 0.0.0. The dev tree always has a versioned package.json two levels up, so this only
// reproduces against a real install (where ../../package.json is the versionless marker
// and gsd-core/VERSION carries the truth). The CLI computes hostVersion itself, so this
// covers capHostVersion() in gsd-tools.cjs — a path the loader unit test does not touch.
// ---------------------------------------------------------------------------
describe('Gap 1 (end-to-end CLI): installed capability install uses the real host version (#1920)', () => {
const HOST_MAJOR = require('../package.json').version.split('.')[0];
function writeProbeCapability(engines) {
const src = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1920-cap-'));
const cap = {
id: 'p1920-probe', role: 'feature', version: '1.0.0', title: 'probe',
description: 'test capability', tier: 'standard', requires: [],
runtimeCompat: { supported: ['*'], unsupported: [] },
skills: [], agents: [], hooks: [], config: {}, steps: [],
contributions: [], gates: [], engines,
};
fs.writeFileSync(path.join(src, 'capability.json'), JSON.stringify(cap, null, 2));
return src;
}
test('a capability requiring engines.gsd ">=<host major>.0.0" is not rejected as GSD 0.0.0', () => {
const dir = realInstall();
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1920-home-'));
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1920-cwd-'));
fs.mkdirSync(path.join(cwd, '.planning'), { recursive: true });
fs.writeFileSync(path.join(cwd, '.planning', 'config.json'), '{}');
const src = writeProbeCapability({ gsd: `>=${HOST_MAJOR}.0.0` });
try {
const installedTools = path.join(dir, 'gsd-core', 'bin', 'gsd-tools.cjs');
const env = { ...process.env, GSD_HOME: home, GSD_WORKSTREAM: '', GSD_PROJECT: '', GSD_SESSION_KEY: '', CLAUDE_SESSION_ID: '' };
delete env.GSD_TEST_MODE;
const res = spawnSync(
process.execPath,
[installedTools, 'capability', 'install', src, '--scope', 'global', '--yes', '--json'],
{ cwd, env, encoding: 'utf8', timeout: 60000 },
);
const combined = `${res.stdout || ''}\n${res.stderr || ''}`;
assert.doesNotMatch(
combined,
/incompatible with GSD 0\.0\.0/,
`installed CLI saw host version 0.0.0 — the engines gate read the versionless marker: ${combined}`,
);
assert.strictEqual(res.status, 0, `capability install failed on the installed layout: ${combined}`);
} finally {
cleanup(dir); cleanup(home); cleanup(cwd); cleanup(src);
}
});
});
describe('Gap 2: installer ships the capability registry generator scripts (#1920)', () => {
test('the generator scripts are copied into scripts/', () => {
const dir = realInstall();
try {
for (const gen of GENERATORS) {
const dest = path.join(dir, 'scripts', gen);
assert.ok(fs.existsSync(dest), `installer must ship scripts/${gen}`);
assert.ok(fs.statSync(dest).size > 0, `scripts/${gen} must not be empty`);
}
} finally {
cleanup(dir);
}
});
test('the shipped generator scripts are tracked in the file manifest', () => {
const dir = realInstall();
try {
const manifest = JSON.parse(fs.readFileSync(path.join(dir, MANIFEST_NAME), 'utf8'));
for (const gen of GENERATORS) {
assert.ok(
manifest.files[`scripts/${gen}`],
`manifest must track scripts/${gen} for drift/uninstall accounting`,
);
}
} finally {
cleanup(dir);
}
});
});