* enhance(#4377): opt-in project-relative includes for local installs A local install wrote the includes that point at GSD's own files as absolute paths — whatever the installer resolved at install time. For one checkout that is invisible. Across git worktrees it is not: each worktree gets its own .claude/ copy, but all of them point back at the checkout that ran the installer, so a worktree runs its own gsd-tools.cjs while reading workflow prose from a different checkout. Update that one checkout and every other worktree is running new instructions against an old engine, with nothing to stage the update with. --relative-includes (or GSD_RELATIVE_INCLUDES=1) makes a local install emit `@.claude/gsd-core/...`. Opt-in, and staying opt-in: absolute works for a single checkout, which is most people, and flipping the default would change every existing local install to solve a problem those users do not have. The prefix is the runtime's own localConfigDir descriptor value, never a literal — the same value resolveScope joins onto the cwd to produce the install target, and the same one the rewrite engine already uses for its ./.claude/ -> ./<dir>/ substitutions. Copilot and Antigravity have shipped this shape for local installs since they were added, with hardcoded .github/ and .agents/; this is that behavior, derived rather than written down. Six seams compute a path prefix and all six had to be threaded, which is why the opt-in travels through the environment the way --portable-hooks already does: one variable they all read cannot fall out of sync the way six signatures can. The launcher shim deliberately keeps its ABSOLUTE fallbacks. It probes gsd-tools through ${CLAUDE_CONFIG_DIR:-$HOME/.claude} and one such default per runtime; those are shell word expansions, not includes, and a relative value there resolves against the shell's cwd rather than the project. Trading an include that points at the wrong checkout for a path that points at nothing is not a fix. All three rewrite paths mask ${VAR:-default} spans before substituting and restore them after, and the mask only runs when the prefix is relative, so an absolute install is byte-for-byte unchanged. Every unexpressible case falls back to absolute: no opt-in, a global install, a missing dir name, the configHome.kind === 'none' sentinel, an absolute descriptor value, or one climbing out of the project with '..'. * chore(#4377): add changeset for project-relative local includes * fix(#4377): compare against POSIX-normalized roots in the install e2e arms The emitted prefix is POSIX-normalized by design — it is substituted into markdown @-references, which use forward slashes universally, so a backslash would leak into shipped content (#1615). The e2e arms compared against the raw temp root, which on Windows is `D:\a\...` and appears in no emitted file. That reddened the control arm on the windows shard, and it was worse than a red: the negative arm ("nothing references the checkout") was passing VACUOUSLY there, because a string that cannot occur is trivially absent. Both now go through the same normalization, so the Windows lane asserts what the Linux lane does. * fix(#4377): tolerate a resolved temp root, and make the e2e diff self-diagnosing Two changes, one confirmed and one to stop guessing. Confirmed: the emitted content carries the RESOLVED root, not the spelling mkdtemp handed back. Reproduced on Linux with a symlinked install root — 236 emitted files carry the realpath, zero carry the link path. macOS has this structurally, since /var is a symlink to /private/var. Comparisons now go through both spellings, or the negative arms pass vacuously: "nothing references the checkout" is trivially true when the string being searched for cannot occur. Not confirmed: the macOS shard reported ~every workflow file differing in the "differ ONLY" arm while the five arms around it passed, and the assertion printed a list of filenames — which says a difference exists somewhere across 236 files and leaves the reader to guess which bytes. I cannot reproduce that platform locally, and guessing turns one CI round-trip into four. The assertion now reports the first divergence as text: the file, the byte offset, and a bounded window of both sides. * fix(#4377): strip the longest root spelling first in the install e2e diff The macOS failure was my test corrupting its own comparison, not a product defect. /var/folders/…/X is a SUBSTRING of /private/var/folders/…/X, so stripping the unresolved spelling first matched inside the resolved one and left the /private prefix glued to what followed: @/private/var/…/X/.claude/gsd-core/… -> @/private.claude/gsd-core/… a string present in neither install, which is why all 236 files "differed". Sorting the spellings longest-first consumes the whole occurrence, and the short form then has nothing left to match. Proven in isolation on the exact macOS shapes: short-first yields @/private.claude/…, longest-first yields @.claude/…. The self-diagnosing assertion added in the previous commit is what found this — it named the file, the byte offset, and printed both sides, so the corrupted string was visible rather than inferred from a list of 236 filenames. Keeping it. * fix(#4377): address review findings * test(#4377): scan nested shell defaults without regex backtracking * fix(#4377): close relative include review gaps * fix(#4377): preserve root-target runtime includes * fix(#4377): guard project-root relative includes * test(#4377): normalize Cline fallback roots * fix(#4377): persist relative include style --------- Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
5
.changeset/patient-finches-rest.md
Normal file
5
.changeset/patient-finches-rest.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
type: Fixed
|
||||||
|
pr: 4425
|
||||||
|
---
|
||||||
|
**Local installs can keep `@` includes inside each worktree** — `--relative-includes` (or `GSD_RELATIVE_INCLUDES=1`) writes project-relative includes instead of binding every worktree to whichever checkout ran the installer. (#4377)
|
||||||
@@ -321,7 +321,7 @@ Module owning the per-runtime mapping from artifact kind to filesystem placement
|
|||||||
Issue #4132 keeps `resolveRuntimeArtifactLayout` as the single no-I/O placement resolver. Ordinary kind stage closures lazily select one complete provider shared by every source class the layout requires. Global installs provision canonical raw commands under `gsd-core/commands/gsd` and agents under `gsd-core/agents`, with manifest-owned refresh/removal; installer staging first uses that normal installed/marker selection and privately retries against the executing package only when source resolution fails before creating any staged output. Deployed Runtime Surface staging prefers the complete installation-owned corpus, then a complete independent legacy `.gsd-source` compatibility provider, and otherwise fails closed with an install/upgrade diagnostic. Provider independence and installed-corpus integrity are synchronous admission checks over the filesystem state observed during provider selection; same-user concurrent mutation after resolution remains outside this issue's threat model. No layout may mix commands and agents from different providers.
|
Issue #4132 keeps `resolveRuntimeArtifactLayout` as the single no-I/O placement resolver. Ordinary kind stage closures lazily select one complete provider shared by every source class the layout requires. Global installs provision canonical raw commands under `gsd-core/commands/gsd` and agents under `gsd-core/agents`, with manifest-owned refresh/removal; installer staging first uses that normal installed/marker selection and privately retries against the executing package only when source resolution fails before creating any staged output. Deployed Runtime Surface staging prefers the complete installation-owned corpus, then a complete independent legacy `.gsd-source` compatibility provider, and otherwise fails closed with an install/upgrade diagnostic. Provider independence and installed-corpus integrity are synchronous admission checks over the filesystem state observed during provider selection; same-user concurrent mutation after resolution remains outside this issue's threat model. No layout may mix commands and agents from different providers.
|
||||||
|
|
||||||
### Runtime Artifact Conversion Module
|
### 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`. 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). #4032 added `appendAgentTools` as step 3 of the ADR-1235 pre-converter pipeline (`stageAgentsForRuntimeWithConverter`, `src/install-profiles.cts` — not this module): appends `readGsdEffectiveAgentTools` grants (Install Model Override Resolver Module) into the canonical agent's `tools:` frontmatter — line-surgical, both inline-comma and YAML block-list forms — before the runtime-specific converter runs, so every converter (including Kilo's `mcp__*`→`{server}_{tool}` permission mapping and ZCode's `mcp__*` omission policy) sees configured grants without a duplicated per-runtime write path.
|
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?})`. #4377 extends `computePathPrefix` with `projectRelative?` and `localDirName?`: an explicitly opted-in local install may emit the descriptor-derived project-relative prefix, while globals and unsafe/unrepresentable descriptor values retain the absolute fallback. The exported test seams `_relativeIncludesEnabled`, `_projectRelativePrefix`, `_isRelativePathPrefix`, and `_withShellDefaultsPreserved` pin that policy; the last is also the single balanced `${VAR:-...}` masking implementation used by both rewrite paths so nested launcher defaults remain absolute. `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`. 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). #4032 added `appendAgentTools` as step 3 of the ADR-1235 pre-converter pipeline (`stageAgentsForRuntimeWithConverter`, `src/install-profiles.cts` — not this module): appends `readGsdEffectiveAgentTools` grants (Install Model Override Resolver Module) into the canonical agent's `tools:` frontmatter — line-surgical, both inline-comma and YAML block-list forms — before the runtime-specific converter runs, so every converter (including Kilo's `mcp__*`→`{server}_{tool}` permission mapping and ZCode's `mcp__*` omission policy) sees configured grants without a duplicated per-runtime write path.
|
||||||
|
|
||||||
### Runtime Artifact Install Plan Module
|
### Runtime Artifact Install Plan Module
|
||||||
Module owning install-time staging and content-rewrite selection for a pre-resolved Runtime Artifact Layout. Interface: `createRuntimeArtifactInstallPlan({ layout, resolvedProfile, homedir?, platform?, resolveAttribution?, deps? }) -> { ok:true, plan:{ items, cleanupDirs } } | { ok:false, kind:'stage_failed'|'rewrite_failed', message, cleanupDirs, failedKind? }`. It iterates `layout.kinds` in order, calls each kind's `stage(resolvedProfile)`, delegates `commands` to Runtime Artifact Conversion `rewriteStagedCommandBodies`, delegates `skills` and `kimi-agents` to `rewriteStagedSkillBodies`, leaves non-rewritten kinds unchanged, and projects copy items as `{ kind, sourceDir, destDir }`. It deliberately does not prune, copy, run legacy migrations, print output, or execute cleanup; those remain Installer Module adapter responsibilities until later slices wire the plan into `bin/install.js`. **Write-confinement (ADR-1239 Phase B / #1679):** the exported pure `assertDestWithinConfigHome(configDir, destSubpath) -> resolvedDest` is the security gate — every kind's `destDir` is computed through it on both the install and uninstall plan paths, so a `destSubpath` that escapes `configHome` (`../../etc`, a NUL byte, etc.) is rejected at plan-build time with a clear error; `surface.cjs:applySurface` and `bin/install.js:installOpencodeFamilySkills` route their joins through the same helper, and `_copyStaged` carries a defense-in-depth containment check. This is security-load-bearing for the Phase C third-party-descriptor loader (which is where an untrusted `destSubpath` could arrive). Source: `gsd-core/bin/lib/runtime-artifact-install-plan.cjs`. See Runtime Artifact Layout Module and Runtime Artifact Conversion Module.
|
Module owning install-time staging and content-rewrite selection for a pre-resolved Runtime Artifact Layout. Interface: `createRuntimeArtifactInstallPlan({ layout, resolvedProfile, homedir?, platform?, resolveAttribution?, deps? }) -> { ok:true, plan:{ items, cleanupDirs } } | { ok:false, kind:'stage_failed'|'rewrite_failed', message, cleanupDirs, failedKind? }`. It iterates `layout.kinds` in order, calls each kind's `stage(resolvedProfile)`, delegates `commands` to Runtime Artifact Conversion `rewriteStagedCommandBodies`, delegates `skills` and `kimi-agents` to `rewriteStagedSkillBodies`, leaves non-rewritten kinds unchanged, and projects copy items as `{ kind, sourceDir, destDir }`. It deliberately does not prune, copy, run legacy migrations, print output, or execute cleanup; those remain Installer Module adapter responsibilities until later slices wire the plan into `bin/install.js`. **Write-confinement (ADR-1239 Phase B / #1679):** the exported pure `assertDestWithinConfigHome(configDir, destSubpath) -> resolvedDest` is the security gate — every kind's `destDir` is computed through it on both the install and uninstall plan paths, so a `destSubpath` that escapes `configHome` (`../../etc`, a NUL byte, etc.) is rejected at plan-build time with a clear error; `surface.cjs:applySurface` and `bin/install.js:installOpencodeFamilySkills` route their joins through the same helper, and `_copyStaged` carries a defense-in-depth containment check. This is security-load-bearing for the Phase C third-party-descriptor loader (which is where an untrusted `destSubpath` could arrive). Source: `gsd-core/bin/lib/runtime-artifact-install-plan.cjs`. See Runtime Artifact Layout Module and Runtime Artifact Conversion Module.
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -553,6 +553,48 @@ diagnostic and exits non-zero when nothing resolves.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Local installs across several git worktrees
|
||||||
|
|
||||||
|
A local (`--local`) install writes the includes that point at GSD's own installed
|
||||||
|
files as absolute paths — the path the installer resolved at install time. For a
|
||||||
|
single checkout that is invisible and works fine.
|
||||||
|
|
||||||
|
It stops working once the same repository is checked out more than once. Each git
|
||||||
|
worktree gets its own `.claude/` copy, but every one of those copies points back at
|
||||||
|
the checkout that ran the installer. So a worktree runs its own `gsd-tools.cjs`
|
||||||
|
(correctly resolved through `git rev-parse --show-toplevel`) while reading its
|
||||||
|
workflow prose out of a different checkout — and the moment you update that one
|
||||||
|
checkout, every other worktree is executing new instructions against an old engine,
|
||||||
|
with no way to stage the update.
|
||||||
|
|
||||||
|
Install with `--relative-includes` (or `GSD_RELATIVE_INCLUDES=1`) to write the
|
||||||
|
includes relative to the project instead:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx @opengsd/gsd-core@latest --claude --local --relative-includes
|
||||||
|
```
|
||||||
|
|
||||||
|
Every emitted `@` include then reads `@.claude/gsd-core/...` rather than
|
||||||
|
`@/absolute/path/to/checkout/.claude/gsd-core/...`, so each worktree resolves its
|
||||||
|
own copy and the worktrees become independent.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- **It is opt-in and stays opt-in.** Absolute includes work for a single checkout,
|
||||||
|
which is most people; the flag exists for those who need it.
|
||||||
|
- **Global installs are unaffected.** They keep their `$HOME`-relative form, which
|
||||||
|
is already checkout-independent.
|
||||||
|
- **The runtime launcher keeps its absolute fallbacks.** The shell snippet that
|
||||||
|
locates `gsd-tools.cjs` probes `${CLAUDE_CONFIG_DIR:-$HOME/.claude}` and one such
|
||||||
|
default per runtime; those are shell word expansions, not includes, and a relative
|
||||||
|
value there would resolve against the current shell's directory rather than the
|
||||||
|
project. The launcher already probes `$(git rev-parse --show-toplevel)/.claude`
|
||||||
|
first, so it finds the current worktree before it ever reaches those defaults.
|
||||||
|
- **`--relative-includes` with `--global` does nothing.** The flag is only consulted
|
||||||
|
for local installs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Installing without Node.js
|
## Installing without Node.js
|
||||||
|
|
||||||
If you cannot run `npx` (for example, on a Windows machine without Node.js), you have two options.
|
If you cannot run `npx` (for example, on a Windows machine without Node.js), you have two options.
|
||||||
|
|||||||
@@ -2044,6 +2044,9 @@ function installOpencodeFamilyArtifacts(
|
|||||||
isWindowsHost: process.platform === 'win32',
|
isWindowsHost: process.platform === 'win32',
|
||||||
resolvedTarget: posixNormalize(path.resolve(configDir)),
|
resolvedTarget: posixNormalize(path.resolve(configDir)),
|
||||||
homeDir: posixNormalize(os.homedir()),
|
homeDir: posixNormalize(os.homedir()),
|
||||||
|
// #4377: the runtime's own localConfigDir, so an opted-in local install
|
||||||
|
// emits `<dir>/...` instead of this checkout's absolute path.
|
||||||
|
localDirName: runtimeArtifactConversion._localIncludeDirName(runtime),
|
||||||
});
|
});
|
||||||
|
|
||||||
// #2329: destDir is derived from the SAME hostBehaviors.flatCommandDir
|
// #2329: destDir is derived from the SAME hostBehaviors.flatCommandDir
|
||||||
|
|||||||
@@ -2971,15 +2971,64 @@ function convertClaudeCommandToKiloSkill(content, skillName) {
|
|||||||
// to the originals; the only change is the injected `attribution` 5th param in
|
// to the originals; the only change is the injected `attribution` 5th param in
|
||||||
// _applyRuntimeRewrites (replacing the internal getCommitAttribution() call).
|
// _applyRuntimeRewrites (replacing the internal getCommitAttribution() call).
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #4377: is the project-relative include style opted in?
|
||||||
|
*
|
||||||
|
* Dual-sourced exactly like `--portable-hooks`/`GSD_PORTABLE_HOOKS`:
|
||||||
|
* `bin/install.js` sets the variable when the flag is passed, so the flag and
|
||||||
|
* the environment cannot disagree, and every seam that computes a path prefix
|
||||||
|
* (the installer copy path, the install engine, the two rewrite entry points,
|
||||||
|
* the install plan, and `applySurface`) reads the same answer without six signatures having to
|
||||||
|
* grow a parameter each and stay in sync.
|
||||||
|
*
|
||||||
|
* Opt-in, not the new default. Making relative the default would change every
|
||||||
|
* existing single-checkout local install — which works today — to fix a
|
||||||
|
* multi-worktree case those users do not have.
|
||||||
|
*
|
||||||
|
* @param env - Environment to read (injectable for tests).
|
||||||
|
* @returns Whether local installs should emit project-relative includes.
|
||||||
|
*/
|
||||||
|
function relativeIncludesEnabled(env = process.env): boolean {
|
||||||
|
return env.GSD_RELATIVE_INCLUDES === '1';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute the path prefix for a runtime install.
|
* Compute the path prefix for a runtime install.
|
||||||
* Global installs under $HOME use $HOME/... form; others use the resolved target.
|
* Global installs under $HOME use $HOME/... form; others use the resolved target.
|
||||||
* isOpencode excludes OpenCode (uses ~/.config/opencode which breaks $HOME shorthand).
|
* isOpencode excludes OpenCode (uses ~/.config/opencode which breaks $HOME shorthand).
|
||||||
* isWindowsHost is not used today but reserved for future Windows-specific logic.
|
* isWindowsHost is not used today but reserved for future Windows-specific logic.
|
||||||
*
|
*
|
||||||
|
* #4377: a LOCAL install can instead emit a project-relative prefix, so a repo
|
||||||
|
* worked from several git worktrees does not get every worktree's `@` includes
|
||||||
|
* baked to whichever checkout happened to run the installer. Absolute is still
|
||||||
|
* the default; `projectRelative` opts in.
|
||||||
|
*
|
||||||
|
* `localDirName` is the runtime's own `localConfigDir` descriptor value
|
||||||
|
* (via `getDirName`), never a hardcoded literal — the same value the rewrite
|
||||||
|
* engine already uses for its `./.claude/` -> `./<dir>/` substitutions, and
|
||||||
|
* exactly what `resolveScope` joins onto the cwd to produce `resolvedTarget`
|
||||||
|
* for a local install. Copilot and Antigravity have shipped this shape for
|
||||||
|
* local installs since they were added, with hardcoded `.github/` and
|
||||||
|
* `.agents/`; this is the same behavior, derived instead of written down.
|
||||||
|
*
|
||||||
|
* Fails safe to the absolute prefix: no opt-in, a global install, a missing
|
||||||
|
* dir name, or the `configHome.kind === 'none'` sentinel all fall through. A
|
||||||
|
* wrong-but-absolute include still resolves to a real file; a wrong relative
|
||||||
|
* one silently resolves against whatever the reader's cwd happens to be.
|
||||||
|
*
|
||||||
* @private — exported as `_computePathPrefix` for tests.
|
* @private — exported as `_computePathPrefix` for tests.
|
||||||
*/
|
*/
|
||||||
function computePathPrefix({ isGlobal, isOpencode, isWindowsHost: _isWindowsHost, resolvedTarget, homeDir }) {
|
function computePathPrefix({
|
||||||
|
isGlobal,
|
||||||
|
isOpencode,
|
||||||
|
isWindowsHost: _isWindowsHost,
|
||||||
|
resolvedTarget,
|
||||||
|
homeDir,
|
||||||
|
projectRelative = relativeIncludesEnabled(),
|
||||||
|
localDirName,
|
||||||
|
projectRoot = process.cwd(),
|
||||||
|
projectRelativePath,
|
||||||
|
}) {
|
||||||
// #1615: normalize Windows backslashes to forward slashes. This prefix is
|
// #1615: normalize Windows backslashes to forward slashes. This prefix is
|
||||||
// substituted into markdown @-references (e.g. Windsurf workflow files),
|
// substituted into markdown @-references (e.g. Windsurf workflow files),
|
||||||
// which use POSIX paths universally. Idempotent on POSIX (no backslashes).
|
// which use POSIX paths universally. Idempotent on POSIX (no backslashes).
|
||||||
@@ -2991,9 +3040,59 @@ function computePathPrefix({ isGlobal, isOpencode, isWindowsHost: _isWindowsHost
|
|||||||
if (isGlobal && posixTarget.startsWith(posixHome) && !isOpencode) {
|
if (isGlobal && posixTarget.startsWith(posixHome) && !isOpencode) {
|
||||||
return '$HOME' + posixTarget.slice(posixHome.length) + '/';
|
return '$HOME' + posixTarget.slice(posixHome.length) + '/';
|
||||||
}
|
}
|
||||||
|
if (!isGlobal && projectRelative) {
|
||||||
|
// An explicit local config dir need not be the descriptor's conventional
|
||||||
|
// `.claude`-style directory. Derive from the resolved target first; only
|
||||||
|
// use the descriptor as the legacy fallback when no project root exists.
|
||||||
|
const relative = projectRelativePrefix(projectRelativePath)
|
||||||
|
|| projectRelativePrefixFromProjectRoot(projectRoot, resolvedTarget)
|
||||||
|
|| projectRelativePrefix(localDirName);
|
||||||
|
if (relative) return relative;
|
||||||
|
}
|
||||||
return `${posixTarget}/`;
|
return `${posixTarget}/`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Return a safe project-relative prefix for a resolved install target. */
|
||||||
|
function projectRelativePrefixFromProjectRoot(projectRoot: unknown, resolvedTarget: unknown): string {
|
||||||
|
if (typeof projectRoot !== 'string' || typeof resolvedTarget !== 'string') return '';
|
||||||
|
const relative = posixNormalize(path.relative(projectRoot, resolvedTarget));
|
||||||
|
if (!relative || relative === '.' || relative === '..' || relative.startsWith('../') || path.isAbsolute(relative)) return '';
|
||||||
|
return projectRelativePrefix(relative);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A runtime installed directly at the project root cannot use its descriptor
|
||||||
|
* directory in a project-relative include: that directory was never created.
|
||||||
|
*/
|
||||||
|
function localIncludeDirName(runtime: string): string | undefined {
|
||||||
|
return _hostBehaviors(runtime).localTargetIsProjectRoot === true ? undefined : getDirName(runtime);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #4377: the project-relative prefix for a local install, or `''` when the
|
||||||
|
* runtime cannot express one and the caller must fall back to absolute.
|
||||||
|
*
|
||||||
|
* Rejects the `configHome.kind === 'none'` sentinel (a runtime with no local
|
||||||
|
* config dir at all — interpolating it would produce a literal
|
||||||
|
* `(no-local-config-dir)/` path segment), anything absolute, and anything that
|
||||||
|
* climbs out of the project with `..`. Trailing slashes are normalized so a
|
||||||
|
* descriptor value written either way yields one prefix.
|
||||||
|
*
|
||||||
|
* @param localDirName - The runtime's `localConfigDir` descriptor value.
|
||||||
|
* @returns A `dir/` prefix, or `''` to signal "use the absolute form".
|
||||||
|
*/
|
||||||
|
function projectRelativePrefix(localDirName): string {
|
||||||
|
if (typeof localDirName !== 'string' || localDirName.length === 0) return '';
|
||||||
|
if (localDirName === runtimeNamePolicy.NO_LOCAL_CONFIG_DIR_SENTINEL) return '';
|
||||||
|
const normalized = posixNormalize(localDirName).replace(/\/+$/, '');
|
||||||
|
if (!normalized || normalized.startsWith('/') || /^[A-Za-z]:/.test(normalized)) return '';
|
||||||
|
// Reject a climb in ANY segment, not only at the beginning. posixNormalize
|
||||||
|
// deliberately normalizes separators rather than resolving path segments,
|
||||||
|
// so `nested/../../outside` must be caught explicitly here.
|
||||||
|
if (normalized.split('/').includes('..')) return '';
|
||||||
|
return `${normalized}/`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Canonical list of every non-Claude runtime that gsd-core emits artifacts for.
|
* Canonical list of every non-Claude runtime that gsd-core emits artifacts for.
|
||||||
* DERIVED from the capability registry (ADR-1239 Phase B, #1679) — the registry's
|
* DERIVED from the capability registry (ADR-1239 Phase B, #1679) — the registry's
|
||||||
@@ -3174,7 +3273,114 @@ function restoreClaudeGlobalAtRefTilde(content, pathPrefix) {
|
|||||||
*
|
*
|
||||||
* @private — exported as `_applyRuntimeRewrites` for tests.
|
* @private — exported as `_applyRuntimeRewrites` for tests.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* #4377: is this prefix a project-relative one?
|
||||||
|
*
|
||||||
|
* Anything not rooted -- no leading `/`, no `$HOME`, no `~`, no drive letter.
|
||||||
|
* Used only to decide whether the shell-default guard below needs to run, so
|
||||||
|
* an absolute install is byte-for-byte untouched by any of this.
|
||||||
|
*
|
||||||
|
* @param pathPrefix - The computed prefix.
|
||||||
|
* @returns Whether it resolves relative to something.
|
||||||
|
*/
|
||||||
|
function isRelativePathPrefix(pathPrefix): boolean {
|
||||||
|
if (typeof pathPrefix !== 'string' || pathPrefix.length === 0) return false;
|
||||||
|
return !(pathPrefix.startsWith('/')
|
||||||
|
|| pathPrefix.startsWith('$HOME')
|
||||||
|
|| pathPrefix.startsWith('~')
|
||||||
|
|| /^[A-Za-z]:/.test(pathPrefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #4377: shield `${VAR:-default}` shell defaults from a RELATIVE path prefix.
|
||||||
|
*
|
||||||
|
* The runtime launcher snippet probes for gsd-tools through a chain of shell
|
||||||
|
* defaults -- `${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/...`, one per
|
||||||
|
* runtime. Those are shell word expansions, not markdown `@` includes, and
|
||||||
|
* they are the one place where substituting a project-relative prefix makes
|
||||||
|
* things WORSE rather than better: `$HOME/.claude` resolves the same from
|
||||||
|
* anywhere, while a bare `.claude` resolves against whatever directory the
|
||||||
|
* shell happens to be sitting in. Trading an include that points at the wrong
|
||||||
|
* checkout for a path that points at nothing is not a fix.
|
||||||
|
*
|
||||||
|
* The issue asked for project-relative INCLUDES, and this keeps the change to
|
||||||
|
* exactly that. The launcher already handles the multi-worktree case on its
|
||||||
|
* own, and better -- it probes `$(git rev-parse --show-toplevel)/.claude/...`
|
||||||
|
* first, so it finds the current worktree's copy long before it reaches these
|
||||||
|
* defaults.
|
||||||
|
*
|
||||||
|
* The mask token is `@@GSD4377:<n>@@`. It has to survive every substitution in
|
||||||
|
* the rewrite body untouched, so it deliberately contains no `.claude`, no
|
||||||
|
* `~`, no `$HOME` and no path separator -- there is nothing in it for those
|
||||||
|
* regexes to match. A collision would need that literal to already exist in
|
||||||
|
* the shipped corpus, which is asserted against in the tests.
|
||||||
|
*
|
||||||
|
* Only runs when the prefix is relative, so an absolute install never sees
|
||||||
|
* this transformation at all.
|
||||||
|
*
|
||||||
|
* @param content - The body being rewritten.
|
||||||
|
* @param rewrite - The substitution pass to run on the unguarded remainder.
|
||||||
|
* @returns The rewritten body, with every shell default restored verbatim.
|
||||||
|
*/
|
||||||
|
function withShellDefaultsPreserved(content, rewrite) {
|
||||||
|
const preserved: string[] = [];
|
||||||
|
let masked = '';
|
||||||
|
let copiedThrough = 0;
|
||||||
|
let searchFrom = 0;
|
||||||
|
|
||||||
|
// Scan balanced `${...}` expansions instead of stopping at the first `}`.
|
||||||
|
// The launcher has nested defaults such as `${A:-${B:-$HOME/.x}}`; a
|
||||||
|
// single `[^}]*` regex only recognizes a prefix of that expression and
|
||||||
|
// makes preservation depend accidentally on where the rewritten text sits.
|
||||||
|
while (searchFrom < content.length) {
|
||||||
|
const start = content.indexOf('${', searchFrom);
|
||||||
|
if (start === -1) break;
|
||||||
|
const opener = /^\$\{[A-Za-z_][A-Za-z0-9_]*:-/.exec(content.slice(start));
|
||||||
|
if (!opener) {
|
||||||
|
searchFrom = start + 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let depth = 1;
|
||||||
|
let end = start + opener[0].length;
|
||||||
|
while (end < content.length && depth > 0) {
|
||||||
|
if (content.startsWith('${', end)) {
|
||||||
|
depth += 1;
|
||||||
|
end += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (content[end] === '}') depth -= 1;
|
||||||
|
end += 1;
|
||||||
|
}
|
||||||
|
if (depth !== 0) {
|
||||||
|
searchFrom = start + 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
masked += content.slice(copiedThrough, start);
|
||||||
|
preserved.push(content.slice(start, end));
|
||||||
|
masked += `@@GSD4377:${preserved.length - 1}@@`;
|
||||||
|
copiedThrough = end;
|
||||||
|
searchFrom = end;
|
||||||
|
}
|
||||||
|
masked += content.slice(copiedThrough);
|
||||||
|
return rewrite(masked).replace(/@@GSD4377:(\d+)@@/g, (_m, i) => preserved[Number(i)]);
|
||||||
|
}
|
||||||
|
|
||||||
function _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal = false, attribution = undefined) {
|
function _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal = false, attribution = undefined) {
|
||||||
|
// #4377: with a project-relative prefix, run the whole substitution body
|
||||||
|
// with `${VAR:-default}` shell defaults masked out, so the launcher shim
|
||||||
|
// keeps its absolute fallbacks. A no-op for the absolute (default) prefix.
|
||||||
|
if (isRelativePathPrefix(pathPrefix)) {
|
||||||
|
return withShellDefaultsPreserved(
|
||||||
|
content,
|
||||||
|
(masked) => _applyRuntimeRewritesInner(masked, runtime, pathPrefix, isGlobal, attribution),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return _applyRuntimeRewritesInner(content, runtime, pathPrefix, isGlobal, attribution);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _applyRuntimeRewritesInner(content, runtime, pathPrefix, isGlobal = false, attribution = undefined) {
|
||||||
const dirName = getDirName(runtime);
|
const dirName = getDirName(runtime);
|
||||||
const normalizedPathPrefix = pathPrefix.replace(/\/$/, '');
|
const normalizedPathPrefix = pathPrefix.replace(/\/$/, '');
|
||||||
|
|
||||||
@@ -3533,7 +3739,9 @@ function rewriteStagedSkillBodies(stagedDir, opts) {
|
|||||||
const isGlobal = isGlobalScope(scope);
|
const isGlobal = isGlobalScope(scope);
|
||||||
const isOpencode = false; // #2087: opencode installs via the combined-family engine path, never through the generic rewrite
|
const isOpencode = false; // #2087: opencode installs via the combined-family engine path, never through the generic rewrite
|
||||||
const isWindowsHost = platform === 'win32';
|
const isWindowsHost = platform === 'win32';
|
||||||
const pathPrefix = computePathPrefix({ isGlobal, isOpencode, isWindowsHost, resolvedTarget, homeDir });
|
// #4377: localDirName lets a local install emit a project-relative prefix
|
||||||
|
// when opted in; ignored for a global install and when the opt-in is off.
|
||||||
|
const pathPrefix = computePathPrefix({ isGlobal, isOpencode, isWindowsHost, resolvedTarget, homeDir, localDirName: localIncludeDirName(runtime) });
|
||||||
const attribution = resolveAttribution ? resolveAttribution(runtime) : undefined;
|
const attribution = resolveAttribution ? resolveAttribution(runtime) : undefined;
|
||||||
|
|
||||||
applyRuntimeContentRewritesInPlace(stagedDir, runtime, pathPrefix, isGlobal, attribution);
|
applyRuntimeContentRewritesInPlace(stagedDir, runtime, pathPrefix, isGlobal, attribution);
|
||||||
@@ -3585,7 +3793,9 @@ function rewriteStagedCommandBodies(stagedDir, opts) {
|
|||||||
const isGlobal = isGlobalScope(scope);
|
const isGlobal = isGlobalScope(scope);
|
||||||
const isOpencode = false; // #2087: opencode installs via the combined-family engine path, never through the generic rewrite
|
const isOpencode = false; // #2087: opencode installs via the combined-family engine path, never through the generic rewrite
|
||||||
const isWindowsHost = platform === 'win32';
|
const isWindowsHost = platform === 'win32';
|
||||||
const pathPrefix = computePathPrefix({ isGlobal, isOpencode, isWindowsHost, resolvedTarget, homeDir });
|
// #4377: localDirName lets a local install emit a project-relative prefix
|
||||||
|
// when opted in; ignored for a global install and when the opt-in is off.
|
||||||
|
const pathPrefix = computePathPrefix({ isGlobal, isOpencode, isWindowsHost, resolvedTarget, homeDir, localDirName: localIncludeDirName(runtime) });
|
||||||
const attribution = resolveAttribution ? resolveAttribution(runtime) : undefined;
|
const attribution = resolveAttribution ? resolveAttribution(runtime) : undefined;
|
||||||
|
|
||||||
return applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathPrefix, isGlobal, attribution);
|
return applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathPrefix, isGlobal, attribution);
|
||||||
@@ -3636,6 +3846,22 @@ function normalizeAgentBodyForRuntime(content: string, runtime: string, cmdNames
|
|||||||
*/
|
*/
|
||||||
function applyAgentPathRewrites(content: string, runtime: string, pathPrefix: string): string {
|
function applyAgentPathRewrites(content: string, runtime: string, pathPrefix: string): string {
|
||||||
if (_hostBehaviors(runtime).noPathRewrite === true) return content;
|
if (_hostBehaviors(runtime).noPathRewrite === true) return content;
|
||||||
|
// #4377: the agents pipeline is the third emit path that substitutes this
|
||||||
|
// prefix (skills/commands via _applyRuntimeRewrites, the gsd-core spec tree
|
||||||
|
// via copyWithPathReplacement, and here). All three carry the same guard:
|
||||||
|
// with a project-relative prefix, `${VAR:-default}` shell defaults are
|
||||||
|
// masked out so the runtime launcher keeps its absolute fallbacks. A no-op
|
||||||
|
// for the absolute prefix. See withShellDefaultsPreserved for why.
|
||||||
|
if (isRelativePathPrefix(pathPrefix)) {
|
||||||
|
return withShellDefaultsPreserved(
|
||||||
|
content,
|
||||||
|
(masked) => applyAgentPathRewritesInner(masked, runtime, pathPrefix),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return applyAgentPathRewritesInner(content, runtime, pathPrefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAgentPathRewritesInner(content: string, runtime: string, pathPrefix: string): string {
|
||||||
const normalizedPathPrefix = pathPrefix.replace(/\/$/, '');
|
const normalizedPathPrefix = pathPrefix.replace(/\/$/, '');
|
||||||
content = content.replace(/~\/\.claude\//g, pathPrefix);
|
content = content.replace(/~\/\.claude\//g, pathPrefix);
|
||||||
content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
|
content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
|
||||||
@@ -3952,6 +4178,12 @@ export = {
|
|||||||
READONLY_AGENT_DISALLOWED_TOOLS,
|
READONLY_AGENT_DISALLOWED_TOOLS,
|
||||||
applyAgentFrontmatterExtensions,
|
applyAgentFrontmatterExtensions,
|
||||||
_computePathPrefix: computePathPrefix,
|
_computePathPrefix: computePathPrefix,
|
||||||
|
_withShellDefaultsPreserved: withShellDefaultsPreserved,
|
||||||
|
_isRelativePathPrefix: isRelativePathPrefix,
|
||||||
|
_relativeIncludesEnabled: relativeIncludesEnabled,
|
||||||
|
_projectRelativePrefix: projectRelativePrefix,
|
||||||
|
_projectRelativePrefixFromProjectRoot: projectRelativePrefixFromProjectRoot,
|
||||||
|
_localIncludeDirName: localIncludeDirName,
|
||||||
_restoreClaudeGlobalAtRefTilde: restoreClaudeGlobalAtRefTilde,
|
_restoreClaudeGlobalAtRefTilde: restoreClaudeGlobalAtRefTilde,
|
||||||
_applyRuntimeRewrites,
|
_applyRuntimeRewrites,
|
||||||
_stampNonClaudeRuntimeDefaults,
|
_stampNonClaudeRuntimeDefaults,
|
||||||
|
|||||||
@@ -79,12 +79,21 @@ interface ComputePathPrefixOpts {
|
|||||||
isWindowsHost: boolean;
|
isWindowsHost: boolean;
|
||||||
resolvedTarget: string;
|
resolvedTarget: string;
|
||||||
homeDir: string;
|
homeDir: string;
|
||||||
|
/** #4377: the runtime's `localConfigDir`, used only when the project-relative
|
||||||
|
* include style is opted in on a local install. Optional — an omitted value
|
||||||
|
* falls back to the absolute prefix, which is the pre-#4377 behavior. */
|
||||||
|
localDirName?: string;
|
||||||
|
/** #4377: explicit opt-in override. Defaults to `GSD_RELATIVE_INCLUDES === '1'`
|
||||||
|
* inside `_computePathPrefix`; present here so tests can drive both arms
|
||||||
|
* without mutating the environment. */
|
||||||
|
projectRelative?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RuntimeArtifactConversionExports {
|
interface RuntimeArtifactConversionExports {
|
||||||
rewriteStagedSkillBodies: (stagedDir: string, opts: RewriteOpts) => string | void;
|
rewriteStagedSkillBodies: (stagedDir: string, opts: RewriteOpts) => string | void;
|
||||||
rewriteStagedCommandBodies: (stagedDir: string, opts: RewriteOpts) => string | void;
|
rewriteStagedCommandBodies: (stagedDir: string, opts: RewriteOpts) => string | void;
|
||||||
_computePathPrefix: (opts: ComputePathPrefixOpts) => string;
|
_computePathPrefix: (opts: ComputePathPrefixOpts) => string;
|
||||||
|
_localIncludeDirName: (runtime: string) => string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PlanItem {
|
interface PlanItem {
|
||||||
@@ -211,7 +220,9 @@ function createRuntimeArtifactInstallPlan(args: CreateRuntimeArtifactInstallPlan
|
|||||||
const isGlobal = isGlobalScope(scope);
|
const isGlobal = isGlobalScope(scope);
|
||||||
const isOpencode = layout.runtime === 'opencode';
|
const isOpencode = layout.runtime === 'opencode';
|
||||||
const isWindowsHost = (platform ?? process.platform) === 'win32';
|
const isWindowsHost = (platform ?? process.platform) === 'win32';
|
||||||
const pathPrefix = conversionExports._computePathPrefix({ isGlobal, isOpencode, isWindowsHost, resolvedTarget, homeDir });
|
// #4377: descriptor-derived local dir name, so an opted-in local install
|
||||||
|
// emits a project-relative prefix instead of this checkout's absolute path.
|
||||||
|
const pathPrefix = conversionExports._computePathPrefix({ isGlobal, isOpencode, isWindowsHost, resolvedTarget, homeDir, localDirName: conversionExports._localIncludeDirName(layout.runtime) });
|
||||||
const attribution = resolveAttribution ? resolveAttribution(layout.runtime) : undefined;
|
const attribution = resolveAttribution ? resolveAttribution(layout.runtime) : undefined;
|
||||||
// #2875 Part 2 (row I1): layout.configDir IS the install root the inline
|
// #2875 Part 2 (row I1): layout.configDir IS the install root the inline
|
||||||
// agent loop called `targetDir` — same value, same resolution.
|
// agent loop called `targetDir` — same value, same resolution.
|
||||||
|
|||||||
@@ -73,6 +73,18 @@ import retiredArtifactCleanup = require('./retired-artifact-cleanup.cjs');
|
|||||||
const { assertDestWithinConfigHome } = runtimeArtifactInstallPlan;
|
const { assertDestWithinConfigHome } = runtimeArtifactInstallPlan;
|
||||||
|
|
||||||
const SURFACE_FILE_NAME = '.gsd-surface.json';
|
const SURFACE_FILE_NAME = '.gsd-surface.json';
|
||||||
|
const INSTALL_MANIFEST_FILE_NAME = 'gsd-file-manifest.json';
|
||||||
|
|
||||||
|
/** Read only the installer-owned relative-include style, failing closed. */
|
||||||
|
function readRelativeIncludePrefix(runtimeConfigDir: string): string | undefined {
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(fs.readFileSync(path.join(runtimeConfigDir, INSTALL_MANIFEST_FILE_NAME), 'utf8'));
|
||||||
|
const prefix = (parsed as Record<string, unknown>)?.['relativeIncludePrefix'];
|
||||||
|
return typeof prefix === 'string' ? prefix : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -409,7 +421,11 @@ function applySurface(runtimeConfigDir: string, layout: Layout, manifest: Map<st
|
|||||||
const _isGlobal = isGlobalScope(layout.scope ?? 'global');
|
const _isGlobal = isGlobalScope(layout.scope ?? 'global');
|
||||||
const _isOpencode = layout.runtime === 'opencode';
|
const _isOpencode = layout.runtime === 'opencode';
|
||||||
const _isWindowsHost = (opts?.platform ?? process.platform) === 'win32';
|
const _isWindowsHost = (opts?.platform ?? process.platform) === 'win32';
|
||||||
const _pathPrefix = runtimeArtifactConversion._computePathPrefix({ isGlobal: _isGlobal, isOpencode: _isOpencode, isWindowsHost: _isWindowsHost, resolvedTarget: _resolvedTarget, homeDir: _homeDir });
|
// #4377: style is an install-time fact, not a process environment setting.
|
||||||
|
// A later gsd-tools surface apply runs in another process, so it must reuse
|
||||||
|
// the persisted, root-relative prefix the installer actually emitted.
|
||||||
|
const _relativeIncludePrefix = readRelativeIncludePrefix(runtimeConfigDir);
|
||||||
|
const _pathPrefix = runtimeArtifactConversion._computePathPrefix({ isGlobal: _isGlobal, isOpencode: _isOpencode, isWindowsHost: _isWindowsHost, resolvedTarget: _resolvedTarget, homeDir: _homeDir, projectRelative: !_isGlobal && typeof _relativeIncludePrefix === 'string', projectRelativePath: _relativeIncludePrefix, localDirName: runtimeArtifactConversion._localIncludeDirName(layout.runtime) });
|
||||||
const _attribution = opts?.resolveAttribution ? opts.resolveAttribution(layout.runtime) : undefined;
|
const _attribution = opts?.resolveAttribution ? opts.resolveAttribution(layout.runtime) : undefined;
|
||||||
// #2875 Part 2 (row I1): layout.configDir is this call's install root.
|
// #2875 Part 2 (row I1): layout.configDir is this call's install root.
|
||||||
const agentCtx: AgentCtx = { runtime: layout.runtime, pathPrefix: _pathPrefix, attribution: _attribution, targetDir: layout.configDir };
|
const agentCtx: AgentCtx = { runtime: layout.runtime, pathPrefix: _pathPrefix, attribution: _attribution, targetDir: layout.configDir };
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const path = require('node:path');
|
|||||||
const crypto = require('node:crypto');
|
const crypto = require('node:crypto');
|
||||||
const os = require('node:os');
|
const os = require('node:os');
|
||||||
const espree = require('espree');
|
const espree = require('espree');
|
||||||
|
const fc = require('./helpers/fast-check-setup.cjs');
|
||||||
const { splitLines, joinLines } = require('../gsd-core/bin/lib/text-lines.cjs');
|
const { splitLines, joinLines } = require('../gsd-core/bin/lib/text-lines.cjs');
|
||||||
|
|
||||||
const { createTempDir, cleanup, writePackageSourceMarkerFixture } = require('./helpers.cjs');
|
const { createTempDir, cleanup, writePackageSourceMarkerFixture } = require('./helpers.cjs');
|
||||||
@@ -5219,6 +5220,213 @@ describe('rewriteStagedCommandBodies', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// #4377: project-relative includes for local installs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('#4377 _computePathPrefix — project-relative local includes', () => {
|
||||||
|
// This block sits inside the enh-1511 fold, whose scope has its own
|
||||||
|
// `conversion` binding and does NOT see the outer file's
|
||||||
|
// `runtimeNamePolicy` (that one belongs to the fold that closed above).
|
||||||
|
// require() is cached, so this is a lookup, not a second load.
|
||||||
|
const namePolicy = require('../gsd-core/bin/lib/runtime-name-policy.cjs');
|
||||||
|
// A local install baked the install-time absolute path into every generated
|
||||||
|
// @ include. Worked from several git worktrees, that means each worktree
|
||||||
|
// runs its own engine but reads its workflow prose out of ONE checkout —
|
||||||
|
// and updating that checkout breaks every other worktree at once, with no
|
||||||
|
// way to stage it. The relative form lets each worktree read its own copy.
|
||||||
|
//
|
||||||
|
// Every arm below passes `projectRelative` explicitly rather than leaning on
|
||||||
|
// the environment default, so these assertions cannot flip on an ambient
|
||||||
|
// GSD_RELATIVE_INCLUDES leaking in from the runner.
|
||||||
|
const prefix = (over) => conversion._computePathPrefix({
|
||||||
|
isGlobal: false,
|
||||||
|
isOpencode: false,
|
||||||
|
isWindowsHost: false,
|
||||||
|
resolvedTarget: '/project/.cursor',
|
||||||
|
homeDir: '/home/u',
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opted in, a local install emits the descriptor dir, not the checkout path', () => {
|
||||||
|
assert.equal(prefix({ projectRelative: true, localDirName: '.cursor' }), '.cursor/');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opted OUT, a local install is byte-identical to the pre-#4377 behavior', () => {
|
||||||
|
// The default must not change for the single-checkout majority.
|
||||||
|
assert.equal(prefix({ projectRelative: false, localDirName: '.cursor' }), '/project/.cursor/');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a GLOBAL install ignores the opt-in entirely', () => {
|
||||||
|
// The $HOME-shorthand branch is the global contract and #4377 does not
|
||||||
|
// touch it — a relative include in a global install would resolve against
|
||||||
|
// whatever project the user happens to be sitting in.
|
||||||
|
assert.equal(
|
||||||
|
conversion._computePathPrefix({
|
||||||
|
isGlobal: true, isOpencode: false, isWindowsHost: false,
|
||||||
|
resolvedTarget: '/home/u/.cursor', homeDir: '/home/u',
|
||||||
|
projectRelative: true, localDirName: '.cursor',
|
||||||
|
}),
|
||||||
|
'$HOME/.cursor/',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a nested descriptor dir survives as a nested relative prefix', () => {
|
||||||
|
assert.equal(
|
||||||
|
prefix({ projectRelative: true, localDirName: '.config/opencode', resolvedTarget: '/project/.config/opencode' }),
|
||||||
|
'.config/opencode/',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an explicit local target derives its prefix from the resolved target, not the runtime default', () => {
|
||||||
|
assert.equal(
|
||||||
|
prefix({ projectRelative: true, projectRoot: '/project', resolvedTarget: '/project/.custom/claude', localDirName: '.claude' }),
|
||||||
|
'.custom/claude/',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a Windows-style descriptor value is normalized to POSIX', () => {
|
||||||
|
// The prefix is substituted into markdown @-references, which are POSIX
|
||||||
|
// universally — a backslash here leaks into shipped content (#1615).
|
||||||
|
assert.equal(prefix({ projectRelative: true, localDirName: '.claude\\nested' }), '.claude/nested/');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a trailing slash in the descriptor does not double up', () => {
|
||||||
|
assert.equal(prefix({ projectRelative: true, localDirName: '.cursor/' }), '.cursor/');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── fail-safe arms: anything unexpressible falls back to absolute ──────────
|
||||||
|
// A wrong-but-absolute include still points at a real file. A wrong RELATIVE
|
||||||
|
// one silently resolves against whatever the reader's cwd happens to be,
|
||||||
|
// which is a worse failure than the one being fixed.
|
||||||
|
for (const [label, localDirName] of [
|
||||||
|
['the no-local-config-dir sentinel (vscode)', namePolicy.NO_LOCAL_CONFIG_DIR_SENTINEL],
|
||||||
|
['an absolute descriptor value', '/etc/gsd'],
|
||||||
|
['a Windows-absolute descriptor value', 'C:/gsd'],
|
||||||
|
['a value climbing out of the project', '../outside'],
|
||||||
|
['a value climbing out of the project mid-path', 'nested/../../outside'],
|
||||||
|
['a bare ..', '..'],
|
||||||
|
['an empty value', ''],
|
||||||
|
['a missing value', undefined],
|
||||||
|
]) {
|
||||||
|
test(`falls back to the absolute prefix for ${label}`, () => {
|
||||||
|
assert.equal(prefix({ projectRelative: true, localDirName }), '/project/.cursor/');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#4377 _relativeIncludesEnabled — the opt-in is off unless asked for', () => {
|
||||||
|
test('reads GSD_RELATIVE_INCLUDES=1 from the injected environment', () => {
|
||||||
|
assert.equal(conversion._relativeIncludesEnabled({ GSD_RELATIVE_INCLUDES: '1' }), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('anything other than the exact string 1 is off', () => {
|
||||||
|
// No truthiness coercion: 'true'/'0'/'' must not silently opt a user in.
|
||||||
|
for (const value of ['true', 'yes', '0', '', 'TRUE', ' 1']) {
|
||||||
|
assert.equal(conversion._relativeIncludesEnabled({ GSD_RELATIVE_INCLUDES: value }), false, `value=${JSON.stringify(value)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an absent variable is off', () => {
|
||||||
|
assert.equal(conversion._relativeIncludesEnabled({}), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the opt-in is true for exactly the string 1 over arbitrary JSON values', () => {
|
||||||
|
fc.assert(fc.property(fc.jsonValue(), (value) => {
|
||||||
|
assert.equal(
|
||||||
|
conversion._relativeIncludesEnabled({ GSD_RELATIVE_INCLUDES: value }),
|
||||||
|
value === '1',
|
||||||
|
);
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#4377 project-relative prefix properties', () => {
|
||||||
|
const segment = fc.array(
|
||||||
|
fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789_-'),
|
||||||
|
{ minLength: 1, maxLength: 12 },
|
||||||
|
).map((chars) => chars.join(''));
|
||||||
|
|
||||||
|
test('safe descriptor segments normalize to one POSIX prefix', () => {
|
||||||
|
fc.assert(fc.property(
|
||||||
|
fc.array(segment, { minLength: 1, maxLength: 5 }),
|
||||||
|
fc.constantFrom('/', '\\'),
|
||||||
|
fc.boolean(),
|
||||||
|
(segments, separator, trailingSlash) => {
|
||||||
|
const localDirName = segments.join(separator) + (trailingSlash ? separator : '');
|
||||||
|
assert.equal(conversion._projectRelativePrefix(localDirName), `${segments.join('/')}/`);
|
||||||
|
},
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a traversal segment is rejected at every generated depth', () => {
|
||||||
|
fc.assert(fc.property(
|
||||||
|
fc.array(segment, { maxLength: 4 }),
|
||||||
|
fc.array(segment, { maxLength: 4 }),
|
||||||
|
(before, after) => {
|
||||||
|
assert.equal(conversion._projectRelativePrefix([...before, '..', ...after].join('/')), '');
|
||||||
|
},
|
||||||
|
));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('#4377: a project-root local target falls back to absolute includes', () => {
|
||||||
|
assert.equal(conversion._localIncludeDirName('cline'), undefined);
|
||||||
|
assert.equal(conversion._localIncludeDirName('claude'), '.claude');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#4377 relative rewrites preserve every runtime launcher shell default', () => {
|
||||||
|
test('the shared mask preserves a complete nested shell default as one unit', () => {
|
||||||
|
const nested = '${OUTER:-${INNER:-$HOME/.claude}/gsd-core}';
|
||||||
|
const input = `outside=$HOME/.claude inside=${nested}`;
|
||||||
|
const rewritten = conversion._withShellDefaultsPreserved(
|
||||||
|
input,
|
||||||
|
(body) => body.replace(/\$HOME\/\.claude/g, '.claude'),
|
||||||
|
);
|
||||||
|
assert.equal(rewritten, `outside=.claude inside=${nested}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('all emitted runtime conversions leave ${VAR:-default} probes verbatim', () => {
|
||||||
|
const launcher = fs.readFileSync(
|
||||||
|
path.join(__dirname, '..', 'gsd-core', 'workflows', '_runtime-launcher.snippet.sh'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const defaults = [];
|
||||||
|
for (let start = launcher.indexOf('${'); start !== -1; start = launcher.indexOf('${', start + 2)) {
|
||||||
|
let depth = 1;
|
||||||
|
let end = start + 2;
|
||||||
|
while (end < launcher.length && depth > 0) {
|
||||||
|
if (launcher.startsWith('${', end)) {
|
||||||
|
depth += 1;
|
||||||
|
end += 2;
|
||||||
|
} else {
|
||||||
|
if (launcher[end] === '}') depth -= 1;
|
||||||
|
end += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (depth !== 0) break;
|
||||||
|
const expansion = launcher.slice(start, end);
|
||||||
|
if (/^\$\{[A-Za-z_][A-Za-z0-9_]*:-/.test(expansion)) defaults.push(expansion);
|
||||||
|
start = end - 2;
|
||||||
|
}
|
||||||
|
assert.ok(defaults.length > 0, 'the launcher fixture must contain shell-default probes');
|
||||||
|
|
||||||
|
for (const runtime of ['claude', ...conversion.NON_CLAUDE_RUNTIMES]) {
|
||||||
|
const rewritten = conversion._applyRuntimeRewrites(
|
||||||
|
launcher,
|
||||||
|
runtime,
|
||||||
|
`.${runtime}/`,
|
||||||
|
);
|
||||||
|
for (const shellDefault of defaults) {
|
||||||
|
assert.ok(
|
||||||
|
rewritten.includes(shellDefault),
|
||||||
|
`${runtime} relative rewrite must preserve ${shellDefault}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Error-path: applyRuntimeContentRewritesForCommandsInPlace must rm the tempDir
|
// Error-path: applyRuntimeContentRewritesForCommandsInPlace must rm the tempDir
|
||||||
// on any exception and NOT leave an orphaned gsd-cmd-rewrites-* directory.
|
// on any exception and NOT leave an orphaned gsd-cmd-rewrites-* directory.
|
||||||
|
|||||||
@@ -8204,3 +8204,223 @@ describe('#607 cleanupLegacyGsdCc: exported helper unit tests', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// #4377 — --relative-includes: a local install must not bake the checkout path
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// The unit arms in tests/install-runtime-artifacts.test.cjs pin
|
||||||
|
// _computePathPrefix itself. This is the end-to-end proof: the prefix has to
|
||||||
|
// travel from the CLI flag, through the install engine, through the rewrite
|
||||||
|
// pass, and land in the bytes on disk. A pure-function test alone would pass
|
||||||
|
// happily while any one of those six seams kept computing the absolute form.
|
||||||
|
describe('#4377: --relative-includes emits project-relative @ includes for a local install', () => {
|
||||||
|
// `before`/`after` are not in this file's tail scope (the earlier folds each
|
||||||
|
// bring their own); pull them from node:test directly rather than relying on
|
||||||
|
// whatever binding happens to be visible here.
|
||||||
|
const { before: beforeAll, after: afterAll } = require('node:test');
|
||||||
|
const { installSpawnEnv } = require('./helpers.cjs');
|
||||||
|
const { throwIfFailed } = require('./helpers/git-fixture.cjs');
|
||||||
|
const { INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
||||||
|
const installPath = path.join(__dirname, '..', 'bin', 'install.js');
|
||||||
|
|
||||||
|
// Two independent reasons the raw temp root does not appear in emitted
|
||||||
|
// content, both of which would make the negative arms pass VACUOUSLY —
|
||||||
|
// "nothing references the checkout" is trivially true when the string being
|
||||||
|
// searched for cannot occur:
|
||||||
|
//
|
||||||
|
// 1. The emitted prefix is POSIX-normalized by design, because it is
|
||||||
|
// substituted into markdown @-references, which use forward slashes
|
||||||
|
// universally; a backslash there would leak into shipped content
|
||||||
|
// (#1615). On Windows the roots arrive as `D:\a\...`.
|
||||||
|
// 2. On macOS `os.tmpdir()` yields `/var/folders/...`, but `/var` is a
|
||||||
|
// symlink to `/private/var`, and the installer resolves it — so the
|
||||||
|
// emitted content carries `/private/var/folders/...` while
|
||||||
|
// `fs.mkdtempSync` handed us the unresolved spelling.
|
||||||
|
//
|
||||||
|
// Every comparison therefore goes through both spellings.
|
||||||
|
const toPosix = (p2) => p2.replace(/\\/g, '/');
|
||||||
|
//
|
||||||
|
// LONGEST FIRST, and that ordering is load-bearing. `/var/folders/…/X` is a
|
||||||
|
// SUBSTRING of `/private/var/folders/…/X`, so stripping the short spelling
|
||||||
|
// first matches inside the long one and leaves the `/private` prefix glued
|
||||||
|
// to what followed it: `@/private/var/…/X/.claude/gsd-core/…` normalizes to
|
||||||
|
// `@/private.claude/gsd-core/…`, a string that appears in neither install.
|
||||||
|
// Removing the long form first consumes the whole occurrence, and the short
|
||||||
|
// form then has nothing left to match.
|
||||||
|
const rootSpellings = (root) => {
|
||||||
|
const forms = new Set([toPosix(root)]);
|
||||||
|
try { forms.add(toPosix(fs.realpathSync(root))); } catch { /* root already gone */ }
|
||||||
|
return [...forms].sort((a, b) => b.length - a.length);
|
||||||
|
};
|
||||||
|
const mentionsRoot = (content, root) => rootSpellings(root).some((r) => content.includes(r));
|
||||||
|
|
||||||
|
// Self-contained runner: the file's other runInstall helpers live inside
|
||||||
|
// folded blocks and are not in scope here. #3156's sandboxed HOME still
|
||||||
|
// applies — the installer writes <home>/.gsd/defaults.json via os.homedir()
|
||||||
|
// directly, which no env scrub reaches.
|
||||||
|
const install = (cwd, args) => {
|
||||||
|
const env = installSpawnEnv();
|
||||||
|
delete env.GSD_TEST_MODE;
|
||||||
|
// The relative-includes opt-in is dual-sourced (flag OR env), so an
|
||||||
|
// ambient GSD_RELATIVE_INCLUDES would silently opt the CONTROL arm in and
|
||||||
|
// make this suite prove nothing. Scrub it and let the flag speak.
|
||||||
|
delete env.GSD_RELATIVE_INCLUDES;
|
||||||
|
const r = runNode([installPath, ...args], {
|
||||||
|
cwd, env, timeoutMs: INSTALL_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
throwIfFailed(r, `node ${installPath} ${args.join(' ')}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
let absDir;
|
||||||
|
let relDir;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
absDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4377-abs-'));
|
||||||
|
relDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4377-rel-'));
|
||||||
|
install(absDir, ['--claude', '--local', '--no-sdk']);
|
||||||
|
install(relDir, ['--claude', '--local', '--no-sdk', '--relative-includes']);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
cleanup(absDir);
|
||||||
|
cleanup(relDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Walk the WHOLE installed tree, not just commands/. The issue measured 145
|
||||||
|
// affected files across commands, workflows and references on a real repo —
|
||||||
|
// a commands-only assertion would have called this fixed while 124 workflow
|
||||||
|
// and reference files still carried the baked path.
|
||||||
|
const allBodies = (root, base = path.join(root, '.claude')) => {
|
||||||
|
const out = [];
|
||||||
|
const walk = (dir) => {
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) { walk(full); continue; }
|
||||||
|
if (!entry.isFile()) continue;
|
||||||
|
out.push({ rel: path.relative(base, full), content: fs.readFileSync(full, 'utf-8') });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (fs.existsSync(base)) walk(base);
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
test('the default install still bakes the absolute checkout path (unchanged behavior)', () => {
|
||||||
|
// The control. Without it, a change that broke the absolute form entirely
|
||||||
|
// would satisfy every assertion below and look like a fix.
|
||||||
|
const carriers = allBodies(absDir).filter((f) => mentionsRoot(f.content, absDir));
|
||||||
|
assert.ok(
|
||||||
|
carriers.length > 0,
|
||||||
|
'#4377 is opt-in: the default local install must keep emitting absolute includes',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('with the flag, NOTHING in the installed tree references the checkout it came from', () => {
|
||||||
|
const offenders = allBodies(relDir).filter((f) => mentionsRoot(f.content, relDir)).map((f) => f.rel);
|
||||||
|
assert.deepEqual(offenders, [], 'no installed file may reference the checkout it was installed from');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('with the flag, the includes are project-relative and still resolve to gsd-core', () => {
|
||||||
|
// Not merely "absent" — an implementation that stripped the prefix would
|
||||||
|
// pass the arm above while shipping includes that point nowhere.
|
||||||
|
const withRelative = allBodies(relDir).filter((f) => f.content.includes('@.claude/gsd-core/'));
|
||||||
|
assert.ok(withRelative.length > 0, 'expected @.claude/gsd-core/ includes in the relative install');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the local install manifest persists the relative style for a later surface apply', () => {
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(path.join(relDir, '.claude', 'gsd-file-manifest.json'), 'utf8'));
|
||||||
|
assert.equal(manifest.relativeIncludePrefix, '.claude/');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a project-root runtime falls back to absolute includes instead of inventing its descriptor directory', (t) => {
|
||||||
|
// Cline declares localTargetIsProjectRoot: local agents live in `agents/`,
|
||||||
|
// not `.cline/agents/`. A relative `.cline/` prefix would therefore point
|
||||||
|
// at a directory the install never creates. This crosses the real install
|
||||||
|
// plan and agent-rewrite seam; testing _localIncludeDirName alone would not
|
||||||
|
// prove every production consumer uses the guard.
|
||||||
|
const clineDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4377-cline-'));
|
||||||
|
t.after(() => cleanup(clineDir));
|
||||||
|
install(clineDir, ['--cline', '--local', '--no-sdk', '--relative-includes']);
|
||||||
|
|
||||||
|
const agents = allBodies(clineDir, path.join(clineDir, 'agents'));
|
||||||
|
assert.ok(agents.length > 0, 'the local Cline install must emit agents at the project root');
|
||||||
|
const descriptorRelative = agents.filter((f) => f.content.includes('@.cline/gsd-core/')).map((f) => f.rel);
|
||||||
|
assert.deepEqual(descriptorRelative, [], 'Cline has no .cline/ local target, so its includes must not use one');
|
||||||
|
const absoluteFallback = agents.filter((f) => rootSpellings(clineDir)
|
||||||
|
.some((root) => f.content.includes(`@${root}/gsd-core/`)));
|
||||||
|
assert.ok(absoluteFallback.length > 0, 'an unrepresentable project-relative target must retain the safe absolute prefix');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the launcher shim keeps ABSOLUTE shell defaults even with the flag', () => {
|
||||||
|
// The one place a relative prefix would make things worse. The runtime
|
||||||
|
// launcher probes gsd-tools through `${CLAUDE_CONFIG_DIR:-$HOME/.claude}`;
|
||||||
|
// that is a shell word expansion, not an @ include, and a bare `.claude`
|
||||||
|
// there resolves against the shell's cwd rather than the project. Trading
|
||||||
|
// an include that points at the wrong checkout for a path that points at
|
||||||
|
// nothing would not be a fix.
|
||||||
|
const carriers = allBodies(relDir).filter((f) => f.content.includes('CLAUDE_CONFIG_DIR:-'));
|
||||||
|
assert.ok(carriers.length > 0, 'expected the launcher snippet in the installed tree');
|
||||||
|
const offenders = carriers
|
||||||
|
.filter((f) => !/CLAUDE_CONFIG_DIR:-\$HOME\/\.claude\}/.test(f.content))
|
||||||
|
.map((f) => f.rel);
|
||||||
|
assert.deepEqual(offenders, [], 'the launcher fallback must stay $HOME-absolute');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no masking sentinel survives into the installed tree', () => {
|
||||||
|
// The shell-default guard masks with a literal token before rewriting and
|
||||||
|
// restores after. A restore that missed would ship that token as content.
|
||||||
|
// gsd-core/bin/lib/ carries the implementation itself, so exclude it.
|
||||||
|
const leaked = allBodies(relDir)
|
||||||
|
.filter((f) => !f.rel.startsWith(path.join('gsd-core', 'bin', 'lib')))
|
||||||
|
.filter((f) => f.content.includes('@@GSD4377:'))
|
||||||
|
.map((f) => f.rel);
|
||||||
|
assert.deepEqual(leaked, [], 'the shell-default mask must be fully restored');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the two installs differ ONLY where the flag is meant to change things', () => {
|
||||||
|
// The strongest arm. Normalize the absolute install into what the relative
|
||||||
|
// one should be — undo the two deliberate differences — and the trees must
|
||||||
|
// then be byte-identical. Anything ELSE the flag touched surfaces here
|
||||||
|
// instead of going unnoticed.
|
||||||
|
const abs = allBodies(absDir);
|
||||||
|
const rel = new Map(allBodies(relDir).map((f) => [f.rel, f.content]));
|
||||||
|
assert.ok(abs.length > 0, 'the control install must produce files');
|
||||||
|
const mismatched = [];
|
||||||
|
for (const { rel: name, content } of abs) {
|
||||||
|
if (!rel.has(name)) { mismatched.push(`${name} (missing from relative install)`); continue; }
|
||||||
|
// Both manifests record per-file absolute paths, which legitimately
|
||||||
|
// differ between two different install roots.
|
||||||
|
if (name === 'gsd-file-manifest.json' || name === 'gsd-install-state.json') continue;
|
||||||
|
let normalized = content;
|
||||||
|
for (const absRoot of rootSpellings(absDir)) {
|
||||||
|
normalized = normalized
|
||||||
|
// (1) shell defaults: the absolute install rewrote them to its own
|
||||||
|
// root; the relative install left them at $HOME.
|
||||||
|
.split(`:-${absRoot}/.claude}`).join(':-$HOME/.claude}')
|
||||||
|
// (2) the include prefix itself, both the slash form and the bare
|
||||||
|
// trailing form the word-boundary passes emit.
|
||||||
|
.split(`${absRoot}/.claude/`).join('.claude/')
|
||||||
|
.split(`${absRoot}/.claude`).join('.claude');
|
||||||
|
}
|
||||||
|
if (normalized !== rel.get(name)) mismatched.push({ name, normalized, actual: rel.get(name) });
|
||||||
|
}
|
||||||
|
// Report the FIRST divergence as text, not just a list of filenames. A
|
||||||
|
// bare file list says a difference exists somewhere in 236 files and
|
||||||
|
// leaves the reader to guess which bytes — and on a platform I cannot
|
||||||
|
// reproduce locally, guessing is what turns one CI round-trip into four.
|
||||||
|
const detail = mismatched.length === 0 ? '' : (() => {
|
||||||
|
const { name, normalized, actual } = mismatched[0];
|
||||||
|
let at = 0;
|
||||||
|
while (at < normalized.length && at < actual.length && normalized[at] === actual[at]) at += 1;
|
||||||
|
const from = Math.max(0, at - 60);
|
||||||
|
return `\nfirst divergence in ${name} at offset ${at}:\n`
|
||||||
|
+ ` absolute(normalized): ${JSON.stringify(normalized.slice(from, at + 120))}\n`
|
||||||
|
+ ` relative(actual): ${JSON.stringify(actual.slice(from, at + 120))}\n`
|
||||||
|
+ `(${mismatched.length} file(s) differ: ${mismatched.slice(0, 8).map((m) => m.name).join(', ')}${mismatched.length > 8 ? ', …' : ''})`;
|
||||||
|
})();
|
||||||
|
assert.deepEqual(
|
||||||
|
mismatched.map((m) => m.name), [],
|
||||||
|
`the flag must change the include prefix, and nothing beyond it${detail}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user