Files
msd-core/src/update-context.cts
Tom Boucher 96a82bbffb enhance(#3245): report the detected host runtime in init (#3307)
* test(#3245): failing-first coverage for host runtime detection in init

Locks the behavior epic #2313 Phase 5 must produce before any of it exists: init reports the detected host, explicit GSD_RUNTIME and config runtime still outrank detection, non-Codex sessions are untouched, and nothing is ever written to shared defaults (#2297).

* enhance(#3245): report the detected host runtime in init

init reported agent_runtime: claude inside a Codex session, and resolved agents_dir to the Claude agents root with agents_installed: true — a spuriously healthy triple. Runtime identity was only ever read from GSD_RUNTIME or an explicit runtime in .planning/config.json.

Adds a detection rung beneath both explicit sources, in a new pure module. Codex is identified from its own documented session environment (CODEX_SANDBOX / CODEX_SANDBOX_NETWORK_DISABLED), else an explicitly exported CODEX_HOME whose config.toml exists. The default ~/.codex is never probed: that file exists on every machine that has run Codex, so probing it would misreport other runtimes' sessions.

resolveRuntime keeps its exact contract and all 71 dependents, including formatGsdSlash command-style emission; only withProjectRoot consumes the new rung. Nothing is written on any path (#2297). Explicit config still wins (#2517).

* fix(#3245): make the parity guard real and single-source the marker

Four independent review passes found the generative-fix-divergence guard was vacuous: it asserted agreement at the one input where inferPreferredRuntime and detectHostRuntime do not differ, so it could not fail. It now pins the actual divergence point (CODEX_HOME set, config.toml absent) and records that the asymmetry is deliberate.

The config.toml marker is now single-sourced from update-context.cts and imported, rather than carried independently by two surfaces. tests/helpers.cjs now scrubs CODEX_SANDBOX and CODEX_SANDBOX_NETWORK_DISABLED: GSD reads them, so an ambient Codex session would otherwise make the non-codex control test fail non-deterministically.

Also: detection is throw-safe end to end rather than only around the fs probe; the Windows-join test is replaced with one that can actually fail (trailing-separator, catches hand-rolled concatenation); the #2297 no-write proof now wraps resolveReportedRuntime, the function that ships, across all three ladder outcomes.

* chore(#3245): backfill changeset pr number

---------

Co-authored-by: sim <sim@local>
2026-08-10 09:48:26 -04:00

244 lines
9.6 KiB
TypeScript

/**
* Update-context resolver (issue #498, candidate 3).
*
* ADR-457 build-at-publish: the hand-written bin/lib/update-context.cjs
* collapsed to a TypeScript source of truth. Behaviour is preserved
* byte-for-behaviour from the prior hand-written .cjs; only types are added.
*/
import path from 'node:path';
import nodeFs from 'node:fs';
import nodeOs from 'node:os';
/** Runtime → candidate relative dir pairs. */
export type RuntimeDirEntry = [string, string];
// Runtime -> candidate relative dir. Order matters: it is the probe order, and
// mirrors the RUNTIME_DIRS array the bash used (a runtime may have several
// candidate dirs). Kept here, not derived from the installer's getDirName,
// because update detection probes ALL historical dirs per runtime.
export const RUNTIME_DIRS: RuntimeDirEntry[] = [
['claude', '.claude'],
['opencode', '.config/opencode'],
['opencode', '.opencode'],
['antigravity', '.gemini/antigravity-ide'],
['antigravity', '.gemini/antigravity-cli'],
['antigravity', '.gemini/antigravity'],
['antigravity', '.agents'], // local Antigravity install dir canonical (#791; bin/install.js getDirName('antigravity'))
['antigravity', '.agent'], // local Antigravity install dir legacy (#503; backward-compat with pre-#791 installs)
['windsurf', '.windsurf'], // local Windsurf workflow dir canonical (#1615; bin/install.js getDirName('windsurf'))
['windsurf', '.devin'], // local Devin Desktop install dir legacy (#1085; backward-compat)
['kilo', '.config/kilo'],
['kilo', '.kilo'],
['codex', '.codex'],
];
const SEMVER_PREFIX = /^\d+\.\d+\.\d+/;
// Shared Codex config-root marker filename. Single-sourced here (this module
// is the pre-existing, dependency-free owner) and re-exported by
// host-runtime-detection.cts, whose detection rung reuses this exact probe
// filename (though NOT the truthiness rule below — see that module's header
// comment for the deliberate divergence).
export const CODEX_CONFIG_MARKER = 'config.toml';
function expandHome(p: string | undefined | null, home: string): string {
if (!p) return '';
return p.startsWith('~/') ? path.join(home, p.slice(2)) : p;
}
function versionFile(dir: string): string { return path.join(dir, 'gsd-core', 'VERSION'); }
function markerFile(dir: string): string { return path.join(dir, 'gsd-core', 'workflows', 'update.md'); }
export interface FsAdapter {
exists(p: string): boolean;
readFile(p: string): string | null;
}
// Detection: a dir "has GSD" if it carries a VERSION file or the update.md
// workflow marker.
function hasInstall(fs: FsAdapter, dir: string): boolean {
return fs.exists(versionFile(dir)) || fs.exists(markerFile(dir));
}
// Read VERSION at dir; return a trimmed semver string, or null if missing/invalid.
function validVersionAt(fs: FsAdapter, dir: string): string | null {
const raw = fs.readFile(versionFile(dir));
if (raw == null) return null;
const trimmed = String(raw).trim();
return SEMVER_PREFIX.test(trimmed) ? trimmed : null;
}
// A version is TRUSTED only when BOTH the VERSION file and the update.md marker
// exist (and VERSION is valid semver).
function trustedVersionAt(fs: FsAdapter, dir: string | undefined): string | null {
return dir && fs.exists(markerFile(dir)) ? validVersionAt(fs, dir) : null;
}
export interface InferPreferredRuntimeOpts {
fs: FsAdapter;
env: Record<string, string | undefined>;
preferredConfigDir: string;
}
// Infer the preferred runtime from preferredConfigDir config files, then env.
export function inferPreferredRuntime({ fs, env, preferredConfigDir }: InferPreferredRuntimeOpts): string {
if (preferredConfigDir) {
if (fs.exists(path.join(preferredConfigDir, 'kilo.json')) ||
fs.exists(path.join(preferredConfigDir, 'kilo.jsonc'))) return 'kilo';
if (fs.exists(path.join(preferredConfigDir, 'opencode.json')) ||
fs.exists(path.join(preferredConfigDir, 'opencode.jsonc'))) return 'opencode';
if (fs.exists(path.join(preferredConfigDir, CODEX_CONFIG_MARKER))) return 'codex';
}
if (env['CODEX_HOME']) return 'codex';
if (env['ANTIGRAVITY_CONFIG_DIR']) return 'antigravity';
if (env['KILO_CONFIG_DIR'] || env['KILO_CONFIG']) return 'kilo';
if (env['OPENCODE_CONFIG_DIR'] || env['OPENCODE_CONFIG']) return 'opencode';
if (env['CLAUDE_CONFIG_DIR']) return 'claude';
return 'claude';
}
export interface EnvRuntimeDirsOpts {
env: Record<string, string | undefined>;
home: string;
}
// Absolute env-override candidates, mirroring the bash ENV_RUNTIME_DIRS block.
export function envRuntimeDirs({ env, home }: EnvRuntimeDirsOpts): RuntimeDirEntry[] {
const out: RuntimeDirEntry[] = [];
const ex = (v: string | undefined) => expandHome(v, home);
if (env['CLAUDE_CONFIG_DIR']) out.push(['claude', ex(env['CLAUDE_CONFIG_DIR'])]);
if (env['ANTIGRAVITY_CONFIG_DIR']) out.push(['antigravity', ex(env['ANTIGRAVITY_CONFIG_DIR'])]);
if (env['KILO_CONFIG_DIR']) out.push(['kilo', ex(env['KILO_CONFIG_DIR'])]);
else if (env['KILO_CONFIG']) out.push(['kilo', path.dirname(ex(env['KILO_CONFIG']))]);
else if (env['XDG_CONFIG_HOME']) out.push(['kilo', path.join(ex(env['XDG_CONFIG_HOME']), 'kilo')]);
if (env['OPENCODE_CONFIG_DIR']) out.push(['opencode', ex(env['OPENCODE_CONFIG_DIR'])]);
else if (env['OPENCODE_CONFIG']) out.push(['opencode', path.dirname(ex(env['OPENCODE_CONFIG']))]);
else if (env['XDG_CONFIG_HOME']) out.push(['opencode', path.join(ex(env['XDG_CONFIG_HOME']), 'opencode')]);
if (env['CODEX_HOME']) out.push(['codex', ex(env['CODEX_HOME'])]);
return out;
}
// Stable reorder: entries whose runtime === preferred first, original order kept.
function preferFirst(entries: RuntimeDirEntry[], preferred: string): RuntimeDirEntry[] {
const pref = entries.filter(([rt]) => rt === preferred);
const rest = entries.filter(([rt]) => rt !== preferred);
return [...pref, ...rest];
}
export interface ResolveUpdateContextOpts {
home: string;
cwd: string;
env?: Record<string, string | undefined>;
fs: FsAdapter;
preferredConfigDir?: string;
preferredRuntime?: string;
}
export interface UpdateContext {
installedVersion: string;
scope: 'LOCAL' | 'GLOBAL' | 'UNKNOWN';
runtime: string;
gsdDir: string;
}
/**
* Pure resolver. Returns { installedVersion, scope, runtime, gsdDir }.
*/
export function resolveUpdateContext({
home,
cwd,
env = {},
fs,
preferredConfigDir = '',
preferredRuntime = '',
}: ResolveUpdateContextOpts): UpdateContext {
// Expand a leading `~/` before any probe.
preferredConfigDir = expandHome(preferredConfigDir, home);
const preferred = preferredRuntime || inferPreferredRuntime({ fs, env, preferredConfigDir });
// Fast path: a validated preferredConfigDir (custom --config-dir install).
if (preferredConfigDir && hasInstall(fs, preferredConfigDir)) {
const resolvedPref = path.resolve(preferredConfigDir);
let scope: 'LOCAL' | 'GLOBAL' = 'GLOBAL';
for (const [, reldir] of RUNTIME_DIRS) {
if (path.resolve(cwd, reldir) === resolvedPref) { scope = 'LOCAL'; break; }
}
return {
installedVersion: trustedVersionAt(fs, preferredConfigDir) ?? '0.0.0',
scope,
runtime: preferred,
gsdDir: preferredConfigDir,
};
}
const orderedEnv = preferFirst(envRuntimeDirs({ env, home }), preferred);
const orderedRuntime = preferFirst(RUNTIME_DIRS, preferred);
// LOCAL probe (relative to cwd).
let localRuntime = '', localDir = '';
for (const [rt, reldir] of orderedRuntime) {
const cand = path.resolve(cwd, reldir);
if (hasInstall(fs, cand)) { localRuntime = rt; localDir = cand; break; }
}
// GLOBAL probe: absolute env candidates first, then $HOME-relative.
let globalRuntime = '', globalDir = '';
for (const [rt, absdir] of orderedEnv) {
if (hasInstall(fs, absdir)) { globalRuntime = rt; globalDir = path.resolve(absdir); break; }
}
if (!globalRuntime) {
for (const [rt, reldir] of orderedRuntime) {
const cand = path.resolve(home, reldir);
if (hasInstall(fs, cand)) { globalRuntime = rt; globalDir = cand; break; }
}
}
const localValid = trustedVersionAt(fs, localDir);
const isLocal = !!localValid && (!globalDir || localDir !== globalDir);
if (isLocal) {
return { installedVersion: localValid, scope: 'LOCAL', runtime: localRuntime, gsdDir: localDir };
}
const globalValid = trustedVersionAt(fs, globalDir);
if (globalValid) {
return { installedVersion: globalValid, scope: 'GLOBAL', runtime: globalRuntime, gsdDir: globalDir };
}
// A runtime dir was detected (VERSION or marker present) but is not a
// complete, valid install: keep scope/runtime/dir and report 0.0.0 so the
// caller re-installs.
if (localRuntime && (!globalDir || localDir !== globalDir)) {
return { installedVersion: '0.0.0', scope: 'LOCAL', runtime: localRuntime, gsdDir: localDir };
}
if (globalRuntime) {
return { installedVersion: '0.0.0', scope: 'GLOBAL', runtime: globalRuntime, gsdDir: globalDir };
}
return { installedVersion: '0.0.0', scope: 'UNKNOWN', runtime: 'claude', gsdDir: '' };
}
export interface LoadUpdateContextOpts {
home?: string;
cwd?: string;
env?: Record<string, string | undefined>;
preferredConfigDir?: string;
preferredRuntime?: string;
}
/**
* CLI wiring: resolve against the real filesystem.
*/
export function loadUpdateContext(opts: LoadUpdateContextOpts = {}): UpdateContext {
const fs: FsAdapter = {
exists: (p: string) => nodeFs.existsSync(p),
readFile: (p: string) => { try { return nodeFs.readFileSync(p, 'utf8'); } catch { return null; } },
};
return resolveUpdateContext({
home: opts.home ?? nodeOs.homedir(),
cwd: opts.cwd ?? process.cwd(),
env: opts.env ?? process.env,
fs,
preferredConfigDir: opts.preferredConfigDir ?? '',
preferredRuntime: opts.preferredRuntime ?? '',
});
}