/** * System Tool Install Recipes * * Data-driven registry of install commands for system tools that clopen * depends on (Git, Claude Code, OpenCode, Chrome for puppeteer). Each * recipe is platform-aware and privilege-aware: when the runner cannot * reasonably complete the install non-interactively (e.g. `apt` without * root, `choco` without Administrator), the recipe is marked * `autoInstallable: false` and the frontend renders a copy-command * fallback instead of the install button. * * Recipes also carry manual instructions so the frontend can always * surface a copy-able command and a docs link, regardless of platform. */ import { existsSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; import { isElevated } from '$backend/utils/privilege'; import { getClopenDir } from '$backend/utils/paths'; import { resolveBinary, resolveBinaryWithRefresh } from '$backend/utils/cli'; import { resolveStaticCurlAsset } from '$backend/utils/static-curl'; import { getStackEnginesDir, readEngineSdkVersion, getRequiredSdkVersion } from './sdk-loader'; import { engineCliInstallArg, getEngineCliSpec, getRequiredEngineCliSpec, resolveEngineCli } from './engine-cli'; export type ToolId = 'git' | 'claude' | 'opencode' | 'copilot' | 'codex' | 'qwen' | 'pi' | 'cline' | 'cursor' | 'chrome'; export interface ManualInstruction { label: string; command: string; docs?: string; } export interface Recipe { tool: ToolId; autoInstallable: boolean; /** Reason displayed when autoInstallable is false. */ unavailableReason?: string; /** Spawn arg vector when autoInstallable. */ command?: string[]; /** Working directory for the spawned install (e.g. the managed stack dir for engines). */ cwd?: string; /** Optional shell to wrap command (e.g. sh -c for pipe chains). */ shell?: { program: string; args: string[] }; /** * True when the install command (or script it downloads) shells out * to `curl`. When set, the runner ensures curl is on PATH — * downloading a SHA-pinned static curl from stunnel/static-curl if * the system lacks one. */ requiresCurl?: boolean; /** * When requiresCurl is true and the system has no curl, this carries * the pinned asset metadata so the frontend can surface URL + SHA256 * in the install confirmation dialog for explicit user consent. * Undefined when system curl is already present or no asset covers * the current platform/arch. */ pendingCurlDownload?: { version: string; url: string; sha256: string; archKey: string }; /** Human-readable command string for confirmation dialog preview. */ displayCommand?: string; /** Extra env vars for the install subprocess. */ env?: Record; /** Missing prerequisites (other tools that must be installed first). */ missingPrereqs: ToolId[]; /** Manual-install options shown regardless of autoInstallable. */ manualInstructions: ManualInstruction[]; } export interface ToolStatus { tool: ToolId; installed: boolean; version: string | null; /** Where the binary was found (e.g. "/usr/bin/git", "system", "~/.clopen/bin"). */ source: string | null; /** Version clopen pins for this engine SDK (engines only); omitted for host tools. */ requiredVersion?: string | null; /** True when the engine SDK is installed but at a different version than required. */ needsUpdate?: boolean; } // ───────────────────────────────────────────────────────────────────────────── // Engine SDK packages (SSOT: versions read from package.json) // ───────────────────────────────────────────────────────────────────────────── /** * Engine → the npm package(s) clopen must install to run that engine on demand. * The FIRST entry is the SDK clopen imports (used for install-state detection); * any extras pin something the SDK would otherwise float — a transitive CLI * (copilot), a sibling package (pi, cline), or an unmet PEER dependency. * * Peers are the subtle case. `@anthropic-ai/claude-agent-sdk` declares * `@anthropic-ai/sdk`, `@modelcontextprotocol/sdk` and `zod` as peers and ships * no copy of its own, so installing the SDK alone lets bun resolve all three at * `@latest` inside the stack dir — clopen would then type-check against the * versions in its own package.json while the adapter loads different ones at * runtime (observed: pinned `@anthropic-ai/sdk` 0.100.1 vs 0.115.0 installed). * Listing them here holds them to the same pin. Engines whose SDK depends on * these packages directly (copilot, cursor, qwen) need no entry: their own * range governs, and bun nests a separate copy when it disagrees. * * Versions are read from package.json (the single source of truth), so an * on-demand install always matches the exact version clopen was tested against. * Every package listed here must therefore be declared in package.json — * `install-recipes.test.ts` enforces that, since an undeclared one would * silently install as `@latest`. */ export const ENGINE_PACKAGES: Partial> = { claude: [ '@anthropic-ai/claude-agent-sdk', '@anthropic-ai/sdk', '@modelcontextprotocol/sdk', 'zod' ], opencode: ['@opencode-ai/sdk'], copilot: ['@github/copilot-sdk', '@github/copilot'], codex: ['@openai/codex-sdk'], qwen: ['@qwen-code/sdk'], pi: ['@earendil-works/pi-coding-agent', '@earendil-works/pi-ai', '@earendil-works/pi-agent-core'], cline: ['@cline/sdk', '@cline/agents'], cursor: ['@cursor/sdk'], }; function isEngineTool(tool: ToolId): boolean { return tool in ENGINE_PACKAGES; } /** * `bun add` arg vector for an engine: each package pinned to its package.json * version, plus the engine's CLI package when it needs one (see engine-cli.ts). * The CLI is pinned through its SDK's version instead of a package.json entry * of its own — it is a platform binary, not something to put in devDependencies. */ export function engineInstallArgs(tool: ToolId): string[] { const packages = ENGINE_PACKAGES[tool] ?? []; const args = packages.map(name => { const v = getRequiredSdkVersion(name); return v ? `${name}@${v}` : name; }); const cli = getEngineCliSpec(tool); const cliArg = cli ? engineCliInstallArg(cli) : null; if (cliArg) args.push(cliArg); return args; } // ───────────────────────────────────────────────────────────────────────────── // Package manager detection // ───────────────────────────────────────────────────────────────────────────── type LinuxPkgMgr = 'apt' | 'dnf' | 'pacman' | 'apk' | 'zypper'; type WindowsPkgMgr = 'winget' | 'scoop' | 'choco'; function detectLinuxPkgMgr(): LinuxPkgMgr | null { if (resolveBinary('apt-get')) return 'apt'; if (resolveBinary('dnf')) return 'dnf'; if (resolveBinary('pacman')) return 'pacman'; if (resolveBinary('apk')) return 'apk'; if (resolveBinary('zypper')) return 'zypper'; return null; } function detectWindowsPkgMgr(): WindowsPkgMgr | null { if (resolveBinary('winget')) return 'winget'; if (resolveBinary('scoop')) return 'scoop'; if (resolveBinary('choco')) return 'choco'; return null; } function detectMacPkgMgr(): 'brew' | null { return resolveBinary('brew') ? 'brew' : null; } // ───────────────────────────────────────────────────────────────────────────── // Chrome detection // ───────────────────────────────────────────────────────────────────────────── /** * Resolve the Chrome for Testing executable path under ~/.clopen/bin using * the @puppeteer/browsers cache layout: /chrome/-//. * Only macOS and Windows install Chrome this way — on Linux we install * Google Chrome via the distro package manager and skip this scan. */ export function resolveClopenChromePath(): string | null { const cacheDir = join(getClopenDir(), 'bin', 'chrome'); if (!existsSync(cacheDir)) return null; try { const entries = readdirSync(cacheDir); for (const entry of entries) { const buildDir = join(cacheDir, entry); if (process.platform === 'darwin') { const macDirs = ['chrome-mac-arm64', 'chrome-mac-x64', 'chrome-mac']; for (const dir of macDirs) { const candidate = join( buildDir, dir, 'Google Chrome for Testing.app', 'Contents', 'MacOS', 'Google Chrome for Testing' ); if (existsSync(candidate)) return candidate; } } else if (process.platform === 'win32') { const winDirs = ['chrome-win64', 'chrome-win']; for (const dir of winDirs) { const candidate = join(buildDir, dir, 'chrome.exe'); if (existsSync(candidate)) return candidate; } } else { const linuxDirs = ['chrome-linux64', 'chrome-linux']; for (const dir of linuxDirs) { const candidate = join(buildDir, dir, 'chrome'); if (existsSync(candidate)) return candidate; } } } } catch { // Fall through } return null; } function detectSystemChrome(): string | null { if (process.platform === 'darwin') { const p = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; return existsSync(p) ? p : null; } if (process.platform === 'win32') { const paths = [ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe' ]; for (const p of paths) if (existsSync(p)) return p; return resolveBinary('chrome'); } // Linux: Google Chrome only (installed via apt/dnf/zypper from dl.google.com). const chromePaths = [ '/opt/google/chrome/chrome', '/usr/bin/google-chrome-stable', '/usr/bin/google-chrome' ]; for (const p of chromePaths) if (existsSync(p)) return p; return resolveBinary('google-chrome-stable') ?? resolveBinary('google-chrome'); } /** * Preferred Chrome executable path for puppeteer. Clopen-managed install * (macOS/Windows via @puppeteer/browsers → Chrome for Testing) wins over * system Google Chrome so the bundled version stays consistent; on Linux * only the system Google Chrome path is used. */ export function getChromeExecutablePath(): string | null { return resolveClopenChromePath() ?? detectSystemChrome(); } // ───────────────────────────────────────────────────────────────────────────── // Status detection // ───────────────────────────────────────────────────────────────────────────── async function runVersion(binary: string, versionFlag = '--version'): Promise { try { const proc = Bun.spawn([binary, versionFlag], { stdout: 'pipe', stderr: 'pipe' }); const exitCode = await proc.exited; if (exitCode !== 0) return null; const stdout = await new Response(proc.stdout).text(); const first = stdout.trim().split('\n')[0]?.trim() ?? ''; return first || null; } catch { return null; } } export async function getToolStatus(tool: ToolId): Promise { if (tool === 'chrome') { const resolved = getChromeExecutablePath(); if (!resolved) return { tool, installed: false, version: null, source: null }; const version = await runVersion(resolved); const source = resolveClopenChromePath() ? 'clopen' : resolved; return { tool, installed: true, version, source }; } if (isEngineTool(tool)) { // Engines are detected by the presence of their SDK in the clopen-managed // stack dir (installed on demand there) — that is what the adapter loads. const sdkPkg = ENGINE_PACKAGES[tool]![0]; const sdkVersion = readEngineSdkVersion(sdkPkg); const requiredVersion = getRequiredSdkVersion(sdkPkg); if (!sdkVersion) return { tool, installed: false, version: null, source: null, requiredVersion, needsUpdate: false }; const sdkNeedsUpdate = requiredVersion !== null && sdkVersion !== requiredVersion; // Only a CLI the engine cannot run without gates its install state; a CLI // the SDK bundles and locates itself arrives with the engine anyway. const cliSpec = getRequiredEngineCliSpec(tool); if (!cliSpec) { return { tool, installed: true, version: sdkVersion, source: getStackEnginesDir(), requiredVersion, needsUpdate: sdkNeedsUpdate }; } // Engines that cannot run without an external CLI (Open Code) count as // installed only when a runnable binary exists. Reporting the SDK alone is // what let Stack show "installed" for an engine nothing could spawn. const cli = await resolveEngineCli(tool); if (!cli) return { tool, installed: false, version: null, source: null, requiredVersion, needsUpdate: false }; // The CLI is the artifact that actually runs, so it decides the reported // version — a user's own older copy on PATH surfaces as "needs update" // instead of hiding behind the pinned SDK version. const cliNeedsUpdate = requiredVersion !== null && cli.version !== null && cli.version !== requiredVersion; return { tool, installed: true, version: cli.version ?? sdkVersion, source: cli.path, requiredVersion, needsUpdate: sdkNeedsUpdate || cliNeedsUpdate }; } const resolved = await resolveBinaryWithRefresh(tool); if (!resolved) return { tool, installed: false, version: null, source: null }; const version = await runVersion(resolved); if (!version) return { tool, installed: false, version: null, source: null }; return { tool, installed: true, version, source: resolved }; } // ───────────────────────────────────────────────────────────────────────────── // Recipe resolution // ───────────────────────────────────────────────────────────────────────────── async function resolveGitRecipe(): Promise { const base: Recipe = { tool: 'git', autoInstallable: false, missingPrereqs: [], manualInstructions: [] }; if (process.platform === 'darwin') { const mgr = detectMacPkgMgr(); base.manualInstructions.push({ label: 'Homebrew', command: 'brew install git', docs: 'https://git-scm.com/download/mac' }); if (!mgr) { base.unavailableReason = 'Homebrew not found. Install Homebrew first.'; base.manualInstructions.push({ label: 'Install Homebrew', command: '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"', docs: 'https://brew.sh' }); return base; } base.autoInstallable = true; base.command = ['brew', 'install', 'git']; base.displayCommand = 'brew install git'; return base; } if (process.platform === 'win32') { const mgr = detectWindowsPkgMgr(); base.manualInstructions.push( { label: 'winget', command: 'winget install --id Git.Git -e', docs: 'https://git-scm.com/download/win' }, { label: 'scoop', command: 'scoop install git' }, { label: 'Chocolatey', command: 'choco install git -y' } ); if (!mgr) { base.unavailableReason = 'No supported package manager found (winget / scoop / choco).'; return base; } if (mgr === 'winget') { base.autoInstallable = true; base.command = ['winget', 'install', '--id', 'Git.Git', '-e', '--accept-source-agreements', '--accept-package-agreements']; base.displayCommand = 'winget install --id Git.Git -e'; return base; } if (mgr === 'scoop') { base.autoInstallable = true; base.command = ['scoop', 'install', 'git']; base.displayCommand = 'scoop install git'; return base; } // choco — requires admin const elevated = await isElevated(); if (!elevated) { base.unavailableReason = 'Chocolatey requires Administrator. Run clopen as Administrator or use winget/scoop.'; return base; } base.autoInstallable = true; base.command = ['choco', 'install', 'git', '-y']; base.displayCommand = 'choco install git -y'; return base; } // Linux const mgr = detectLinuxPkgMgr(); base.manualInstructions.push( { label: 'apt (Debian/Ubuntu)', command: 'sudo apt update && sudo apt install -y git' }, { label: 'dnf (Fedora/RHEL)', command: 'sudo dnf install -y git' }, { label: 'pacman (Arch)', command: 'sudo pacman -S --noconfirm git' }, { label: 'apk (Alpine)', command: 'sudo apk add git' } ); if (!mgr) { base.unavailableReason = 'No supported Linux package manager found.'; return base; } const elevated = await isElevated(); if (!elevated) { base.unavailableReason = 'Linux package install requires root. Run the command manually with sudo, or run clopen as root.'; return base; } base.autoInstallable = true; if (mgr === 'apt') { base.shell = { program: 'sh', args: ['-c'] }; base.command = ['apt-get update && apt-get install -y git']; base.displayCommand = 'apt-get update && apt-get install -y git'; } else if (mgr === 'dnf') { base.command = ['dnf', 'install', '-y', 'git']; base.displayCommand = 'dnf install -y git'; } else if (mgr === 'pacman') { base.command = ['pacman', '-S', '--noconfirm', 'git']; base.displayCommand = 'pacman -S --noconfirm git'; } else if (mgr === 'apk') { base.command = ['apk', 'add', 'git']; base.displayCommand = 'apk add git'; } else if (mgr === 'zypper') { base.command = ['zypper', '--non-interactive', 'install', 'git']; base.displayCommand = 'zypper --non-interactive install git'; } return base; } /** * Populate `requiresCurl` and (when the system lacks curl) the pending * static-curl download metadata. Returns false when this platform/arch * has no pinned asset — caller should mark the recipe unavailable. */ function attachCurlRequirement(base: Recipe, toolLabel: string): boolean { base.requiresCurl = true; if (resolveBinary('curl')) return true; const asset = resolveStaticCurlAsset(); if (!asset) { base.unavailableReason = `curl is required by the ${toolLabel} installer, and no static curl is available for ${process.platform}/${process.arch}.`; return false; } base.pendingCurlDownload = { version: asset.version, url: asset.url, sha256: asset.sha256, archKey: asset.archKey }; return true; } /** * Engine install recipe — installs the exact engine SDK package(s) declared in * package.json (the single source of truth) into the clopen-managed stack dir * (`~/.clopen/stack/engines`), NOT the user's global bun store. Installing the * SDK also pulls whatever CLI binary the SDK bundles, plus — for engines whose * SDK bundles none (Open Code) — the separate package that carries the CLI. * Versions are pinned, never floated to `latest`. */ function resolveEngineRecipe(tool: ToolId): Recipe { const args = engineInstallArgs(tool); const displayCommand = `bun add ${args.join(' ')}`; return { tool, autoInstallable: true, missingPrereqs: [], // Engine installs are fully clopen-managed (into ~/.clopen/stack/engines), // so there is no separate manual command for the user to run. manualInstructions: [], command: ['bun', 'add', ...args], cwd: getStackEnginesDir(), displayCommand }; } // ───────────────────────────────────────────────────────────────────────────── // Chrome recipe — Puppeteer download (macOS/Windows) or Google Chrome (Linux) // ───────────────────────────────────────────────────────────────────────────── const PPTR_CHROME_DOCS = 'https://pptr.dev/browsers-api'; const CHROME_LINUX_DOCS = 'https://www.google.com/chrome/'; // Google Chrome Linux package URLs. Google only publishes Linux x86_64 // .deb (apt) and .rpm (dnf/zypper) — other distros / arm64 surface manual // instructions. const GOOGLE_CHROME_DEB_URL = 'https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb'; const GOOGLE_CHROME_RPM_URL = 'https://dl.google.com/linux/direct/google-chrome-stable_current_x86_64.rpm'; async function resolveChromeRecipe(): Promise { const cacheDir = join(getClopenDir(), 'bin'); const pptrArgs = ['bun', 'x', '@puppeteer/browsers', 'install', 'chrome@stable', '--path', cacheDir]; const pptrDisplay = `bun x @puppeteer/browsers install chrome@stable --path "${cacheDir}"`; // macOS / Windows: Puppeteer downloads Chrome for Testing, self-contained. if (process.platform === 'darwin' || process.platform === 'win32') { return { tool: 'chrome', autoInstallable: true, missingPrereqs: [], manualInstructions: [{ label: 'Puppeteer browsers CLI', command: pptrDisplay, docs: PPTR_CHROME_DOCS }], command: pptrArgs, displayCommand: pptrDisplay }; } // Linux: Google Chrome deb/rpm for x86_64 (apt/dnf/zypper). Other // distros / arm64 are marked non-auto-installable with manual steps. return resolveLinuxChromeRecipe(); } interface LinuxChromeStrategy { pkgMgrLabel: string; /** Argv-form command to spawn (sh -c ). */ installCommand: string; /** User-facing display command (with sudo prefix if interactive). */ manualCommand: string; /** Whether this strategy shells out to curl (for static-curl fallback). */ requiresCurl?: boolean; /** Extra env to inject into the spawn. */ env?: Record; } async function resolveLinuxChromeRecipe(): Promise { const mgr = detectLinuxPkgMgr(); const arch = process.arch; const cacheDir = join(getClopenDir(), 'bin'); const pptrArgs = ['bun', 'x', '@puppeteer/browsers', 'install', 'chrome@stable', '--path', cacheDir]; const pptrDisplay = `bun x @puppeteer/browsers install chrome@stable --path "${cacheDir}"`; const manual: ManualInstruction[] = []; const strategy = pickLinuxChromeStrategy(mgr, arch); if (strategy) { manual.push({ label: `${strategy.pkgMgrLabel} (recommended)`, command: strategy.manualCommand, docs: CHROME_LINUX_DOCS }); } manual.push({ label: 'Puppeteer browsers CLI', command: pptrDisplay, docs: PPTR_CHROME_DOCS }); // Try Google Chrome via system package manager (x64, apt/dnf/zypper, requires root). if (strategy) { const elevated = await isElevated(); if (elevated) { const base: Recipe = { tool: 'chrome', autoInstallable: false, missingPrereqs: [], manualInstructions: manual }; if (!strategy.requiresCurl || attachCurlRequirement(base, 'Google Chrome')) { base.autoInstallable = true; base.shell = { program: 'sh', args: ['-c'] }; base.command = [strategy.installCommand]; base.displayCommand = strategy.manualCommand; if (strategy.env) base.env = strategy.env; return base; } // curl unavailable for system install — fall through to puppeteer } } // Fallback: puppeteer Chrome for Testing (no root needed, all distros and arm64). return { tool: 'chrome', autoInstallable: true, missingPrereqs: [], manualInstructions: manual, command: pptrArgs, displayCommand: pptrDisplay }; } function pickLinuxChromeStrategy( mgr: LinuxPkgMgr | null, arch: string ): LinuxChromeStrategy | null { // Google only publishes Linux Chrome for x86_64. if (arch !== 'x64') return null; if (mgr === 'apt') { // Download + install the .deb from dl.google.com. apt-get install with // a local deb path auto-resolves dependencies (libatk, libnss3, etc.) // from the distro repos. The postinst adds Google's apt source for // future updates. const cmd = 'apt-get update && apt-get install -y curl ca-certificates && ' + `curl -fsSL -o /tmp/google-chrome-stable.deb ${GOOGLE_CHROME_DEB_URL} && ` + 'apt-get install -y /tmp/google-chrome-stable.deb && ' + 'rm -f /tmp/google-chrome-stable.deb'; return { pkgMgrLabel: 'apt', installCommand: cmd, manualCommand: `sudo apt-get update && sudo apt-get install -y curl ca-certificates && ` + `curl -fsSL -o /tmp/google-chrome-stable.deb ${GOOGLE_CHROME_DEB_URL} && ` + `sudo apt-get install -y /tmp/google-chrome-stable.deb`, requiresCurl: true, env: { DEBIAN_FRONTEND: 'noninteractive' } }; } if (mgr === 'dnf') { return { pkgMgrLabel: 'dnf', installCommand: `dnf install -y ${GOOGLE_CHROME_RPM_URL}`, manualCommand: `sudo dnf install -y ${GOOGLE_CHROME_RPM_URL}` }; } if (mgr === 'zypper') { return { pkgMgrLabel: 'zypper', installCommand: `zypper --non-interactive --gpg-auto-import-keys install ${GOOGLE_CHROME_RPM_URL}`, manualCommand: `sudo zypper install ${GOOGLE_CHROME_RPM_URL}` }; } return null; } /** * Resolve the install recipe for a tool on the current platform. * Result is platform- and privilege-aware: recipes that would fail * non-interactively are marked autoInstallable=false. */ export async function resolveRecipe(tool: ToolId): Promise { switch (tool) { case 'git': return resolveGitRecipe(); case 'chrome': return resolveChromeRecipe(); case 'claude': case 'opencode': case 'copilot': case 'codex': case 'qwen': case 'pi': case 'cline': case 'cursor': return resolveEngineRecipe(tool); } }