* 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:
co-authored by
Claude Opus 4.8
parent
df6f4bec43
commit
13b2575dfe
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user