Skip to content
Merged
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"prebuild:renderer": "npm run gen:catalog",
"catalog:check": "node scripts/check-catalog-stale.mjs",
"test": "npm run build:main && npm run test:unit",
"test:unit": "node scripts/test-extract-base-url.mjs && node scripts/test-doctor.mjs && node scripts/test-versions.mjs && node scripts/test-secrets.mjs && node scripts/test-homebrew.mjs && node scripts/test-install-methods.mjs && node scripts/test-codex-auth.mjs && node scripts/test-probes-cloud.mjs && node scripts/test-probe-context.mjs",
"test:unit": "node scripts/test-extract-base-url.mjs && node scripts/test-doctor.mjs && node scripts/test-versions.mjs && node scripts/test-secrets.mjs && node scripts/test-homebrew.mjs && node scripts/test-install-methods.mjs && node scripts/test-codex-auth.mjs && node scripts/test-probes-cloud.mjs && node scripts/test-probe-context.mjs && node scripts/test-discovery-cache.mjs",
"test:extractor": "node scripts/test-extract-base-url.mjs",
"package": "npm run build && electron-builder"
},
Expand Down
51 changes: 51 additions & 0 deletions scripts/test-discovery-cache.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#!/usr/bin/env node
/**
* Unit tests for the OpenCode discovery cache (via dist build).
*/
import { createRequire } from 'node:module'
import { existsSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'

const root = join(dirname(fileURLToPath(import.meta.url)), '..')
const installerJs = join(root, 'dist/main/installer/index.js')

if (!existsSync(installerJs)) {
console.error('dist/main/installer/index.js missing - run npm run build:main first')
process.exit(1)
}

const require = createRequire(import.meta.url)
const { parseDiscoveryCache, discoveryCachePath, DISCOVERY_CACHE_TTL_MS } = require(installerJs)

let failed = 0
function assert(cond, msg) {
if (cond) console.log(` ok ${msg}`)
else {
failed++
console.error(` FAIL ${msg}`)
}
}

const now = 1_700_000_000_000
const fresh = { 'claude-code': { path: '/opt/homebrew/bin/claude', version: '2.1.204' } }

assert(parseDiscoveryCache(fresh, now, now)?.['claude-code']?.version === '2.1.204', 'fresh cache parses')
assert(parseDiscoveryCache(fresh, now, now + DISCOVERY_CACHE_TTL_MS) !== null, 'cache at TTL boundary is fresh')
assert(parseDiscoveryCache(fresh, now, now + DISCOVERY_CACHE_TTL_MS + 1) === null, 'stale cache rejected')
assert(parseDiscoveryCache(null, now, now) === null, 'null payload rejected')
assert(parseDiscoveryCache('nope', now, now) === null, 'string payload rejected')
assert(parseDiscoveryCache([], now, now) === null, 'array payload rejected')
assert(parseDiscoveryCache({}, now, now) === null, 'empty payload rejected')
assert(parseDiscoveryCache({ x: { version: '1' } }, now, now) === null, 'entry without path dropped')
assert(
parseDiscoveryCache({ x: { path: '/bin/x', version: 3 } }, now, now)?.x?.version === null,
'non-string version normalised to null',
)
assert(discoveryCachePath('/Users/x').endsWith(join('.cache', 'hoist', 'discovery.json')), 'cache path shape')

if (failed > 0) {
console.error(`\n${failed} discovery cache assertion(s) failed`)
process.exit(1)
}
console.log('\nall discovery cache cases passed')
61 changes: 59 additions & 2 deletions src/main/installer/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { spawn } from 'node:child_process'
import { access, chmod, constants, mkdir, unlink, writeFile } from 'node:fs/promises'
import { access, chmod, constants, mkdir, readFile, stat, unlink, writeFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
import { basename, dirname, join } from 'node:path'
import which from 'which'
import type { InstalledTool, ToolInstallMethod, ToolInstallSpec } from '../../shared/types'

Expand Down Expand Up @@ -81,7 +81,64 @@ async function readVersion(binary: string, args: string[] = ['--version']): Prom

export interface DiscoverResult extends InstalledTool {}

// Written by the OpenCode discovery plugin (src/opencode-plugin). Fresh
// entries win over the which + npm-global probe; anything stale, malformed
// or missing falls back to the probe with no behaviour change.
export const DISCOVERY_CACHE_TTL_MS = 5 * 60 * 1000

export interface DiscoveryCacheEntry {
path: string
version: string | null
}

export function discoveryCachePath(home: string = homedir()): string {
return join(home, '.cache', 'hoist', 'discovery.json')
}

export function parseDiscoveryCache(
raw: unknown,
mtimeMs: number,
now: number = Date.now(),
): Record<string, DiscoveryCacheEntry> | null {
if (!Number.isFinite(mtimeMs) || now - mtimeMs > DISCOVERY_CACHE_TTL_MS) return null
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return null
const out: Record<string, DiscoveryCacheEntry> = {}
for (const [id, value] of Object.entries(raw as Record<string, unknown>)) {
if (typeof value !== 'object' || value === null) continue
const entry = value as Record<string, unknown>
if (typeof entry.path !== 'string' || entry.path.length === 0) continue
out[id] = {
path: entry.path,
version: typeof entry.version === 'string' && entry.version.length > 0 ? entry.version : null,
}
}
return Object.keys(out).length > 0 ? out : null
}

async function readDiscoveryCache(): Promise<Record<string, DiscoveryCacheEntry> | null> {
try {
const file = discoveryCachePath()
const [text, info] = await Promise.all([readFile(file, 'utf8'), stat(file)])
return parseDiscoveryCache(JSON.parse(text), info.mtimeMs)
} catch {
return null
}
}

export async function discoverInstalled(spec: ToolInstallSpec): Promise<DiscoverResult> {
const cached = (await readDiscoveryCache())?.[spec.id]
if (cached) {
try {
await access(cached.path, constants.X_OK)
const installMethod =
spec.installMethods.find((m) => binaryNameFor(m, spec.id) === basename(cached.path))
?? spec.installMethods[0]
?? null
return { spec, version: cached.version, path: cached.path, installMethod }
} catch {
// Stale path in a fresh cache file: fall through to the live probe.
}
}
for (const method of spec.installMethods) {
const binary = binaryNameFor(method, spec.id)
const path = await resolveBinaryPath(binary)
Expand Down
30 changes: 30 additions & 0 deletions src/opencode-plugin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Hoist discovery plugin for OpenCode

Reports installed harness binaries to Hoist so the app does not have to
guess install locations across Homebrew, npm-global and version managers.

On OpenCode startup the plugin runs `which` for each binary in the hoist
catalog (claude, opencode, codex), reads `--version`, and writes
`~/.cache/hoist/discovery.json`:

```json
{
"claude-code": { "path": "/opt/homebrew/bin/claude", "version": "2.1.204" }
}
```

Hoist reads the file when it is fresher than five minutes and otherwise
falls back to its own probe. No cache file means no behaviour change.

## Install (local development)

Point OpenCode at the plugin file from `~/.config/opencode/opencode.json`:

```json
{
"plugin": ["/path/to/hoist/src/opencode-plugin/hoist-discover.ts"]
}
```

Once published, the equivalent is `opencode plugin install @hoist/discover`
with the published package name in the `plugin` array instead.
66 changes: 66 additions & 0 deletions src/opencode-plugin/hoist-discover.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Hoist discovery plugin for OpenCode. No runtime dependencies.
//
// On OpenCode startup it resolves each harness binary from the hoist
// catalog, reads its version, and writes ~/.cache/hoist/discovery.json.
// Hoist (src/main/installer) reads that file when it is fresher than five
// minutes and falls back to its own which + npm-global probe otherwise.
//
// Binary names mirror HARNESS_CATALOG in src/main/providers/harnesses.ts.
// Keep the two in sync when a harness is added.

import { execFile } from "node:child_process"
import { mkdir, rename, writeFile } from "node:fs/promises"
import { homedir } from "node:os"
import { dirname, join } from "node:path"

const HARNESSES: Record<string, string> = {
"claude-code": "claude",
opencode: "opencode",
codex: "codex",
}

function run(cmd: string, args: string[]): Promise<string | null> {
return new Promise((resolve) => {
execFile(cmd, args, { timeout: 2_000 }, (err, stdout) => {
resolve(err ? null : stdout.trim() || null)
})
})
}

async function discoverOne(binary: string): Promise<{ path: string; version: string | null } | null> {
const path = await run("which", [binary])
if (!path) return null
const versionLine = await run(path, ["--version"])
return { path: path.split("\n")[0], version: versionLine?.split("\n")[0] ?? null }
}

export async function writeDiscoveryCache(
home: string = homedir(),
): Promise<Record<string, { path: string; version: string | null }>> {
const found: Record<string, { path: string; version: string | null }> = {}
await Promise.all(
Object.entries(HARNESSES).map(async ([id, binary]) => {
const entry = await discoverOne(binary)
if (entry) found[id] = entry
}),
)
const target = join(home, ".cache", "hoist", "discovery.json")
await mkdir(dirname(target), { recursive: true })
const tmp = `${target}.tmp`
await writeFile(tmp, `${JSON.stringify(found, null, 2)}\n`, "utf8")
await rename(tmp, target)
return found
}

// OpenCode loads default-exported / named plugin functions and awaits them
// at startup. Discovery runs once here and never blocks startup on failure.
export const HoistDiscoverPlugin = async () => {
try {
await writeDiscoveryCache()
} catch {
// Hoist falls back to its own probe when the cache is absent.
}
return {}
}

export default HoistDiscoverPlugin
Loading