Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions cli/src/lib/harnesses.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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<DiscoveredHarness | null> {
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<string, unknown>)[spec.id]
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) return null
const { path, version } = entry as Record<string, unknown>
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<DiscoveredHarness> {
return (await cachedHarness(spec)) ?? discoverLiveHarness(spec)
}

async function discoverLiveHarness(spec: HarnessSpec): Promise<DiscoveredHarness> {
for (const method of spec.installMethods) {
const path = await resolveBinary(binaryFor(method, spec.id))
if (path) {
Expand Down Expand Up @@ -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') {
Expand All @@ -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}`)
Expand Down
142 changes: 142 additions & 0 deletions cli/test/discovery-cache.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, { path: string; version: unknown }> = {}
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)
})
}
})
Loading