fix(#1477): write .gsd-source marker at install for the Claude-global layout
This commit is contained in:
5
.changeset/brave-hawks-fly.md
Normal file
5
.changeset/brave-hawks-fly.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 1487
|
||||
---
|
||||
**`/gsd-surface` (`list`/`status`) works on Claude Code global installs** — the installer now writes a `.gsd-source` marker pointing at its `commands/gsd` source, so `findInstallSourceRoot` resolves on the global skills layout (which ships no `commands/gsd` tree) instead of throwing `could not locate commands/gsd`. (#1487)
|
||||
@@ -164,7 +164,7 @@ Module owning install detection for `/gsd:update`. `resolveUpdateContext({ home,
|
||||
Module owning which skills and agents are written to runtime config directories at install time (Phase 1) and at runtime via cluster-level toggles (Phase 2). Phase 1: `gsd-core/bin/lib/install-profiles.cjs` defines named profiles (`core`, `standard`, `full`), computes transitive closure over `requires:` frontmatter, stages skills/agents to runtime config dirs, and persists the chosen profile in a `.gsd-profile` marker. Profile resolution precedence: explicit `--profile=` flag > `.gsd-profile` marker > `full`. `--minimal`/`--core-only` are back-compat aliases for `--profile=core`. Phase 2: `gsd-core/bin/lib/surface.cjs` implements the `/gsd:surface` slash command for cluster-level enable/disable without reinstall; cluster definitions live in `gsd-core/bin/lib/clusters.cjs`; per-runtime state persists in `<runtimeConfigDir>/.gsd-surface.json` independent from the `.gsd-profile` marker. See ADR-0011.
|
||||
|
||||
### Runtime Artifact Layout Module
|
||||
Module owning the per-runtime mapping from artifact kind to filesystem placement. ADR-3660 defines the typed `kinds` per runtime (`commands`, `agents`, `skills`) with destination subpath, prefix, and stage adapter (with per-runtime converters in `bin/install.js`: `convertClaudeCommandToClaudeSkill`, `…CodexSkill`, `…CopilotSkill`, `…AntigravitySkill`). Owns the per-runtime `nested` skill-bundle decision (#69): a `skillsKind` flag in `src/runtime-artifact-layout.cts` drives whether a runtime receives the nested router layout (6 `gsd-ns-*` routers + concrete skills under `<router>/skills/<name>/`) or the flat `skills/gsd-<stem>/` layout; the evidence/doc-link matrix is recorded in a comment above `resolveRuntimeArtifactLayout`. Phase 1 applies this seam to the Runtime Surface Module (`surface.cjs:applySurface`); as of #813, `applySurface` applies the same per-runtime skill-body path rewrites as `installRuntimeArtifacts` for `skills` kinds — re-surfacing no longer overwrites installed SKILL.md bodies with converter-default `~/.claude` paths. Per ADR-1508 / #1511 the former `getInstallExports`/`loadInstallExports` relay (a `GSD_TEST_MODE`-guarded `require('bin/install.js')` by which `surface.cjs` reached `computePathPrefix`/`applyRuntimeContentRewritesInPlace`) was DELETED from this module; content rewriting now lives in the Runtime Artifact Conversion Module and `surface.cjs:applySurface` calls its `rewriteStagedSkillBodies` directly. The resolved `scope` is still carried on the `Layout` object so `applySurface` derives the same `pathPrefix` (global `$HOME` form vs. absolute) as a fresh install. Phase 2 is planned to migrate install/uninstall in `bin/install.js` so all lifecycle sites iterate one shared layout table instead of re-encoding runtime layout logic. This design is intended to remove the #3659 class of omissions. Migrations remain under the Installer Migration Module (ADR-0008). See ADR-3660.
|
||||
Module owning the per-runtime mapping from artifact kind to filesystem placement. ADR-3660 defines the typed `kinds` per runtime (`commands`, `agents`, `skills`) with destination subpath, prefix, and stage adapter (with per-runtime converters in `bin/install.js`: `convertClaudeCommandToClaudeSkill`, `…CodexSkill`, `…CopilotSkill`, `…AntigravitySkill`). Owns the per-runtime `nested` skill-bundle decision (#69): a `skillsKind` flag in `src/runtime-artifact-layout.cts` drives whether a runtime receives the nested router layout (6 `gsd-ns-*` routers + concrete skills under `<router>/skills/<name>/`) or the flat `skills/gsd-<stem>/` layout; the evidence/doc-link matrix is recorded in a comment above `resolveRuntimeArtifactLayout`. Phase 1 applies this seam to the Runtime Surface Module (`surface.cjs:applySurface`); as of #813, `applySurface` applies the same per-runtime skill-body path rewrites as `installRuntimeArtifacts` for `skills` kinds — re-surfacing no longer overwrites installed SKILL.md bodies with converter-default `~/.claude` paths. Per ADR-1508 / #1511 the former `getInstallExports`/`loadInstallExports` relay (a `GSD_TEST_MODE`-guarded `require('bin/install.js')` by which `surface.cjs` reached `computePathPrefix`/`applyRuntimeContentRewritesInPlace`) was DELETED from this module; content rewriting now lives in the Runtime Artifact Conversion Module and `surface.cjs:applySurface` calls its `rewriteStagedSkillBodies` directly. The resolved `scope` is still carried on the `Layout` object so `applySurface` derives the same `pathPrefix` (global `$HOME` form vs. absolute) as a fresh install. Phase 2 is planned to migrate install/uninstall in `bin/install.js` so all lifecycle sites iterate one shared layout table instead of re-encoding runtime layout logic. This design is intended to remove the #3659 class of omissions. Migrations remain under the Installer Migration Module (ADR-0008). The `.gsd-source` marker (#1477) is a two-party provisioning contract that lets source resolution succeed on the Claude global skills layout, which ships `gsd-core/{bin,contexts,references,templates,workflows}` but no `commands/gsd` source tree for `findInstallSourceRoot` to walk up to: the writer is `bin/install.js`, which writes `<configDir>/.gsd-source` (content: the absolute path to its own `commands/gsd`, terminated by a newline) when `runtime === 'claude' && isGlobal`, guarded by `fs.existsSync` so a half-published package never writes a dangling marker; the reader is `findInstallSourceRoot(configDir)`, which prefers the marker over its walk-up but falls through to the walk-up if the marker is absent, dangling, or empty/whitespace-only. See ADR-3660.
|
||||
|
||||
### Runtime Artifact Conversion Module
|
||||
Sibling Module to Runtime Artifact Layout Module. Owns projection from canonical Claude-authored command/agent/skill markdown into runtime-specific artifact bodies, including converter selection, frontmatter/body normalization, runtime path rewrites, and staged artifact generation. Runtime Artifact Layout remains responsible for filesystem placement (`kind`, destination subpath, prefix, nesting); Runtime Artifact Conversion owns the content Implementation behind that placement seam so install, uninstall/surface parity, and future plugin/package projections stop reaching back through `bin/install.js` for converter functions or `GSD_TEST_MODE`-guarded installer exports. Chosen direction: sibling Module, not an expanded Layout Module, to preserve ADR-3660's narrow placement responsibility while deepening artifact content locality. First slice: relocate only the layout-reached conversion family (`convertClaudeCommandTo*Skill`, converted command-file emitters, `buildKimiAgentArtifacts`) plus the minimal helper closure they need; do not leave helper dependencies in `bin/install.js` because that would preserve the same shallow seam under a new filename. Installer integration decision: `bin/install.js` imports the conversion Module at top level and re-exports the moved names for compatibility; the conversion Module must not import `bin/install.js` or Runtime Artifact Layout, so the dependency direction becomes installer/layout Adapters -> conversion Module, never conversion -> installer. First-slice Interface decision: export the existing compatibility names only; do not introduce a grouped `convertRuntimeArtifact` Interface until after relocation proves byte-for-byte behavior. SHIPPED (ADR-1508): the converter family relocated in #1510 Phase 1 (`getDirName`→runtime-name-policy, `processAttribution` here); #1511 Phase 2 moved the content-rewrite engine here in full — `_applyRuntimeRewrites` (per-runtime switch, injected attribution), the staged-content walkers `applyRuntimeContentRewritesInPlace`/`applyRuntimeContentRewritesForCommandsInPlace`, `computePathPrefix` (private; `_computePathPrefix` for tests), and the deep public seam `rewriteStagedSkillBodies`/`rewriteStagedCommandBodies({runtime,configDir,scope,homedir?,platform?,resolveAttribution?})`. `bin/install.js` binds these back (single owner, exports preserved); `getCommitAttribution` stays in `bin/install.js` (impure install-time config I/O) and is injected. The `getInstallExports` relay in Runtime Artifact Layout Module was deleted; the dependency direction installer/layout → conversion (never upward) is now enforced. Exception: opencode and kilo path-prefix rewriting is a deliberate `bin/install.js`-owned pre-conversion step (`applyOpencodeFamilyPathPrefix`) per #784, not a violation of the single-owner rule. Source: `gsd-core/bin/lib/runtime-artifact-conversion.cjs` (generated from `src/runtime-artifact-conversion.cts`). Also exports `resolveVersionFrom(libDir)` — a lazy, defensive GSD-version resolver (installed-tree `gsd-core/VERSION` first, then the source/npm `package.json` three dirs up, both validated against the repo's shared semver-prefix shape, degrading to `''` on failure) that replaced a module-load-time `require('../../../package.json')` which crashed on runtimes whose root carries no `package.json` (e.g. Codex) (#1383).
|
||||
|
||||
@@ -8889,6 +8889,33 @@ 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) 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.
|
||||
// Scoped to the Claude-global layout (issue #1477) — the only install path
|
||||
// that ships the skills layout without a commands/gsd source tree; every
|
||||
// other runtime/scope deploys commands/gsd, so its walk-up already resolves
|
||||
// and needs no marker. Guarded on source presence so a half-published
|
||||
// package never writes a dangling marker.
|
||||
if (runtime === 'claude' && isGlobal) {
|
||||
const gsdSourceCommands = path.join(src, 'commands', 'gsd');
|
||||
if (fs.existsSync(gsdSourceCommands)) {
|
||||
try {
|
||||
fs.writeFileSync(path.join(targetDir, '.gsd-source'), gsdSourceCommands + '\n', 'utf8');
|
||||
} catch (err) {
|
||||
// Non-fatal: install proceeds. But on the Claude-global layout walk-up
|
||||
// also fails (no commands/gsd source tree), so a silent write failure
|
||||
// still leaves /gsd-surface broken at runtime — warn so it's diagnosable.
|
||||
console.warn(` ${yellow}!${reset} Could not write .gsd-source marker (${err.message}); /gsd-surface list/status may fail`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// #1629 critical fix: Windsurf workflow wrappers (convertClaudeCommandToWindsurfWorkflow)
|
||||
// delegate to command bodies at <targetDir>/gsd-core/commands/gsd/${stem}.md via a
|
||||
// hardcoded @~/.claude/gsd-core/commands/gsd/ path that _applyRuntimeRewrites rewrites
|
||||
|
||||
@@ -11,10 +11,11 @@
|
||||
*
|
||||
* After replacing every occurrence of the temp root path with the literal
|
||||
* '<HOME>' in file contents, the install output is byte-identical run-to-run
|
||||
* for ALL files EXCEPT exactly two volatile metadata files that are EXCLUDED
|
||||
* for ALL files EXCEPT the volatile metadata files that are EXCLUDED
|
||||
* from the parity manifest:
|
||||
* - gsd-file-manifest.json (timestamp + install-time absolute paths)
|
||||
* - gsd-install-state.json (install-time absolute paths)
|
||||
* - .gsd-source (#1477, claude-global: install-time source path)
|
||||
*
|
||||
* Everything else (≈545–616 files per runtime) is deterministic.
|
||||
*
|
||||
@@ -48,11 +49,20 @@ const UPDATE = process.env.UPDATE_GOLDEN === '1';
|
||||
const FIXTURE_DIR = path.join(__dirname, 'fixtures', 'golden-install-parity');
|
||||
|
||||
// Volatile metadata files always excluded from the parity manifest.
|
||||
// .gsd-source (#1477, claude-global only) records the install-time absolute path
|
||||
// to the package's commands/gsd source tree, which is the checkout/CI workspace
|
||||
// path — NOT the temp HOME root, so it is never normalized to '<HOME>' and its
|
||||
// hash varies by environment. Excluded for the same reason as gsd-install-state.json.
|
||||
// gsd-core/CHANGELOG.md is excluded because it contains historical version strings
|
||||
// that cause hash drift between local (PKG_VERSION=1.x.x) and CI (PKG_VERSION=1.x.x-rc.N):
|
||||
// the PKG_VERSION normalization below replaces only the *current* version, but
|
||||
// CHANGELOG.md references prior-release versions, so the normalized hash diverges.
|
||||
const VOLATILE_FILES = new Set(['gsd-file-manifest.json', 'gsd-install-state.json', 'gsd-core/CHANGELOG.md']);
|
||||
const VOLATILE_FILES = new Set([
|
||||
'gsd-file-manifest.json',
|
||||
'gsd-install-state.json',
|
||||
'.gsd-source',
|
||||
'gsd-core/CHANGELOG.md',
|
||||
]);
|
||||
|
||||
// The installed package version, normalized to '<VERSION>' in hash computation so
|
||||
// the golden is stable across version bumps (the rc step runs `npm version X.Y.Z-rc.N`
|
||||
|
||||
@@ -17,14 +17,17 @@
|
||||
* runtime-artifact-layout-install-profiles.test.cjs — install-profiles seam
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const { test, describe, beforeEach, afterEach, mock } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs');
|
||||
const { resolveRuntimeArtifactLayout, findInstallSourceRoot } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs');
|
||||
const installProfiles = require('../gsd-core/bin/lib/install-profiles.cjs');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
const { install } = require('../bin/install.js');
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..');
|
||||
|
||||
const FAKE_DIR = '/tmp/fake-config-dir';
|
||||
|
||||
@@ -637,3 +640,261 @@ describe('stage — cursor commands kind (#785)', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #1477: .gsd-source marker provisioning ───────────────────────────────────
|
||||
//
|
||||
// Regression for #1477: the Claude Code global skills layout ships
|
||||
// gsd-core/{bin,contexts,references,templates,workflows} but no commands/gsd
|
||||
// source tree, and _runLegacyUninstallCleanup removes any commands/gsd/ for that
|
||||
// scope. At runtime findInstallSourceRoot's walk-up from gsd-core/bin/lib had
|
||||
// nothing to find and /gsd-surface threw for every subcommand (list/status
|
||||
// included); the marker reader added in #1476 never fired because nothing wrote
|
||||
// the marker.
|
||||
//
|
||||
// Fix: bin/install.js writes <configDir>/.gsd-source pointing at a resolvable
|
||||
// commands/gsd, and findInstallSourceRoot prefers that marker over its walk-up.
|
||||
//
|
||||
// (The original #1477 also covered a deployed-install MODULE_NOT_FOUND from the
|
||||
// surface path's relative require('../../../bin/install.js'); ADR-1508 / #1511
|
||||
// removed that getInstallExports relay entirely — surface.cjs now calls the
|
||||
// shipped runtime-artifact-conversion sibling directly — so that half no longer
|
||||
// applies and is covered by the #1511 relocation suite.)
|
||||
|
||||
describe('#1477 .gsd-source marker provisioning', () => {
|
||||
let tmpRoot;
|
||||
let savedHome;
|
||||
let savedUserProfile;
|
||||
let savedExplicitConfigDir;
|
||||
let savedTestMode;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
savedTestMode = process.env.GSD_TEST_MODE;
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
});
|
||||
|
||||
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;
|
||||
if (savedTestMode === undefined) delete process.env.GSD_TEST_MODE;
|
||||
else process.env.GSD_TEST_MODE = savedTestMode;
|
||||
cleanup(tmpRoot);
|
||||
});
|
||||
|
||||
// ── 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');
|
||||
});
|
||||
|
||||
// ── Guard: marker is scoped to claude-global ONLY (#1477 PR-scope) ───────────
|
||||
// Locks the `runtime === 'claude' && isGlobal` write guard. Every other layout
|
||||
// (non-claude runtimes, and claude *local*) ships a commands/gsd source tree, so
|
||||
// findInstallSourceRoot's walk-up already resolves and the marker must not appear.
|
||||
function findGsdSourceMarkers(root) {
|
||||
const found = [];
|
||||
const walk = (dir) => {
|
||||
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()) walk(full);
|
||||
else if (e.name === '.gsd-source') found.push(full);
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
return found;
|
||||
}
|
||||
|
||||
test('non-claude global install writes no .gsd-source marker (locks runtime === claude)', () => {
|
||||
runInstall(true /* isGlobal */, 'cursor');
|
||||
assert.deepEqual(
|
||||
findGsdSourceMarkers(tmpRoot), [],
|
||||
'a non-claude runtime must not provision the claude-global marker',
|
||||
);
|
||||
});
|
||||
|
||||
test('claude local install writes no .gsd-source marker (locks isGlobal)', () => {
|
||||
// Local installs target process.cwd()/.claude — chdir into the fixture so the
|
||||
// install is contained within tmpRoot rather than polluting the repo.
|
||||
const savedCwd = process.cwd();
|
||||
process.chdir(tmpRoot);
|
||||
try {
|
||||
runInstall(false /* isGlobal */, 'claude');
|
||||
} finally {
|
||||
process.chdir(savedCwd);
|
||||
}
|
||||
assert.deepEqual(
|
||||
findGsdSourceMarkers(tmpRoot), [],
|
||||
'a claude local install must not provision the marker — local ships commands/gsd',
|
||||
);
|
||||
});
|
||||
|
||||
// ── Writer fault-injection: marker write failure is non-fatal + warns ────────
|
||||
// CONTRIBUTING.md filesystem-write QA matrix: prove the marker-writer catch
|
||||
// branch (bin/install.js) is reachable. A failed write (e.g. read-only target)
|
||||
// must not abort the install, and — because walk-up also fails on this layout —
|
||||
// must surface a diagnostic so the broken /gsd-surface is traceable.
|
||||
test('marker write failure does not abort install and warns (locks the writer catch)', () => {
|
||||
const claudeDir = path.join(tmpRoot, '.claude');
|
||||
fs.mkdirSync(claudeDir, { recursive: true });
|
||||
|
||||
const markerPath = path.join(claudeDir, '.gsd-source');
|
||||
const realWriteFileSync = fs.writeFileSync;
|
||||
// Fault-inject ONLY the marker write; every other install write proceeds.
|
||||
mock.method(fs, 'writeFileSync', (file, ...rest) => {
|
||||
if (path.resolve(String(file)) === path.resolve(markerPath)) {
|
||||
throw new Error('EACCES: read-only target (injected)');
|
||||
}
|
||||
return realWriteFileSync(file, ...rest);
|
||||
});
|
||||
|
||||
// Capture console.warn around the install (runInstall's silenceConsole would
|
||||
// otherwise swallow it); still guard process.exit.
|
||||
const warnings = [];
|
||||
const origExit = process.exit;
|
||||
const origWarn = console.warn;
|
||||
const origLog = console.log;
|
||||
process.exit = (code) => { throw new Error(`process.exit(${code}) during install`); };
|
||||
console.warn = (msg) => { warnings.push(String(msg)); };
|
||||
console.log = () => {};
|
||||
try {
|
||||
assert.doesNotThrow(() => install(true /* isGlobal */, 'claude'),
|
||||
'a marker write failure must be non-fatal — install proceeds via the catch');
|
||||
} finally {
|
||||
process.exit = origExit;
|
||||
console.warn = origWarn;
|
||||
console.log = origLog;
|
||||
mock.restoreAll();
|
||||
}
|
||||
|
||||
assert.ok(!fs.existsSync(markerPath), 'the injected fault must leave no marker on disk');
|
||||
assert.ok(
|
||||
warnings.some((w) => /\.gsd-source marker/.test(w)),
|
||||
`the writer catch must warn for diagnosability; got: ${JSON.stringify(warnings)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// ── Failure 1 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 the source root 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));
|
||||
});
|
||||
|
||||
// ── 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');
|
||||
|
||||
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'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user