* fix(#1441): antigravity resolver prefers GSD-owned dir over first-existing resolveConfigHomeFromDescriptor's dot-home-nested probe returned the first bare-existing candidate, so a CLI user (~/.gemini/antigravity-cli) who also had the IDE's ~/.gemini/antigravity dir was silently shadowed to the legacy dir (probed first). Regression from #217 — pre-#217 returned ~/.gemini/antigravity unconditionally. Add an optional probeMarker (gsd-core/VERSION) to the dot-home-nested descriptor: a two-pass probe prefers the candidate GSD installed into, then bare existence, then probe[0]. Behavior is byte-identical when probeMarker is absent (windsurf etc. unaffected). Adds detectAntigravityDirAmbiguity() for installer/operator guidance on already-misinstalled users (auto-relocation ruled out per ADR-0008's single-configDir migration bound). Regression tests fail before / pass after: coexistence + marker-priority cases, end-to-end through the registry descriptor. Claude-Session: https://claude.ai/code/session_01JU2WB23JLE3QTysPXuFJjB * chore(#1441): add changeset for antigravity resolver fix Claude-Session: https://claude.ai/code/session_01JU2WB23JLE3QTysPXuFJjB
This commit is contained in:
5
.changeset/fierce-quails-wave.md
Normal file
5
.changeset/fierce-quails-wave.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
type: Fixed
|
||||||
|
pr: 1442
|
||||||
|
---
|
||||||
|
**Antigravity config-dir resolution no longer shadows the active runtime** — when more than one of `~/.gemini/antigravity`, `antigravity-ide`, or `antigravity-cli` exists, GSD now resolves to the directory it actually installed into (marked by `gsd-core/VERSION`) instead of whichever directory existed first. Fixes silent misresolution where a CLI user who also had the Antigravity-IDE dir present was sent to the legacy dir (regression from #217).
|
||||||
@@ -119,7 +119,7 @@ Module owning runtime identity normalization at runtime-selection seams. Canonic
|
|||||||
Module owning validation for Installer Migration Module records and planned actions. It enforces migration metadata, explicit install scopes, ownership evidence for destructive/config actions, and runtime contract citations for runtime config rewrites before a migration can enter planning or apply.
|
Module owning validation for Installer Migration Module records and planned actions. It enforces migration metadata, explicit install scopes, ownership evidence for destructive/config actions, and runtime contract citations for runtime config rewrites before a migration can enter planning or apply.
|
||||||
|
|
||||||
### Installer Module
|
### Installer Module
|
||||||
Primary installer for all runtimes. Single production file: `bin/install.js` (generated). Exports: `install(isGlobal, runtime[, configDir])` → typed result `{ runtime, configDir, settingsPath, settings, statuslineCommand, updateBannerCommand }`; `uninstall(isGlobal, runtime[, configDir])`; `installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile)`; `uninstallRuntimeArtifacts(runtime, configDir, scope)`; `writeManifest(configDir, runtime)`. Runtime enum: `allRuntimes` (15 values: claude, antigravity, augment, cline, codebuddy, codex, copilot, cursor, gemini, hermes, kilo, opencode, qwen, trae, windsurf). Directory helpers: `getDirName(runtime)` → local dir name; `getConfigDirFromHome(runtime, isGlobal)` → shell-quoted path fragment. Per-runtime global config-dir resolution is delegated to `gsd-core/bin/lib/runtime-homes.cjs:getGlobalConfigDir(runtime[, explicitDir])` — the canonical, env-var–aware projection (`explicitDir` override + opencode/kilo `*_CONFIG` file-path precedence); the legacy in-installer `getGlobalDir`/`getOpencodeGlobalDir`/`getKiloGlobalDir` were retired into it (#56). Runtime-specific helpers: `resolveKiloConfigPath(configDir)`, `configureKiloPermissions(isGlobal[, explicitDir])`. Claude-specific permission helpers: `mergeClaudePermissions(settings)` — non-destructively appends GSD-owned allow/deny entries (see `GSD_CLAUDE_ALLOW_PERMISSIONS`, `GSD_CLAUDE_DENY_PERMISSIONS` constants) to a Claude Code settings object; called from `finishInstall` for `runtime === 'claude'` only; uninstall removes exactly these entries (#768). Layout-driven artifact copy/removal delegates to `gsd-core/bin/lib/runtime-artifact-layout.cjs:resolveRuntimeArtifactLayout` (throws `TypeError` for unknown runtimes). Seven runtimes with non-recursive skill loaders (claude global, cline, qwen, hermes, augment, trae, antigravity) use a nested router layout: 6 `gsd-ns-*` router bundles emitted as top-level skills, with concrete skills nested at `<router>/skills/<name>/SKILL.md` (hermes prefix='': `skills/gsd/ns-*/…`). The remaining skills-runtimes (cursor, codex, copilot, windsurf, codebuddy, opencode, kilo) use the flat `skills/gsd-<stem>/` layout unchanged. See Skill Surface Budget Module and Runtime Artifact Layout Module.
|
Primary installer for all runtimes. Single production file: `bin/install.js` (generated). Exports: `install(isGlobal, runtime[, configDir])` → typed result `{ runtime, configDir, settingsPath, settings, statuslineCommand, updateBannerCommand }`; `uninstall(isGlobal, runtime[, configDir])`; `installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile)`; `uninstallRuntimeArtifacts(runtime, configDir, scope)`; `writeManifest(configDir, runtime)`. Runtime enum: `allRuntimes` (15 values: claude, antigravity, augment, cline, codebuddy, codex, copilot, cursor, gemini, hermes, kilo, opencode, qwen, trae, windsurf). Directory helpers: `getDirName(runtime)` → local dir name; `getConfigDirFromHome(runtime, isGlobal)` → shell-quoted path fragment. Per-runtime global config-dir resolution is delegated to `gsd-core/bin/lib/runtime-homes.cjs:getGlobalConfigDir(runtime[, explicitDir])` — the canonical, env-var–aware projection (`explicitDir` override + opencode/kilo `*_CONFIG` file-path precedence); the legacy in-installer `getGlobalDir`/`getOpencodeGlobalDir`/`getKiloGlobalDir` were retired into it (#56). The same module exposes `detectAntigravityDirAmbiguity(opts)` — a side-effect-free probe reporting whether multiple `~/.gemini/antigravity{,-ide,-cli}` dirs coexist and which one GSD's `gsd-core/VERSION` marker (the `dot-home-nested` `probeExists`) resolves to, for installer / `/gsd-update` operator guidance when a pre-#217 install landed in the wrong sibling dir (#1441). Runtime-specific helpers: `resolveKiloConfigPath(configDir)`, `configureKiloPermissions(isGlobal[, explicitDir])`. Claude-specific permission helpers: `mergeClaudePermissions(settings)` — non-destructively appends GSD-owned allow/deny entries (see `GSD_CLAUDE_ALLOW_PERMISSIONS`, `GSD_CLAUDE_DENY_PERMISSIONS` constants) to a Claude Code settings object; called from `finishInstall` for `runtime === 'claude'` only; uninstall removes exactly these entries (#768). Layout-driven artifact copy/removal delegates to `gsd-core/bin/lib/runtime-artifact-layout.cjs:resolveRuntimeArtifactLayout` (throws `TypeError` for unknown runtimes). Seven runtimes with non-recursive skill loaders (claude global, cline, qwen, hermes, augment, trae, antigravity) use a nested router layout: 6 `gsd-ns-*` router bundles emitted as top-level skills, with concrete skills nested at `<router>/skills/<name>/SKILL.md` (hermes prefix='': `skills/gsd/ns-*/…`). The remaining skills-runtimes (cursor, codex, copilot, windsurf, codebuddy, opencode, kilo) use the flat `skills/gsd-<stem>/` layout unchanged. See Skill Surface Budget Module and Runtime Artifact Layout Module.
|
||||||
|
|
||||||
### I/O Module
|
### I/O Module
|
||||||
Module owning the tool's CLI I/O primitives: `output()` result emission (with large-payload temp-file spillover via `GSD_TEMP_DIR`/`ensureGsdTempDir`/`reapStaleTempFiles`), `error()` stderr emission with exit-code mapping, and the JSON-error-mode toggle (`setJsonErrorMode`/`getJsonErrorMode`, `ERROR_REASON`). Extracted from the Core module per ADR-857 rollout phase 1 (#859) so feature modules (`graphify`, `intel`, `audit`, `profile-pipeline`) depend on a small I/O seam instead of the core god-module; the `core.cjs` re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: `gsd-core/bin/lib/io.cjs` (generated from `src/io.cts`).
|
Module owning the tool's CLI I/O primitives: `output()` result emission (with large-payload temp-file spillover via `GSD_TEMP_DIR`/`ensureGsdTempDir`/`reapStaleTempFiles`), `error()` stderr emission with exit-code mapping, and the JSON-error-mode toggle (`setJsonErrorMode`/`getJsonErrorMode`, `ERROR_REASON`). Extracted from the Core module per ADR-857 rollout phase 1 (#859) so feature modules (`graphify`, `intel`, `audit`, `profile-pipeline`) depend on a small I/O seam instead of the core god-module; the `core.cjs` re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: `gsd-core/bin/lib/io.cjs` (generated from `src/io.cts`).
|
||||||
|
|||||||
@@ -21,7 +21,8 @@
|
|||||||
"antigravity",
|
"antigravity",
|
||||||
"antigravity-ide",
|
"antigravity-ide",
|
||||||
"antigravity-cli"
|
"antigravity-cli"
|
||||||
]
|
],
|
||||||
|
"probeExists": "gsd-core/VERSION"
|
||||||
},
|
},
|
||||||
"configFormat": "settings-json",
|
"configFormat": "settings-json",
|
||||||
"artifactLayout": {
|
"artifactLayout": {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ configHome: {
|
|||||||
parent?: string, // for dot-home-nested: e.g. '.gemini' (antigravity), '.codeium' (windsurf)
|
parent?: string, // for dot-home-nested: e.g. '.gemini' (antigravity), '.codeium' (windsurf)
|
||||||
env: string[], // ordered override env vars, e.g. ['CLAUDE_CONFIG_DIR'] (required; may be empty)
|
env: string[], // ordered override env vars, e.g. ['CLAUDE_CONFIG_DIR'] (required; may be empty)
|
||||||
probe?: string[], // ordered candidate subpaths; first existing wins (antigravity, kimi)
|
probe?: string[], // ordered candidate subpaths; first existing wins (antigravity, kimi)
|
||||||
probeExists?: string, // if set, select the first probe candidate where <candidate>/<probeExists> exists (kimi: 'skills')
|
probeExists?: string, // marker sub-path on a probe candidate. generic-agents-root: hard filter (kimi: 'skills'). dot-home-nested: marker-priority preference, then bare-existence fallback (antigravity: 'gsd-core/VERSION', #213/#217) — see amendment below
|
||||||
skillsHome?: { kind, name, ... } // override when the skills dir ≠ config dir (kilo only)
|
skillsHome?: { kind, name, ... } // override when the skills dir ≠ config dir (kilo only)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -48,6 +48,10 @@ This absorbs kilo (`skillsHome` split), kimi & antigravity (`probe`), windsurf (
|
|||||||
|
|
||||||
`configHome` resolution is **pure and read-only** (a first-existing probe; no `mkdirSync` — verified in `runtime-homes.cts`). Any directory creation at install time is the `configFormat` permissions-writer's responsibility (opencode/kilo), never the descriptor-resolution step — so `configHome` carries no `createIfMissing` flag.
|
`configHome` resolution is **pure and read-only** (a first-existing probe; no `mkdirSync` — verified in `runtime-homes.cts`). Any directory creation at install time is the `configFormat` permissions-writer's responsibility (opencode/kilo), never the descriptor-resolution step — so `configHome` carries no `createIfMissing` flag.
|
||||||
|
|
||||||
|
#### Amendment — `probeExists` on `dot-home-nested` (#1441, 2026-06-18)
|
||||||
|
|
||||||
|
`probeExists` was originally honoured only by `generic-agents-root` (kimi), as a *hard filter*. It is now also honoured by `dot-home-nested`, where it acts as a *preference*: probing first returns the candidate whose `<candidate>/<probeExists>` exists (the dir GSD installed into, marked by `gsd-core/VERSION`), then falls back to the legacy first-bare-existing pass, then `probe[0]`. When `probeExists` is absent the behaviour is byte-identical to the original first-bare-existing probe, so other `dot-home-nested` runtimes (e.g. windsurf, which has no `probe`) are unaffected. This fixes silent misresolution where an active sibling dir (the Antigravity-IDE `~/.gemini/antigravity`) shadowed a CLI install in `~/.gemini/antigravity-cli` — a regression introduced by #217. The existing `probeExists` field name is reused rather than introducing a parallel `probeMarker`, keeping the `configHome` vocabulary closed.
|
||||||
|
|
||||||
### 2. `configFormat` — the existing closed enum (unchanged)
|
### 2. `configFormat` — the existing closed enum (unchanged)
|
||||||
|
|
||||||
`settings-json | toml | markdown | markdown-dir | none`. Cursor is `none` (it writes no settings file — its only managed file is the hooks manifest, captured by `hooksSurface`, not `configFormat`). opencode/kilo are `settings-json` with a JSONC **permissions sidecar** expressed by an optional `permissions: 'opencode-jsonc' | 'kilo-jsonc' | 'none'` sub-field (the only two runtimes that write one).
|
`settings-json | toml | markdown | markdown-dir | none`. Cursor is `none` (it writes no settings file — its only managed file is the hooks manifest, captured by `hooksSurface`, not `configFormat`). opencode/kilo are `settings-json` with a JSONC **permissions sidecar** expressed by an optional `permissions: 'opencode-jsonc' | 'kilo-jsonc' | 'none'` sub-field (the only two runtimes that write one).
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ Runtime capabilities describe how GSD projects its artefacts onto one host CLI.
|
|||||||
|
|
||||||
| Axis | Field | Type summary |
|
| Axis | Field | Type summary |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Config home | `runtime.configHome` | Structured object with `kind` (`dot-home` \| `dot-home-nested` \| `xdg` \| `generic-agents-root`), `name`, optional `parent`, `env[]`, `probe[]`, `probeExists`, `skillsHome`. |
|
| Config home | `runtime.configHome` | Structured object with `kind` (`dot-home` \| `dot-home-nested` \| `xdg` \| `generic-agents-root`), `name`, optional `parent`, `env[]`, `probe[]`, `probeExists`, `skillsHome`. `probeExists` is an optional sub-path applied to probe candidates: for `generic-agents-root` it is a hard filter (a candidate qualifies only if `<candidate>/<probeExists>` exists); for `dot-home-nested` it is a preference that makes probing pick the candidate GSD owns (e.g. `gsd-core/VERSION`) over a bare-existing sibling before falling back — see ADR-1016 and #213/#217. |
|
||||||
| Config format | `runtime.configFormat` | Closed enum: `settings-json` \| `toml` \| `markdown` \| `markdown-dir` \| `none`. |
|
| Config format | `runtime.configFormat` | Closed enum: `settings-json` \| `toml` \| `markdown` \| `markdown-dir` \| `none`. |
|
||||||
| Artefact layout | `runtime.artifactLayout` | Object with `global` and `local` arrays of `ArtifactKind` (`kind`, `destSubpath`, `prefix`, `nesting`, `recursive`, `stage`). |
|
| Artefact layout | `runtime.artifactLayout` | Object with `global` and `local` arrays of `ArtifactKind` (`kind`, `destSubpath`, `prefix`, `nesting`, `recursive`, `stage`). |
|
||||||
| Command style | `runtime.commandStyle` | Closed enum: `slash-hyphen` \| `shell-var`. |
|
| Command style | `runtime.commandStyle` | Closed enum: `slash-hyphen` \| `shell-var`. |
|
||||||
|
|||||||
@@ -83,7 +83,8 @@ const capabilities = {
|
|||||||
"antigravity",
|
"antigravity",
|
||||||
"antigravity-ide",
|
"antigravity-ide",
|
||||||
"antigravity-cli"
|
"antigravity-cli"
|
||||||
]
|
],
|
||||||
|
"probeExists": "gsd-core/VERSION"
|
||||||
},
|
},
|
||||||
"configFormat": "settings-json",
|
"configFormat": "settings-json",
|
||||||
"artifactLayout": {
|
"artifactLayout": {
|
||||||
@@ -2740,7 +2741,8 @@ const runtimes = {
|
|||||||
"antigravity",
|
"antigravity",
|
||||||
"antigravity-ide",
|
"antigravity-ide",
|
||||||
"antigravity-cli"
|
"antigravity-cli"
|
||||||
]
|
],
|
||||||
|
"probeExists": "gsd-core/VERSION"
|
||||||
},
|
},
|
||||||
"configFormat": "settings-json",
|
"configFormat": "settings-json",
|
||||||
"artifactLayout": {
|
"artifactLayout": {
|
||||||
|
|||||||
@@ -76,6 +76,20 @@ interface DotHomeNestedDescriptor {
|
|||||||
parent: string;
|
parent: string;
|
||||||
env: string[];
|
env: string[];
|
||||||
probe?: string[];
|
probe?: string[];
|
||||||
|
/**
|
||||||
|
* Optional sub-path that qualifies which probe candidate GSD actually owns
|
||||||
|
* (e.g. `gsd-core/VERSION`). The same field name and check used by the
|
||||||
|
* generic-agents-root descriptor — unified vocabulary per ADR-1016. The
|
||||||
|
* resolution *strength* differs per kind: generic-agents-root treats it as a
|
||||||
|
* hard filter (a candidate only qualifies if `<candidate>/<probeExists>`
|
||||||
|
* exists), whereas dot-home-nested treats it as a *preference* — probing runs
|
||||||
|
* in two passes: first the candidate whose `<candidate>/<probeExists>` exists
|
||||||
|
* wins (the dir GSD installed into), then a bare-existence pass, then
|
||||||
|
* `probe[0]`. Without it, behaviour is the legacy first-bare-existing-wins
|
||||||
|
* probe, so other dot-home-nested runtimes (e.g. windsurf, which has no probe)
|
||||||
|
* are unaffected. See ADR-1016 and #213/#217 (antigravity split).
|
||||||
|
*/
|
||||||
|
probeExists?: string;
|
||||||
skillsHome?: ConfigHomeDescriptor;
|
skillsHome?: ConfigHomeDescriptor;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +177,20 @@ export function resolveConfigHomeFromDescriptor(
|
|||||||
}
|
}
|
||||||
const base = path.join(home, configHome.parent);
|
const base = path.join(home, configHome.parent);
|
||||||
if (configHome.probe && configHome.probe.length > 0) {
|
if (configHome.probe && configHome.probe.length > 0) {
|
||||||
// probe each candidate under base; return first that exists
|
// Pass 1 (marker-priority): when probeExists is declared, prefer the
|
||||||
|
// candidate GSD actually owns (its `<candidate>/<probeExists>` exists).
|
||||||
|
// This disambiguates an active-but-shadowing sibling dir (e.g. the
|
||||||
|
// Antigravity-IDE `~/.gemini/antigravity` dir) from the dir GSD was
|
||||||
|
// installed into, instead of blindly taking the first dir that exists.
|
||||||
|
if (configHome.probeExists) {
|
||||||
|
for (const candidate of configHome.probe) {
|
||||||
|
const resolved = path.join(base, candidate);
|
||||||
|
if (existsSyncFn(path.join(resolved, configHome.probeExists))) {
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Pass 2 (legacy bare-existence): first candidate dir that exists.
|
||||||
for (const candidate of configHome.probe) {
|
for (const candidate of configHome.probe) {
|
||||||
const resolved = path.join(base, candidate);
|
const resolved = path.join(base, candidate);
|
||||||
if (existsSyncFn(resolved)) return resolved;
|
if (existsSyncFn(resolved)) return resolved;
|
||||||
@@ -239,11 +266,72 @@ export function resolveAntigravityGlobalDir(opts: ResolveAntigravityOpts = {}):
|
|||||||
parent: '.gemini',
|
parent: '.gemini',
|
||||||
env: ['ANTIGRAVITY_CONFIG_DIR'],
|
env: ['ANTIGRAVITY_CONFIG_DIR'],
|
||||||
probe: ['antigravity', 'antigravity-ide', 'antigravity-cli'],
|
probe: ['antigravity', 'antigravity-ide', 'antigravity-cli'],
|
||||||
|
// Prefer the candidate GSD installed into (carries gsd-core/VERSION) over
|
||||||
|
// a bare-existing sibling. Without this, a CLI user (antigravity-cli) who
|
||||||
|
// also has the IDE's ~/.gemini/antigravity dir is shadowed to the legacy
|
||||||
|
// dir because it is probed first. See #213/#217. The posix-slash literal
|
||||||
|
// matches capabilities/antigravity/capability.json; both normalize via
|
||||||
|
// path.join at the check site, so Windows backslash handling is covered.
|
||||||
|
probeExists: 'gsd-core/VERSION',
|
||||||
},
|
},
|
||||||
{ env, home, existsSync: existsSyncFn },
|
{ env, home, existsSync: existsSyncFn },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AntigravityAmbiguity {
|
||||||
|
/** True when more than one ~/.gemini/antigravity{,-ide,-cli} dir is present. */
|
||||||
|
ambiguous: boolean;
|
||||||
|
/** The dir GSD currently resolves to (where install/update will write). */
|
||||||
|
resolved: string;
|
||||||
|
/** All probe candidate dirs that exist on disk (absolute paths). */
|
||||||
|
presentDirs: string[];
|
||||||
|
/**
|
||||||
|
* Candidate dirs that carry the GSD marker (gsd-core/VERSION). When this has
|
||||||
|
* exactly one entry, resolution is unambiguous. Zero or >1 entries (or a
|
||||||
|
* marker in a dir other than the one a CLI/IDE user expects) is the #213/#217
|
||||||
|
* misinstall surface: a prior install may have landed in the wrong sibling dir.
|
||||||
|
*/
|
||||||
|
gsdMarkedDirs: string[];
|
||||||
|
/** ANTIGRAVITY_CONFIG_DIR is the operator escape hatch; true when already set. */
|
||||||
|
envOverridden: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect whether the Antigravity config-dir resolution is ambiguous — i.e. more
|
||||||
|
* than one of ~/.gemini/{antigravity,antigravity-ide,antigravity-cli} exists, so
|
||||||
|
* a user upgrading from a pre-#217 install may have had GSD written into the
|
||||||
|
* wrong sibling dir (the legacy/IDE dir shadowing an active CLI dir).
|
||||||
|
*
|
||||||
|
* This is a pure, side-effect-free probe intended for the installer and
|
||||||
|
* /gsd-update to surface operator guidance (set ANTIGRAVITY_CONFIG_DIR or move
|
||||||
|
* gsd-core/ into the intended dir). The migration framework cannot relocate an
|
||||||
|
* install across sibling config dirs (it is bounded to a single configDir and
|
||||||
|
* has no cross-dir move primitive — see installer-migrations 004), so existing
|
||||||
|
* misinstalls are corrected by re-detection + operator guidance, not an
|
||||||
|
* automatic move.
|
||||||
|
*/
|
||||||
|
export function detectAntigravityDirAmbiguity(
|
||||||
|
opts: ResolveAntigravityOpts = {},
|
||||||
|
): AntigravityAmbiguity {
|
||||||
|
const env: Record<string, string | undefined> = opts.env ?? process.env;
|
||||||
|
const home = opts.home ?? os.homedir();
|
||||||
|
const existsSyncFn = opts.existsSync ?? fs.existsSync;
|
||||||
|
const marker = path.join('gsd-core', 'VERSION');
|
||||||
|
const base = path.join(home, '.gemini');
|
||||||
|
const candidates = ['antigravity', 'antigravity-ide', 'antigravity-cli'].map((c) =>
|
||||||
|
path.join(base, c),
|
||||||
|
);
|
||||||
|
const presentDirs = candidates.filter((dir) => existsSyncFn(dir));
|
||||||
|
const gsdMarkedDirs = candidates.filter((dir) => existsSyncFn(path.join(dir, marker)));
|
||||||
|
return {
|
||||||
|
ambiguous: presentDirs.length > 1,
|
||||||
|
resolved: resolveAntigravityGlobalDir({ env, home, existsSync: existsSyncFn }),
|
||||||
|
presentDirs,
|
||||||
|
gsdMarkedDirs,
|
||||||
|
envOverridden: Boolean(env['ANTIGRAVITY_CONFIG_DIR']),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve Kimi's generic user root using Kimi CLI's documented first-existing
|
* Resolve Kimi's generic user root using Kimi CLI's documented first-existing
|
||||||
* generic skills directory policy:
|
* generic skills directory policy:
|
||||||
|
|||||||
@@ -159,6 +159,46 @@ describe('getGlobalConfigDir/getConfigDirFromHome — antigravity 2.x layout det
|
|||||||
cleanup(home);
|
cleanup(home);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #213/#217 coexistence regression (end-to-end through the registry descriptor).
|
||||||
|
// A CLI user who ALSO has the Antigravity-IDE's ~/.gemini/antigravity dir was
|
||||||
|
// previously shadowed to the legacy dir because it is probed first. The
|
||||||
|
// The probeExists marker (gsd-core/VERSION) makes the dir GSD installed into win.
|
||||||
|
test('coexistence: legacy antigravity + GSD-marked antigravity-cli both present → resolves to antigravity-cli', (t) => {
|
||||||
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-antigravity-coexist-'));
|
||||||
|
t.after(() => cleanup(home));
|
||||||
|
// Both dirs exist on disk...
|
||||||
|
fs.mkdirSync(path.join(home, '.gemini', 'antigravity'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(home, '.gemini', 'antigravity-cli', 'gsd-core'), { recursive: true });
|
||||||
|
// ...but only the cli dir carries the GSD marker.
|
||||||
|
fs.writeFileSync(path.join(home, '.gemini', 'antigravity-cli', 'gsd-core', 'VERSION'), '1.6.0\n');
|
||||||
|
process.env.HOME = home;
|
||||||
|
process.env.USERPROFILE = home;
|
||||||
|
assert.strictEqual(
|
||||||
|
getGlobalConfigDir('antigravity'),
|
||||||
|
path.join(home, '.gemini', 'antigravity-cli'),
|
||||||
|
'GSD-marked antigravity-cli must win over the bare-existing legacy antigravity dir',
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
getConfigDirFromHome('antigravity', true),
|
||||||
|
"'.gemini', 'antigravity-cli'",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('coexistence: legacy antigravity carries the GSD marker (real 1.x install) → resolves to legacy even when cli dir exists bare', (t) => {
|
||||||
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-antigravity-legacy-marked-'));
|
||||||
|
t.after(() => cleanup(home));
|
||||||
|
fs.mkdirSync(path.join(home, '.gemini', 'antigravity', 'gsd-core'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(home, '.gemini', 'antigravity', 'gsd-core', 'VERSION'), '1.5.0\n');
|
||||||
|
fs.mkdirSync(path.join(home, '.gemini', 'antigravity-cli'), { recursive: true });
|
||||||
|
process.env.HOME = home;
|
||||||
|
process.env.USERPROFILE = home;
|
||||||
|
assert.strictEqual(
|
||||||
|
getGlobalConfigDir('antigravity'),
|
||||||
|
path.join(home, '.gemini', 'antigravity'),
|
||||||
|
'a genuine GSD install in the legacy dir must not be abandoned for a bare sibling',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getGlobalConfigDir — explicit configDir overrides env for all runtimes', () => {
|
describe('getGlobalConfigDir — explicit configDir overrides env for all runtimes', () => {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const {
|
|||||||
resolveKimiGlobalDir,
|
resolveKimiGlobalDir,
|
||||||
resolveConfigHomeFromDescriptor,
|
resolveConfigHomeFromDescriptor,
|
||||||
resolveSkillsBaseFromDescriptor,
|
resolveSkillsBaseFromDescriptor,
|
||||||
|
detectAntigravityDirAmbiguity,
|
||||||
} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-homes.cjs'));
|
} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-homes.cjs'));
|
||||||
|
|
||||||
const HOME = os.homedir();
|
const HOME = os.homedir();
|
||||||
@@ -393,6 +394,109 @@ describe('descriptor-driven equivalence: dot-home-nested antigravity probe hit/m
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── #213/#217 coexistence regression: probeExists disambiguation ──────────
|
||||||
|
// Before probeExists on dot-home-nested, first-bare-existing-wins meant a CLI
|
||||||
|
// user (antigravity-cli) who also had the IDE's ~/.gemini/antigravity dir
|
||||||
|
// present was shadowed to the legacy dir (probed first). probeExists =
|
||||||
|
// 'gsd-core/VERSION' makes the dir GSD actually owns win, regardless of order.
|
||||||
|
const AG_PROBE = ['antigravity', 'antigravity-ide', 'antigravity-cli'];
|
||||||
|
const AG_MARKER = path.join('gsd-core', 'VERSION');
|
||||||
|
|
||||||
|
function antigravityDescriptor(withMarker) {
|
||||||
|
const d = {
|
||||||
|
kind: 'dot-home-nested',
|
||||||
|
name: 'antigravity',
|
||||||
|
parent: '.gemini',
|
||||||
|
env: ['ANTIGRAVITY_CONFIG_DIR'],
|
||||||
|
probe: AG_PROBE,
|
||||||
|
};
|
||||||
|
if (withMarker) d.probeExists = AG_MARKER;
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('coexistence: legacy antigravity + antigravity-cli both exist, only cli is GSD-marked → returns antigravity-cli', () => {
|
||||||
|
const home = '/home/u';
|
||||||
|
const cliDir = path.join(home, '.gemini', 'antigravity-cli');
|
||||||
|
const legacyDir = path.join(home, '.gemini', 'antigravity');
|
||||||
|
const markerPath = path.join(cliDir, AG_MARKER);
|
||||||
|
// Both dirs exist on disk; only the cli dir carries gsd-core/VERSION.
|
||||||
|
const existsSync = (p) =>
|
||||||
|
p === markerPath || p === cliDir || p === legacyDir;
|
||||||
|
const result = resolveConfigHomeFromDescriptor(antigravityDescriptor(true), {
|
||||||
|
env: {},
|
||||||
|
home,
|
||||||
|
existsSync,
|
||||||
|
});
|
||||||
|
assert.strictEqual(result, cliDir, 'GSD-marked cli dir must win over bare-existing legacy dir');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('coexistence WITHOUT probeExists still shadows to legacy (documents the pre-fix behavior)', () => {
|
||||||
|
const home = '/home/u';
|
||||||
|
const cliDir = path.join(home, '.gemini', 'antigravity-cli');
|
||||||
|
const legacyDir = path.join(home, '.gemini', 'antigravity');
|
||||||
|
const existsSync = (p) => p === cliDir || p === legacyDir;
|
||||||
|
const result = resolveConfigHomeFromDescriptor(antigravityDescriptor(false), {
|
||||||
|
env: {},
|
||||||
|
home,
|
||||||
|
existsSync,
|
||||||
|
});
|
||||||
|
// No marker → legacy first-bare-existing wins. This is exactly the #217 bug
|
||||||
|
// and proves probeExists is the load-bearing fix.
|
||||||
|
assert.strictEqual(result, legacyDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('coexistence: legacy + ide both exist, only ide is GSD-marked → returns antigravity-ide', () => {
|
||||||
|
const home = '/home/u';
|
||||||
|
const ideDir = path.join(home, '.gemini', 'antigravity-ide');
|
||||||
|
const legacyDir = path.join(home, '.gemini', 'antigravity');
|
||||||
|
const markerPath = path.join(ideDir, AG_MARKER);
|
||||||
|
const existsSync = (p) => p === markerPath || p === ideDir || p === legacyDir;
|
||||||
|
const result = resolveConfigHomeFromDescriptor(antigravityDescriptor(true), {
|
||||||
|
env: {},
|
||||||
|
home,
|
||||||
|
existsSync,
|
||||||
|
});
|
||||||
|
assert.strictEqual(result, ideDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('marker on legacy dir: GSD lives in legacy antigravity (a real 1.x install) → returns legacy even when cli dir exists bare', () => {
|
||||||
|
const home = '/home/u';
|
||||||
|
const legacyDir = path.join(home, '.gemini', 'antigravity');
|
||||||
|
const cliDir = path.join(home, '.gemini', 'antigravity-cli');
|
||||||
|
const markerPath = path.join(legacyDir, AG_MARKER);
|
||||||
|
// Legacy carries the marker; cli dir exists but is not GSD's. Legacy wins.
|
||||||
|
const existsSync = (p) => p === markerPath || p === legacyDir || p === cliDir;
|
||||||
|
const result = resolveConfigHomeFromDescriptor(antigravityDescriptor(true), {
|
||||||
|
env: {},
|
||||||
|
home,
|
||||||
|
existsSync,
|
||||||
|
});
|
||||||
|
assert.strictEqual(result, legacyDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no marker anywhere (dirs exist but no GSD installed yet): falls back to bare-existence first match', () => {
|
||||||
|
const home = '/home/u';
|
||||||
|
const ideDir = path.join(home, '.gemini', 'antigravity-ide');
|
||||||
|
// Only ide dir exists, no gsd-core/VERSION anywhere → pass 2 returns ide.
|
||||||
|
const existsSync = (p) => p === ideDir;
|
||||||
|
const result = resolveConfigHomeFromDescriptor(antigravityDescriptor(true), {
|
||||||
|
env: {},
|
||||||
|
home,
|
||||||
|
existsSync,
|
||||||
|
});
|
||||||
|
assert.strictEqual(result, ideDir, 'with no marker, bare-existence pass still resolves the single existing 2.x dir');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('probeExists present but nothing exists → fallback to probe[0] (legacy default preserved)', () => {
|
||||||
|
const home = '/home/u';
|
||||||
|
const result = resolveConfigHomeFromDescriptor(antigravityDescriptor(true), {
|
||||||
|
env: {},
|
||||||
|
home,
|
||||||
|
existsSync: () => false,
|
||||||
|
});
|
||||||
|
assert.strictEqual(result, path.join(home, '.gemini', 'antigravity'));
|
||||||
|
});
|
||||||
|
|
||||||
test('antigravity: ANTIGRAVITY_CONFIG_DIR env override wins over any probe', () => {
|
test('antigravity: ANTIGRAVITY_CONFIG_DIR env override wins over any probe', () => {
|
||||||
const result = resolveConfigHomeFromDescriptor(
|
const result = resolveConfigHomeFromDescriptor(
|
||||||
{
|
{
|
||||||
@@ -421,6 +525,67 @@ describe('descriptor-driven equivalence: dot-home-nested antigravity probe hit/m
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── #213/#217 thread-4: existing-install ambiguity detector ───────────────────
|
||||||
|
describe('detectAntigravityDirAmbiguity (migration/operator-guidance signal)', () => {
|
||||||
|
const HOMEU = '/home/u';
|
||||||
|
const dir = (name) => path.join(HOMEU, '.gemini', name);
|
||||||
|
const markerOf = (name) => path.join(dir(name), 'gsd-core', 'VERSION');
|
||||||
|
|
||||||
|
test('single dir present → not ambiguous', () => {
|
||||||
|
const cli = dir('antigravity-cli');
|
||||||
|
const r = detectAntigravityDirAmbiguity({
|
||||||
|
env: {},
|
||||||
|
home: HOMEU,
|
||||||
|
existsSync: (p) => p === cli || p === markerOf('antigravity-cli'),
|
||||||
|
});
|
||||||
|
assert.strictEqual(r.ambiguous, false);
|
||||||
|
assert.strictEqual(r.resolved, cli);
|
||||||
|
assert.deepStrictEqual(r.presentDirs, [cli]);
|
||||||
|
assert.deepStrictEqual(r.gsdMarkedDirs, [cli]);
|
||||||
|
assert.strictEqual(r.envOverridden, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('legacy + cli both present, GSD marked in cli → ambiguous, resolves to cli', () => {
|
||||||
|
const legacy = dir('antigravity');
|
||||||
|
const cli = dir('antigravity-cli');
|
||||||
|
const r = detectAntigravityDirAmbiguity({
|
||||||
|
env: {},
|
||||||
|
home: HOMEU,
|
||||||
|
existsSync: (p) => p === legacy || p === cli || p === markerOf('antigravity-cli'),
|
||||||
|
});
|
||||||
|
assert.strictEqual(r.ambiguous, true, 'two probe dirs present must flag ambiguity');
|
||||||
|
assert.strictEqual(r.resolved, cli, 'marker disambiguates resolution to cli');
|
||||||
|
assert.deepStrictEqual(r.presentDirs.sort(), [legacy, cli].sort());
|
||||||
|
assert.deepStrictEqual(r.gsdMarkedDirs, [cli]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('misinstall surface: legacy + cli present but GSD marked ONLY in legacy → ambiguous, resolves to legacy', () => {
|
||||||
|
// This is exactly the #217 victim: GSD was written into the legacy/IDE dir,
|
||||||
|
// so the marker is in legacy and the resolver keeps it there. The detector
|
||||||
|
// flags ambiguity so the installer/update can prompt the operator.
|
||||||
|
const legacy = dir('antigravity');
|
||||||
|
const cli = dir('antigravity-cli');
|
||||||
|
const r = detectAntigravityDirAmbiguity({
|
||||||
|
env: {},
|
||||||
|
home: HOMEU,
|
||||||
|
existsSync: (p) => p === legacy || p === cli || p === markerOf('antigravity'),
|
||||||
|
});
|
||||||
|
assert.strictEqual(r.ambiguous, true);
|
||||||
|
assert.strictEqual(r.resolved, legacy);
|
||||||
|
assert.deepStrictEqual(r.gsdMarkedDirs, [legacy]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('env override short-circuits: envOverridden flag set when ANTIGRAVITY_CONFIG_DIR present', () => {
|
||||||
|
const r = detectAntigravityDirAmbiguity({
|
||||||
|
env: { ANTIGRAVITY_CONFIG_DIR: '/custom/ag' },
|
||||||
|
home: HOMEU,
|
||||||
|
existsSync: () => true,
|
||||||
|
});
|
||||||
|
assert.strictEqual(r.envOverridden, true);
|
||||||
|
assert.strictEqual(r.resolved, '/custom/ag', 'env override wins over probe entirely');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ── GOLDEN GENERIC-AGENTS-ROOT (kimi probe) ───────────────────────────────────
|
// ── GOLDEN GENERIC-AGENTS-ROOT (kimi probe) ───────────────────────────────────
|
||||||
|
|
||||||
describe('descriptor-driven equivalence: generic-agents-root kimi probe hit/miss', () => {
|
describe('descriptor-driven equivalence: generic-agents-root kimi probe hit/miss', () => {
|
||||||
|
|||||||
127
tests/runtime-homes.property.test.cjs
Normal file
127
tests/runtime-homes.property.test.cjs
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Property-based tests for runtime-homes.cjs dot-home-nested probe resolution.
|
||||||
|
*
|
||||||
|
* Module: gsd-core/bin/lib/runtime-homes.cjs
|
||||||
|
* Exported: resolveConfigHomeFromDescriptor(descriptor, opts)
|
||||||
|
*
|
||||||
|
* `resolveConfigHomeFromDescriptor` (dot-home-nested kind) is a deterministic
|
||||||
|
* transformation: (descriptor + filesystem-existence state) → resolved path.
|
||||||
|
* Per RULESET.TESTS.property-based-testing it carries an invariant worth
|
||||||
|
* pinning across randomized existence/marker combinations — especially the
|
||||||
|
* #213/#217 `probeExists` marker-priority branch.
|
||||||
|
*
|
||||||
|
* Properties tested:
|
||||||
|
* (a) Membership: the resolved dir is ALWAYS one of `base/<candidate>` for
|
||||||
|
* some candidate in `probe` (never an off-list path).
|
||||||
|
* (b) Precedence: resolution follows the documented order —
|
||||||
|
* first marked candidate (when probeExists set) → first bare-existing
|
||||||
|
* candidate → `probe[0]` fallback.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const path = require('node:path');
|
||||||
|
const fc = require('./helpers/fast-check-setup.cjs');
|
||||||
|
|
||||||
|
const { resolveConfigHomeFromDescriptor } = require(
|
||||||
|
path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'runtime-homes.cjs'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const MARKER = 'gsd-core/VERSION';
|
||||||
|
const CANDIDATE_POOL = ['antigravity', 'antigravity-ide', 'antigravity-cli', 'foo', 'bar'];
|
||||||
|
|
||||||
|
describe('runtime-homes: dot-home-nested probe resolution properties', () => {
|
||||||
|
test('property: resolved dir is always a probe candidate, in documented precedence', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
fc.record({
|
||||||
|
home: fc.constantFrom('/home/u', '/Users/x', '/root', '/srv/app'),
|
||||||
|
probe: fc.uniqueArray(fc.constantFrom(...CANDIDATE_POOL), { minLength: 1, maxLength: 5 }),
|
||||||
|
useMarker: fc.boolean(),
|
||||||
|
existMask: fc.array(fc.boolean(), { minLength: 5, maxLength: 5 }),
|
||||||
|
markMask: fc.array(fc.boolean(), { minLength: 5, maxLength: 5 }),
|
||||||
|
}),
|
||||||
|
({ home, probe, useMarker, existMask, markMask }) => {
|
||||||
|
const parent = '.gemini';
|
||||||
|
const base = path.join(home, parent);
|
||||||
|
const candDir = (c) => path.join(base, c);
|
||||||
|
|
||||||
|
// Which candidate dirs exist on disk, and which carry the marker.
|
||||||
|
// A marker only matters where the dir itself exists (realistic install).
|
||||||
|
const exists = new Set();
|
||||||
|
const marked = new Set();
|
||||||
|
probe.forEach((c, i) => {
|
||||||
|
if (existMask[i]) exists.add(candDir(c));
|
||||||
|
if (useMarker && markMask[i] && existMask[i]) marked.add(candDir(c));
|
||||||
|
});
|
||||||
|
|
||||||
|
const existsSync = (p) =>
|
||||||
|
exists.has(p) || [...marked].some((d) => p === path.join(d, MARKER));
|
||||||
|
|
||||||
|
const descriptor = {
|
||||||
|
kind: 'dot-home-nested',
|
||||||
|
name: 'antigravity',
|
||||||
|
parent,
|
||||||
|
env: ['ANTIGRAVITY_CONFIG_DIR'],
|
||||||
|
probe,
|
||||||
|
};
|
||||||
|
if (useMarker) descriptor.probeExists = MARKER;
|
||||||
|
|
||||||
|
const result = resolveConfigHomeFromDescriptor(descriptor, {
|
||||||
|
env: {},
|
||||||
|
home,
|
||||||
|
existsSync,
|
||||||
|
});
|
||||||
|
|
||||||
|
// (a) Membership invariant.
|
||||||
|
const allCandidateDirs = probe.map(candDir);
|
||||||
|
assert.ok(
|
||||||
|
allCandidateDirs.includes(result),
|
||||||
|
`result ${result} must be one of ${JSON.stringify(allCandidateDirs)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// (b) Precedence oracle: first marked → first bare-existing → probe[0].
|
||||||
|
const firstMarked = allCandidateDirs.find((d) => marked.has(d));
|
||||||
|
const firstExisting = allCandidateDirs.find((d) => exists.has(d));
|
||||||
|
const expected =
|
||||||
|
(useMarker && firstMarked) || firstExisting || candDir(probe[0]);
|
||||||
|
assert.equal(result, expected);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('property: an env override always wins over any probe/marker state', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
fc.record({
|
||||||
|
home: fc.constantFrom('/home/u', '/root'),
|
||||||
|
// Absolute overrides only: the resolver's env branch tilde-expands
|
||||||
|
// against the real os.homedir(), so a '~/' case would not be hermetic.
|
||||||
|
override: fc.constantFrom('/custom/ag', '/opt/x', '/var/data/ag'),
|
||||||
|
probe: fc.uniqueArray(fc.constantFrom(...CANDIDATE_POOL), { minLength: 1, maxLength: 5 }),
|
||||||
|
useMarker: fc.boolean(),
|
||||||
|
}),
|
||||||
|
({ home, override, probe, useMarker }) => {
|
||||||
|
const descriptor = {
|
||||||
|
kind: 'dot-home-nested',
|
||||||
|
name: 'antigravity',
|
||||||
|
parent: '.gemini',
|
||||||
|
env: ['ANTIGRAVITY_CONFIG_DIR'],
|
||||||
|
probe,
|
||||||
|
};
|
||||||
|
if (useMarker) descriptor.probeExists = MARKER;
|
||||||
|
|
||||||
|
const result = resolveConfigHomeFromDescriptor(descriptor, {
|
||||||
|
env: { ANTIGRAVITY_CONFIG_DIR: override },
|
||||||
|
home,
|
||||||
|
existsSync: () => true, // every dir + marker "exists" — override must still win
|
||||||
|
});
|
||||||
|
assert.equal(result, override);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user