diff --git a/.changeset/brave-hawks-fly.md b/.changeset/brave-hawks-fly.md new file mode 100644 index 000000000..b07b243e8 --- /dev/null +++ b/.changeset/brave-hawks-fly.md @@ -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) diff --git a/CONTEXT.md b/CONTEXT.md index 31c2d72b1..6796a3da9 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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 `/.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 `/skills//`) or the flat `skills/gsd-/` 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 `/skills//`) or the flat `skills/gsd-/` 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 `/.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). diff --git a/bin/install.js b/bin/install.js index bc6b891bc..5c5cc1a2c 100755 --- a/bin/install.js +++ b/bin/install.js @@ -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 /gsd-core/commands/gsd/${stem}.md via a // hardcoded @~/.claude/gsd-core/commands/gsd/ path that _applyRuntimeRewrites rewrites diff --git a/tests/golden-install-parity.test.cjs b/tests/golden-install-parity.test.cjs index 4649d4818..2bf4ff1e3 100644 --- a/tests/golden-install-parity.test.cjs +++ b/tests/golden-install-parity.test.cjs @@ -11,10 +11,11 @@ * * After replacing every occurrence of the temp root path with the literal * '' 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 '' 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 '' in hash computation so // the golden is stable across version bumps (the rc step runs `npm version X.Y.Z-rc.N` diff --git a/tests/runtime-artifact-layout.test.cjs b/tests/runtime-artifact-layout.test.cjs index c8c16bef5..1bb726368 100644 --- a/tests/runtime-artifact-layout.test.cjs +++ b/tests/runtime-artifact-layout.test.cjs @@ -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 /.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 /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')); + }); + }); +});