Skip to content

Commit d4b15b7

Browse files
authored
Fix stale OpenCode plugin cache doctor check (#199)
Compare the cached @cortexkit/opencode-magic-context against its OWN npm latest version instead of the CLI's self-version, so doctor clears a stale plugin cache even when invoked via a stale cached CLI. Extract the duplicated plugin-cache path literals into a shared opencode-plugin-cache module, clear the versionless cache root as well as the @latest root, and preserve the cache when the npm latest check is unavailable (offline) rather than blindly clearing it. Authored by @coleleavitt.
1 parent d42e07d commit d4b15b7

7 files changed

Lines changed: 300 additions & 124 deletions

File tree

packages/cli/src/adapters/opencode.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ import { dirname } from "node:path";
33
import { parse as parseJsonc, stringify as stringifyJsonc } from "comment-json";
44
import { writeFileAtomic } from "../lib/atomic-write";
55
import { isOpenCodeInstalledOnSystem } from "../lib/opencode-install";
6+
import {
7+
getOpenCodePluginPackageJsonPaths,
8+
OPENCODE_PLUGIN_ENTRY_WITH_VERSION as PLUGIN_ENTRY,
9+
OPENCODE_PLUGIN_NAME as PLUGIN_NAME,
10+
} from "../lib/opencode-plugin-cache";
611
import {
712
detectConfigPaths,
813
dirSizeBytes,
@@ -16,9 +21,6 @@ import type {
1621
PluginEntryResult,
1722
} from "./types";
1823

19-
const PLUGIN_NAME = "@cortexkit/opencode-magic-context";
20-
const PLUGIN_ENTRY = `${PLUGIN_NAME}@latest`;
21-
2224
export class OpenCodeAdapter implements HarnessAdapter {
2325
readonly kind = "opencode" as const;
2426
readonly displayName = "OpenCode";
@@ -214,12 +216,7 @@ export class OpenCodeAdapter implements HarnessAdapter {
214216

215217
getInstalledPluginVersion(): string | null {
216218
// Look in OpenCode's plugin cache for the installed package version.
217-
const cacheDir = getOpenCodePluginCacheDir();
218-
const candidates = [
219-
`${cacheDir}/${PLUGIN_NAME}@latest/node_modules/${PLUGIN_NAME}/package.json`,
220-
`${cacheDir}/${PLUGIN_NAME}/node_modules/${PLUGIN_NAME}/package.json`,
221-
];
222-
for (const candidate of candidates) {
219+
for (const candidate of getOpenCodePluginPackageJsonPaths()) {
223220
if (!existsSync(candidate)) continue;
224221
try {
225222
const raw = readFileSync(candidate, "utf-8");
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { existsSync, readFileSync, rmSync } from "node:fs";
2+
import {
3+
getOpenCodePluginCacheRoots,
4+
getOpenCodePluginPackageJsonPath,
5+
} from "../lib/opencode-plugin-cache";
6+
7+
export interface PluginCacheResult {
8+
action: "cleared" | "up_to_date" | "not_found" | "check_unavailable" | "error";
9+
path: string;
10+
paths?: string[];
11+
cached?: string;
12+
latest?: string;
13+
error?: string;
14+
}
15+
16+
function readCachedPluginVersion(pluginCacheDir: string): string | undefined {
17+
try {
18+
const installedPkgPath = getOpenCodePluginPackageJsonPath(pluginCacheDir);
19+
if (!existsSync(installedPkgPath)) return undefined;
20+
const pkg = JSON.parse(readFileSync(installedPkgPath, "utf-8")) as { version?: unknown };
21+
return typeof pkg.version === "string" ? pkg.version : undefined;
22+
} catch {
23+
return undefined;
24+
}
25+
}
26+
27+
export async function clearPluginCache(
28+
options: { force?: boolean; latestVersion?: string | null } = {},
29+
): Promise<PluginCacheResult> {
30+
const pluginCacheRoots = getOpenCodePluginCacheRoots();
31+
const existingRoots = pluginCacheRoots.filter((root) => existsSync(root));
32+
33+
if (existingRoots.length === 0) {
34+
return { action: "not_found", path: pluginCacheRoots[0] ?? "" };
35+
}
36+
37+
const latestVersion = options.latestVersion ?? undefined;
38+
const cacheEntries = existingRoots.map((path) => ({
39+
path,
40+
cached: readCachedPluginVersion(path),
41+
}));
42+
43+
if (options.force !== true && latestVersion === undefined) {
44+
const firstEntry = cacheEntries[0];
45+
return {
46+
action: "check_unavailable",
47+
path: firstEntry?.path ?? pluginCacheRoots[0] ?? "",
48+
paths: cacheEntries.map((entry) => entry.path),
49+
cached: firstEntry?.cached,
50+
};
51+
}
52+
53+
const clearTargets = cacheEntries.filter(
54+
(entry) =>
55+
options.force === true || entry.cached === undefined || entry.cached !== latestVersion,
56+
);
57+
58+
if (clearTargets.length === 0) {
59+
const firstEntry = cacheEntries[0];
60+
return {
61+
action: "up_to_date",
62+
path: firstEntry?.path ?? pluginCacheRoots[0] ?? "",
63+
paths: cacheEntries.map((entry) => entry.path),
64+
cached: firstEntry?.cached,
65+
latest: latestVersion,
66+
};
67+
}
68+
69+
try {
70+
for (const entry of clearTargets) {
71+
rmSync(entry.path, { recursive: true, force: true });
72+
}
73+
const firstTarget = clearTargets[0];
74+
return {
75+
action: "cleared",
76+
path: firstTarget?.path ?? pluginCacheRoots[0] ?? "",
77+
paths: clearTargets.map((entry) => entry.path),
78+
cached: firstTarget?.cached,
79+
latest: latestVersion,
80+
};
81+
} catch (err: unknown) {
82+
const message = err instanceof Error ? err.message : String(err);
83+
return {
84+
action: "error",
85+
path: clearTargets[0]?.path ?? existingRoots[0] ?? "",
86+
error: message,
87+
};
88+
}
89+
}

packages/cli/src/commands/doctor-opencode.test.ts

Lines changed: 128 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
11
import { afterEach, describe, expect, it } from "bun:test";
2-
import { mkdtempSync, rmSync } from "node:fs";
2+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
33
import { tmpdir } from "node:os";
4-
import { join } from "node:path";
4+
import { dirname, join } from "node:path";
55
import {
66
initializeDatabase,
77
runMigrations,
88
} from "@magic-context/core/features/magic-context/storage";
99
import { computeLegacyRustDirIdentity } from "@magic-context/core/features/magic-context/v22-deferred-backfill";
1010
import { Database } from "@magic-context/core/shared/sqlite";
1111
import { parse as parseJsonc, stringify as stringifyJsonc } from "comment-json";
12+
import {
13+
OPENCODE_PLUGIN_ENTRY_WITH_VERSION,
14+
OPENCODE_PLUGIN_NAME,
15+
} from "../lib/opencode-plugin-cache";
1216
import { runV22BackfillCommands } from "../lib/v22-backfill-commands";
1317
import { migrateLegacyAgentEnabledConfigForDoctor } from "./doctor-opencode";
18+
import { clearPluginCache } from "./doctor-opencode-cache";
1419

1520
function migrate(input: Record<string, unknown>) {
1621
const logs: Array<{ level: "success" | "warn"; message: string }> = [];
@@ -71,6 +76,7 @@ describe("doctor OpenCode legacy agent enabled migration", () => {
7176

7277
const tempDirs: string[] = [];
7378
const dbs: Database[] = [];
79+
let originalXdgCacheHome: string | undefined;
7480

7581
function makeTempDir(prefix = "mc-v22-doctor-"): string {
7682
const dir = mkdtempSync(join(tmpdir(), prefix));
@@ -121,6 +127,12 @@ function makeHarness(database: Database, messages: string[]) {
121127
}
122128

123129
afterEach(() => {
130+
if (originalXdgCacheHome === undefined) {
131+
delete process.env.XDG_CACHE_HOME;
132+
} else {
133+
process.env.XDG_CACHE_HOME = originalXdgCacheHome;
134+
}
135+
originalXdgCacheHome = undefined;
124136
for (const db of dbs.splice(0)) {
125137
db.close();
126138
}
@@ -129,6 +141,120 @@ afterEach(() => {
129141
}
130142
});
131143

144+
function createCachedOpenCodePlugin(
145+
root: string,
146+
version: string,
147+
entry = OPENCODE_PLUGIN_ENTRY_WITH_VERSION,
148+
): string {
149+
const pluginCachePath = join(root, "opencode", "packages", entry);
150+
const installedPackagePath = join(
151+
pluginCachePath,
152+
"node_modules",
153+
"@cortexkit",
154+
"opencode-magic-context",
155+
"package.json",
156+
);
157+
mkdirSync(dirname(installedPackagePath), { recursive: true });
158+
writeFileSync(installedPackagePath, `${JSON.stringify({ version })}\n`);
159+
return pluginCachePath;
160+
}
161+
162+
describe("doctor OpenCode plugin cache", () => {
163+
it("clears stale @latest cache when cached plugin is older than npm latest", async () => {
164+
const cacheRoot = makeTempDir("mc-opencode-cache-");
165+
originalXdgCacheHome = process.env.XDG_CACHE_HOME;
166+
process.env.XDG_CACHE_HOME = cacheRoot;
167+
const pluginCachePath = createCachedOpenCodePlugin(cacheRoot, "0.26.0");
168+
169+
const result = await clearPluginCache({ latestVersion: "0.29.1" });
170+
171+
expect(result).toMatchObject({
172+
action: "cleared",
173+
cached: "0.26.0",
174+
latest: "0.29.1",
175+
path: pluginCachePath,
176+
});
177+
expect(existsSync(pluginCachePath)).toBe(false);
178+
});
179+
180+
it("keeps @latest cache when cached plugin matches npm latest", async () => {
181+
const cacheRoot = makeTempDir("mc-opencode-cache-");
182+
originalXdgCacheHome = process.env.XDG_CACHE_HOME;
183+
process.env.XDG_CACHE_HOME = cacheRoot;
184+
const pluginCachePath = createCachedOpenCodePlugin(cacheRoot, "0.29.1");
185+
186+
const result = await clearPluginCache({ latestVersion: "0.29.1" });
187+
188+
expect(result).toMatchObject({
189+
action: "up_to_date",
190+
cached: "0.29.1",
191+
latest: "0.29.1",
192+
path: pluginCachePath,
193+
});
194+
expect(existsSync(pluginCachePath)).toBe(true);
195+
});
196+
197+
it("clears stale versionless cache even when @latest cache is current", async () => {
198+
const cacheRoot = makeTempDir("mc-opencode-cache-");
199+
originalXdgCacheHome = process.env.XDG_CACHE_HOME;
200+
process.env.XDG_CACHE_HOME = cacheRoot;
201+
const latestCachePath = createCachedOpenCodePlugin(cacheRoot, "0.29.1");
202+
const versionlessCachePath = createCachedOpenCodePlugin(
203+
cacheRoot,
204+
"0.26.0",
205+
OPENCODE_PLUGIN_NAME,
206+
);
207+
208+
const result = await clearPluginCache({ latestVersion: "0.29.1" });
209+
210+
expect(result).toMatchObject({
211+
action: "cleared",
212+
cached: "0.26.0",
213+
latest: "0.29.1",
214+
path: versionlessCachePath,
215+
paths: [versionlessCachePath],
216+
});
217+
expect(existsSync(latestCachePath)).toBe(true);
218+
expect(existsSync(versionlessCachePath)).toBe(false);
219+
});
220+
221+
it("preserves existing cache when plugin npm latest is unavailable", async () => {
222+
const cacheRoot = makeTempDir("mc-opencode-cache-");
223+
originalXdgCacheHome = process.env.XDG_CACHE_HOME;
224+
process.env.XDG_CACHE_HOME = cacheRoot;
225+
const pluginCachePath = createCachedOpenCodePlugin(cacheRoot, "0.29.1");
226+
227+
const result = await clearPluginCache({ latestVersion: null });
228+
229+
expect(result).toMatchObject({
230+
action: "check_unavailable",
231+
cached: "0.29.1",
232+
path: pluginCachePath,
233+
paths: [pluginCachePath],
234+
});
235+
expect(result.latest).toBeUndefined();
236+
expect(existsSync(pluginCachePath)).toBe(true);
237+
});
238+
239+
it("force-clears existing cache even when plugin npm latest is unavailable", async () => {
240+
const cacheRoot = makeTempDir("mc-opencode-cache-");
241+
originalXdgCacheHome = process.env.XDG_CACHE_HOME;
242+
process.env.XDG_CACHE_HOME = cacheRoot;
243+
const pluginCachePath = createCachedOpenCodePlugin(cacheRoot, "0.29.1");
244+
245+
const result = await clearPluginCache({ force: true, latestVersion: null });
246+
247+
expect(result).toMatchObject({
248+
action: "cleared",
249+
cached: "0.29.1",
250+
path: pluginCachePath,
251+
paths: [pluginCachePath],
252+
});
253+
expect(result.latest).toBeUndefined();
254+
expect(existsSync(pluginCachePath)).toBe(false);
255+
});
256+
});
257+
132258
describe("doctor v22 backfill commands", () => {
133259
it("--check-v22-backfill reports status", async () => {
134260
const database = makeDb();

0 commit comments

Comments
 (0)