diff --git a/.changeset/merry-cats-jump.md b/.changeset/merry-cats-jump.md new file mode 100644 index 000000000..7d22ff955 --- /dev/null +++ b/.changeset/merry-cats-jump.md @@ -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) diff --git a/.gitignore b/.gitignore index 1d38502b9..886efee6e 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CONTEXT.md b/CONTEXT.md index 5de265298..c14bc658d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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. diff --git a/bin/install.js b/bin/install.js index 4542de43e..5e0d752a8 100755 --- a/bin/install.js +++ b/bin/install.js @@ -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). diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 3df383224..b76622af0 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -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", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 131afa2e3..596476891 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -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 | diff --git a/eslint.config.mjs b/eslint.config.mjs index e851fd436..e9a035a43 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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', diff --git a/src/install-engine.cts b/src/install-engine.cts index cf37038aa..20f5c84fe 100644 --- a/src/install-engine.cts +++ b/src/install-engine.cts @@ -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 diff --git a/src/install-scope.cts b/src/install-scope.cts new file mode 100644 index 000000000..903b57203 --- /dev/null +++ b/src/install-scope.cts @@ -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; + 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 = 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 = { + global: 2, + local: 1, +}; + +interface HostBehaviorsForScope { + settingsFileByScope?: Partial>; +} + +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; +} + +/** 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'; +} diff --git a/src/runtime-artifact-conversion.cts b/src/runtime-artifact-conversion.cts index c7e085b3a..210564d64 100644 --- a/src/runtime-artifact-conversion.cts +++ b/src/runtime-artifact-conversion.cts @@ -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 }); diff --git a/src/runtime-artifact-install-plan.cts b/src/runtime-artifact-install-plan.cts index e25a2c25f..311798552 100644 --- a/src/runtime-artifact-install-plan.cts +++ b/src/runtime-artifact-install-plan.cts @@ -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 }); diff --git a/src/runtime-artifact-layout.cts b/src/runtime-artifact-layout.cts index af7153f4b..3cd27973a 100644 --- a/src/runtime-artifact-layout.cts +++ b/src/runtime-artifact-layout.cts @@ -30,6 +30,11 @@ const conversionExports = runtimeArtifactConversion as Record & 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); } diff --git a/src/surface.cts b/src/surface.cts index a12117f9c..f04cb999a 100644 --- a/src/surface.cts +++ b/src/surface.cts @@ -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 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 }); diff --git a/tests/install-scope.test.cjs b/tests/install-scope.test.cjs new file mode 100644 index 000000000..de33431e4 --- /dev/null +++ b/tests/install-scope.test.cjs @@ -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), + ); + }); +});