fix(#1477): write .gsd-source marker at install; resolve install-exports for the deployed layout

The Claude Code global skills layout ships gsd-core/{bin,contexts,references,
templates,workflows} but not the commands/gsd source tree, and
_runLegacyUninstallCleanup removes any commands/gsd/ for that scope. So at
runtime findInstallSourceRoot's walk-up from gsd-core/bin/lib has nothing to
find and /gsd-surface throws for every subcommand (list/status included). The
marker reader added in #1476 never fired because nothing wrote the marker.

Writer half (Failure 1): install() writes <configDir>/.gsd-source pointing at
the package's commands/gsd source (guarded on its presence so a half-published
package never leaves a dangling marker). findInstallSourceRoot already prefers
this marker over the walk-up.

Deployed install-exports (Failure 2): loadInstallExports' relative
'../../../bin/install.js' only resolves in the repo; in a deployed tree it
points at <configDir>/bin/install.js, which is never shipped, so the surface
write subcommands threw MODULE_NOT_FOUND. Derive install.js from the resolved
commands/gsd source root instead (its package root holds both commands/gsd and
bin/install.js), which is correct in the repo (walk-up) and in deployed installs
(marker) alike. No file relocation needed, so install.js' own internal requires
keep resolving. applySurface threads layout.configDir so the marker is honored.

Regression test reproduces the deployed global layout end-to-end: without the
marker the deployed walk-up throws, and getInstallExports under the old relative
path throws MODULE_NOT_FOUND; with both fixes list/status resolve and the
install-exports load. Adversarial marker cases (dangling, empty/whitespace)
fall through to walk-up.

Claude-Session: https://claude.ai/code/session_01XNT3SWgzjmEycNuweURDme
This commit is contained in:
Joe Slitzker
2026-06-20 09:15:59 -05:00
parent 67a3842776
commit ee6f3b70c5
4 changed files with 260 additions and 5 deletions

View File

@@ -10026,6 +10026,24 @@ function install(isGlobal, runtime = 'claude', options = {}) {
failures.push('gsd-core');
}
// Write the .gsd-source marker so runtime source resolution succeeds at
// runtime (#1477). The Claude-global skills layout ships gsd-core/{bin,
// contexts,references,templates,workflows} but NOT the commands/gsd source
// tree, and _runLegacyUninstallCleanup actively removes any commands/gsd/
// for that scope — so findInstallSourceRoot's walk-up has nothing to find
// and /gsd-surface (list/status and the write subcommands) throws. This is
// the writer half of the marker that runtime-artifact-layout.cjs's finders
// already read (the reader landed in #1476). It points at the package's own
// commands/gsd source, whose parent also holds bin/install.js — the path
// loadInstallExports derives the installer exports from. Guarded on source
// presence so a half-published package never writes a dangling marker.
const gsdSourceCommands = path.join(src, 'commands', 'gsd');
if (fs.existsSync(gsdSourceCommands)) {
try {
fs.writeFileSync(path.join(targetDir, '.gsd-source'), gsdSourceCommands + '\n', 'utf8');
} catch (_) { /* non-fatal: surface degrades to walk-up resolution */ }
}
// Copy shared manifests into the gsd-core payload
// at the co-located path that CJS modules resolve first:
// gsd-core/bin/shared/*.json

View File

@@ -44,17 +44,42 @@ interface InstallExports {
[converterName: string]: unknown;
}
/**
* Resolve the absolute path to bin/install.js for the current layout (#1477).
*
* The relative specifier '../../../bin/install.js' only resolves in the repo
* (where this module lives at gsd-core/bin/lib/). In a deployed install the
* module sits at <configDir>/gsd-core/bin/lib/ and that specifier points at
* <configDir>/bin/install.js, which is never shipped — so the surface write
* subcommands threw MODULE_NOT_FOUND. Instead, derive install.js from the
* resolved commands/gsd source root: its parent package root holds both
* commands/gsd and bin/install.js. findInstallSourceRoot honors the
* <configDir>/.gsd-source marker (deployed) and walks up to the repo root
* (repo/tests), so this single derivation is correct in both layouts. Falls
* back to the legacy relative path if no source root can be resolved.
*/
function resolveInstallJsPath(runtimeConfigDir?: string): string {
try {
const commandsGsd = findInstallSourceRoot(runtimeConfigDir);
// <packageRoot>/commands/gsd -> <packageRoot>/bin/install.js
const candidate = path.resolve(commandsGsd, '..', '..', 'bin', 'install.js');
if (fs.existsSync(candidate)) return candidate;
} catch { /* fall through to the legacy module-relative path */ }
return path.join(__dirname, '..', '..', '..', 'bin', 'install.js');
}
/**
* Load bin/install.js exports in a test-safe way.
* Sets GSD_TEST_MODE only for the duration of the require() call and only if
* it was not already set, restoring the original value in a finally block so
* the module-level environment is never permanently mutated.
*/
function loadInstallExports(): InstallExports {
function loadInstallExports(runtimeConfigDir?: string): InstallExports {
const installPath = resolveInstallJsPath(runtimeConfigDir);
const savedTestMode = process.env['GSD_TEST_MODE'];
if (savedTestMode === undefined) process.env['GSD_TEST_MODE'] = '1';
try {
return _require('../../../bin/install.js') as InstallExports;
return _require(installPath) as InstallExports;
} finally {
if (savedTestMode === undefined) delete process.env['GSD_TEST_MODE'];
else process.env['GSD_TEST_MODE'] = savedTestMode;
@@ -63,8 +88,8 @@ function loadInstallExports(): InstallExports {
/** Cache after first successful load. */
let _installExports: InstallExports | null = null;
function getInstallExports(): InstallExports {
if (!_installExports) _installExports = loadInstallExports();
function getInstallExports(runtimeConfigDir?: string): InstallExports {
if (!_installExports) _installExports = loadInstallExports(runtimeConfigDir);
return _installExports;
}

View File

@@ -311,7 +311,9 @@ function applySurface(runtimeConfigDir: string, layout: Layout, manifest: Map<st
for (const kind of layout.kinds) {
const staged = kind.stage(resolved);
if (kind.kind === 'skills') {
const installExports = getInstallExports();
// Pass the config dir so install.js resolution can honor the
// <configDir>/.gsd-source marker in deployed installs (#1477).
const installExports = getInstallExports(layout.configDir);
if (pathPrefix === null) {
const scope = layout.scope ?? 'global';
const resolvedTarget = path.resolve(layout.configDir).replace(/\\/g, '/');

View File

@@ -0,0 +1,210 @@
/**
* Regression test for #1477: Claude Code global install ships no commands/gsd
* source and never writes the .gsd-source marker, so /gsd-surface is fully
* non-functional (list/status throw at findInstallSourceRoot, and the write
* subcommands throw MODULE_NOT_FOUND at loadInstallExports).
*
* Repro (deployed Claude global layout):
* ~/.claude/gsd-core/{bin,contexts,references,templates,workflows} — no commands/gsd
* ~/.claude/.gsd-source — never written
* ~/.claude/bin/install.js — never shipped
*
* findInstallSourceRoot walks up from gsd-core/bin/lib looking for
* commands/gsd, finds nothing, and throws — killing list/status. The marker
* step that PR #1476 added (read side) never fires because nothing writes the
* marker (this issue is the write side). loadInstallExports' relative
* '../../../bin/install.js' resolves to ~/.claude/bin/install.js, which does
* not exist — killing profile/disable/enable/reset.
*
* Fix contract (both halves required):
* 1. bin/install.js writes <configDir>/.gsd-source pointing at a resolvable
* commands/gsd source whose package root also holds bin/install.js.
* 2. runtime-artifact-layout.cjs derives bin/install.js from that resolved
* source root, so install-exports load in the deployed layout too.
*/
'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 REPO_ROOT = path.join(__dirname, '..');
const { install } = require('../bin/install.js');
const { createTempDir, cleanup } = require('./helpers.cjs');
// The repo-resident module — exercised directly for the adversarial marker-reader
// cases (its walk-up always finds the repo commands/gsd, so marker precedence and
// fall-through can be asserted without a full install).
const {
findInstallSourceRoot,
} = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs');
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;
}
}
describe('bug #1477: .gsd-source marker provisioning + deployed install-exports resolution', () => {
let tmpRoot;
let savedHome;
let savedUserProfile;
let savedExplicitConfigDir;
beforeEach(() => {
tmpRoot = createTempDir('gsd-1477-');
savedHome = process.env.HOME;
// os.homedir() reads USERPROFILE on win32, HOME elsewhere; redirect both so
// install() targets the fixture regardless of platform.
savedUserProfile = process.env.USERPROFILE;
process.env.HOME = tmpRoot;
process.env.USERPROFILE = tmpRoot;
savedExplicitConfigDir = process.env.GSD_EXPLICIT_CONFIG_DIR;
delete process.env.GSD_EXPLICIT_CONFIG_DIR;
});
afterEach(() => {
if (savedHome === undefined) delete process.env.HOME;
else process.env.HOME = savedHome;
if (savedUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = savedUserProfile;
if (savedExplicitConfigDir === undefined) delete process.env.GSD_EXPLICIT_CONFIG_DIR;
else process.env.GSD_EXPLICIT_CONFIG_DIR = savedExplicitConfigDir;
cleanup(tmpRoot);
});
// Guard against process.exit killing the runner mid-install.
function runInstall(isGlobal, runtime) {
const origExit = process.exit;
let exitCalled = false;
process.exit = (code) => {
exitCalled = true;
throw new Error(`process.exit(${code}) during install — should not happen`);
};
try {
return silenceConsole(() => install(isGlobal, runtime));
} catch (e) {
if (exitCalled) assert.fail(`install() called process.exit — unexpected: ${e.message}`);
throw e;
} finally {
process.exit = origExit;
}
}
// ── Failure 1: the installer provisions a valid marker ──────────────────────
test('global claude install writes a .gsd-source marker pointing at a real commands/gsd', () => {
const claudeDir = path.join(tmpRoot, '.claude');
fs.mkdirSync(claudeDir, { recursive: true });
runInstall(true /* isGlobal */, 'claude');
const markerPath = path.join(claudeDir, '.gsd-source');
assert.ok(fs.existsSync(markerPath), `.gsd-source marker must be written at ${markerPath}`);
const markerSrc = fs.readFileSync(markerPath, 'utf8').trim();
assert.ok(path.isAbsolute(markerSrc), `marker must contain an absolute path, got: ${markerSrc}`);
assert.ok(fs.existsSync(markerSrc), `marker target must exist on disk: ${markerSrc}`);
assert.equal(
path.basename(markerSrc), 'gsd',
'marker must point at a commands/gsd directory',
);
assert.equal(path.basename(path.dirname(markerSrc)), 'commands');
// The package root that holds commands/gsd must also hold bin/install.js —
// this is what loadInstallExports derives the installer exports from.
const derivedInstallJs = path.resolve(markerSrc, '..', '..', 'bin', 'install.js');
assert.ok(
fs.existsSync(derivedInstallJs),
`bin/install.js must be reachable from the marker's package root: ${derivedInstallJs}`,
);
});
// ── Failures 1+2 end-to-end: resolution succeeds FROM the deployed tree ──────
// The deployed module's __dirname is <claudeDir>/gsd-core/bin/lib, which has no
// commands/gsd ancestor (global skills layout). Only the marker rescues it.
test('deployed global layout resolves source root + install-exports via the marker', () => {
const claudeDir = path.join(tmpRoot, '.claude');
fs.mkdirSync(claudeDir, { recursive: true });
runInstall(true /* isGlobal */, 'claude');
// Sanity: the global layout genuinely ships no commands/gsd source tree.
assert.ok(
!fs.existsSync(path.join(claudeDir, 'commands', 'gsd')),
'precondition: global claude install must not ship commands/gsd',
);
const deployedLayoutPath = path.join(claudeDir, 'gsd-core', 'bin', 'lib', 'runtime-artifact-layout.cjs');
assert.ok(fs.existsSync(deployedLayoutPath), 'deployed runtime-artifact-layout.cjs must exist');
delete require.cache[deployedLayoutPath];
const deployed = require(deployedLayoutPath);
// Negative proof that the bug condition exists: WITHOUT consulting the marker
// (no configDir argument), walk-up from the deployed tree has nothing to find.
assert.throws(
() => deployed.findInstallSourceRoot(),
/could not locate commands\/gsd/,
'deployed walk-up must fail without the marker — this is the regression condition',
);
// With the marker (configDir provided), list/status resolution succeeds.
let resolved;
assert.doesNotThrow(() => {
resolved = deployed.findInstallSourceRoot(claudeDir);
}, 'findInstallSourceRoot must resolve via the .gsd-source marker');
assert.equal(path.basename(resolved), 'gsd');
assert.ok(fs.existsSync(resolved));
// The write-subcommand path: install-exports must load in the deployed layout.
const exportsObj = deployed.getInstallExports(claudeDir);
assert.equal(typeof exportsObj.computePathPrefix, 'function',
'getInstallExports must expose computePathPrefix (used by applySurface)');
assert.equal(typeof exportsObj.applyRuntimeContentRewritesInPlace, 'function',
'getInstallExports must expose applyRuntimeContentRewritesInPlace (used by applySurface)');
});
// ── Adversarial marker-reader cases (no full install needed) ─────────────────
describe('findInstallSourceRoot marker handling', () => {
let cfgDir;
beforeEach(() => { cfgDir = createTempDir('gsd-1477-marker-'); });
afterEach(() => { cleanup(cfgDir); });
test('marker pointing at a valid commands/gsd takes precedence over walk-up', () => {
const fakeSrc = path.join(cfgDir, 'pkg', 'commands', 'gsd');
fs.mkdirSync(fakeSrc, { recursive: true });
fs.writeFileSync(path.join(cfgDir, '.gsd-source'), fakeSrc + '\n', 'utf8');
const resolved = findInstallSourceRoot(cfgDir);
assert.equal(path.resolve(resolved), path.resolve(fakeSrc),
'marker target must win over the repo walk-up');
});
test('marker pointing at a non-existent path is ignored (falls through to walk-up)', () => {
const ghost = path.join(cfgDir, 'does', 'not', 'exist', 'commands', 'gsd');
fs.writeFileSync(path.join(cfgDir, '.gsd-source'), ghost + '\n', 'utf8');
// In-repo walk-up still resolves the real commands/gsd — no throw, and it is
// NOT the dangling marker target.
const resolved = findInstallSourceRoot(cfgDir);
assert.notEqual(path.resolve(resolved), path.resolve(ghost));
assert.equal(path.resolve(resolved), path.resolve(REPO_ROOT, 'commands', 'gsd'));
});
test('empty / whitespace-only marker is ignored', () => {
fs.writeFileSync(path.join(cfgDir, '.gsd-source'), ' \n', 'utf8');
const resolved = findInstallSourceRoot(cfgDir);
assert.equal(path.resolve(resolved), path.resolve(REPO_ROOT, 'commands', 'gsd'));
});
});
});