fix(installer): opencode global config goes to ~/.config/opencode on every platform (#535) (#836)

* fix(installer): opencode global config goes to ~/.config/opencode on every platform (#535)

opencode resolves its config dir with xdg-basedir (XDG_CONFIG_HOME ??
~/.config) unconditionally — it never reads %APPDATA%; that layout
belonged to the discontinued Go fork. Writing there on Windows meant
opencode never saw the MCP entry.

- globalConfigDir(): drop the win32 APPDATA branch; XDG resolution everywhere
- install/uninstall (global): sweep a stale codegraph entry + AGENTS.md block
  out of the legacy %APPDATA%/opencode location (siblings/comments untouched)
- detect(global): a legacy-only dir still counts as installed so the sweep
  is reachable
- tests are env-gated, not platform-gated, so the whole matrix runs on any
  OS; the suite previously pointed APPDATA and XDG_CONFIG_HOME at the same
  dir, which is exactly how the divergence stayed invisible

Supersedes the prefer-if-exists approach of #670 (greenfield installs --
before opencode's first run -- would still have fallen back to APPDATA).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(installer): match legacy sweep paths by dir prefix, not 'AppData' substring

On Windows os.tmpdir() lives under AppData\Local\Temp, so every harness
path contains 'AppData' and the substring assertions false-positive.
Caught on the real Windows VM.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-06-12 11:43:18 -05:00
committed by GitHub
co-authored by Claude Opus 4.8
parent df6f4bec43
commit 13b2575dfe
3 changed files with 236 additions and 37 deletions
+86 -37
View File
@@ -2,10 +2,18 @@
* opencode target.
*
* - MCP server entry to `~/.config/opencode/opencode.jsonc` (global,
* XDG-style; `%APPDATA%/opencode/opencode.jsonc` on Windows) or
* XDG-style on EVERY platform, Windows included — see below) or
* `./opencode.jsonc` (local). Falls back to `opencode.json` when a
* `.json` file already exists; defaults new installs to `.jsonc`
* because that's what opencode itself creates on first run.
*
* opencode resolves its config dir with the `xdg-basedir` package
* (sst/opencode `packages/core/src/global.ts`): `XDG_CONFIG_HOME`
* if set, else `~/.config` — unconditionally, on all platforms. It
* never reads `%APPDATA%`; that layout belonged to the discontinued
* Go fork. We previously wrote there on Windows, so opencode never
* saw the entry (#535) — install/uninstall now also sweep a stale
* codegraph entry out of the legacy `%APPDATA%/opencode` location.
* - Instructions to `~/.config/opencode/AGENTS.md` (global) or
* `./AGENTS.md` (local). opencode reads AGENTS.md for agent
* instructions — same convention Codex CLI uses.
@@ -49,17 +57,29 @@ import {
} from '../instructions-template';
function globalConfigDir(): string {
if (process.platform === 'win32') {
const appData = process.env.APPDATA ?? path.join(os.homedir(), 'AppData', 'Roaming');
return path.join(appData, 'opencode');
}
// XDG_CONFIG_HOME if set, else ~/.config — matches opencode's docs.
// XDG_CONFIG_HOME if set, else ~/.config — on every platform, matching
// opencode's own `xdg-basedir` resolution (no Windows special case; #535).
const xdg = process.env.XDG_CONFIG_HOME && process.env.XDG_CONFIG_HOME.trim().length > 0
? process.env.XDG_CONFIG_HOME
: path.join(os.homedir(), '.config');
return path.join(xdg, 'opencode');
}
/**
* Pre-#535 installs wrote the global entry to `%APPDATA%/opencode` — a dir
* today's opencode never reads. Returns that legacy dir when it could hold
* stale state (APPDATA set and resolving somewhere other than the real config
* dir). Gated on the env var rather than `process.platform` so the cleanup
* logic runs under the cross-platform test suite; on POSIX, APPDATA is unset
* in real life and this is a no-op.
*/
function legacyWindowsConfigDir(): string | null {
const appData = process.env.APPDATA;
if (!appData || !appData.trim()) return null;
const legacy = path.join(appData, 'opencode');
return path.resolve(legacy) === path.resolve(globalConfigDir()) ? null : legacy;
}
function configBaseDir(loc: Location): string {
return loc === 'global' ? globalConfigDir() : process.cwd();
}
@@ -118,8 +138,12 @@ class OpencodeTarget implements AgentTarget {
const file = configPath(loc);
const config = parseConfig(readConfigText(file));
const alreadyConfigured = !!config.mcp?.codegraph;
// Global: the XDG dir is what current opencode creates on first run; the
// legacy %APPDATA% dir still counts as "opencode present" so a re-install
// can sweep the stale pre-#535 entry out of it.
const legacy = legacyWindowsConfigDir();
const installed = loc === 'global'
? fs.existsSync(globalConfigDir())
? fs.existsSync(globalConfigDir()) || (!!legacy && fs.existsSync(legacy))
: fs.existsSync(file);
return { installed, alreadyConfigured, configPath: file };
}
@@ -133,42 +157,18 @@ class OpencodeTarget implements AgentTarget {
// initialize instructions. Upsert self-heals a stale pre-#529 block.
files.push(upsertInstructionsEntry(instructionsPath(loc)));
// Self-heal a pre-#535 install that wrote to %APPDATA%/opencode —
// opencode never reads it, so anything of ours there is stale.
if (loc === 'global') files.push(...cleanupLegacyWindowsState());
return { files };
}
uninstall(loc: Location): WriteResult {
const files: WriteResult['files'] = [];
const file = configPath(loc);
if (!fs.existsSync(file)) {
files.push({ path: file, action: 'not-found' });
} else {
const text = readConfigText(file);
const config = parseConfig(text);
if (!config.mcp?.codegraph) {
files.push({ path: file, action: 'not-found' });
} else {
// Drop our key surgically. Leaves siblings + comments untouched.
let edits = modify(text, ['mcp', 'codegraph'], undefined, {
formattingOptions: FORMATTING,
});
let updated = applyEdits(text, edits);
// If `mcp` is now an empty object, drop the wrapper too.
const afterParsed = parseConfig(updated);
if (afterParsed.mcp && typeof afterParsed.mcp === 'object' &&
Object.keys(afterParsed.mcp).length === 0) {
edits = modify(updated, ['mcp'], undefined, { formattingOptions: FORMATTING });
updated = applyEdits(updated, edits);
}
atomicWriteFileSync(file, updated);
files.push({ path: file, action: 'removed' });
}
}
files.push(removeMcpEntryAt(configPath(loc)));
files.push(removeInstructionsEntry(loc));
if (loc === 'global') files.push(...cleanupLegacyWindowsState());
return { files };
}
@@ -225,6 +225,55 @@ function writeMcpEntry(loc: Location): WriteResult['files'][number] {
return { path: file, action: existed ? 'updated' : 'created' };
}
/**
* Surgically drop `mcp.codegraph` from one config file. Leaves sibling
* servers, comments, and formatting untouched; drops an emptied `mcp`
* wrapper too. Shared by uninstall and the legacy-%APPDATA% sweep.
*/
function removeMcpEntryAt(file: string): WriteResult['files'][number] {
if (!fs.existsSync(file)) return { path: file, action: 'not-found' };
const text = readConfigText(file);
const config = parseConfig(text);
if (!config.mcp?.codegraph) return { path: file, action: 'not-found' };
let edits = modify(text, ['mcp', 'codegraph'], undefined, {
formattingOptions: FORMATTING,
});
let updated = applyEdits(text, edits);
// If `mcp` is now an empty object, drop the wrapper too.
const afterParsed = parseConfig(updated);
if (afterParsed.mcp && typeof afterParsed.mcp === 'object' &&
Object.keys(afterParsed.mcp).length === 0) {
edits = modify(updated, ['mcp'], undefined, { formattingOptions: FORMATTING });
updated = applyEdits(updated, edits);
}
atomicWriteFileSync(file, updated);
return { path: file, action: 'removed' };
}
/**
* Remove whatever a pre-#535 install left in `%APPDATA%/opencode` — an MCP
* entry opencode never reads, plus our marker-fenced AGENTS.md block. Returns
* only files actually changed, so install output stays quiet when there is
* nothing to heal. Never touches anything else in the legacy dir: a user may
* genuinely keep other tools' state under %APPDATA%.
*/
function cleanupLegacyWindowsState(): WriteResult['files'] {
const dir = legacyWindowsConfigDir();
if (!dir || !fs.existsSync(dir)) return [];
const out: WriteResult['files'] = [];
for (const name of ['opencode.jsonc', 'opencode.json']) {
const res = removeMcpEntryAt(path.join(dir, name));
if (res.action === 'removed') out.push(res);
}
const agents = path.join(dir, 'AGENTS.md');
const action = removeMarkedSection(agents, CODEGRAPH_SECTION_START, CODEGRAPH_SECTION_END);
if (action === 'removed') out.push({ path: agents, action });
return out;
}
/**
* Strip the marker-delimited CodeGraph block from AGENTS.md if a prior
* install wrote one. Used by both install (self-heal on upgrade) and