* 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>
281 lines
11 KiB
TypeScript
281 lines
11 KiB
TypeScript
'use strict';
|
|
|
|
/**
|
|
* Runtime Artifact Install Plan Module.
|
|
*
|
|
* Turns a pre-resolved runtime artifact layout into staged copy inputs. The
|
|
* installer adapter still owns pruning, copying, migrations, output, and final
|
|
* cleanup execution.
|
|
*/
|
|
|
|
// In .cts (CommonJS output) files, `require` is available as a global.
|
|
const _require: NodeRequire = require;
|
|
const path = _require('node:path') as typeof import('node:path');
|
|
const { tryWithinRootLexical } = _require('./security.cjs') as typeof import('./security.cjs');
|
|
|
|
// #2870: InstallScope is owned by install-scope.cts, not re-declared here.
|
|
// `isGlobalScope` centralizes the `scope === 'global'` boolean projection
|
|
// this module needs at `_computePathPrefix`'s `isGlobal: boolean` boundary
|
|
// (see the module-level doc comment on `isGlobalScope` for why the
|
|
// projection is centralized rather than eliminated).
|
|
import { isGlobalScope, type InstallScope } from './install-scope.cjs';
|
|
|
|
type ArtifactKindName = 'commands' | 'agents' | 'skills' | 'kimi-agents';
|
|
|
|
interface ResolvedProfile {
|
|
name?: string;
|
|
skills?: Set<string> | '*';
|
|
agents?: Set<string>;
|
|
}
|
|
|
|
interface AgentCtx {
|
|
runtime: string;
|
|
pathPrefix: string;
|
|
attribution: string | null | undefined;
|
|
/** #2875 Part 2 (row I1): install root, threaded through so the
|
|
* descriptor pipeline's frontmatter-extensions step and model-override
|
|
* resolution can read config exactly as the inline agent loop's own
|
|
* `targetDir` variable did. */
|
|
targetDir?: string | null;
|
|
/** Project/config discovery root, distinct from global artifact destinations. */
|
|
projectDir?: string | null;
|
|
}
|
|
|
|
interface ArtifactKind {
|
|
kind: ArtifactKindName;
|
|
destSubpath: string;
|
|
prefix?: string;
|
|
stage: (resolvedProfile: ResolvedProfile, agentCtx?: AgentCtx) => string;
|
|
/** Resolved absolute alternate install root for this kind, if the descriptor
|
|
* specifies one (e.g. codex skills → $HOME/.agents). Undefined means the
|
|
* kind installs under the runtime's normal configDir. */
|
|
home?: string;
|
|
}
|
|
|
|
interface Layout {
|
|
runtime: string;
|
|
configDir: string;
|
|
scope?: InstallScope;
|
|
kinds: ArtifactKind[];
|
|
}
|
|
|
|
interface RewriteOpts {
|
|
runtime: string;
|
|
configDir: string;
|
|
scope: InstallScope;
|
|
homedir?: () => string;
|
|
platform?: NodeJS.Platform;
|
|
resolveAttribution?: (runtime: string) => string | null | undefined;
|
|
}
|
|
|
|
interface Dependencies {
|
|
rewriteStagedSkillBodies?: (stagedDir: string, opts: RewriteOpts) => string | void;
|
|
rewriteStagedCommandBodies?: (stagedDir: string, opts: RewriteOpts) => string | void;
|
|
}
|
|
|
|
interface ComputePathPrefixOpts {
|
|
isGlobal: boolean;
|
|
isOpencode: boolean;
|
|
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 {
|
|
kind: ArtifactKindName;
|
|
sourceDir: string;
|
|
destDir: string;
|
|
}
|
|
|
|
interface InstallPlan {
|
|
items: PlanItem[];
|
|
cleanupDirs: string[];
|
|
}
|
|
|
|
interface UninstallPlanItem {
|
|
kind: ArtifactKindName;
|
|
destDir: string;
|
|
}
|
|
|
|
interface UninstallPlan {
|
|
items: UninstallPlanItem[];
|
|
}
|
|
|
|
type InstallPlanResult =
|
|
| { ok: true; plan: InstallPlan }
|
|
| { ok: false; kind: 'stage_failed' | 'rewrite_failed'; message: string; cleanupDirs: string[]; failedKind?: ArtifactKindName };
|
|
|
|
interface CreateRuntimeArtifactInstallPlanArgs {
|
|
layout: Layout;
|
|
resolvedProfile: ResolvedProfile;
|
|
homedir?: () => string;
|
|
platform?: NodeJS.Platform;
|
|
resolveAttribution?: (runtime: string) => string | null | undefined;
|
|
projectDir?: string | null;
|
|
deps?: Dependencies;
|
|
}
|
|
|
|
/**
|
|
* Asserts that `destSubpath` resolves to a path inside `configDir`.
|
|
*
|
|
* Rejects any path that escapes the configDir root (e.g. "../../etc") and any
|
|
* path containing a NUL byte. This is a security gate for Phase B of
|
|
* ADR-1239: third-party descriptors must never be able to write outside the
|
|
* designated config home directory.
|
|
*
|
|
* @param configDir - The root config directory (e.g. ~/.claude).
|
|
* @param destSubpath - The relative path declared by the runtime descriptor.
|
|
* @returns The resolved absolute path under configDir.
|
|
* @throws {Error} if destSubpath escapes configDir or contains a NUL byte.
|
|
*/
|
|
function assertDestWithinConfigHome(configDir: string, destSubpath: string): string {
|
|
if (destSubpath.includes('\0')) {
|
|
throw new Error(
|
|
`destSubpath "${destSubpath}" contains a NUL byte and is not valid`,
|
|
);
|
|
}
|
|
const root = path.resolve(configDir);
|
|
// `resolved === root` is a DELIBERATE ADDITIONAL rejection, separate from
|
|
// the containment decision: `tryWithinRootLexical` treats target === root
|
|
// as CONTAINED, but a destSubpath of "" (or one that resolves to configDir
|
|
// itself) must never be accepted here — this is the strict-subpath
|
|
// requirement Phase B of ADR-1239 imposes on third-party descriptors, and
|
|
// it prevents a descriptor from writing at configHome itself. Kept as its
|
|
// own check per ADR-4650 decision 6 (a wrapper may add its own conditions
|
|
// on top of the canonical predicate, never invert it).
|
|
const contained = tryWithinRootLexical(destSubpath, configDir);
|
|
if (contained === null || contained === root) {
|
|
throw new Error(
|
|
`destSubpath "${destSubpath}" must be a strict subpath of configHome "${configDir}" — not configHome itself or outside it (escapes configHome)`,
|
|
);
|
|
}
|
|
return contained;
|
|
}
|
|
|
|
function errorMessage(err: unknown): string {
|
|
if (err instanceof Error) return err.message;
|
|
return String(err);
|
|
}
|
|
|
|
function addCleanupDir(cleanupDirs: string[], stagedDir: string, rewrittenDir: string | void): string {
|
|
const sourceDir = rewrittenDir ?? stagedDir;
|
|
if (sourceDir !== stagedDir) cleanupDirs.push(sourceDir);
|
|
return sourceDir;
|
|
}
|
|
|
|
function createRuntimeArtifactInstallPlan(args: CreateRuntimeArtifactInstallPlanArgs): InstallPlanResult {
|
|
const {
|
|
layout,
|
|
resolvedProfile,
|
|
homedir,
|
|
platform,
|
|
resolveAttribution,
|
|
projectDir,
|
|
deps = {},
|
|
} = args;
|
|
const conversionExports = _require('./runtime-artifact-conversion.cjs') as RuntimeArtifactConversionExports;
|
|
const rewriteStagedSkillBodies = deps.rewriteStagedSkillBodies ?? conversionExports.rewriteStagedSkillBodies;
|
|
const rewriteStagedCommandBodies = deps.rewriteStagedCommandBodies ?? conversionExports.rewriteStagedCommandBodies;
|
|
const cleanupDirs: string[] = [];
|
|
const items: PlanItem[] = [];
|
|
const scope = layout.scope ?? 'global';
|
|
const rewriteOpts: RewriteOpts = {
|
|
runtime: layout.runtime,
|
|
configDir: layout.configDir,
|
|
scope,
|
|
homedir,
|
|
platform,
|
|
resolveAttribution,
|
|
};
|
|
|
|
// ADR-1235 §1: build the staging context once per plan. Agent kinds apply
|
|
// the CORRECT pre-converter cross-cutting (path rewrites → attribution →
|
|
// converter → normalize). This
|
|
// mirrors the exact per-file order in the former inline agent loop.
|
|
// NO _stampNonClaudeRuntimeDefaults — agents are NOT stamped in the inline loop.
|
|
const os = _require('node:os') as typeof import('node:os');
|
|
const { posixNormalize } = _require('./shell-command-projection.cjs') as { posixNormalize: (p: string) => string };
|
|
const homedirFn: () => string = homedir ?? (() => os.homedir());
|
|
const resolvedTarget = posixNormalize(path.resolve(layout.configDir));
|
|
const homeDir = posixNormalize(homedirFn());
|
|
// #2870: `scope` above is already the module-owned `InstallScope` value
|
|
// (`layout.scope ?? 'global'`, defaulted before this point, so it is never
|
|
// `undefined` here) — `isGlobalScope` projects it to the boolean
|
|
// `_computePathPrefix`'s existing `isGlobal: boolean` API requires.
|
|
const isGlobal = isGlobalScope(scope);
|
|
const isOpencode = layout.runtime === 'opencode';
|
|
const isWindowsHost = (platform ?? process.platform) === 'win32';
|
|
// #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.
|
|
const agentCtx: AgentCtx = {
|
|
runtime: layout.runtime,
|
|
pathPrefix,
|
|
attribution,
|
|
targetDir: layout.configDir,
|
|
projectDir: projectDir ?? layout.configDir,
|
|
};
|
|
for (const kind of layout.kinds) {
|
|
let stagedDir: string;
|
|
try {
|
|
// Agent kinds use the context for their pre-converter cross-cutting
|
|
// sequence; other kinds ignore it.
|
|
stagedDir = kind.stage(resolvedProfile, agentCtx);
|
|
} catch (err) {
|
|
return { ok: false, kind: 'stage_failed', message: errorMessage(err), cleanupDirs, failedKind: kind.kind };
|
|
}
|
|
|
|
let sourceDir = stagedDir;
|
|
try {
|
|
if (kind.kind === 'commands') {
|
|
const rewrittenDir = rewriteStagedCommandBodies(stagedDir, rewriteOpts);
|
|
sourceDir = addCleanupDir(cleanupDirs, stagedDir, rewrittenDir);
|
|
} else if (kind.kind === 'skills' || kind.kind === 'kimi-agents') {
|
|
const rewrittenDir = rewriteStagedSkillBodies(stagedDir, rewriteOpts);
|
|
sourceDir = addCleanupDir(cleanupDirs, stagedDir, rewrittenDir);
|
|
}
|
|
// Agent kinds: cross-cutting already applied INSIDE kind.stage() via agentCtx.
|
|
// No POST-step needed. sourceDir stays as stagedDir.
|
|
} catch (err) {
|
|
return { ok: false, kind: 'rewrite_failed', message: errorMessage(err), cleanupDirs, failedKind: kind.kind };
|
|
}
|
|
|
|
items.push({
|
|
kind: kind.kind,
|
|
sourceDir,
|
|
destDir: assertDestWithinConfigHome(kind.home ?? layout.configDir, kind.destSubpath),
|
|
});
|
|
}
|
|
|
|
return { ok: true, plan: { items, cleanupDirs } };
|
|
}
|
|
|
|
function createRuntimeArtifactUninstallPlan(layout: Layout): UninstallPlan {
|
|
return {
|
|
items: layout.kinds.map((kind) => ({
|
|
kind: kind.kind,
|
|
destDir: assertDestWithinConfigHome(kind.home ?? layout.configDir, kind.destSubpath),
|
|
})),
|
|
};
|
|
}
|
|
|
|
export = { assertDestWithinConfigHome, createRuntimeArtifactInstallPlan, createRuntimeArtifactUninstallPlan };
|