diff --git a/package.json b/package.json index 6860eaf..b59e5cd 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/scripts/test-discovery-cache.mjs b/scripts/test-discovery-cache.mjs new file mode 100644 index 0000000..c216a30 --- /dev/null +++ b/scripts/test-discovery-cache.mjs @@ -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') diff --git a/src/main/installer/index.ts b/src/main/installer/index.ts index dfc09f2..e4add61 100644 --- a/src/main/installer/index.ts +++ b/src/main/installer/index.ts @@ -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' @@ -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 | 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 = {} + for (const [id, value] of Object.entries(raw as Record)) { + if (typeof value !== 'object' || value === null) continue + const entry = value as Record + 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 | 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 { + 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) diff --git a/src/opencode-plugin/README.md b/src/opencode-plugin/README.md new file mode 100644 index 0000000..fdc36c0 --- /dev/null +++ b/src/opencode-plugin/README.md @@ -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. diff --git a/src/opencode-plugin/hoist-discover.ts b/src/opencode-plugin/hoist-discover.ts new file mode 100644 index 0000000..06e390b --- /dev/null +++ b/src/opencode-plugin/hoist-discover.ts @@ -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 = { + "claude-code": "claude", + opencode: "opencode", + codex: "codex", +} + +function run(cmd: string, args: string[]): Promise { + 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> { + const found: Record = {} + 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