* 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:
@@ -2044,6 +2044,9 @@ function installOpencodeFamilyArtifacts(
|
||||
isWindowsHost: process.platform === 'win32',
|
||||
resolvedTarget: posixNormalize(path.resolve(configDir)),
|
||||
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
|
||||
|
||||
@@ -2971,15 +2971,64 @@ function convertClaudeCommandToKiloSkill(content, skillName) {
|
||||
// to the originals; the only change is the injected `attribution` 5th param in
|
||||
// _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.
|
||||
* Global installs under $HOME use $HOME/... form; others use the resolved target.
|
||||
* isOpencode excludes OpenCode (uses ~/.config/opencode which breaks $HOME shorthand).
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
// substituted into markdown @-references (e.g. Windsurf workflow files),
|
||||
// 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) {
|
||||
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 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.
|
||||
* 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.
|
||||
*/
|
||||
/**
|
||||
* #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) {
|
||||
// #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 normalizedPathPrefix = pathPrefix.replace(/\/$/, '');
|
||||
|
||||
@@ -3533,7 +3739,9 @@ function rewriteStagedSkillBodies(stagedDir, opts) {
|
||||
const isGlobal = isGlobalScope(scope);
|
||||
const isOpencode = false; // #2087: opencode installs via the combined-family engine path, never through the generic rewrite
|
||||
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;
|
||||
|
||||
applyRuntimeContentRewritesInPlace(stagedDir, runtime, pathPrefix, isGlobal, attribution);
|
||||
@@ -3585,7 +3793,9 @@ function rewriteStagedCommandBodies(stagedDir, opts) {
|
||||
const isGlobal = isGlobalScope(scope);
|
||||
const isOpencode = false; // #2087: opencode installs via the combined-family engine path, never through the generic rewrite
|
||||
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;
|
||||
|
||||
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 {
|
||||
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(/\/$/, '');
|
||||
content = content.replace(/~\/\.claude\//g, pathPrefix);
|
||||
content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
|
||||
@@ -3952,6 +4178,12 @@ export = {
|
||||
READONLY_AGENT_DISALLOWED_TOOLS,
|
||||
applyAgentFrontmatterExtensions,
|
||||
_computePathPrefix: computePathPrefix,
|
||||
_withShellDefaultsPreserved: withShellDefaultsPreserved,
|
||||
_isRelativePathPrefix: isRelativePathPrefix,
|
||||
_relativeIncludesEnabled: relativeIncludesEnabled,
|
||||
_projectRelativePrefix: projectRelativePrefix,
|
||||
_projectRelativePrefixFromProjectRoot: projectRelativePrefixFromProjectRoot,
|
||||
_localIncludeDirName: localIncludeDirName,
|
||||
_restoreClaudeGlobalAtRefTilde: restoreClaudeGlobalAtRefTilde,
|
||||
_applyRuntimeRewrites,
|
||||
_stampNonClaudeRuntimeDefaults,
|
||||
|
||||
@@ -79,12 +79,21 @@ interface ComputePathPrefixOpts {
|
||||
isWindowsHost: boolean;
|
||||
resolvedTarget: 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 {
|
||||
rewriteStagedSkillBodies: (stagedDir: string, opts: RewriteOpts) => string | void;
|
||||
rewriteStagedCommandBodies: (stagedDir: string, opts: RewriteOpts) => string | void;
|
||||
_computePathPrefix: (opts: ComputePathPrefixOpts) => string;
|
||||
_localIncludeDirName: (runtime: string) => string | undefined;
|
||||
}
|
||||
|
||||
interface PlanItem {
|
||||
@@ -211,7 +220,9 @@ function createRuntimeArtifactInstallPlan(args: CreateRuntimeArtifactInstallPlan
|
||||
const isGlobal = isGlobalScope(scope);
|
||||
const isOpencode = layout.runtime === 'opencode';
|
||||
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;
|
||||
// #2875 Part 2 (row I1): layout.configDir IS the install root the inline
|
||||
// agent loop called `targetDir` — same value, same resolution.
|
||||
|
||||
@@ -73,6 +73,18 @@ import retiredArtifactCleanup = require('./retired-artifact-cleanup.cjs');
|
||||
const { assertDestWithinConfigHome } = runtimeArtifactInstallPlan;
|
||||
|
||||
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
|
||||
@@ -409,7 +421,11 @@ function applySurface(runtimeConfigDir: string, layout: Layout, manifest: Map<st
|
||||
const _isGlobal = isGlobalScope(layout.scope ?? 'global');
|
||||
const _isOpencode = layout.runtime === 'opencode';
|
||||
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;
|
||||
// #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 };
|
||||
|
||||
Reference in New Issue
Block a user