diff --git a/cli/src/lib/harnesses.ts b/cli/src/lib/harnesses.ts index 1026802..0eeca3a 100644 --- a/cli/src/lib/harnesses.ts +++ b/cli/src/lib/harnesses.ts @@ -1,5 +1,6 @@ import { spawn } from 'node:child_process' -import { access, constants } from 'node:fs/promises' +import { access, constants, readFile, stat } from 'node:fs/promises' +import { homedir } from 'node:os' import { dirname, join } from 'node:path' import which from 'which' @@ -114,7 +115,32 @@ export interface DiscoveredHarness { version: string | null } +// Same cache contract as src/main/installer and src/opencode-plugin. +const DISCOVERY_CACHE_TTL_MS = 5 * 60 * 1000 + +async function cachedHarness(spec: HarnessSpec): Promise { + try { + const file = join(homedir(), '.cache', 'hoist', 'discovery.json') + const [text, info] = await Promise.all([readFile(file, 'utf8'), stat(file)]) + if (Date.now() - info.mtimeMs > DISCOVERY_CACHE_TTL_MS) return null + const raw: unknown = JSON.parse(text) + if (typeof raw !== 'object' || raw === null || Array.isArray(raw) || !Object.hasOwn(raw, spec.id)) return null + const entry: unknown = (raw as Record)[spec.id] + if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) return null + const { path, version } = entry as Record + if (typeof path !== 'string' || path.length === 0) return null + await access(path, constants.X_OK) + return { spec, installed: true, path, version: typeof version === 'string' && version.length > 0 ? version : null } + } catch { + return null + } +} + export async function discoverHarness(spec: HarnessSpec): Promise { + return (await cachedHarness(spec)) ?? discoverLiveHarness(spec) +} + +async function discoverLiveHarness(spec: HarnessSpec): Promise { for (const method of spec.installMethods) { const path = await resolveBinary(binaryFor(method, spec.id)) if (path) { @@ -149,7 +175,7 @@ export async function installHarness(spec: HarnessSpec, opts: InstallOptions = { if (result.code !== 0) { throw new Error(`npm install -g ${pkg} failed:\n${result.stderr || result.stdout}`) } - return discoverHarness(spec) + return discoverLiveHarness(spec) } if (method.type === 'brew') { @@ -171,7 +197,7 @@ export async function installHarness(spec: HarnessSpec, opts: InstallOptions = { if (result.code !== 0) { throw new Error(`brew ${brewArgs.join(' ')} failed:\n${result.stderr || result.stdout}`) } - return discoverHarness(spec) + return discoverLiveHarness(spec) } throw new Error(`Install method not supported: ${(method as InstallMethod).type}`) diff --git a/cli/test/discovery-cache.test.ts b/cli/test/discovery-cache.test.ts new file mode 100644 index 0000000..56f6e05 --- /dev/null +++ b/cli/test/discovery-cache.test.ts @@ -0,0 +1,142 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { chmod, mkdir, mkdtemp, readFile, rm, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +const moduleUrl = new URL('../src/lib/harnesses.ts', import.meta.url).href +const homes: string[] = [] +const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'` + +afterEach(async () => { + await Promise.all(homes.splice(0).map((home) => rm(home, { recursive: true, force: true }))) +}) + +async function fixture() { + const home = await mkdtemp(join(tmpdir(), 'hoist-cli-discovery-')) + homes.push(home) + const bin = join(home, 'bin') + const cacheFile = join(home, '.cache', 'hoist', 'discovery.json') + const calls = join(home, 'calls') + await mkdir(bin) + await mkdir(dirname(cacheFile), { recursive: true }) + await writeFile(calls, '') + + async function executable(path: string, body: string) { + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, `#!/bin/sh\n${body}\n`, { mode: 0o755 }) + return path + } + + async function tool(path: string, version: string) { + return executable(path, `printf '%s\\n' ${quote(path)} >> ${quote(calls)}\nprintf '%s\\n' ${quote(version)}`) + } + + async function cache(raw: unknown, ageMs = 0) { + await writeFile(cacheFile, JSON.stringify(raw)) + const time = new Date(Date.now() - ageMs) + await utimes(cacheFile, time, time) + } + + // An isolated HOME/PATH prevents real harness or npm execution. An unexpected + // npm fallback is visible in the calls log, even when it finds nothing. + await executable(join(bin, 'npm'), `printf 'npm %s\\n' "$*" >> ${quote(calls)}\nprintf '%s\\n' ${quote(join(home, 'npm', 'node_modules'))}`) + + async function discover(expression = 'await discoverHarness(findHarness("claude-code"))') { + const child = Bun.spawn([ + process.execPath, '-e', + `import { discoverHarness, discoverAll, findHarness, installHarness } from ${JSON.stringify(moduleUrl)}; console.log(JSON.stringify(${expression}))`, + ], { + env: { ...process.env, HOME: home, PATH: bin }, + stdout: 'pipe', + stderr: 'pipe', + }) + const [stdout, stderr, code] = await Promise.all([ + new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited, + ]) + expect(stderr).toBe('') + expect(code).toBe(0) + return JSON.parse(stdout) + } + + return { home, bin, cacheFile, calls, executable, tool, cache, discover } +} + +describe.skipIf(process.platform === 'win32')('CLI OpenCode discovery cache (isolated fixtures)', () => { + test('fresh cache wins over PATH without running the cached binary or npm', async () => { + const f = await fixture() + const path = await f.tool(join(f.home, 'hidden', 'claude'), 'must not run') + await f.tool(join(f.bin, 'claude'), 'PATH version') + await f.cache({ 'claude-code': { path, version: 'cached 2.1.204' } }) + expect(await f.discover()).toMatchObject({ installed: true, path, version: 'cached 2.1.204' }) + expect(await readFile(f.calls, 'utf8')).toBe('') + }) + + test('discoverAll reads all plugin harness ids and normalises unknown versions', async () => { + const f = await fixture() + const raw: Record = {} + for (const [id, binary, version] of [ + ['claude-code', 'claude', 'cached claude'], ['opencode', 'opencode', null], ['codex', 'codex', 42], + ] as const) { + raw[id] = { path: await f.tool(join(f.home, 'hidden', binary), 'must not run'), version } + } + await f.cache(raw) + const results = await f.discover('await discoverAll()') + expect(results.map((h: { installed: boolean }) => h.installed)).toEqual([true, true, true]) + expect(results.map((h: { version: string | null }) => h.version)).toEqual(['cached claude', null, null]) + expect(await readFile(f.calls, 'utf8')).toBe('') + }) + + for (const scenario of ['absent', 'stale', 'malformed JSON', 'array', 'null', 'missing id', 'missing path', 'non-string path', 'deleted binary', 'non-executable binary']) { + test(`${scenario} cache falls back to PATH and reads the live version`, async () => { + const f = await fixture() + const live = await f.tool(join(f.bin, 'claude'), 'live 3.0') + const cached = await f.tool(join(f.home, 'hidden', 'claude'), 'must not run') + switch (scenario) { + case 'stale': await f.cache({ 'claude-code': { path: cached, version: 'old' } }, 5 * 60 * 1000 + 1000); break + case 'malformed JSON': await writeFile(f.cacheFile, '{'); break + case 'array': await f.cache([]); break + case 'null': await f.cache(null); break + case 'missing id': await f.cache({ codex: { path: cached } }); break + case 'missing path': await f.cache({ 'claude-code': { version: 'old' } }); break + case 'non-string path': await f.cache({ 'claude-code': { path: 42 } }); break + case 'deleted binary': await f.cache({ 'claude-code': { path: join(f.home, 'gone') } }); break + case 'non-executable binary': + await chmod(cached, 0o644) + await f.cache({ 'claude-code': { path: cached } }) + break + } + expect(await f.discover()).toMatchObject({ installed: true, path: live, version: 'live 3.0' }) + expect(await readFile(f.calls, 'utf8')).toBe(`${live}\n`) + }) + } + + test('absent cache preserves npm-global discovery when PATH misses', async () => { + const f = await fixture() + const path = await f.tool(join(f.home, 'npm', 'bin', 'claude'), 'npm-global 3.0') + expect(await f.discover()).toMatchObject({ installed: true, path, version: 'npm-global 3.0' }) + expect(await readFile(f.calls, 'utf8')).toBe(`npm root -g\n${path}\n`) + }) + + test('absent cache and missing binary keep the not-installed result', async () => { + const f = await fixture() + expect(await f.discover()).toMatchObject({ installed: false, path: null, version: null }) + }) + + for (const prefer of ['npm', 'brew']) { + test(`${prefer} install bypasses a fresh cache to report the installed version`, async () => { + const f = await fixture() + const live = await f.tool(join(f.bin, 'claude'), 'new 4.0') + const cached = await f.tool(join(f.home, 'hidden', 'claude'), 'must not run') + await f.cache({ 'claude-code': { path: cached, version: 'old 2.0' } }) + if (prefer === 'brew') { + await f.executable(join(f.bin, 'brew'), `printf 'brew %s\\n' "$*" >> ${quote(f.calls)}\nexit 0`) + } + expect(await f.discover(`await installHarness(findHarness("claude-code"), { prefer: "${prefer}" })`)) + .toMatchObject({ installed: true, path: live, version: 'new 4.0' }) + const calls = await readFile(f.calls, 'utf8') + expect(calls).toContain(prefer === 'npm' ? 'npm install -g @anthropic-ai/claude-code' : 'brew upgrade claude-code') + expect(calls).toContain(live) + expect(calls).not.toContain(cached) + }) + } +})