* 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
@@ -1399,3 +1399,152 @@ function listAllFiles(dir: string): string[] {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// opencode global config path — XDG on every platform (#535)
|
||||
//
|
||||
// opencode resolves its config dir with `xdg-basedir`: XDG_CONFIG_HOME if
|
||||
// set, else ~/.config — on ALL platforms, Windows included. It never reads
|
||||
// %APPDATA%; we used to write there on Windows, so opencode never saw the
|
||||
// entry. The suite-wide setHome() points APPDATA and XDG_CONFIG_HOME at the
|
||||
// SAME directory (which is exactly how this bug stayed invisible), so these
|
||||
// tests deliberately split them.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Installer targets — opencode XDG config path (#535)', () => {
|
||||
let tmpHome: string;
|
||||
let tmpCwd: string;
|
||||
let origCwd: string;
|
||||
let homeRestore: { restore: () => void };
|
||||
let appDataDir: string; // distinct from ~/.config, like real Windows
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHome = mkTmpDir('home');
|
||||
tmpCwd = mkTmpDir('cwd');
|
||||
origCwd = process.cwd();
|
||||
process.chdir(tmpCwd);
|
||||
homeRestore = setHome(tmpHome);
|
||||
appDataDir = path.join(tmpHome, 'AppData', 'Roaming');
|
||||
process.env.APPDATA = appDataDir; // realistic split: APPDATA ≠ ~/.config
|
||||
delete process.env.XDG_CONFIG_HOME; // default resolution: ~/.config
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
homeRestore.restore();
|
||||
process.chdir(origCwd);
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
fs.rmSync(tmpCwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const xdgConfigFile = () => path.join(tmpHome, '.config', 'opencode', 'opencode.jsonc');
|
||||
const legacyDir = () => path.join(appDataDir, 'opencode');
|
||||
// NOTE: never match on an 'AppData' substring — on Windows os.tmpdir()
|
||||
// itself lives under AppData\Local\Temp, so EVERY harness path contains
|
||||
// it. Match on the legacy dir prefix instead.
|
||||
const inLegacyDir = (p: string) => path.resolve(p).startsWith(path.resolve(legacyDir()) + path.sep);
|
||||
|
||||
it('global install writes to ~/.config/opencode, never %APPDATA% (#535)', () => {
|
||||
const opencode = getTarget('opencode')!;
|
||||
const result = opencode.install('global', { autoAllow: true });
|
||||
|
||||
const written = result.files.find((f) => f.path.endsWith('opencode.jsonc'))!;
|
||||
expect(written.action).toBe('created');
|
||||
expect(path.resolve(written.path)).toBe(path.resolve(xdgConfigFile()));
|
||||
expect(fs.existsSync(xdgConfigFile())).toBe(true);
|
||||
// Nothing of ours may land in the legacy location.
|
||||
expect(fs.existsSync(legacyDir())).toBe(false);
|
||||
});
|
||||
|
||||
it('greenfield: targets ~/.config/opencode even when the dir does not exist yet (#535)', () => {
|
||||
// The rejected fallback design (#670) would send this install to
|
||||
// %APPDATA% — where opencode would never find it. opencode creates
|
||||
// ~/.config/opencode itself on first run; installing codegraph FIRST
|
||||
// must land where opencode will look.
|
||||
expect(fs.existsSync(path.join(tmpHome, '.config', 'opencode'))).toBe(false);
|
||||
const opencode = getTarget('opencode')!;
|
||||
const result = opencode.install('global', { autoAllow: true });
|
||||
expect(path.resolve(result.files[0]!.path)).toBe(path.resolve(xdgConfigFile()));
|
||||
expect(fs.existsSync(xdgConfigFile())).toBe(true);
|
||||
expect(fs.existsSync(legacyDir())).toBe(false);
|
||||
});
|
||||
|
||||
it('honors XDG_CONFIG_HOME for the global path, like opencode does', () => {
|
||||
const custom = path.join(tmpHome, 'xdg-custom');
|
||||
process.env.XDG_CONFIG_HOME = custom;
|
||||
const opencode = getTarget('opencode')!;
|
||||
const result = opencode.install('global', { autoAllow: true });
|
||||
expect(path.resolve(result.files[0]!.path))
|
||||
.toBe(path.resolve(path.join(custom, 'opencode', 'opencode.jsonc')));
|
||||
});
|
||||
|
||||
it('install self-heals a pre-#535 %APPDATA% entry, preserving siblings and comments', () => {
|
||||
// A previous codegraph version wrote into %APPDATA%/opencode. The user
|
||||
// also has another MCP server and a comment there — those must survive.
|
||||
fs.mkdirSync(legacyDir(), { recursive: true });
|
||||
fs.writeFileSync(path.join(legacyDir(), 'opencode.jsonc'), [
|
||||
'{',
|
||||
' // my servers',
|
||||
' "$schema": "https://opencode.ai/config.json",',
|
||||
' "mcp": {',
|
||||
' "codegraph": { "type": "local", "command": ["codegraph", "serve", "--mcp"], "enabled": true },',
|
||||
' "other": { "type": "local", "command": ["other"], "enabled": true }',
|
||||
' }',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'));
|
||||
fs.writeFileSync(path.join(legacyDir(), 'AGENTS.md'), LEGACY_BLOCK + '\n');
|
||||
|
||||
const opencode = getTarget('opencode')!;
|
||||
const result = opencode.install('global', { autoAllow: true });
|
||||
|
||||
// New entry in the right place…
|
||||
expect(fs.existsSync(xdgConfigFile())).toBe(true);
|
||||
// …stale entry swept out of the legacy file, siblings + comment intact.
|
||||
const legacyText = fs.readFileSync(path.join(legacyDir(), 'opencode.jsonc'), 'utf-8');
|
||||
expect(legacyText).not.toContain('codegraph');
|
||||
expect(legacyText).toContain('"other"');
|
||||
expect(legacyText).toContain('// my servers');
|
||||
// …and the legacy AGENTS.md — block-only, so emptied — removed outright
|
||||
// (removeMarkedSection unlinks a file it leaves empty).
|
||||
expect(fs.existsSync(path.join(legacyDir(), 'AGENTS.md'))).toBe(false);
|
||||
// Both cleanups are reported.
|
||||
const removed = result.files.filter((f) => f.action === 'removed').map((f) => f.path);
|
||||
expect(removed.some((p) => inLegacyDir(p) && p.endsWith('opencode.jsonc'))).toBe(true);
|
||||
expect(removed.some((p) => inLegacyDir(p) && p.endsWith('AGENTS.md'))).toBe(true);
|
||||
});
|
||||
|
||||
it('uninstall sweeps the legacy %APPDATA% entry too (no prior re-install needed)', () => {
|
||||
// A user on the broken version goes straight to `codegraph uninstall`:
|
||||
// the only entry that exists is the stale %APPDATA% one.
|
||||
fs.mkdirSync(legacyDir(), { recursive: true });
|
||||
fs.writeFileSync(path.join(legacyDir(), 'opencode.json'),
|
||||
'{\n "mcp": {\n "codegraph": { "type": "local", "command": ["codegraph", "serve", "--mcp"], "enabled": true }\n }\n}\n');
|
||||
|
||||
const opencode = getTarget('opencode')!;
|
||||
const result = opencode.uninstall('global');
|
||||
|
||||
expect(fs.readFileSync(path.join(legacyDir(), 'opencode.json'), 'utf-8')).not.toContain('codegraph');
|
||||
expect(result.files.some((f) => f.action === 'removed' && inLegacyDir(f.path))).toBe(true);
|
||||
});
|
||||
|
||||
it('install after install sweeps only once — second run reports no legacy changes', () => {
|
||||
fs.mkdirSync(legacyDir(), { recursive: true });
|
||||
fs.writeFileSync(path.join(legacyDir(), 'opencode.json'),
|
||||
'{\n "mcp": {\n "codegraph": { "type": "local", "command": ["codegraph", "serve", "--mcp"], "enabled": true }\n }\n}\n');
|
||||
|
||||
const opencode = getTarget('opencode')!;
|
||||
const first = opencode.install('global', { autoAllow: true });
|
||||
expect(first.files.some((f) => f.action === 'removed' && inLegacyDir(f.path))).toBe(true);
|
||||
|
||||
const second = opencode.install('global', { autoAllow: true });
|
||||
expect(second.files.some((f) => inLegacyDir(f.path))).toBe(false);
|
||||
expect(second.files.find((f) => f.path.endsWith('opencode.jsonc'))!.action).toBe('unchanged');
|
||||
});
|
||||
|
||||
it('detects opencode as installed from a legacy-only %APPDATA% dir (so install can heal it)', () => {
|
||||
fs.mkdirSync(legacyDir(), { recursive: true });
|
||||
const opencode = getTarget('opencode')!;
|
||||
expect(opencode.detect('global').installed).toBe(true);
|
||||
// But configuration state is read from the REAL path only.
|
||||
expect(opencode.detect('global').alreadyConfigured).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user