From f0493ce68d0b43364b539ca4da3f17d0edecdc94 Mon Sep 17 00:00:00 2001 From: weishu Date: Sat, 20 Dec 2025 16:44:58 +0800 Subject: [PATCH] feat(cli): add Bun compiled binary runtime asset management Introduce runtime path abstraction for Bun-compiled binaries that extracts assets to ~/.happy/runtime/{version} instead of using project paths. This enables proper distribution of CLI as a self-contained binary with embedded tools (ripgrep, difftastic) and scripts that are extracted at runtime. --- cli/src/claude/claudeLocal.ts | 4 +- cli/src/claude/claudeRemote.ts | 6 +- cli/src/claude/runClaude.ts | 6 +- cli/src/codex/runCodex.ts | 8 +- cli/src/daemon/run.ts | 4 +- cli/src/index.ts | 2 + cli/src/modules/difftastic/index.ts | 6 +- cli/src/modules/ripgrep/index.ts | 4 +- cli/src/projectPath.ts | 26 +++-- cli/src/runtime/assets.ts | 142 ++++++++++++++++++++++++++++ cli/src/runtime/embeddedAssets.ts | 93 ++++++++++++++++++ 11 files changed, 276 insertions(+), 25 deletions(-) create mode 100644 cli/src/runtime/assets.ts create mode 100644 cli/src/runtime/embeddedAssets.ts diff --git a/cli/src/claude/claudeLocal.ts b/cli/src/claude/claudeLocal.ts index a0b9d861..395b25de 100644 --- a/cli/src/claude/claudeLocal.ts +++ b/cli/src/claude/claudeLocal.ts @@ -6,12 +6,12 @@ import { randomUUID } from "node:crypto"; import { logger } from "@/ui/logger"; import { claudeCheckSession } from "./utils/claudeCheckSession"; import { getProjectPath } from "./utils/path"; -import { projectPath } from "@/projectPath"; +import { runtimePath } from "@/projectPath"; import { systemPrompt } from "./utils/systemPrompt"; // Get Claude CLI path from project root -export const claudeCliPath = resolve(join(projectPath(), 'scripts', 'claude_local_launcher.cjs')) +export const claudeCliPath = resolve(join(runtimePath(), 'scripts', 'claude_local_launcher.cjs')) export async function claudeLocal(opts: { abort: AbortSignal, diff --git a/cli/src/claude/claudeRemote.ts b/cli/src/claude/claudeRemote.ts index 0f423ded..9b3b4f58 100644 --- a/cli/src/claude/claudeRemote.ts +++ b/cli/src/claude/claudeRemote.ts @@ -2,7 +2,7 @@ import { EnhancedMode, PermissionMode } from "./loop"; import { query, type QueryOptions as Options, type SDKMessage, type SDKSystemMessage, AbortError, SDKUserMessage } from '@/claude/sdk' import { claudeCheckSession } from "./utils/claudeCheckSession"; import { join, resolve } from 'node:path'; -import { projectPath } from "@/projectPath"; +import { runtimePath } from "@/projectPath"; import { parseSpecialCommand } from "@/parsers/specialCommands"; import { logger } from "@/lib"; import { PushableAsyncIterable } from "@/utils/PushableAsyncIterable"; @@ -122,7 +122,7 @@ export async function claudeRemote(opts: { executable: 'node', abort: opts.signal, pathToClaudeCodeExecutable: (() => { - return resolve(join(projectPath(), 'scripts', 'claude_remote_launcher.cjs')); + return resolve(join(runtimePath(), 'scripts', 'claude_remote_launcher.cjs')); })(), } @@ -232,4 +232,4 @@ export async function claudeRemote(opts: { } finally { updateThinking(false); } -} \ No newline at end of file +} diff --git a/cli/src/claude/runClaude.ts b/cli/src/claude/runClaude.ts index 85408ebf..305dffd8 100644 --- a/cli/src/claude/runClaude.ts +++ b/cli/src/claude/runClaude.ts @@ -18,7 +18,7 @@ import { notifyDaemonSessionStarted } from '@/daemon/controlClient'; import { initialMachineMetadata } from '@/daemon/run'; import { startHappyServer } from '@/claude/utils/startHappyServer'; import { registerKillSessionHandler } from './registerKillSessionHandler'; -import { projectPath } from '../projectPath'; +import { runtimePath } from '../projectPath'; import { resolve } from 'node:path'; export interface StartOptions { @@ -76,8 +76,8 @@ export async function runClaude(options: StartOptions = {}): Promise { machineId: machineId, homeDir: os.homedir(), happyHomeDir: configuration.happyHomeDir, - happyLibDir: projectPath(), - happyToolsDir: resolve(projectPath(), 'tools', 'unpacked'), + happyLibDir: runtimePath(), + happyToolsDir: resolve(runtimePath(), 'tools', 'unpacked'), startedFromDaemon: options.startedBy === 'daemon', hostPid: process.pid, startedBy: options.startedBy || 'terminal', diff --git a/cli/src/codex/runCodex.ts b/cli/src/codex/runCodex.ts index 84436391..ded19987 100644 --- a/cli/src/codex/runCodex.ts +++ b/cli/src/codex/runCodex.ts @@ -15,7 +15,7 @@ import packageJson from '../../package.json'; import os from 'node:os'; import { MessageQueue2 } from '@/utils/MessageQueue2'; import { hashObject } from '@/utils/deterministicJson'; -import { projectPath } from '@/projectPath'; +import { runtimePath } from '@/projectPath'; import { resolve, join } from 'node:path'; import fs from 'node:fs'; import { startHappyServer } from '@/claude/utils/startHappyServer'; @@ -108,8 +108,8 @@ export async function runCodex(opts: { machineId: machineId, homeDir: os.homedir(), happyHomeDir: configuration.happyHomeDir, - happyLibDir: projectPath(), - happyToolsDir: resolve(projectPath(), 'tools', 'unpacked'), + happyLibDir: runtimePath(), + happyToolsDir: resolve(runtimePath(), 'tools', 'unpacked'), startedFromDaemon: opts.startedBy === 'daemon', hostPid: process.pid, startedBy: opts.startedBy || 'terminal', @@ -521,7 +521,7 @@ export async function runCodex(opts: { // Start Happy MCP server (HTTP) and prepare STDIO bridge config for Codex const happyServer = await startHappyServer(session); - const bridgeCommand = join(projectPath(), 'bin', 'happy-mcp.mjs'); + const bridgeCommand = join(runtimePath(), 'bin', 'happy-mcp.mjs'); const mcpServers = { happy: { command: bridgeCommand, diff --git a/cli/src/daemon/run.ts b/cli/src/daemon/run.ts index d874dac1..350f2fa8 100644 --- a/cli/src/daemon/run.ts +++ b/cli/src/daemon/run.ts @@ -18,7 +18,7 @@ import { cleanupDaemonState, isDaemonRunningCurrentlyInstalledHappyVersion, stop import { startDaemonControlServer } from './controlServer'; import { readFileSync } from 'fs'; import { join } from 'path'; -import { projectPath } from '@/projectPath'; +import { projectPath, runtimePath } from '@/projectPath'; // Prepare initial metadata export const initialMachineMetadata: MachineMetadata = { @@ -27,7 +27,7 @@ export const initialMachineMetadata: MachineMetadata = { happyCliVersion: packageJson.version, homeDir: os.homedir(), happyHomeDir: configuration.happyHomeDir, - happyLibDir: projectPath() + happyLibDir: runtimePath() }; export async function startDaemon(): Promise { diff --git a/cli/src/index.ts b/cli/src/index.ts index 6d504f90..6690a090 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -27,9 +27,11 @@ import { spawnHappyCLI } from './utils/spawnHappyCLI' import { claudeCliPath } from './claude/claudeLocal' import { execFileSync } from 'node:child_process' import { initializeToken } from './ui/tokenInit' +import { ensureRuntimeAssets } from './runtime/assets' (async () => { + ensureRuntimeAssets() const args = process.argv.slice(2) // If --version is passed - do not log, its likely daemon inquiring about our version diff --git a/cli/src/modules/difftastic/index.ts b/cli/src/modules/difftastic/index.ts index d716038c..2cadcc7b 100644 --- a/cli/src/modules/difftastic/index.ts +++ b/cli/src/modules/difftastic/index.ts @@ -5,7 +5,7 @@ import { spawn } from 'child_process'; import { join, resolve } from 'path'; import { platform, arch } from 'os'; -import { projectPath } from '@/projectPath'; +import { runtimePath } from '@/projectPath'; export interface DifftasticResult { exitCode: number @@ -23,7 +23,7 @@ export interface DifftasticOptions { function getBinaryPath(): string { const platformName = platform(); const binaryName = platformName === 'win32' ? 'difft.exe' : 'difft'; - return resolve(join(projectPath(), 'tools', 'unpacked', binaryName)); + return resolve(join(runtimePath(), 'tools', 'unpacked', binaryName)); } /** @@ -69,4 +69,4 @@ export function run(args: string[], options?: DifftasticOptions): Promise { - const runnerPath = resolve(join(projectPath(), 'scripts', 'ripgrep_launcher.cjs')); + const runnerPath = resolve(join(runtimePath(), 'scripts', 'ripgrep_launcher.cjs')); return new Promise((resolve, reject) => { const child = spawn(process.execPath, [runnerPath, JSON.stringify(args)], { stdio: ['pipe', 'pipe', 'pipe'], diff --git a/cli/src/projectPath.ts b/cli/src/projectPath.ts index e6ed29ed..20113140 100644 --- a/cli/src/projectPath.ts +++ b/cli/src/projectPath.ts @@ -1,10 +1,24 @@ -import { dirname, resolve } from 'path'; +import { dirname, resolve, join } from 'path'; import { fileURLToPath } from 'url'; +import { configuration } from '@/configuration'; +import packageJson from '../package.json'; const __dirname = dirname(fileURLToPath(import.meta.url)); +const bunRuntime = (globalThis as typeof globalThis & { Bun?: { isCompiled?: boolean } }).Bun; +const isCompiled = Boolean(bunRuntime?.isCompiled); -export function projectPath() { - const path = resolve(__dirname, '..'); - // console.log('path', path) - return path; -} \ No newline at end of file +export function projectPath(): string { + return resolve(__dirname, '..'); +} + +export function runtimePath(): string { + if (!isCompiled) { + return projectPath(); + } + + return join(configuration.happyHomeDir, 'runtime', packageJson.version); +} + +export function isBunCompiled(): boolean { + return isCompiled; +} diff --git a/cli/src/runtime/assets.ts b/cli/src/runtime/assets.ts new file mode 100644 index 00000000..04f23ceb --- /dev/null +++ b/cli/src/runtime/assets.ts @@ -0,0 +1,142 @@ +import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { arch, platform } from 'node:os'; +import * as tar from 'tar'; +import packageJson from '../../package.json'; +import { EMBEDDED_ASSETS } from './embeddedAssets'; +import { isBunCompiled, runtimePath } from '@/projectPath'; + +const RUNTIME_MARKER = '.runtime-version'; + +function ensureDirectory(path: string): void { + mkdirSync(path, { recursive: true }); +} + +function copyAssetFile(sourcePath: string, targetPath: string): void { + ensureDirectory(dirname(targetPath)); + copyFileSync(sourcePath, targetPath); + try { + const stats = statSync(sourcePath); + chmodSync(targetPath, stats.mode); + } catch { + // Best-effort; permission adjustments are not critical. + } +} + +function getPlatformDir(): string { + const platformName = platform(); + const archName = arch(); + + if (platformName === 'darwin') { + if (archName === 'arm64') return 'arm64-darwin'; + if (archName === 'x64') return 'x64-darwin'; + } else if (platformName === 'linux') { + if (archName === 'arm64') return 'arm64-linux'; + if (archName === 'x64') return 'x64-linux'; + } else if (platformName === 'win32') { + if (archName === 'x64') return 'x64-win32'; + } + + throw new Error(`Unsupported platform: ${archName}-${platformName}`); +} + +function areToolsUnpacked(unpackedPath: string): boolean { + if (!existsSync(unpackedPath)) { + return false; + } + + const isWin = platform() === 'win32'; + const difftBinary = isWin ? 'difft.exe' : 'difft'; + const rgBinary = isWin ? 'rg.exe' : 'rg'; + + const expectedFiles = [ + join(unpackedPath, difftBinary), + join(unpackedPath, rgBinary), + join(unpackedPath, 'ripgrep.node') + ]; + + return expectedFiles.every((file) => existsSync(file)); +} + +function unpackTools(runtimeRoot: string): void { + const platformDir = getPlatformDir(); + const toolsDir = join(runtimeRoot, 'tools'); + const archivesDir = join(toolsDir, 'archives'); + const unpackedPath = join(toolsDir, 'unpacked'); + + if (areToolsUnpacked(unpackedPath)) { + return; + } + + rmSync(unpackedPath, { recursive: true, force: true }); + ensureDirectory(unpackedPath); + + const archives = [ + `difftastic-${platformDir}.tar.gz`, + `ripgrep-${platformDir}.tar.gz` + ]; + + for (const archiveName of archives) { + const archivePath = join(archivesDir, archiveName); + if (!existsSync(archivePath)) { + throw new Error(`Archive not found: ${archivePath}`); + } + tar.extract({ + file: archivePath, + cwd: unpackedPath, + sync: true, + preserveOwner: false + }); + } + + if (platform() !== 'win32') { + const files = readdirSync(unpackedPath); + for (const file of files) { + if (file.endsWith('.node')) { + continue; + } + const filePath = join(unpackedPath, file); + const stats = statSync(filePath); + if (stats.isFile()) { + chmodSync(filePath, 0o755); + } + } + } +} + +function runtimeAssetsReady(runtimeRoot: string): boolean { + return ( + existsSync(join(runtimeRoot, 'scripts', 'ripgrep_launcher.cjs')) && + existsSync(join(runtimeRoot, 'bin', 'happy-mcp.mjs')) && + areToolsUnpacked(join(runtimeRoot, 'tools', 'unpacked')) + ); +} + +export function ensureRuntimeAssets(): void { + if (!isBunCompiled()) { + return; + } + + const runtimeRoot = runtimePath(); + const markerPath = join(runtimeRoot, RUNTIME_MARKER); + + if (existsSync(markerPath)) { + const markerVersion = readFileSync(markerPath, 'utf-8').trim(); + if (markerVersion === packageJson.version && runtimeAssetsReady(runtimeRoot)) { + return; + } + } + + ensureDirectory(runtimeRoot); + + for (const asset of EMBEDDED_ASSETS) { + if (!existsSync(asset.sourcePath)) { + throw new Error(`Embedded asset missing: ${asset.sourcePath}`); + } + const targetPath = join(runtimeRoot, asset.relativePath); + copyAssetFile(asset.sourcePath, targetPath); + } + + unpackTools(runtimeRoot); + writeFileSync(markerPath, packageJson.version, 'utf-8'); +} diff --git a/cli/src/runtime/embeddedAssets.ts b/cli/src/runtime/embeddedAssets.ts new file mode 100644 index 00000000..aeb0d5ae --- /dev/null +++ b/cli/src/runtime/embeddedAssets.ts @@ -0,0 +1,93 @@ +import { fileURLToPath } from 'node:url'; + +export interface EmbeddedAsset { + relativePath: string; + sourcePath: string; +} + +export const EMBEDDED_ASSETS: EmbeddedAsset[] = [ + { + relativePath: 'scripts/claude_local_launcher.cjs', + sourcePath: fileURLToPath(new URL('../../scripts/claude_local_launcher.cjs', import.meta.url)) + }, + { + relativePath: 'scripts/claude_remote_launcher.cjs', + sourcePath: fileURLToPath(new URL('../../scripts/claude_remote_launcher.cjs', import.meta.url)) + }, + { + relativePath: 'scripts/claude_version_utils.cjs', + sourcePath: fileURLToPath(new URL('../../scripts/claude_version_utils.cjs', import.meta.url)) + }, + { + relativePath: 'scripts/ripgrep_launcher.cjs', + sourcePath: fileURLToPath(new URL('../../scripts/ripgrep_launcher.cjs', import.meta.url)) + }, + { + relativePath: 'scripts/unpack-tools.cjs', + sourcePath: fileURLToPath(new URL('../../scripts/unpack-tools.cjs', import.meta.url)) + }, + { + relativePath: 'bin/happy.mjs', + sourcePath: fileURLToPath(new URL('../../bin/happy.mjs', import.meta.url)) + }, + { + relativePath: 'bin/happy-mcp.mjs', + sourcePath: fileURLToPath(new URL('../../bin/happy-mcp.mjs', import.meta.url)) + }, + { + relativePath: 'tools/archives/difftastic-LICENSE', + sourcePath: fileURLToPath(new URL('../../tools/archives/difftastic-LICENSE', import.meta.url)) + }, + { + relativePath: 'tools/archives/ripgrep-LICENSE', + sourcePath: fileURLToPath(new URL('../../tools/archives/ripgrep-LICENSE', import.meta.url)) + }, + { + relativePath: 'tools/archives/difftastic-arm64-darwin.tar.gz', + sourcePath: fileURLToPath(new URL('../../tools/archives/difftastic-arm64-darwin.tar.gz', import.meta.url)) + }, + { + relativePath: 'tools/archives/difftastic-arm64-linux.tar.gz', + sourcePath: fileURLToPath(new URL('../../tools/archives/difftastic-arm64-linux.tar.gz', import.meta.url)) + }, + { + relativePath: 'tools/archives/difftastic-x64-darwin.tar.gz', + sourcePath: fileURLToPath(new URL('../../tools/archives/difftastic-x64-darwin.tar.gz', import.meta.url)) + }, + { + relativePath: 'tools/archives/difftastic-x64-linux.tar.gz', + sourcePath: fileURLToPath(new URL('../../tools/archives/difftastic-x64-linux.tar.gz', import.meta.url)) + }, + { + relativePath: 'tools/archives/difftastic-x64-win32.tar.gz', + sourcePath: fileURLToPath(new URL('../../tools/archives/difftastic-x64-win32.tar.gz', import.meta.url)) + }, + { + relativePath: 'tools/archives/ripgrep-arm64-darwin.tar.gz', + sourcePath: fileURLToPath(new URL('../../tools/archives/ripgrep-arm64-darwin.tar.gz', import.meta.url)) + }, + { + relativePath: 'tools/archives/ripgrep-arm64-linux.tar.gz', + sourcePath: fileURLToPath(new URL('../../tools/archives/ripgrep-arm64-linux.tar.gz', import.meta.url)) + }, + { + relativePath: 'tools/archives/ripgrep-x64-darwin.tar.gz', + sourcePath: fileURLToPath(new URL('../../tools/archives/ripgrep-x64-darwin.tar.gz', import.meta.url)) + }, + { + relativePath: 'tools/archives/ripgrep-x64-linux.tar.gz', + sourcePath: fileURLToPath(new URL('../../tools/archives/ripgrep-x64-linux.tar.gz', import.meta.url)) + }, + { + relativePath: 'tools/archives/ripgrep-x64-win32.tar.gz', + sourcePath: fileURLToPath(new URL('../../tools/archives/ripgrep-x64-win32.tar.gz', import.meta.url)) + }, + { + relativePath: 'tools/licenses/difftastic-LICENSE', + sourcePath: fileURLToPath(new URL('../../tools/licenses/difftastic-LICENSE', import.meta.url)) + }, + { + relativePath: 'tools/licenses/ripgrep-LICENSE', + sourcePath: fileURLToPath(new URL('../../tools/licenses/ripgrep-LICENSE', import.meta.url)) + } +];