feat(#2870): resolve install scope as a value (#3278)

* test(#2870): failing-first suite for the Install Scope Module

19 tests over the 50-test-matrix rows 1-19. RED by construction: the
module under test does not exist yet, so the suite fails at require with
MODULE_NOT_FOUND until src/install-scope.cts lands.

Every row asserts a returned value with injected env/home/existsSync --
no filesystem, per the issue's acceptance criterion that tests assert the
resolved value directly.

Row 7 asserts the RELATION rank(global) > rank(local) rather than a
literal, so Phase 2 (#2871) can re-base the numbers without a fixture
edit. Row 4 iterates the real runtime registry rather than a hardcoded
list, excluding vscode, which declares configHome.kind none and is never
CLI-installed.

* feat(#2870): add the Install Scope Module

Scope becomes one resolved value instead of a bare string re-derived at
every layer. resolveScope({id, runtime, ...}) returns
{id, configHome, settingsFile, consentRequired, hostPrecedenceRank}.

It COMPOSES resolveConfigHomeFromDescriptor rather than extending it.
That function has 60 dependents across 13 files and 2 process flows -- a
CRITICAL blast radius -- so adding a scope parameter to it, which the
issue's framing invites, would ripple through all of them. Composing
costs nothing and leaves every existing caller byte-identical.

The module owns the InstallScope type name, which previously lived
privately in runtime-artifact-install-plan.cts; that module now imports
it. A fifth spelling of the same concept would have defeated the phase.

settingsFile is null for the 18 runtimes that declare no
settingsFileByScope -- absence is a value, not an error, and inventing a
Claude-shaped default would leak that host's shape onto every other one.

hostPrecedenceRank ships unread: Phase 2 (#2871) is its first consumer.
It is carried as data only, per this issue's out-of-scope note that
precedence semantics belong to that phase.

Vocabulary: the install axis standardizes on local. ConsentRecord.scope
keeps project deliberately -- that literal is serialized into consent
records in the user's home, and renaming it would silently deactivate
every project-scoped capability on the machine. CONTEXT.md records the
boundary mapping instead.

Every environmental input is injectable (env, home, existsSync, cwd), so
the resolved value is assertable with no filesystem at all.

Registration ripple: .gitignore, eslint.config.mjs, CONTEXT.md glossary,
docs/INVENTORY.md, and the inventory manifest (regenerated after
build:lib, never before).

Verified via the remote runner.

* refactor(#2870): route scope re-derivations through the module

bin/install.js resolves scope once per function instead of inline at
each of its 12 sites, and the settingsFileByScope consumer reads it
through resolveScope().

Seven downstream boolean re-derivations now call the module's
isGlobalScope() instead of comparing the literal independently:
runtime-artifact-install-plan, both runtime-artifact-layout kind
builders, dispatchKindEntry, surface, and two install-engine sites. The
fifth through seventh were not named in the issue -- they are the same
re-derivation class, and leaving them would have made the acceptance
criterion false.

_computePathPrefix keeps its isGlobal boolean API, so the projection is
centralized rather than eliminated. resolveScope and isGlobalScope share
one validator, so the two surfaces cannot drift.

TWO SITES DELIBERATELY NOT ROUTED: runtime-artifact-conversion's
rewriteStagedSkillBodies and rewriteStagedCommandBodies. ADR-1508 fixes
the direction as installer/layout -> conversion, never upward, and
install-scope composes runtime-homes, so importing it into the
conversion module would invert that direction. Left as-is on purpose.

Behavior-preserving throughout. Each step was proven by capturing full
layout and plan output -- including every kind's home field and the
hashed contents of emitted files -- before and after, across both scopes
for claude, codex, opencode, hermes, kimi and kilo. Byte-identical.

surface.cts keeps a scope ?? 'global' default before the call because
Layout.scope is optional there; isGlobalScope throws where the old
inline compare returned false, and that difference would have been a
placement regression.

Verified via the remote runner.

* fix(#2870): cover the no-config-home throw and document the strictness

Two findings from the isolated adversarial review.

The vscode case was implemented but untested. resolveScope throws for a
runtime whose descriptor declares configHome.kind 'none', which is the
design's own behavior-table row 13, but the registry sweep excluded
vscode rather than asserting the throw -- so the behavior shipped with
no test. The exclusion is now legitimate because the case has its own
test naming the runtime in the assertion.

isGlobalScope throws where the inline compare it replaced returned
false. No reachable caller can deliver an out-of-union value today, but
the types are not enforced at runtime, so a future caller passing an
optional Layout.scope would crash rather than silently misroute. That is
the better failure -- misrouting writes artifacts to the wrong place --
but it was undocumented, so the reason is now on the function.

Adds the changeset the acceptance criteria require.

* refactor(#2870): route the last two sites; correct the ADR-1508 claim

The previous commit declined to route runtime-artifact-conversion's
rewriteStagedSkillBodies and rewriteStagedCommandBodies, claiming
ADR-1508's dependency direction forbade the import. That reasoning was
wrong, and this commit corrects it.

Two independent reviewers checked the actual import graph:
runtime-artifact-conversion already imports capability-registry,
command-roster, runtime-name-policy and shell-command-projection -- it
depends on leaf-tier siblings today. install-scope imports only
runtime-homes plus node builtins, and runtime-homes imports only node
builtins, so there is no cycle at any depth. ADR-1508 governs the
installer/layout to conversion boundary, not a leaf-to-leaf sibling
import of the same shape conversion already makes.

With those two routed, every isGlobal re-derivation in the tree now goes
through one owner and acceptance criterion 1 is fully met rather than
partially. Nine sites, not the four the issue enumerated.

Also from the review:

Tests were falling through to the real process.cwd() at five local-scope
call sites, which contradicts the acceptance criterion that the resolved
value be assertable with no filesystem. Every one now injects a cwd. One
of the five was a site the review had not spotted.

bin/install.js carried two near-identical copies of the guarded
resolveScope block, one in install() and one in uninstall() -- duplicated
scope logic in the phase whose purpose is removing it. Extracted to one
helper, and the new sites use the file's existing ternary idiom rather
than the if/else that replaced it.

Equivalence re-proven across both scopes for claude, codex, opencode,
kilo and hermes, now including the staged skill and command body
rewrites hashed per file, since those decide the literal spec-root path
baked into every emitted artifact. Byte-identical.

Verified via the remote runner.

* fix(#2870): assert configHome portably instead of with a native separator

The windows-latest node24 shard failed on two install-scope assertions.
The module was right and the tests were wrong: they built their expected
value with path.join, which emits \fake\home\.claude on Windows, while
resolveScope normalizes separators unconditionally to /fake/home/.claude.

That unconditional normalization is deliberate -- backslash paths arrive
on Linux too, so normalizing via path.sep is the documented defect this
repo guards against. Weakening it to make the assertion pass would have
inverted the fix.

Every path.join-built expectation in the suite now goes through
toPosixPath from tests/helpers.cjs, which is the pattern the
no-path-literal-in-assert rule's own valid-case list sanctions. It splits
on the running platform's path.sep and rejoins with forward slashes, so
it reverses whatever path.join produced on that same platform and the
expectation is invariant everywhere.

Two more call sites had the same latent problem and passed on Linux and
macOS by luck; they are fixed too.

This is the class of defect the remote runner structurally cannot catch
-- its matrix is Linux-only, so a green pass there is not evidence of
portability, and CI's Windows lane is the only place it surfaces.

Verified via the remote runner.

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-09 20:16:23 -04:00
committed by GitHub
parent b901d1e06f
commit c2f24265f2
14 changed files with 777 additions and 24 deletions

View File

@@ -0,0 +1,5 @@
---
type: Changed
pr: 3278
---
**Install scope is now resolved once, as a value** — the installer and the modules downstream of it no longer each re-derive whether an install is global or local from a bare string. One module owns the scope axis and reports its config home, its per-scope settings file, and whether it requires a consent record. No behavior changes for any install. (#2870)

1
.gitignore vendored
View File

@@ -198,6 +198,7 @@ build/
/gsd-core/bin/lib/command-roster.cjs
/gsd-core/bin/lib/runtime-artifact-conversion.cjs
/gsd-core/bin/lib/runtime-artifact-layout.cjs
/gsd-core/bin/lib/install-scope.cjs
/gsd-core/bin/lib/runtime-config-adapter-registry.cjs
/gsd-core/bin/lib/runtime-hooks-surface.cjs
/gsd-core/bin/lib/command-routing-hub.cjs

View File

@@ -231,6 +231,9 @@ Sibling Module to Runtime Artifact Layout Module. Owns projection from canonical
### 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` (generated from `src/runtime-artifact-install-plan.cts`). See Runtime Artifact Layout Module and Runtime Artifact Conversion Module.
### Install Scope Module
Owns the two-value install-scope axis (`'global' | 'local'`) as a typed value, replacing the bare `isGlobal ? 'global' : 'local'` string re-derived at 12 sites in `bin/install.js` plus several downstream re-derivations (#2870, ADR-2866). Interface: `resolveScope({ id, runtime, explicitDir?, env?, home?, existsSync? }) -> { id, configHome, settingsFile, consentRequired, hostPrecedenceRank }` — pure (no writes, never mutates `input`) and the returned value is frozen so a caller cannot corrupt a subsequent resolution. Owns the `InstallScope` type name: previously a private, non-exported `TypeAlias` inside Runtime Artifact Install Plan Module; that module now `import type`s it from here instead of re-declaring it, so the codebase does not grow a fifth spelling of the axis alongside the layout module's `'local' | 'global'`, `capability-lifecycle.cts`'s `'global' | 'project'`, and `capability-consent.cts`'s single `'project'` literal. `configHome` for `global` composes `resolveConfigHomeFromDescriptor` (Runtime Homes Module) unmodified rather than adding a `scope` parameter to it — that function is CRITICAL blast radius (60 dependents across 13 files); for `local` it joins the capability registry's per-runtime `localConfigDir` onto the real process cwd (the project you are standing in — not injectable via `home`, by design). `explicitDir` short-circuits both scopes identically to `getGlobalConfigDir`'s existing override, and every returned `configHome` is normalized to forward slashes UNCONDITIONALLY (`.replace(/\\/g,'/')`, never gated on `path.sep`). `settingsFile` reads the registry's `hostBehaviors.settingsFileByScope[id]` and is `null` for the 18 of 19 registered runtimes that declare none — absence is a value, not an invented Claude-shaped default; the one caller that legitimately wants a Claude fallback (`bin/install.js:550`) still applies it itself. `consentRequired` is `false` for `global` (nothing is recorded — matches Capability Registry Overlay's rule that a GLOBAL-scope capability is trusted without a consent record) and `true` for `local`; it reports the requirement only; it does not perform or waive consent. `hostPrecedenceRank` (`global` outranks `local`) is carried as data only this phase — unread until Phase 2 (#2871) defines precedence semantics. Throws `TypeError` for an invalid `id` (wrong case, empty, missing, or any non-string value — never coerced), an unknown `runtime`, or a runtime whose `configHome.kind === 'none'` (vscode — non-installable, #2103) — all three share one `instanceof TypeError` catch shape with Runtime Artifact Layout Module's existing unknown-runtime contract. **The `local`/`project` boundary is documented, not unified:** this module's `'local'` spelling — chosen because it is the CLI's own vocabulary (`--local`) and what the layout module and manifest already use — is deliberately NOT reconciled with Capability Consent Store's `ConsentRecord.scope: 'project'` or Capability Lifecycle's `'global' | 'project'` operations. `ConsentRecord.scope` is a value persisted on disk in user-owned consent records outside any repository; renaming that literal to match would silently invalidate every existing project-scoped consent record on a user's machine the next time it is read back — a far worse defect than the vocabulary split. The mapping instead lives here as a fact: install scope `'local'` ⇄ consent scope `'project'`; install scope `'global'` ⇄ no consent record at all. Source: `gsd-core/bin/lib/install-scope.cjs` (generated from `src/install-scope.cts`). See Runtime Homes Module, Runtime Artifact Layout Module, Runtime Artifact Install Plan Module, Capability Consent Store, Capability Lifecycle.
### Command Roster Module
Tiny read-only helper Module owning discovery of canonical `commands/gsd/*.md` command stems for artifact conversion and runtime projection. It is a sibling dependency of Runtime Artifact Conversion Module, not part of conversion itself: conversion consumes a roster to safely rewrite `gsd:` / `/gsd-` references, while roster discovery owns filesystem/catalog knowledge. First slice: extract existing `readGsdCommandNames` behavior behind this Module instead of moving it into Runtime Artifact Conversion Module or keeping it as installer-owned state.

View File

@@ -36,6 +36,11 @@ const {
resolveKimiHooksTomlDir,
isRegisteredRuntimeId,
} = require('../gsd-core/bin/lib/runtime-homes.cjs');
// #2870: the Install Scope Module — turns a bare 'global' | 'local' scope id
// plus a runtime into one resolved value (configHome, settingsFile,
// consentRequired, hostPrecedenceRank) instead of the id being re-derived
// and re-interpreted at each call site. See src/install-scope.cts.
const { resolveScope } = require('../gsd-core/bin/lib/install-scope.cjs');
// getDirName (runtime -> local config dir name) is relocated out of this
// installer to the runtime-name-policy leaf (ADR-1508 / #1510 Phase 1) so the
// conversion module's rewrite engine can consume it without importing
@@ -545,6 +550,17 @@ try {
// hardcoded string-equality branch) so behavior degrades CLOSED (safe), never open.
// The live descriptor (capabilities/claude/capability.json) remains the source of
// truth; this mirrors only the privacy-load-bearing subset. (ADR-1239 / #2086)
//
// #2870: NOT routed through the Install Scope Module (resolveScope,
// src/install-scope.cts) despite that module owning per-scope settings-file
// resolution elsewhere in this file. resolveScope's own descriptor lookup
// goes through the SAME capability registry require this floor exists to
// survive the failure of (see getRegistry() in install-scope.cts) — so on
// exactly the "registry failed to load" path this constant is for,
// resolveScope would throw too. Routing through it here would trade a
// graceful, documented degrade for a crash in the one case this floor was
// added to prevent. This hardcoded literal is the correct, honest answer,
// not an un-migrated leftover.
const FALLBACK_HOST_BEHAVIORS = Object.freeze({
claude: Object.freeze({
settingsFileByScope: Object.freeze({ local: 'settings.local.json', global: 'settings.json' }),
@@ -606,6 +622,22 @@ function _hostIntegrationDispatch(runtime) {
return dispatch || {};
}
/**
* #2870: shared install()/uninstall() scope resolution. Routes `id` through
* the Install Scope Module (src/install-scope.cts) and degrades to `null` on
* failure (unknown/non-installable runtime, broken registry bundle) instead
* of throwing, so each call site's own plain-id fallback keeps working
* exactly as it did before this migration. Both call sites previously carried
* their own copy of this try/catch; this is the one shared copy.
*/
function _resolveScopeSafe(id, runtime) {
try {
return resolveScope({ id, runtime });
} catch (_) {
return null;
}
}
/**
* Resolve the ACTUAL on-disk skills-install directory for a runtime, honoring a
* skills-kind `home` override (ADR-1239 upgrade 3 / #2088: e.g. Codex skills ->
@@ -8191,7 +8223,16 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
} catch {}
// 1. Remove GSD commands/skills (layout-driven)
const scope = isGlobal ? 'global' : 'local';
// #2870: scope id resolved ONCE here and reused below (was two independent
// isGlobal-derived re-derivations). Routed through the Install Scope
// Module (src/install-scope.cts) when the capability registry is
// available; degrades to the plain id on failure (unknown/non-installable
// runtime, broken bundle) so this function's scope-id uses — which never
// depended on registry availability before this migration — keep working
// exactly as they did pre-migration.
const _uninstallScopeId = isGlobal ? 'global' : 'local';
const _resolvedUninstallScope = _resolveScopeSafe(_uninstallScopeId, runtime);
const scope = _resolvedUninstallScope ? _resolvedUninstallScope.id : _uninstallScopeId;
// ADR-1239 / #2086: drive uninstall through the public Host-Integration Interface.
// Fail-open to the engine directly if the composed-registry adapter can't load.
const _uninstallAdapter = _runtimeAdapter(runtime);
@@ -8551,7 +8592,7 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
fs.rmSync(legacyDir, { recursive: true });
removedCount++;
console.log(` ${green}✓${reset} Removed legacy commands/gsd/`);
const _uninstallScope = isGlobal ? 'global' : 'local';
const _uninstallScope = scope;
if (migrateLegacyDevPreferencesToSkill(targetDir, savedLegacyArtifacts, runtime, _uninstallScope)) {
// Compute the actual path written so the log line is accurate per-runtime
const _layout = resolveRuntimeArtifactLayout(runtime, targetDir, _uninstallScope);
@@ -10152,6 +10193,19 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
};
}
// #2870: scope id resolved ONCE here and reused at every use below (was 10
// independent isGlobal-derived re-derivations). Placed AFTER the kimi
// local-deferred early return above so that return path does no extra
// work. Routed through the Install Scope Module (src/install-scope.cts)
// when the capability registry is available; degrades to the plain id on
// failure (unknown/non-installable runtime, broken bundle) so this
// function's plain scope-id uses — which never depended on registry
// availability before this migration — keep working exactly as they did
// pre-migration. `_installScope` (the full resolved value, not just the
// id) additionally backs the settingsFileByScope routing below.
const _installScopeId = isGlobal ? 'global' : 'local';
const _installScope = _resolveScopeSafe(_installScopeId, runtime);
// Reusable helper to copy hooks/lib/ (git-cmd.js + gsd-graphify-rebuild.sh).
// Defined early so it is visible to both the main and Codex code paths.
// `allowlist` (when non-empty) restricts copying to the named top-level entries,
@@ -10349,7 +10403,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
const codexPreInstallAgentContents = new Map();
let codexPreInstallVersionBytes = null;
if (_hostBehaviors(runtime).tomlConfigInstall && !isMinimalMode(_effectiveInstallMode)) {
const _preSkillsDir = _resolveSkillsRootDir(runtime, targetDir, isGlobal ? 'global' : 'local');
const _preSkillsDir = _resolveSkillsRootDir(runtime, targetDir, _installScopeId);
if (fs.existsSync(_preSkillsDir)) {
for (const entry of fs.readdirSync(_preSkillsDir, { withFileTypes: true })) {
if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
@@ -10405,7 +10459,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
const _codexPreConfigRollback = !_hostBehaviors(runtime).tomlConfigInstall || isMinimalMode(_effectiveInstallMode) ? null : () => {
rollbackInstallerMigrations();
// skills/gsd-* — pass 1: restore snapshot entries (may be absent if deleted mid-install).
const _earlySkillsDir = _resolveSkillsRootDir(runtime, targetDir, isGlobal ? 'global' : 'local');
const _earlySkillsDir = _resolveSkillsRootDir(runtime, targetDir, _installScopeId);
for (const skillName of codexPreInstallSkillNames) {
const skillDirPath = path.join(_earlySkillsDir, skillName);
const fileMap = codexPreInstallSkillContents.get(skillName);
@@ -10503,7 +10557,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
installerMigrationResult = runInstallerMigrations({
configDir: targetDir,
runtime,
scope: isGlobal ? 'global' : 'local',
scope: _installScopeId,
migrations: options.installerMigrations,
baselineScan: true,
});
@@ -10636,7 +10690,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
if (_isSkillsRuntime) {
// Layout-driven install for skills-based runtimes (full and minimal modes)
const scope = isGlobal ? 'global' : 'local';
const scope = _installScopeId;
// ADR-1239 upgrade 3 / #2088: a kind may declare an alternate install `home`
// (e.g. Codex skills -> $HOME/.agents/skills) instead of the runtime's normal
// configDir. Resolve the ACTUAL on-disk skills root here, descriptor-driven
@@ -11566,7 +11620,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
}
// Write file manifest for future modification detection
writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: isGlobal ? 'global' : 'local' });
writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: _installScopeId });
console.log(` ${green}✓${reset} Wrote file manifest (${MANIFEST_NAME})`);
// Report any backed-up local patches
@@ -11717,7 +11771,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
// (copyCommandsAsCodexSkills removes pre-existing gsd-* dirs before re-writing)
// are restored even when they are absent from disk at rollback time (#3245 CR).
// • Dirs that did not pre-exist: remove entirely.
const _rollbackSkillsDir = _resolveSkillsRootDir(runtime, targetDir, isGlobal ? 'global' : 'local');
const _rollbackSkillsDir = _resolveSkillsRootDir(runtime, targetDir, _installScopeId);
// Pass 1 — restore snapshot entries (may be absent from disk if deleted mid-install).
for (const skillName of codexPreInstallSkillNames) {
const skillDirPath = path.join(_rollbackSkillsDir, skillName);
@@ -11832,7 +11886,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
// Re-write the manifest now that .toml agent files exist on disk.
// The initial writeManifest call (before Codex config generation) could
// not include agents/gsd-*.toml because those files did not yet exist.
writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: isGlobal ? 'global' : 'local' });
writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: _installScopeId });
} else {
console.log(` ${dim}↳${reset} Skipping Codex agent config generation (minimal install)`);
}
@@ -12120,7 +12174,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
// manifest-tracked (verified) — uninstall removes them explicitly via
// removeCursorHooksJson + its script list, and reconcile is idempotent.
// The re-run is retained for parity with the settings.json install path.
writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: isGlobal ? 'global' : 'local' });
writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: _installScopeId });
persistActiveProfileMarker();
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
}
@@ -12207,7 +12261,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
// explicitly via removeWindsurfHooksJson, and reconcileWindsurfHooksJson
// is idempotent on repeated installs, so manifest tracking isn't needed
// for correctness here.
writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: isGlobal ? 'global' : 'local' });
writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: _installScopeId });
}
persistActiveProfileMarker();
@@ -12221,7 +12275,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
writeClineArtifacts(targetDir, isGlobal);
// Re-run the manifest pass: these artifacts are written *after* the earlier
// writeManifest() call, so a second pass is needed to hash-track them.
writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: isGlobal ? 'global' : 'local' });
writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: _installScopeId });
persistActiveProfileMarker();
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
}
@@ -12236,10 +12290,24 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
// #338: local Claude installs write to settings.local.json (Claude Code's per-user/gitignored slot)
// so engineer-specific absolute paths (Node binary, home dir) never land in the repo-shared
// settings.json. Global installs and all other runtimes continue to use settings.json.
// #2870: the CURRENT scope's settings filename is sourced from the Install
// Scope Module (_installScope.settingsFile, resolveScope's per-scope field)
// instead of indexing _scopedSettings by hand. _scopedSettings itself is
// retained unchanged as the #338-privacy fail-safe path: _hostBehaviors
// already degrades to FALLBACK_HOST_BEHAVIORS (see that constant's comment
// above) when the registry fails to load, whereas resolveScope's registry
// lookup throws in that same scenario (_installScope is null when it did).
// Falling back to _scopedSettings[_installScopeId] there — and keeping the
// non-local-claude branch's expression untouched — means this is
// byte-identical to the pre-migration computation in every case, including
// the broken-registry fail-safe floor.
const _scopedSettings = _hostBehaviors(runtime).settingsFileByScope || null;
const isLocalClaude = (!isGlobal && !!(_scopedSettings && _scopedSettings.local));
const _currentScopeSettingsFile = _installScope
? _installScope.settingsFile
: (_scopedSettings ? (_scopedSettings[_installScopeId] ?? null) : null);
const isLocalClaude = (!isGlobal && !!_currentScopeSettingsFile);
const settingsFileName = isLocalClaude
? _scopedSettings.local
? _currentScopeSettingsFile
: ((_scopedSettings && _scopedSettings.global) || 'settings.json');
// ADR-1239 Phase B write-confinement: the descriptor-sourced settings filename
// must resolve under targetDir (this path also drives a recursive mkdirSync).

View File

@@ -384,6 +384,7 @@
"install-effort-resolver.cjs",
"install-engine.cjs",
"install-profiles.cjs",
"install-scope.cjs",
"installer-migration-authoring.cjs",
"installer-migration-report.cjs",
"installer-migrations.cjs",

View File

@@ -496,6 +496,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
| `install-effort-resolver.cjs` | Install-time effort resolution — `readGsdEffectiveEffortConfig` (merges `~/.gsd/defaults.json` + project `.planning/config.json`) + `resolveInstallTimeEffort`, extracted from `bin/install.js` (#2071) so `gsd-tools effort sync` can require it from the shipped runtime instead of the never-copied package-root installer; install.js imports them back (single source) |
| `install-engine.cjs` | Runtime-artifact install engine — `installRuntimeArtifacts`/`uninstallRuntimeArtifacts`/`installOpencodeFamilySkills` + their helpers, extracted from `bin/install.js` (ADR-1239 Phase B, #1679); install.js imports them back and injects `getCommitAttribution` |
| `install-profiles.cjs` | Install profile allowlist + skill staging for `--minimal` install (#2762); single source of truth for which `gsd-*` skills/agents land in runtime config dirs |
| `install-scope.cjs` | Install Scope Module — `resolveScope({id,runtime,...})` resolves the `'global'\|'local'` install-scope axis into `{id, configHome, settingsFile, consentRequired, hostPrecedenceRank}`, composing `resolveConfigHomeFromDescriptor` (`runtime-homes.cjs`) rather than modifying it (#2870, ADR-2866) |
| `installer-migration-authoring.cjs` | Installer migration authoring guardrails for record metadata, explicit scopes, ownership evidence, and runtime contract citations |
| `installer-migration-report.cjs` | Installer migration report projection and blocked-action guard for install/update integration |
| `installer-migrations.cjs` | Installer migration planning, artifact classification, install-state persistence, journaled apply, and rollback helpers |

View File

@@ -168,6 +168,7 @@ export default tseslint.config(
'gsd-core/bin/lib/runtime-artifact-conversion.cjs',
'gsd-core/bin/lib/runtime-artifact-install-plan.cjs',
'gsd-core/bin/lib/runtime-artifact-layout.cjs',
'gsd-core/bin/lib/install-scope.cjs',
'gsd-core/bin/lib/runtime-config-adapter-registry.cjs',
'gsd-core/bin/lib/runtime-hooks-surface.cjs',
'gsd-core/bin/lib/command-routing-hub.cjs',

View File

@@ -32,6 +32,12 @@ import retiredArtifactCleanup = require('./retired-artifact-cleanup.cjs');
import { posixNormalize } from './shell-command-projection.cjs';
import { isPathConfined } from './external-descriptor-trust.cjs';
import { ensureCommonJsMarker } from './commonjs-marker.cjs';
// #2870: InstallScope is owned by install-scope.cts, not re-declared here.
// `isGlobalScope` centralizes the `scope === 'global'` boolean projection
// this module's two remaining re-derivation sites need (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';
const { processAttribution } = runtimeArtifactConversion;
// resolveRuntimeArtifactLayout: accessed via module ref (not destructured) so
@@ -673,7 +679,15 @@ function _runLegacyUninstallCleanup(runtime: string, configDir: string, scope: s
// to the same location (#1423). Using migrateLegacyDevPreferencesToSkill here
// (which would redirect to skills/) conflicts with the test contract for local installs.
const _lu = _hostBehaviors(runtime).legacyCommandsGsdUninstall;
const isLegacyCommandsGsd = _lu === true || (_lu === 'global' && scope === 'global');
// #2870: `scope` keeps its exported `string = 'global'` signature (no
// signature change), but every real caller — `uninstallRuntimeArtifacts`'s
// own required `scope` param, always fed a validated 'global' | 'local'
// literal by bin/install.js's scope-resolution ternary, plus every direct
// test call site — only ever supplies 'global' or 'local'. The existing
// `= 'global'` default already reproduces today's behavior for an omitted
// scope, so the cast below is safe: `isGlobalScope` never sees a value
// outside its union here.
const isLegacyCommandsGsd = _lu === true || (_lu === 'global' && isGlobalScope(scope as InstallScope));
if (isLegacyCommandsGsd) {
const legacyCommandsGsd = path.join(configDir, 'commands', 'gsd');
if (fs.existsSync(legacyCommandsGsd)) {
@@ -1280,7 +1294,12 @@ function installOpencodeFamilyArtifacts(
behaviors: any = {},
capabilityRegistry?: any,
): void {
const isGlobal = scope === 'global';
// #2870: `scope` keeps its exported required `string` signature (no
// signature change). It is always the `installRuntimeArtifacts`-forwarded
// 'global' | 'local' literal produced by bin/install.js's scope-resolution
// ternary (both real call sites and every test call site), so the cast is
// safe: `isGlobalScope` never sees a value outside its union here.
const isGlobal = isGlobalScope(scope as InstallScope);
// findInstallSourceRoot resolves DIRECTLY to the commands/gsd source dir
// (via the .gsd-source marker or a walk-up from __dirname) — every other
// call site in runtime-artifact-layout.cts feeds its return value straight

321
src/install-scope.cts Normal file
View File

@@ -0,0 +1,321 @@
/**
* install-scope.cts — Install Scope Module (#2870, ADR-2866, governed by
* ADR-2866, Phase 0 PR #3265).
*
* `resolveScope()` turns a bare `'global' | 'local'` string — previously
* re-derived at 12 `isGlobal ? 'global' : 'local'` sites in `bin/install.js`
* plus several downstream consumers — into ONE resolved value produced by
* ONE module. See `.gsd/phase/feat-2870-install-scope-module/40-design.md`
* for the full behavior table and rationale; the summary that matters for
* future readers is captured in the comments below.
*
* This module OWNS the `InstallScope` type name. It was previously declared
* (as a private, non-exported `TypeAlias`) inside
* `runtime-artifact-install-plan.cts`; that module now imports it from here
* instead of re-declaring it, so the codebase has one spelling of "install
* scope" instead of a fifth one appearing alongside the three that already
* existed (`'local' | 'global'` in the layout module, `'global' | 'project'`
* in capability-lifecycle, and the single literal `'project'` in
* capability-consent).
*
* ── Compose, never modify, resolveConfigHomeFromDescriptor ─────────────────
* `resolveConfigHomeFromDescriptor` (`runtime-homes.cts`) is rated CRITICAL
* blast radius: 60 dependents across 13 files and 2 process flows. Adding a
* `scope` parameter to it — the "obvious" refactor — would touch all 60 for
* no reason this module needs: it already resolves the GLOBAL config home
* correctly today. So this module calls it as-is for the global scope and
* derives the LOCAL scope's config dir independently (see
* `resolveScopeConfigHome` below) — genuine composition, not a rename. Every
* one of those 60 call sites stays byte-identical.
*
* ── Why `settingsFile: null` is correct, not a bug ──────────────────────────
* Only `claude` declares `hostBehaviors.settingsFileByScope` in the
* capability registry; the other 18 registered runtimes do not have a
* per-scope settings file at all. Returning `null` for them is honest —
* substituting `'settings.json'` (or any other Claude-shaped default) would
* invent a fact for every non-Claude runtime that asked. Callers that
* legitimately want a Claude-specific fallback (there is exactly one today,
* `bin/install.js:550`) apply it themselves; this module does not.
*/
import path from 'node:path';
import os from 'node:os';
import {
resolveConfigHomeFromDescriptor,
type ConfigHomeDescriptor,
} from './runtime-homes.cjs';
// In .cts (CommonJS output) files, `require` is available as a global.
const _require: NodeRequire = require;
// ── Public types ────────────────────────────────────────────────────────
/**
* The two install-scope axis values. Spelling is `'local'`, not `'project'`:
* `'local'` is the CLI's own vocabulary (`--local`), matches what the
* artifact-layout module and the manifest already use, and is what the user
* types. `'project'` is a SEPARATE, deliberately un-unified vocabulary that
* belongs to the capability consent/lifecycle subsystem — see the boundary
* mapping below.
*/
export type InstallScope = 'global' | 'local';
export interface ResolvedScope {
id: InstallScope;
/** Absolute config directory for this scope, normalized to forward
* slashes (see `normalizeSeparators` below). */
configHome: string;
/** Per-scope settings filename declared by the runtime's descriptor, or
* `null` when the runtime declares none. `null` is a value, not an
* error — see the module-level comment above. */
settingsFile: string | null;
/** `false` for `global` (nothing is recorded — the scope lives under the
* user's own home, matching `capability-lifecycle.cts:163`'s rule).
* `true` for `local`. This module reports the requirement; it does not
* perform or waive consent — `capability-consent.cts` owns that. */
consentRequired: boolean;
/** Higher wins; `global` outranks `local`. Carried as data only — nothing
* in this phase reads it (Phase 2, #2871, is the first consumer). Not a
* shadowing/collision answer on its own: whether two artifacts actually
* collide needs the trigger, which is out of scope here. */
hostPrecedenceRank: number;
}
export interface ResolveScopeInput {
id: InstallScope;
runtime: string;
/** Explicit config-dir override (e.g. `--config-dir`). Honored identically
* to `getGlobalConfigDir(runtime, explicitDir)` — takes precedence over
* every other resolution path, for both scopes. */
explicitDir?: string;
env?: Record<string, string | undefined>;
home?: string;
existsSync?: (p: string) => boolean;
/** Working directory the `local` scope's `configHome` is resolved against
* — defaults to `process.cwd()`, so local-scope resolution is assertable
* without a real working directory, matching `env`/`home`/`existsSync`. */
cwd?: string;
}
// ── The `local` / `project` boundary (see CONTEXT.md glossary entry) ──────
//
// `ConsentRecord.scope: 'project'` (capability-consent.cts) and
// `capability-lifecycle.cts:163`'s `'global' | 'project'` are NOT renamed to
// match this module's `'local'` spelling. `ConsentRecord.scope` is persisted
// on disk in user-owned consent records outside this repo; renaming that
// literal would silently invalidate every existing project-scoped consent
// record on a user's machine the next time it is read back. The
// reconciliation is a documented boundary mapping, not a rename sweep:
// install scope 'local' ⇄ consent scope 'project'
// install scope 'global' ⇄ no consent record at all
// `consentRequired` above reports the install-scope side of that mapping;
// it is deliberately still the CLI's own vocabulary.
const VALID_SCOPE_IDS: ReadonlySet<string> = new Set(['global', 'local']);
/**
* Single owner of the `'global' | 'local'` membership check. `resolveScope`
* and `isGlobalScope` (below) both call this instead of each carrying its
* own copy of the rule — two surfaces reading one validator, not two
* validators that could silently diverge.
*/
function validateScopeId(id: unknown, caller: string): InstallScope {
if (typeof id !== 'string' || !VALID_SCOPE_IDS.has(id)) {
throw new TypeError(
`${caller}: id must be one of 'global' | 'local', got ${JSON.stringify(id)}`,
);
}
return id as InstallScope;
}
// Higher wins. Not exported as a public constant — only the resulting
// `hostPrecedenceRank` field on `ResolvedScope` is public API, so a future
// re-basing of the literal values (Phase 2, #2871) never requires touching
// an exported symbol.
const HOST_PRECEDENCE_RANK: Record<InstallScope, number> = {
global: 2,
local: 1,
};
interface HostBehaviorsForScope {
settingsFileByScope?: Partial<Record<InstallScope, string>>;
}
interface RuntimeDescriptorForScope {
configHome: ConfigHomeDescriptor;
/** Project-relative local config dir (e.g. `.claude`), or `null` for a
* runtime with no installable config dir at all (vscode). Resolved
* against `resolveScope`'s input `cwd` (defaulting to the real process
* cwd), the same way `bin/install.js`'s existing local-scope call sites
* (e.g. `path.join(process.cwd(), '.agents')`) resolve it today. */
localConfigDir?: string | null;
hostBehaviors?: HostBehaviorsForScope;
}
interface RegistryLike {
runtimes: Record<string, { runtime?: RuntimeDescriptorForScope }>;
}
/** Lazy registry accessor — mirrors the pattern in runtime-homes.cts /
* runtime-artifact-layout.cts (5b/5c/5d). */
function getRegistry(): RegistryLike {
return _require('./capability-registry.cjs') as RegistryLike;
}
/**
* Normalize path separators UNCONDITIONALLY (never gated on `path.sep` /
* `process.platform`). A Windows-shaped `home` (`C:\Users\x`) can arrive on
* any host — via an injected test fixture, a cross-platform config sync, or
* a value copied from a Windows machine — so the normalization must not
* depend on which OS this process happens to be running on.
*/
function normalizeSeparators(p: string): string {
return p.replace(/\\/g, '/');
}
/**
* Minimal leading-`~` expansion for `explicitDir`. `runtime-homes.cts`'s own
* `expandTilde` is NOT exported (it is a private helper), and this module
* must not add exports to that CRITICAL-blast-radius file just to reuse
* three lines — so this is an intentionally small, independent
* reimplementation, not a fork of shared logic.
*/
function expandTildeForExplicitDir(p: string, home: string | undefined): string {
const resolvedHome = home ?? os.homedir();
if (p === '~') return resolvedHome;
if (p.startsWith('~/')) return path.join(resolvedHome, p.slice(2));
return p;
}
/**
* Resolve the config-home directory for one scope. `explicitDir` short-
* circuits both scopes identically (matches `getGlobalConfigDir`'s existing
* override behavior — the module must not regress it). Otherwise:
* - `global`: delegates entirely to `resolveConfigHomeFromDescriptor`
* (composition — see the module-level comment).
* - `local`: joins the registry's `localConfigDir` onto `cwd` (defaulting
* to the real process cwd) — the project-local dir, independent of
* `home`/`env`.
*/
function resolveScopeConfigHome(
id: InstallScope,
descriptor: RuntimeDescriptorForScope,
input: ResolveScopeInput,
): string {
const explicitDir = input.explicitDir;
if (typeof explicitDir === 'string' && explicitDir.trim() !== '') {
return normalizeSeparators(expandTildeForExplicitDir(explicitDir, input.home));
}
if (id === 'local') {
// localConfigDir is guaranteed non-null here: the only registered
// runtime with `localConfigDir: null` is vscode, and vscode's
// `configHome.kind === 'none'` already causes resolveScope to throw
// before this function is ever called (see the 'none' guard below).
const localConfigDir = descriptor.localConfigDir as string;
const cwd = input.cwd ?? process.cwd();
return normalizeSeparators(path.join(cwd, localConfigDir));
}
return normalizeSeparators(
resolveConfigHomeFromDescriptor(descriptor.configHome, {
env: input.env,
home: input.home,
existsSync: input.existsSync,
}),
);
}
/**
* Resolve a bare `'global' | 'local'` scope id plus a runtime into a single
* `ResolvedScope` value: the config directory, the per-scope settings
* filename (or `null`), whether the scope requires a consent record, and a
* precedence rank (data only this phase — see `hostPrecedenceRank` above).
*
* Pure: performs no writes and no I/O of its own beyond what
* `resolveConfigHomeFromDescriptor` already performs via the injected
* `existsSync` (for `global`) or the injected `cwd`, defaulting to
* `process.cwd()` (for `local`). Never mutates `input`. The returned object
* is frozen so a caller mutating the result cannot corrupt a subsequent
* call.
*
* Throws `TypeError` for:
* - an `id` outside `'global' | 'local'` — including wrong case, empty,
* missing, or any non-string value (no coercion, ever);
* - an unknown `runtime` (no matching capability-registry entry);
* - a `runtime` whose descriptor has `configHome.kind === 'none'`
* (vscode) — there is no installable config directory to resolve, so
* inventing one (or silently returning `configHome: null`) would be
* dishonest. All three cases share one catch shape (`instanceof
* TypeError`) with `resolveRuntimeArtifactLayout`'s existing contract
* for unknown runtimes, so callers of both never need two different
* catch blocks.
*/
export function resolveScope(input: ResolveScopeInput): ResolvedScope {
const scopeId = validateScopeId(input?.id, 'resolveScope');
const runtime = input.runtime;
const registryEntry = typeof runtime === 'string'
? getRegistry().runtimes[runtime]
: undefined;
const descriptor = registryEntry?.runtime;
if (!descriptor) {
throw new TypeError(
`resolveScope: unknown runtime '${String(runtime)}' — not present in the capability registry`,
);
}
if (descriptor.configHome.kind === 'none') {
// #2103: vscode-shaped runtimes (Marketplace/VSIX, installSurface:
// 'none') have no file-projected config directory at all — the same
// carve-out tests/runtime-flags.test.cjs's NON_INSTALLABLE_RUNTIMES
// already documents. Throwing here matches
// resolveConfigHomeFromDescriptor's own deliberate throw on this kind,
// rather than silently inventing an install scope for a runtime that
// cannot be installed.
throw new TypeError(
`resolveScope: runtime '${runtime}' has no installable config directory (configHome.kind === 'none')`,
);
}
const configHome = resolveScopeConfigHome(scopeId, descriptor, input);
const settingsFile = descriptor.hostBehaviors?.settingsFileByScope?.[scopeId] ?? null;
const consentRequired = scopeId === 'local';
const hostPrecedenceRank = HOST_PRECEDENCE_RANK[scopeId];
return Object.freeze({
id: scopeId,
configHome,
settingsFile,
consentRequired,
hostPrecedenceRank,
});
}
/**
* Project an `InstallScope` down to the boolean shape some downstream APIs
* still require. Four call sites (both kind-builder closures in
* `runtime-artifact-layout.cts`, plus one each in
* `runtime-artifact-install-plan.cts` and `surface.cts`) were each
* independently re-deriving this same `scope === 'global'` comparison — four
* copies of one rule that could silently drift apart (#2870). They exist
* because `runtime-artifact-conversion.cts`'s `_computePathPrefix` takes
* `isGlobal: boolean` at its API boundary, and that boundary is not changing
* here, so the boolean projection cannot be eliminated — only centralized to
* the one place below.
*
* Throws the same `TypeError`, with the same message shape, as
* `resolveScope` throws for an `id` outside `'global' | 'local'` — both call
* `validateScopeId` above, so the two error contracts cannot diverge.
*
* Deliberately throws, rather than returning `false`, for an out-of-union
* value — unlike the inline `scope === 'global'` comparison it replaced,
* which silently returned `false` for anything unrecognized. The
* alternative is silently treating an unknown scope as "not global" and
* writing artifacts to the wrong place, which is worse than failing loud.
* A caller holding an optional `scope` (e.g. a raw `Layout.scope`) must
* default it before calling this — see `surface.cts` for the pattern.
*/
export function isGlobalScope(scope: InstallScope): boolean {
return validateScopeId(scope, 'isGlobalScope') === 'global';
}

View File

@@ -25,6 +25,10 @@ import runtimeNamePolicy = require('./runtime-name-policy.cjs');
const { getDirName } = runtimeNamePolicy;
import capabilityRegistry = require('./capability-registry.cjs');
import { posixNormalize } from './shell-command-projection.cjs';
// #2870: install-scope.cts is a leaf-tier sibling (imports only
// runtime-homes.cjs + node builtins, never this module) — no cycle. See the
// isGlobal sites below for why the boolean projection is centralized here too.
import { isGlobalScope } from './install-scope.cjs';
// #1383: resolve GSD's version WITHOUT a top-level
// `require('../../../package.json')`. That require ran at module load on every
@@ -2862,7 +2866,10 @@ function rewriteStagedSkillBodies(stagedDir, opts) {
const resolvedTarget = posixNormalize(path.resolve(configDir));
const homeDir = posixNormalize(homedir());
const isGlobal = scope === 'global';
// #2870: `scope` is defaulted to 'global' above, so it is never undefined
// here, and every reachable caller passes 'global' | 'local' | undefined —
// isGlobalScope's throw-on-out-of-union case is unreachable at this site.
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 });
@@ -2900,7 +2907,10 @@ function rewriteStagedCommandBodies(stagedDir, opts) {
const resolvedTarget = posixNormalize(path.resolve(configDir));
const homeDir = posixNormalize(homedir());
const isGlobal = scope === 'global';
// #2870: `scope` is defaulted to 'global' above, so it is never undefined
// here, and every reachable caller passes 'global' | 'local' | undefined —
// isGlobalScope's throw-on-out-of-union case is unreachable at this site.
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 });

View File

@@ -12,8 +12,14 @@
const _require: NodeRequire = require;
const path = _require('node:path') as typeof import('node:path');
// #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';
type InstallScope = 'local' | 'global';
interface ResolvedProfile {
name?: string;
@@ -181,7 +187,11 @@ function createRuntimeArtifactInstallPlan(args: CreateRuntimeArtifactInstallPlan
const homedirFn: () => string = homedir ?? (() => os.homedir());
const resolvedTarget = posixNormalize(path.resolve(layout.configDir));
const homeDir = posixNormalize(homedirFn());
const isGlobal = scope === 'global';
// #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';
const pathPrefix = conversionExports._computePathPrefix({ isGlobal, isOpencode, isWindowsHost, resolvedTarget, homeDir });

View File

@@ -30,6 +30,11 @@ const conversionExports = runtimeArtifactConversion as Record<string, unknown> &
readGsdCommandNames?: () => string[];
};
import { posixNormalize } from './shell-command-projection.cjs';
// #2870: `isGlobalScope` centralizes the `scope === 'global'` boolean
// projection both kind-builder closures below need at the converters'
// positional `isGlobal` boundary (see its doc comment in install-scope.cts
// for why the projection is centralized rather than eliminated).
import { isGlobalScope } from './install-scope.cjs';
// In .cts (CommonJS output) files, `require` is available as a global.
const _require: NodeRequire = require;
@@ -272,6 +277,11 @@ function convertedAgentsKind(
// choose global-home vs workspace-relative paths; converters that only take
// (content) ignore the extra positional arg. Mirrors skillsKind's scope
// threading (#1173).
// #2870: `scope` is this function's own parameter (default `'global'`,
// so it is never undefined here), sourced upstream from the Install
// Scope Module's resolved id. `isGlobalScope` projects it to the
// boolean `stageAgentsForRuntimeWithConverter`'s positional API
// requires — see its doc comment in install-scope.cts.
const converter = conversionExports[converterName] as (content: string, isGlobal?: boolean) => string;
// ADR-1235 §1: when agentCtx is provided (by createRuntimeArtifactInstallPlan
// for descriptor-driven runtimes), thread it through so stageAgentsForRuntimeWithConverter
@@ -280,7 +290,7 @@ function convertedAgentsKind(
findAgentsSourceRoot(configDir),
resolved,
converter,
scope === 'global',
isGlobalScope(scope),
agentCtx,
);
},
@@ -380,7 +390,11 @@ function skillsKind(
const cmdNames = conversionExports.readGsdCommandNames
? conversionExports.readGsdCommandNames()
: [];
const isGlobal = scope === 'global';
// #2870: same judgment as convertedAgentsKind above — `scope` is this
// function's own parameter (default `'global'`, so it is never
// undefined here); `isGlobalScope` projects it to the boolean
// `realConverter`'s positional `isGlobal` arg requires.
const isGlobal = isGlobalScope(scope);
const wrappedConverter = (content: string, skillName: string): string =>
realConverter(content, skillName, runtime, cmdNames, isGlobal);
return stageSkillsForRuntimeAsSkills(findInstallSourceRoot(configDir), resolved, wrappedConverter, prefix, nested, capabilityRegistry);
@@ -540,7 +554,11 @@ function dispatchKindEntry(entry: ArtifactKindDescriptor, runtime: string, confi
);
}
if (scope === 'global' && typeof entry.home === 'string' && entry.home !== '') {
// scope is guaranteed 'local' | 'global' here: resolveRuntimeArtifactLayoutFromRegistry
// (the only caller of dispatchKindEntry) throws TypeError before this point if scope is
// anything else (see the `scope !== 'local' && scope !== 'global'` guard above its
// dispatchKindEntry call), so isGlobalScope's throw-on-invalid-input never fires here.
if (isGlobalScope(scope) && typeof entry.home === 'string' && entry.home !== '') {
result.home = path.join(os.homedir(), entry.home);
}

View File

@@ -45,6 +45,11 @@ const {
} = installProfiles;
import { CLUSTERS } from './clusters.cjs';
import type { ClusterMap } from './clusters.cjs';
// #2870: `isGlobalScope` centralizes the `scope === 'global'` boolean
// projection `applySurface` needs at `_computePathPrefix`'s `isGlobal:
// boolean` boundary (see its doc comment in install-scope.cts for why the
// projection is centralized rather than eliminated).
import { isGlobalScope } from './install-scope.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import runtimeArtifactLayout = require('./runtime-artifact-layout.cjs');
const { findInstallSourceRoot } = runtimeArtifactLayout;
@@ -369,7 +374,17 @@ function applySurface(runtimeConfigDir: string, layout: Layout, manifest: Map<st
const _homedirFn: () => string = opts?.homedir ?? (() => os.homedir());
const _resolvedTarget = posixNormalize(path.resolve(layout.configDir));
const _homeDir = posixNormalize(_homedirFn());
const _isGlobal = (layout.scope ?? 'global') === 'global';
// #2870: same judgment as createRuntimeArtifactInstallPlan's identical
// line — `layout.scope` is already the resolved scope value on the
// `Layout` this function received. `layout.scope` is optional, so the
// pre-existing `?? 'global'` default is kept ahead of the call: it must
// run BEFORE `isGlobalScope`, because `isGlobalScope(undefined)` throws
// (unlike the old inline `undefined === 'global'`, which silently
// evaluated to `false`) — the default is what makes an undefined scope
// resolve to `'global'` here, exactly as it did before. `isGlobalScope`
// then projects the defaulted value to the boolean `_computePathPrefix`'s
// existing `isGlobal: boolean` API requires.
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 });

View File

@@ -0,0 +1,280 @@
'use strict';
/**
* Failing-first suite for the Install Scope Module (#2870, ADR-2866).
*
* Asserts `resolveScope`'s contract from
* .gsd/phase/feat-2870-install-scope-module/40-design.md against the test
* matrix at .gsd/phase/feat-2870-install-scope-module/50-test-matrix.md
* (rows 1-19; row 20 lands with the bin/install.js call-site migration and
* is deliberately out of scope here).
*
* Every case injects `env` / `home` / `existsSync` — mirroring
* `runtime-homes.cts`'s `ResolveConfigHomeOpts` shape — instead of touching
* the real filesystem or the real home directory: no `mkdtempSync`, no
* `os.homedir()`. The module under test does not exist yet, so requiring it
* below throws `MODULE_NOT_FOUND`. That is the point: this suite is RED
* until src/install-scope.cts lands and builds to
* gsd-core/bin/lib/install-scope.cjs.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const { toPosixPath } = require('./helpers.cjs');
const { resolveScope, isGlobalScope } = require('../gsd-core/bin/lib/install-scope.cjs');
const registry = require('../gsd-core/bin/lib/capability-registry.cjs');
const { createRuntimeArtifactInstallPlan } = require('../gsd-core/bin/lib/runtime-artifact-install-plan.cjs');
const FAKE_HOME = '/fake/home';
const NO_EXISTS = () => false;
function fixture(overrides) {
return { env: {}, home: FAKE_HOME, existsSync: NO_EXISTS, ...overrides };
}
// #2103: `vscode` enters `registry.runtimes` (role:runtime, kept for
// validator / host-integration coverage) but declares
// `configHome.kind === 'none'` — no file-projected config directory exists
// to resolve at all — and it is never CLI-installed (no --vscode flag,
// absent from bin/install.js's `allRuntimes`). This is the same carve-out
// tests/runtime-flags.test.cjs's `NON_INSTALLABLE_RUNTIMES` documents:
// install-scope's global-configHome resolution has nothing to resolve for a
// runtime with no config directory, so it is excluded from the "every
// runtime" sweep below rather than assumed to resolve like every other
// registered runtime. The excluded case itself is asserted directly by
// 'rejects a runtime with no config home' below (design row 13) — it is
// covered, not dropped.
const NON_INSTALLABLE_RUNTIMES = new Set(['vscode']);
const INSTALL_SCOPE_RUNTIME_IDS = Object.keys(registry.runtimes)
.filter((id) => !NON_INSTALLABLE_RUNTIMES.has(id));
describe('resolveScope', () => {
// Row 1
test('resolves claude global settings file', () => {
const result = resolveScope(fixture({ id: 'global', runtime: 'claude' }));
assert.strictEqual(result.settingsFile, 'settings.json');
});
// Row 2
test('resolves claude local settings file', () => {
const result = resolveScope(fixture({ id: 'local', runtime: 'claude', cwd: '/fake/project' }));
assert.strictEqual(result.settingsFile, 'settings.local.json');
});
// Row 3
test('returns null settingsFile for runtimes that declare none', () => {
const result = resolveScope(fixture({ id: 'global', runtime: 'codex' }));
assert.strictEqual(result.settingsFile, null);
});
// Row 4
test('resolves for every registered runtime at both scopes', () => {
assert.ok(INSTALL_SCOPE_RUNTIME_IDS.length > 0, 'registry must contain at least one installable runtime');
for (const runtime of INSTALL_SCOPE_RUNTIME_IDS) {
for (const id of ['global', 'local']) {
const result = resolveScope(fixture({ id, runtime, cwd: '/fake/project' }));
assert.strictEqual(typeof result.configHome, 'string', `${runtime}/${id}: configHome must be a string`);
assert.ok(result.configHome.length > 0, `${runtime}/${id}: configHome must be non-empty`);
assert.ok(
result.settingsFile === null || typeof result.settingsFile === 'string',
`${runtime}/${id}: settingsFile must be string or null`,
);
}
}
});
// Row 5
test('global scope requires no consent record', () => {
const result = resolveScope(fixture({ id: 'global', runtime: 'claude' }));
assert.strictEqual(result.consentRequired, false);
});
// Row 6
test('local scope requires a consent record', () => {
const result = resolveScope(fixture({ id: 'local', runtime: 'claude', cwd: '/fake/project' }));
assert.strictEqual(result.consentRequired, true);
});
// Row 7 — assert the RELATION, never a literal rank number (unread this
// phase; Phase 2 may re-base the literal values).
test('global outranks local in hostPrecedenceRank', () => {
const globalScope = resolveScope(fixture({ id: 'global', runtime: 'claude' }));
const localScope = resolveScope(fixture({ id: 'local', runtime: 'claude', cwd: '/fake/project' }));
assert.ok(
globalScope.hostPrecedenceRank > localScope.hostPrecedenceRank,
`expected global rank (${globalScope.hostPrecedenceRank}) > local rank (${localScope.hostPrecedenceRank})`,
);
});
// Row 8
test('explicit config dir overrides descriptor resolution', () => {
const result = resolveScope(fixture({ id: 'global', runtime: 'claude', explicitDir: '/custom/config/dir' }));
assert.strictEqual(result.configHome, '/custom/config/dir');
});
// Row 9
test('rejects the consent-vocabulary spelling', () => {
assert.throws(
() => resolveScope(fixture({ id: 'project', runtime: 'claude' })),
(err) => err instanceof TypeError && /global/i.test(err.message) && /local/i.test(err.message),
);
});
// Row 10
test('rejects case variants', () => {
assert.throws(
() => resolveScope(fixture({ id: 'Global', runtime: 'claude' })),
TypeError,
);
});
// Row 11
test('rejects empty and missing scope id', () => {
assert.throws(() => resolveScope(fixture({ id: '', runtime: 'claude' })), TypeError, 'id: empty string');
assert.throws(() => resolveScope(fixture({ id: undefined, runtime: 'claude' })), TypeError, 'id: undefined');
const { home, env, existsSync } = fixture({});
assert.throws(() => resolveScope({ runtime: 'claude', home, env, existsSync }), TypeError, 'id: absent key');
});
// Row 12
test('rejects unknown runtime with the established error contract', () => {
assert.throws(
() => resolveScope(fixture({ id: 'global', runtime: 'no-such-runtime' })),
(err) => err instanceof TypeError && err.message.includes('no-such-runtime'),
);
});
// Design row 13: a runtime whose descriptor has `configHome.kind ===
// 'none'` — vscode is the only one — has no installable config directory
// to resolve at all. Same contract as row 9 (unknown runtime): TypeError
// naming the runtime, one catch shape for callers.
test('rejects a runtime with no config home', () => {
assert.throws(
() => resolveScope(fixture({ id: 'global', runtime: 'vscode' })),
(err) => err instanceof TypeError
&& err.message === "resolveScope: runtime 'vscode' has no installable config directory (configHome.kind === 'none')",
);
assert.throws(
() => resolveScope(fixture({ id: 'local', runtime: 'vscode' })),
(err) => err instanceof TypeError
&& err.message === "resolveScope: runtime 'vscode' has no installable config directory (configHome.kind === 'none')",
);
});
// Row 13
test('rejects non-string scope ids without coercion', () => {
for (const id of [0, null, {}, ['global']]) {
assert.throws(
() => resolveScope(fixture({ id, runtime: 'claude' })),
TypeError,
`id=${JSON.stringify(id)} must throw TypeError, not coerce`,
);
}
});
// Row 14
test('preserves opencode/kilo config-file precedence', () => {
const filePath = '/home/x/custom/opencode-config.json';
const result = resolveScope(fixture({
id: 'global',
runtime: 'opencode',
env: { OPENCODE_CONFIG: filePath },
}));
assert.strictEqual(result.configHome, path.dirname(filePath));
});
// Row 15
test('blank env override does not win', () => {
const expected = toPosixPath(path.join(FAKE_HOME, '.claude'));
const empty = resolveScope(fixture({ id: 'global', runtime: 'claude', env: { CLAUDE_CONFIG_DIR: '' } }));
const whitespace = resolveScope(fixture({ id: 'global', runtime: 'claude', env: { CLAUDE_CONFIG_DIR: ' ' } }));
assert.strictEqual(empty.configHome, expected);
assert.strictEqual(whitespace.configHome, expected);
});
// Row 16
test('is pure and does not mutate its input', () => {
const input = Object.freeze(fixture({ id: 'global', runtime: 'claude' }));
const first = resolveScope(input);
const second = resolveScope(input);
assert.deepStrictEqual(first, second);
});
// Row 17
test('normalizes backslash paths on every platform', () => {
const result = resolveScope(fixture({ id: 'global', runtime: 'claude', home: 'C:\\Users\\x' }));
assert.ok(!result.configHome.includes('\\'), `configHome must not contain backslashes: ${result.configHome}`);
assert.strictEqual(result.configHome, 'C:/Users/x/.claude');
});
// Row 18
test('returned value cannot be corrupted by a caller', () => {
const input = fixture({ id: 'global', runtime: 'claude' });
const first = resolveScope(input);
const originalConfigHome = first.configHome;
try {
first.configHome = 'HACKED';
} catch {
// A frozen result rejecting the mutation outright is an acceptable
// defense too — either way, a second resolution must be unaffected.
}
const second = resolveScope(input);
assert.strictEqual(second.configHome, originalConfigHome);
});
// Row 19
test('install-plan imports the shared InstallScope type', () => {
const globalScope = resolveScope(fixture({ id: 'global', runtime: 'claude' }));
const localScope = resolveScope(fixture({ id: 'local', runtime: 'claude', cwd: '/fake/project' }));
for (const scope of [globalScope, localScope]) {
const result = createRuntimeArtifactInstallPlan({
layout: {
runtime: 'claude',
configDir: scope.configHome,
scope: scope.id,
kinds: [],
},
resolvedProfile: { name: 'core' },
});
assert.strictEqual(
result.ok,
true,
`runtime-artifact-install-plan must accept install-scope's '${scope.id}' spelling directly`,
);
}
});
// Row 16 follow-up: local scope's configHome must be assertable via an
// injected cwd, never the real process.cwd().
test('local scope resolves configHome against an injected cwd', () => {
const first = resolveScope(fixture({ id: 'local', runtime: 'claude', cwd: '/fake/project-a' }));
const second = resolveScope(fixture({ id: 'local', runtime: 'claude', cwd: '/fake/project-b' }));
assert.notStrictEqual(first.configHome, second.configHome);
assert.strictEqual(first.configHome, toPosixPath(path.join('/fake/project-a', '.claude')));
assert.strictEqual(second.configHome, toPosixPath(path.join('/fake/project-b', '.claude')));
});
});
describe('isGlobalScope', () => {
// #2870: the shared boolean projection that replaced four independent
// inline `scope === 'global'` re-derivations.
test('returns true only for global', () => {
assert.strictEqual(isGlobalScope('global'), true);
});
test('returns false for local', () => {
assert.strictEqual(isGlobalScope('local'), false);
});
// Parity assertion: isGlobalScope must throw the SAME TypeError contract
// resolveScope's invalid-id case (Row 9) throws, since both share
// install-scope.cts's one validator — never a second, divergent one.
test('throws TypeError for an invalid id, matching resolveScope\'s contract', () => {
assert.throws(
() => isGlobalScope('project'),
(err) => err instanceof TypeError && /global/i.test(err.message) && /local/i.test(err.message),
);
});
});