mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Diffta
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import { join, resolve } from 'path';
|
||||
import { projectPath } from '@/projectPath';
|
||||
import { runtimePath } from '@/projectPath';
|
||||
|
||||
export interface RipgrepResult {
|
||||
exitCode: number
|
||||
@@ -17,7 +17,7 @@ export interface RipgrepOptions {
|
||||
}
|
||||
|
||||
export function run(args: string[], options?: RipgrepOptions): Promise<RipgrepResult> {
|
||||
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'],
|
||||
|
||||
+20
-6
@@ -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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
];
|
||||
Reference in New Issue
Block a user