From 17eeba10d65503f58212dad30a307d4a8c174fc8 Mon Sep 17 00:00:00 2001 From: weishu Date: Sat, 20 Dec 2025 21:34:16 +0800 Subject: [PATCH] feat(cli): add Bun single executable binary support Enables building hapi as standalone Bun-compiled executables for macOS, Linux, and Windows (x64/arm64). Adds build script, bootstrap entry point, runtime asset management, and automatic deployment of bundled tools (ripgrep, difftastic). Includes MCP stdio bridge support and proper environment handling for compiled binaries. Updates documentation with build and installation instructions for single executable distribution. --- .gitignore | 1 + AGENTS.md | 2 +- README.md | 4 +- bun.lock | 8 +- cli/README.md | 28 +++- cli/bin/happy-mcp.mjs | 10 +- cli/package.json | 4 +- cli/scripts/build-executable.ts | 197 +++++++++++++++++++++++++++ cli/src/bootstrap.ts | 4 + cli/src/claude/claudeLocal.ts | 5 +- cli/src/claude/claudeRemote.ts | 2 +- cli/src/claude/sdk/query.ts | 10 +- cli/src/codex/happyMcpStdioBridge.ts | 144 ++++++++++---------- cli/src/codex/runCodex.ts | 7 +- cli/src/daemon/doctor.ts | 4 +- cli/src/daemon/run.ts | 7 +- cli/src/index.ts | 56 ++++++-- cli/src/modules/ripgrep/index.ts | 4 +- cli/src/projectPath.ts | 4 +- cli/src/runtime/assets.ts | 45 +++--- cli/src/runtime/embeddedAssets.ts | 186 +++++++++++++------------ cli/src/types/assetImports.d.ts | 14 ++ cli/src/types/bunBundle.d.ts | 11 ++ cli/src/ui/doctor.ts | 17 ++- cli/src/utils/bunRuntime.ts | 14 ++ cli/src/utils/spawnHappyCLI.ts | 50 +++++-- cli/tsconfig.json | 3 +- package.json | 2 + 28 files changed, 605 insertions(+), 238 deletions(-) create mode 100644 cli/scripts/build-executable.ts create mode 100644 cli/src/bootstrap.ts create mode 100644 cli/src/types/assetImports.d.ts create mode 100644 cli/src/types/bunBundle.d.ts create mode 100644 cli/src/utils/bunRuntime.ts diff --git a/.gitignore b/.gitignore index ceee91c8..88ad0d48 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ # Build output **/dist/ +**/dist-exe/ **/*.tsbuildinfo # Env files (can contain secrets) diff --git a/AGENTS.md b/AGENTS.md index 7b50b476..44af2544 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ Bun workspaces monorepo with three packages: | Package | Path | Purpose | |---------|------|---------| -| `hapi` | `cli/` | CLI tool (npm package) - `hapi` / `hapi-mcp` commands | +| `hapi` | `cli/` | CLI tool (single executable) - `hapi` / `hapi mcp` | | `hapi-server` | `server/` | API server + Telegram bot (serves `web/dist/`) | | `hapi-web` | `web/` | Web frontend / Mini App / PWA (Vite → `dist/`) | diff --git a/README.md b/README.md index ff8dcd22..682e7b1a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Monorepo with three Bun workspace packages: -- `cli/` (`hapi`): CLI (npm package) exposing `hapi` / `hapi-mcp` +- `cli/` (`hapi`): CLI (single executable) exposing `hapi` / `hapi mcp` - `server/` (`hapi-server`): backend API + Telegram bot server, serves `web/dist/` - `web/` (`hapi-web`): web frontend (Vite) building to `web/dist/` @@ -14,4 +14,6 @@ Run from the repo root: bun install bun run typecheck bun run build +bun run build:cli:exe +bun run build:cli:exe -- --target bun-darwin-x64 ``` diff --git a/bun.lock b/bun.lock index b67ccd06..b468e6c7 100644 --- a/bun.lock +++ b/bun.lock @@ -31,9 +31,9 @@ "ink": "^6.5.1", "ps-list": "^8.1.1", "react": "^19.2.0", + "react-devtools-core": "^7.0.1", "socket.io-client": "^4.8.1", "tar": "^7.5.2", - "tmp": "^0.2.5", "zod": "^3.23.8", }, "devDependencies": { @@ -1744,6 +1744,8 @@ "react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], + "react-devtools-core": ["react-devtools-core@7.0.1", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw=="], + "react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="], "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], @@ -2012,8 +2014,6 @@ "tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="], - "tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="], - "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "toad-cache": ["toad-cache@3.7.0", "", {}, "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw=="], @@ -2388,6 +2388,8 @@ "proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "react-devtools-core/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], + "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "rollup-pluginutils/estree-walker": ["estree-walker@0.6.1", "", {}, "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w=="], diff --git a/cli/README.md b/cli/README.md index 867887bd..f7fc4492 100644 --- a/cli/README.md +++ b/cli/README.md @@ -7,7 +7,8 @@ Free. Open source. Code anywhere. ## Installation ```bash -npm install -g hapi +# Download the prebuilt hapi binary for your platform +# (macOS/Linux/Windows x64/arm64) and place it on your PATH. ``` ## Usage @@ -25,6 +26,7 @@ This will: - `hapi auth` – Manage authentication - `hapi codex` – Start Codex mode +- `hapi mcp` – Start MCP stdio bridge - `hapi connect` – Not available in direct-connect mode - `hapi notify` – Not available in direct-connect mode - `hapi daemon` – Manage background service @@ -43,16 +45,30 @@ This will: - `HAPPY_BOT_URL` - Bot URL (default: http://localhost:3006) - `CLI_API_TOKEN` - Shared secret for bot authentication (required) -- `HAPPY_HOME_DIR` - Custom home directory for hapi data (default: ~/.happy) +- `HAPI_HOME_DIR` - Custom home directory for hapi data (default: ~/.config/hapi) - `HAPPY_EXPERIMENTAL` - Enable experimental features (set to `true`, `1`, or `yes`) ## Requirements -- Node.js >= 20.0.0 - - Required by `eventsource-parser@3.0.5`, which is required by - `@modelcontextprotocol/sdk`, which we used to implement permission forwarding - to mobile app +- Prebuilt hapi binary (no Bun or Node required at runtime) - Claude CLI installed & logged in (`claude` command available in PATH) +- Bun (for building from source) + +## Building the executable + +```bash +# From repo root +bun run build:cli:exe +bun run build:cli:exe -- --target bun-darwin-x64 + +# Platform-only target uses the host arch +bun run build:cli:exe -- --target bun-linux + +# Build all targets +bun run build:cli:exe:all +``` + +Note: Windows arm64 builds require arm64 tool archives in `cli/tools/archives`. ## License diff --git a/cli/bin/happy-mcp.mjs b/cli/bin/happy-mcp.mjs index 6a903ed3..c56f62ba 100755 --- a/cli/bin/happy-mcp.mjs +++ b/cli/bin/happy-mcp.mjs @@ -2,15 +2,14 @@ import { execFileSync } from 'child_process'; import { fileURLToPath } from 'url'; -import { join, dirname } from 'path'; +import { runHappyMcpStdioBridge } from '../dist/codex/happyMcpStdioBridge.mjs'; // Ensure Node flags to reduce noisy warnings on stdout (which could interfere with MCP) const hasNoWarnings = process.execArgv.includes('--no-warnings'); const hasNoDeprecation = process.execArgv.includes('--no-deprecation'); if (!hasNoWarnings || !hasNoDeprecation) { - const projectRoot = dirname(dirname(fileURLToPath(import.meta.url))); - const entrypoint = join(projectRoot, 'dist', 'codex', 'happyMcpStdioBridge.mjs'); + const entrypoint = fileURLToPath(import.meta.url); try { execFileSync(process.execPath, [ @@ -26,7 +25,6 @@ if (!hasNoWarnings || !hasNoDeprecation) { process.exit(error.status || 1); } } else { - // Already have desired flags; import module directly - import('../dist/codex/happyMcpStdioBridge.mjs'); + // Already have desired flags; run bridge directly + await runHappyMcpStdioBridge(process.argv.slice(2)); } - diff --git a/cli/package.json b/cli/package.json index 588c404a..7bb6f6d2 100644 --- a/cli/package.json +++ b/cli/package.json @@ -62,6 +62,8 @@ "why do we need to build before running tests / dev?": "We need the binary to be built so we run daemon commands which directly run the binary - we don't want them to go out of sync or have custom spawn logic depending how we started happy", "typecheck": "tsc --noEmit", "build": "shx rm -rf dist && tsc --noEmit && pkgroll", + "build:exe": "bun run scripts/build-executable.ts", + "build:exe:all": "bun run scripts/build-executable.ts --all", "test": "bun run build && tsx --env-file .env.integration-test node_modules/.bin/vitest run", "test:win": "bun run build && vitest run", "start": "bun run build && ./bin/happy.mjs", @@ -87,9 +89,9 @@ "ink": "^6.5.1", "ps-list": "^8.1.1", "react": "^19.2.0", + "react-devtools-core": "^7.0.1", "socket.io-client": "^4.8.1", "tar": "^7.5.2", - "tmp": "^0.2.5", "zod": "^3.23.8" }, "devDependencies": { diff --git a/cli/scripts/build-executable.ts b/cli/scripts/build-executable.ts new file mode 100644 index 00000000..7754afe5 --- /dev/null +++ b/cli/scripts/build-executable.ts @@ -0,0 +1,197 @@ +import { dirname, isAbsolute, join } from 'node:path'; +import { existsSync, mkdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const DEFAULT_TARGETS = [ + 'bun-darwin-x64', + 'bun-darwin-arm64', + 'bun-linux-x64', + 'bun-linux-arm64', + 'bun-windows-x64', + 'bun-windows-arm64' +]; +const SUPPORTED_PLATFORMS = new Set(['darwin', 'linux', 'windows']); +const SUPPORTED_ARCHES = new Set(['x64', 'arm64']); + +function getArg(args: string[], name: string): string | undefined { + const idx = args.indexOf(name); + if (idx === -1 || idx + 1 >= args.length) { + return undefined; + } + return args[idx + 1]; +} + +function resolveHostPlatform(): string { + if (process.platform === 'win32') { + return 'windows'; + } + if (process.platform === 'darwin' || process.platform === 'linux') { + return process.platform; + } + throw new Error(`Unsupported host platform: ${process.platform}`); +} + +function resolveHostArch(): string { + if (SUPPORTED_ARCHES.has(process.arch)) { + return process.arch; + } + throw new Error(`Unsupported host arch: ${process.arch}`); +} + +function resolveTarget(target?: string): string { + if (!target) { + return `bun-${resolveHostPlatform()}-${resolveHostArch()}`; + } + + const parts = target.split('-'); + if (parts.length < 2 || parts.length > 3 || parts[0] !== 'bun') { + throw new Error(`Invalid target: ${target}`); + } + + const platformPart = parts[1]; + if (!SUPPORTED_PLATFORMS.has(platformPart)) { + throw new Error(`Unsupported platform in target: ${target}`); + } + + const archPart = parts[2] ?? resolveHostArch(); + if (!SUPPORTED_ARCHES.has(archPart)) { + throw new Error(`Unsupported arch in target: ${target}`); + } + + return `bun-${platformPart}-${archPart}`; +} + +function parseTarget(target: string): { platform: string; arch: string } { + const parts = target.split('-'); + if (parts.length !== 3 || parts[0] !== 'bun') { + throw new Error(`Invalid target: ${target}`); + } + + const platformPart = parts[1]; + const archPart = parts[2]; + + if (!SUPPORTED_PLATFORMS.has(platformPart)) { + throw new Error(`Unsupported platform in target: ${target}`); + } + + if (!SUPPORTED_ARCHES.has(archPart)) { + throw new Error(`Unsupported arch in target: ${target}`); + } + + return { + platform: platformPart === 'windows' ? 'win32' : platformPart, + arch: archPart + }; +} + +function getFeatureFlag(platform: string, arch: string): string { + const platformToken = platform === 'win32' ? 'WIN32' : platform.toUpperCase(); + return `HAPI_TARGET_${platformToken}_${arch.toUpperCase()}`; +} + +function getPlatformDir(platform: string, arch: string): string { + if (platform === 'darwin') { + return arch === 'arm64' ? 'arm64-darwin' : 'x64-darwin'; + } + + if (platform === 'linux') { + return arch === 'arm64' ? 'arm64-linux' : 'x64-linux'; + } + + if (platform === 'win32') { + if (arch !== 'x64') { + throw new Error('Windows arm64 archives are missing; add arm64 tool archives before building.'); + } + return 'x64-win32'; + } + + throw new Error(`Unsupported platform: ${platform}`); +} + +function assertArchivesExist(projectRoot: string, platform: string, arch: string): void { + const platformDir = getPlatformDir(platform, arch); + const archives = [ + join(projectRoot, 'tools', 'archives', `difftastic-${platformDir}.tar.gz`), + join(projectRoot, 'tools', 'archives', `ripgrep-${platformDir}.tar.gz`) + ]; + + for (const archive of archives) { + if (!existsSync(archive)) { + throw new Error(`Missing archive: ${archive}`); + } + } +} + +function resolveOutdir(projectRoot: string, outdir: string): string { + if (isAbsolute(outdir)) { + return outdir; + } + return join(projectRoot, outdir); +} + +async function buildTarget(projectRoot: string, target: string, outdir: string, name: string): Promise { + const { platform, arch } = parseTarget(target); + assertArchivesExist(projectRoot, platform, arch); + const outputName = platform === 'win32' ? `${name}.exe` : name; + const outfile = join(outdir, target, outputName); + mkdirSync(dirname(outfile), { recursive: true }); + const featureFlag = getFeatureFlag(platform, arch); + + const cmd = [ + process.execPath, + 'build', + '--compile', + `--feature=${featureFlag}`, + `--target=${target}`, + `--outfile=${outfile}`, + join(projectRoot, 'src', 'bootstrap.ts') + ]; + + console.log(`[build:exe] ${cmd.join(' ')}`); + + const proc = Bun.spawn({ + cmd, + env: process.env, + stdout: 'inherit', + stderr: 'inherit', + cwd: projectRoot + }); + + const exitCode = await proc.exited; + if (exitCode !== 0) { + throw new Error(`Build failed for target ${target} (exit ${exitCode})`); + } +} + +async function main(): Promise { + const args = process.argv.slice(2); + const target = getArg(args, '--target'); + const outdirArg = getArg(args, '--outdir') ?? 'dist-exe'; + const name = getArg(args, '--name') ?? 'hapi'; + const buildAll = args.includes('--all'); + + if (args.includes('--target') && !target) { + console.error('Usage: bun run scripts/build-executable.ts [--target ] [--outdir dist-exe] [--name hapi]'); + console.error(' or: bun run scripts/build-executable.ts --all [--outdir dist-exe] [--name hapi]'); + process.exit(1); + } + + if (!buildAll && !target) { + console.log('[build:exe] No --target provided; defaulting to host platform + arch.'); + } + + const scriptDir = dirname(fileURLToPath(import.meta.url)); + const projectRoot = join(scriptDir, '..'); + const outdir = resolveOutdir(projectRoot, outdirArg); + const resolvedTarget = buildAll ? undefined : resolveTarget(target); + const targets = buildAll ? DEFAULT_TARGETS : [resolvedTarget!]; + + for (const targetName of targets) { + await buildTarget(projectRoot, targetName, outdir, name); + } +} + +main().catch((error) => { + console.error(`[build:exe] ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +}); diff --git a/cli/src/bootstrap.ts b/cli/src/bootstrap.ts new file mode 100644 index 00000000..411ff21f --- /dev/null +++ b/cli/src/bootstrap.ts @@ -0,0 +1,4 @@ +// Disable Ink devtools in compiled binaries to avoid optional dependencies. +process.env.DEV = 'false'; + +await import('./index'); diff --git a/cli/src/claude/claudeLocal.ts b/cli/src/claude/claudeLocal.ts index 395b25de..3823c421 100644 --- a/cli/src/claude/claudeLocal.ts +++ b/cli/src/claude/claudeLocal.ts @@ -8,6 +8,7 @@ import { claudeCheckSession } from "./utils/claudeCheckSession"; import { getProjectPath } from "./utils/path"; import { runtimePath } from "@/projectPath"; import { systemPrompt } from "./utils/systemPrompt"; +import { withBunRuntimeEnv } from "@/utils/bunRuntime"; // Get Claude CLI path from project root @@ -108,11 +109,11 @@ export async function claudeLocal(opts: { logger.debug(`[ClaudeLocal] Spawning launcher: ${claudeCliPath}`); logger.debug(`[ClaudeLocal] Args: ${JSON.stringify(args)}`); - const child = spawn('node', [claudeCliPath, ...args], { + const child = spawn(process.execPath, [claudeCliPath, ...args], { stdio: ['inherit', 'inherit', 'inherit', 'pipe'], signal: opts.abort, cwd: opts.path, - env, + env: withBunRuntimeEnv(env), }); // Listen to the custom fd (fd 3) for thinking state tracking diff --git a/cli/src/claude/claudeRemote.ts b/cli/src/claude/claudeRemote.ts index 9b3b4f58..e23cd878 100644 --- a/cli/src/claude/claudeRemote.ts +++ b/cli/src/claude/claudeRemote.ts @@ -119,7 +119,7 @@ export async function claudeRemote(opts: { allowedTools: initial.mode.allowedTools ? initial.mode.allowedTools.concat(opts.allowedTools) : opts.allowedTools, disallowedTools: initial.mode.disallowedTools, canCallTool: (toolName: string, input: unknown, options: { signal: AbortSignal }) => opts.canCallTool(toolName, input, mode, options), - executable: 'node', + executable: process.execPath, abort: opts.signal, pathToClaudeCodeExecutable: (() => { return resolve(join(runtimePath(), 'scripts', 'claude_remote_launcher.cjs')); diff --git a/cli/src/claude/sdk/query.ts b/cli/src/claude/sdk/query.ts index 9848f9bb..8ab9c28c 100644 --- a/cli/src/claude/sdk/query.ts +++ b/cli/src/claude/sdk/query.ts @@ -23,6 +23,7 @@ import { AbortError } from './types' import { getDefaultClaudeCodePath, getCleanEnv, logDebug, streamToStdin } from './utils' +import { withBunRuntimeEnv } from '@/utils/bunRuntime' import type { Writable } from 'node:stream' import { logger } from '@/ui/logger' @@ -262,7 +263,7 @@ export function query(config: { customSystemPrompt, cwd, disallowedTools = [], - executable = 'node', + executable = process.execPath, executableArgs = [], maxTurns, mcpServers, @@ -320,7 +321,7 @@ export function query(config: { } // Determine how to spawn Claude Code - // - If it's a .js/.cjs file → spawn('node', [path, ...args]) + // - If it's a .js/.cjs file → spawn(current runtime, [path, ...args]) // - If it's just 'claude' command → spawn('claude', args) with shell on Windows // - If it's a full path to binary → spawn(path, args) const isJsFile = pathToClaudeCodeExecutable.endsWith('.js') || pathToClaudeCodeExecutable.endsWith('.cjs') @@ -338,7 +339,8 @@ export function query(config: { // Spawn Claude Code process // Use clean env for global claude to avoid local node_modules/.bin taking precedence - const spawnEnv = isCommandOnly ? getCleanEnv() : process.env + const baseEnv = isCommandOnly ? getCleanEnv() : process.env + const spawnEnv = withBunRuntimeEnv(baseEnv) logDebug(`Spawning Claude Code process: ${spawnCommand} ${spawnArgs.join(' ')} (using ${isCommandOnly ? 'clean' : 'normal'} env)`) const child = spawn(spawnCommand, spawnArgs, { @@ -412,4 +414,4 @@ export function query(config: { }) return query -} \ No newline at end of file +} diff --git a/cli/src/codex/happyMcpStdioBridge.ts b/cli/src/codex/happyMcpStdioBridge.ts index d8e3a5d4..62796305 100644 --- a/cli/src/codex/happyMcpStdioBridge.ts +++ b/cli/src/codex/happyMcpStdioBridge.ts @@ -29,78 +29,76 @@ function parseArgs(argv: string[]): { url: string | null } { return { url }; } -async function main() { - // Resolve target HTTP MCP URL - const { url: urlFromArgs } = parseArgs(process.argv.slice(2)); - const baseUrl = urlFromArgs || process.env.HAPPY_HTTP_MCP_URL || ''; - - if (!baseUrl) { - // Write to stderr; never stdout. - process.stderr.write( - '[happy-mcp] Missing target URL. Set HAPPY_HTTP_MCP_URL or pass --url \n' - ); - process.exit(2); - } - - let httpClient: Client | null = null; - - async function ensureHttpClient(): Promise { - if (httpClient) return httpClient; - const client = new Client( - { name: 'happy-stdio-bridge', version: '1.0.0' }, - { capabilities: {} } - ); - - const transport = new StreamableHTTPClientTransport(new URL(baseUrl)); - await client.connect(transport); - httpClient = client; - return client; - } - - // Create STDIO MCP server - const server = new McpServer({ - name: 'Happy MCP Bridge', - version: '1.0.0', - }); - - // Register the single tool and forward to HTTP MCP - server.registerTool( - 'change_title', - { - description: 'Change the title of the current chat session', - title: 'Change Chat Title', - inputSchema: { - title: z.string().describe('The new title for the chat session'), - }, - }, - async (args) => { - try { - const client = await ensureHttpClient(); - const response = await client.callTool({ name: 'change_title', arguments: args }); - // Pass-through response from HTTP server - return response as any; - } catch (error) { - return { - content: [ - { type: 'text', text: `Failed to change chat title: ${error instanceof Error ? error.message : String(error)}` }, - ], - isError: true, - }; - } - } - ); - - // Start STDIO transport - const stdio = new StdioServerTransport(); - await server.connect(stdio); -} - -// Start and surface fatal errors to stderr only -main().catch((err) => { +export async function runHappyMcpStdioBridge(argv: string[]): Promise { try { - process.stderr.write(`[happy-mcp] Fatal: ${err instanceof Error ? err.message : String(err)}\n`); - } finally { - process.exit(1); - } -}); + // Resolve target HTTP MCP URL + const { url: urlFromArgs } = parseArgs(argv); + const baseUrl = urlFromArgs || process.env.HAPPY_HTTP_MCP_URL || ''; + if (!baseUrl) { + // Write to stderr; never stdout. + process.stderr.write( + '[happy-mcp] Missing target URL. Set HAPPY_HTTP_MCP_URL or pass --url \n' + ); + process.exit(2); + } + + let httpClient: Client | null = null; + + async function ensureHttpClient(): Promise { + if (httpClient) return httpClient; + const client = new Client( + { name: 'happy-stdio-bridge', version: '1.0.0' }, + { capabilities: {} } + ); + + const transport = new StreamableHTTPClientTransport(new URL(baseUrl)); + await client.connect(transport); + httpClient = client; + return client; + } + + // Create STDIO MCP server + const server = new McpServer({ + name: 'Happy MCP Bridge', + version: '1.0.0', + }); + + // Register the single tool and forward to HTTP MCP + server.registerTool( + 'change_title', + { + description: 'Change the title of the current chat session', + title: 'Change Chat Title', + inputSchema: { + title: z.string().describe('The new title for the chat session'), + }, + }, + async (args) => { + try { + const client = await ensureHttpClient(); + const response = await client.callTool({ name: 'change_title', arguments: args }); + // Pass-through response from HTTP server + return response as any; + } catch (error) { + return { + content: [ + { type: 'text', text: `Failed to change chat title: ${error instanceof Error ? error.message : String(error)}` }, + ], + isError: true, + }; + } + } + ); + + // Start STDIO transport + const stdio = new StdioServerTransport(); + await server.connect(stdio); + } catch (err) { + try { + process.stderr.write(`[happy-mcp] Fatal: ${err instanceof Error ? err.message : String(err)}\n`); + } finally { + process.exit(1); + } + } +} diff --git a/cli/src/codex/runCodex.ts b/cli/src/codex/runCodex.ts index ded19987..5ce225f0 100644 --- a/cli/src/codex/runCodex.ts +++ b/cli/src/codex/runCodex.ts @@ -16,6 +16,7 @@ import os from 'node:os'; import { MessageQueue2 } from '@/utils/MessageQueue2'; import { hashObject } from '@/utils/deterministicJson'; import { runtimePath } from '@/projectPath'; +import { getHappyCliCommand } from '@/utils/spawnHappyCLI'; import { resolve, join } from 'node:path'; import fs from 'node:fs'; import { startHappyServer } from '@/claude/utils/startHappyServer'; @@ -521,11 +522,11 @@ 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(runtimePath(), 'bin', 'happy-mcp.mjs'); + const bridgeCommand = getHappyCliCommand(['mcp', '--url', happyServer.url]); const mcpServers = { happy: { - command: bridgeCommand, - args: ['--url', happyServer.url] + command: bridgeCommand.command, + args: bridgeCommand.args } } as const; let first = true; diff --git a/cli/src/daemon/doctor.ts b/cli/src/daemon/doctor.ts index 177db5a4..bbac388c 100644 --- a/cli/src/daemon/doctor.ts +++ b/cli/src/daemon/doctor.ts @@ -21,10 +21,12 @@ export async function findAllHappyProcesses(): Promise { if (options.agent === 'codex') { // Create a temporary directory for Codex - const codexHomeDir = tmp.dirSync(); + const codexHomeDir = await fs.mkdtemp(join(os.tmpdir(), 'hapi-codex-')); // Write the token to the temporary directory - fs.writeFile(join(codexHomeDir.name, 'auth.json'), options.token); + await fs.writeFile(join(codexHomeDir, 'auth.json'), options.token); // Set the environment variable for Codex extraEnv = { - CODEX_HOME: codexHomeDir.name + CODEX_HOME: codexHomeDir }; } else { // Assuming claude extraEnv = { diff --git a/cli/src/index.ts b/cli/src/index.ts index 6690a090..7591ee69 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -8,10 +8,11 @@ import chalk from 'chalk' -import { runClaude, StartOptions } from '@/claude/runClaude' +import type { StartOptions } from '@/claude/runClaude' import { logger } from './ui/logger' import { authAndSetupMachineIfNeeded } from './ui/auth' import packageJson from '../package.json' +import { isBunCompiled } from './projectPath' import { z } from 'zod' import { startDaemon } from './daemon/run' import { checkIfDaemonRunningAndCleanupStaleState, isDaemonRunningCurrentlyInstalledHappyVersion, stopDaemon } from './daemon/controlClient' @@ -28,20 +29,49 @@ import { claudeCliPath } from './claude/claudeLocal' import { execFileSync } from 'node:child_process' import { initializeToken } from './ui/tokenInit' import { ensureRuntimeAssets } from './runtime/assets' +import { runHappyMcpStdioBridge } from './codex/happyMcpStdioBridge' +import { withBunRuntimeEnv } from './utils/bunRuntime' (async () => { - ensureRuntimeAssets() - const args = process.argv.slice(2) + const args = (() => { + if (!isBunCompiled()) { + return process.argv.slice(2) + } + + const arg1 = process.argv[1] || '' + if (arg1 === process.execPath) { + return process.argv.slice(2) + } + if (arg1.includes('$bunfs')) { + return process.argv.slice(2) + } + if (arg1.endsWith('.js') || arg1.endsWith('.mjs') || arg1.endsWith('.ts')) { + return process.argv.slice(2) + } + + return process.argv.slice(1) + })() + + // Check if first argument is a subcommand + const subcommand = args[0] + + if (isBunCompiled()) { + process.env.DEV = 'false' + } + + if (subcommand === 'mcp') { + await runHappyMcpStdioBridge(args.slice(1)) + return + } + + await ensureRuntimeAssets() // If --version is passed - do not log, its likely daemon inquiring about our version if (!args.includes('--version')) { logger.debug('Starting hapi CLI with args: ', process.argv) } - // Check if first argument is a subcommand - const subcommand = args[0] - if (subcommand === 'doctor') { // Check for clean subcommand if (args[1] === 'clean') { @@ -290,6 +320,7 @@ ${chalk.bold('Usage:')} hapi [options] Start Claude with Telegram control (direct-connect) hapi auth Manage authentication hapi codex Start Codex mode + hapi mcp Start MCP stdio bridge hapi connect (not available in direct-connect mode) hapi notify (not available in direct-connect mode) hapi daemon Manage background service that allows @@ -316,7 +347,11 @@ ${chalk.bold.cyan('Claude Code Options (from `claude --help`):')} // Run claude --help and display its output // Use execFileSync with the current Node executable for cross-platform compatibility try { - const claudeHelp = execFileSync(process.execPath, [claudeCliPath, '--help'], { encoding: 'utf8' }) + const claudeHelp = execFileSync( + process.execPath, + [claudeCliPath, '--help'], + { encoding: 'utf8', env: withBunRuntimeEnv() } + ) console.log(claudeHelp) } catch (e) { console.log(chalk.yellow('Could not retrieve claude help. Make sure claude is installed.')) @@ -328,7 +363,11 @@ ${chalk.bold.cyan('Claude Code Options (from `claude --help`):')} // Show version if (showVersion) { console.log(`hapi version: ${packageJson.version}`) - // Don't exit - continue to pass --version to Claude Code + const versionOnly = args.every((value) => value === '-v' || value === '--version') + if (versionOnly) { + process.exit(0) + } + // Continue to pass --version to Claude Code when other args are present. } // Normal flow - auth and machine setup @@ -355,6 +394,7 @@ ${chalk.bold.cyan('Claude Code Options (from `claude --help`):')} // Start the CLI try { + const { runClaude } = await import('@/claude/runClaude'); await runClaude(options); } catch (error) { console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error') diff --git a/cli/src/modules/ripgrep/index.ts b/cli/src/modules/ripgrep/index.ts index 750533c4..c3bc816e 100644 --- a/cli/src/modules/ripgrep/index.ts +++ b/cli/src/modules/ripgrep/index.ts @@ -5,6 +5,7 @@ import { spawn } from 'child_process'; import { join, resolve } from 'path'; import { runtimePath } from '@/projectPath'; +import { withBunRuntimeEnv } from '@/utils/bunRuntime'; export interface RipgrepResult { exitCode: number @@ -21,7 +22,8 @@ export function run(args: string[], options?: RipgrepOptions): Promise { const child = spawn(process.execPath, [runnerPath, JSON.stringify(args)], { stdio: ['pipe', 'pipe', 'pipe'], - cwd: options?.cwd + cwd: options?.cwd, + env: withBunRuntimeEnv() }); let stdout = ''; diff --git a/cli/src/projectPath.ts b/cli/src/projectPath.ts index 20113140..14f4100d 100644 --- a/cli/src/projectPath.ts +++ b/cli/src/projectPath.ts @@ -5,7 +5,9 @@ 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); +const argv1 = process.argv[1] ?? ''; +const bunFsMarker = argv1.includes('$bunfs'); +const isCompiled = Boolean(bunRuntime?.isCompiled) || bunFsMarker; export function projectPath(): string { return resolve(__dirname, '..'); diff --git a/cli/src/runtime/assets.ts b/cli/src/runtime/assets.ts index 04f23ceb..9b13ff56 100644 --- a/cli/src/runtime/assets.ts +++ b/cli/src/runtime/assets.ts @@ -3,7 +3,7 @@ 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 type { EmbeddedAsset } from './embeddedAssets'; import { isBunCompiled, runtimePath } from '@/projectPath'; const RUNTIME_MARKER = '.runtime-version'; @@ -12,11 +12,21 @@ function ensureDirectory(path: string): void { mkdirSync(path, { recursive: true }); } -function copyAssetFile(sourcePath: string, targetPath: string): void { +const bunRuntime = (globalThis as typeof globalThis & { + Bun?: { file: (source: string | URL) => { arrayBuffer: () => Promise } }; +}).Bun; + +async function copyAssetFile(asset: EmbeddedAsset, targetPath: string): Promise { ensureDirectory(dirname(targetPath)); - copyFileSync(sourcePath, targetPath); + if (bunRuntime) { + const data = await bunRuntime.file(asset.sourcePath).arrayBuffer(); + writeFileSync(targetPath, Buffer.from(data)); + return; + } + + copyFileSync(asset.sourcePath, targetPath); try { - const stats = statSync(sourcePath); + const stats = statSync(asset.sourcePath); chmodSync(targetPath, stats.mode); } catch { // Best-effort; permission adjustments are not critical. @@ -105,21 +115,25 @@ function unpackTools(runtimeRoot: string): void { } 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')) - ); + const requiredScripts = [ + join(runtimeRoot, 'scripts', 'claude_local_launcher.cjs'), + join(runtimeRoot, 'scripts', 'claude_remote_launcher.cjs'), + join(runtimeRoot, 'scripts', 'claude_version_utils.cjs'), + join(runtimeRoot, 'scripts', 'ripgrep_launcher.cjs') + ]; + + return requiredScripts.every((script) => existsSync(script)) && + areToolsUnpacked(join(runtimeRoot, 'tools', 'unpacked')); } -export function ensureRuntimeAssets(): void { +export async function ensureRuntimeAssets(): Promise { if (!isBunCompiled()) { return; } + const { loadEmbeddedAssets } = await import('./embeddedAssets'); 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)) { @@ -129,12 +143,11 @@ export function ensureRuntimeAssets(): void { ensureDirectory(runtimeRoot); - for (const asset of EMBEDDED_ASSETS) { - if (!existsSync(asset.sourcePath)) { - throw new Error(`Embedded asset missing: ${asset.sourcePath}`); - } + const embeddedAssets = await loadEmbeddedAssets(); + + for (const asset of embeddedAssets) { const targetPath = join(runtimeRoot, asset.relativePath); - copyAssetFile(asset.sourcePath, targetPath); + await copyAssetFile(asset, targetPath); } unpackTools(runtimeRoot); diff --git a/cli/src/runtime/embeddedAssets.ts b/cli/src/runtime/embeddedAssets.ts index aeb0d5ae..97f3fb43 100644 --- a/cli/src/runtime/embeddedAssets.ts +++ b/cli/src/runtime/embeddedAssets.ts @@ -1,93 +1,107 @@ -import { fileURLToPath } from 'node:url'; +import { feature } from 'bun:bundle'; + +import claudeLocalLauncher from '../../scripts/claude_local_launcher.cjs' assert { type: 'file' }; +import claudeRemoteLauncher from '../../scripts/claude_remote_launcher.cjs' assert { type: 'file' }; +import claudeVersionUtils from '../../scripts/claude_version_utils.cjs' assert { type: 'file' }; +import ripgrepLauncher from '../../scripts/ripgrep_launcher.cjs' assert { type: 'file' }; + +import difftasticArchiveLicense from '../../tools/archives/difftastic-LICENSE' assert { type: 'file' }; +import ripgrepArchiveLicense from '../../tools/archives/ripgrep-LICENSE' assert { type: 'file' }; +import difftasticLicense from '../../tools/licenses/difftastic-LICENSE' assert { type: 'file' }; +import ripgrepLicense from '../../tools/licenses/ripgrep-LICENSE' assert { type: 'file' }; + 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)) - } +function asset(relativePath: string, sourcePath: string): EmbeddedAsset { + return { + relativePath, + sourcePath + }; +} + +const COMMON_ASSETS: EmbeddedAsset[] = [ + asset('scripts/claude_local_launcher.cjs', claudeLocalLauncher), + asset('scripts/claude_remote_launcher.cjs', claudeRemoteLauncher), + asset('scripts/claude_version_utils.cjs', claudeVersionUtils), + asset('scripts/ripgrep_launcher.cjs', ripgrepLauncher), + asset('tools/archives/difftastic-LICENSE', difftasticArchiveLicense), + asset('tools/archives/ripgrep-LICENSE', ripgrepArchiveLicense), + asset('tools/licenses/difftastic-LICENSE', difftasticLicense), + asset('tools/licenses/ripgrep-LICENSE', ripgrepLicense) ]; + +async function selectEmbeddedAssets(): Promise { + if (feature('HAPI_TARGET_DARWIN_ARM64')) { + const [{ default: difftasticArm64Darwin }, { default: ripgrepArm64Darwin }] = await Promise.all([ + import('../../tools/archives/difftastic-arm64-darwin.tar.gz', { assert: { type: 'file' } }), + import('../../tools/archives/ripgrep-arm64-darwin.tar.gz', { assert: { type: 'file' } }) + ]); + return [ + ...COMMON_ASSETS, + asset('tools/archives/difftastic-arm64-darwin.tar.gz', difftasticArm64Darwin), + asset('tools/archives/ripgrep-arm64-darwin.tar.gz', ripgrepArm64Darwin) + ]; + } + + if (feature('HAPI_TARGET_DARWIN_X64')) { + const [{ default: difftasticX64Darwin }, { default: ripgrepX64Darwin }] = await Promise.all([ + import('../../tools/archives/difftastic-x64-darwin.tar.gz', { assert: { type: 'file' } }), + import('../../tools/archives/ripgrep-x64-darwin.tar.gz', { assert: { type: 'file' } }) + ]); + return [ + ...COMMON_ASSETS, + asset('tools/archives/difftastic-x64-darwin.tar.gz', difftasticX64Darwin), + asset('tools/archives/ripgrep-x64-darwin.tar.gz', ripgrepX64Darwin) + ]; + } + + if (feature('HAPI_TARGET_LINUX_ARM64')) { + const [{ default: difftasticArm64Linux }, { default: ripgrepArm64Linux }] = await Promise.all([ + import('../../tools/archives/difftastic-arm64-linux.tar.gz', { assert: { type: 'file' } }), + import('../../tools/archives/ripgrep-arm64-linux.tar.gz', { assert: { type: 'file' } }) + ]); + return [ + ...COMMON_ASSETS, + asset('tools/archives/difftastic-arm64-linux.tar.gz', difftasticArm64Linux), + asset('tools/archives/ripgrep-arm64-linux.tar.gz', ripgrepArm64Linux) + ]; + } + + if (feature('HAPI_TARGET_LINUX_X64')) { + const [{ default: difftasticX64Linux }, { default: ripgrepX64Linux }] = await Promise.all([ + import('../../tools/archives/difftastic-x64-linux.tar.gz', { assert: { type: 'file' } }), + import('../../tools/archives/ripgrep-x64-linux.tar.gz', { assert: { type: 'file' } }) + ]); + return [ + ...COMMON_ASSETS, + asset('tools/archives/difftastic-x64-linux.tar.gz', difftasticX64Linux), + asset('tools/archives/ripgrep-x64-linux.tar.gz', ripgrepX64Linux) + ]; + } + + if (feature('HAPI_TARGET_WIN32_X64')) { + const [{ default: difftasticX64Win32 }, { default: ripgrepX64Win32 }] = await Promise.all([ + import('../../tools/archives/difftastic-x64-win32.tar.gz', { assert: { type: 'file' } }), + import('../../tools/archives/ripgrep-x64-win32.tar.gz', { assert: { type: 'file' } }) + ]); + return [ + ...COMMON_ASSETS, + asset('tools/archives/difftastic-x64-win32.tar.gz', difftasticX64Win32), + asset('tools/archives/ripgrep-x64-win32.tar.gz', ripgrepX64Win32) + ]; + } + + if (feature('HAPI_TARGET_WIN32_ARM64')) { + throw new Error('Windows arm64 archives are missing; add arm64 tool archives before building.'); + } + + throw new Error('No build target feature flag set. Build with --feature=HAPI_TARGET_*.'); +} + +export async function loadEmbeddedAssets(): Promise { + return selectEmbeddedAssets(); +} diff --git a/cli/src/types/assetImports.d.ts b/cli/src/types/assetImports.d.ts new file mode 100644 index 00000000..69135431 --- /dev/null +++ b/cli/src/types/assetImports.d.ts @@ -0,0 +1,14 @@ +declare module '*.cjs' { + const path: string; + export default path; +} + +declare module '*.tar.gz' { + const path: string; + export default path; +} + +declare module '*-LICENSE' { + const path: string; + export default path; +} diff --git a/cli/src/types/bunBundle.d.ts b/cli/src/types/bunBundle.d.ts new file mode 100644 index 00000000..a46766cb --- /dev/null +++ b/cli/src/types/bunBundle.d.ts @@ -0,0 +1,11 @@ +declare module 'bun:bundle' { + interface Registry { + features: + | 'HAPI_TARGET_DARWIN_ARM64' + | 'HAPI_TARGET_DARWIN_X64' + | 'HAPI_TARGET_LINUX_ARM64' + | 'HAPI_TARGET_LINUX_X64' + | 'HAPI_TARGET_WIN32_X64' + | 'HAPI_TARGET_WIN32_ARM64'; + } +} diff --git a/cli/src/ui/doctor.ts b/cli/src/ui/doctor.ts index 4ee7dfa9..156a56ae 100644 --- a/cli/src/ui/doctor.ts +++ b/cli/src/ui/doctor.ts @@ -14,7 +14,7 @@ import { readDaemonState } from '@/persistence' import { existsSync, readdirSync, statSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { join } from 'node:path' -import { projectPath } from '@/projectPath' +import { isBunCompiled, projectPath, runtimePath } from '@/projectPath' import packageJson from '../../package.json' /** @@ -95,11 +95,16 @@ export async function runDoctorCommand(filter?: 'all' | 'daemon'): Promise const wrapperPath = join(projectRoot, 'bin', 'happy.mjs'); const cliEntrypoint = join(projectRoot, 'dist', 'index.mjs'); - console.log(`Project Root: ${chalk.blue(projectRoot)}`); - console.log(`Wrapper Script: ${chalk.blue(wrapperPath)}`); - console.log(`CLI Entrypoint: ${chalk.blue(cliEntrypoint)}`); - console.log(`Wrapper Exists: ${existsSync(wrapperPath) ? chalk.green('✓ Yes') : chalk.red('❌ No')}`); - console.log(`CLI Exists: ${existsSync(cliEntrypoint) ? chalk.green('✓ Yes') : chalk.red('❌ No')}`); + if (isBunCompiled()) { + console.log(`Executable: ${chalk.blue(process.execPath)}`); + console.log(`Runtime Assets: ${chalk.blue(runtimePath())}`); + } else { + console.log(`Project Root: ${chalk.blue(projectRoot)}`); + console.log(`Wrapper Script: ${chalk.blue(wrapperPath)}`); + console.log(`CLI Entrypoint: ${chalk.blue(cliEntrypoint)}`); + console.log(`Wrapper Exists: ${existsSync(wrapperPath) ? chalk.green('✓ Yes') : chalk.red('❌ No')}`); + console.log(`CLI Exists: ${existsSync(cliEntrypoint) ? chalk.green('✓ Yes') : chalk.red('❌ No')}`); + } console.log(''); // Configuration diff --git a/cli/src/utils/bunRuntime.ts b/cli/src/utils/bunRuntime.ts new file mode 100644 index 00000000..0c1bd43b --- /dev/null +++ b/cli/src/utils/bunRuntime.ts @@ -0,0 +1,14 @@ +export function withBunRuntimeEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + const bunRuntime = (globalThis as typeof globalThis & { Bun?: { isCompiled?: boolean } }).Bun; + const argv1 = process.argv[1] ?? ''; + const isCompiled = Boolean(bunRuntime?.isCompiled) || argv1.includes('$bunfs'); + + if (!isCompiled) { + return env; + } + + return { + ...env, + BUN_BE_BUN: '1' + }; +} diff --git a/cli/src/utils/spawnHappyCLI.ts b/cli/src/utils/spawnHappyCLI.ts index 97946557..c470c78c 100644 --- a/cli/src/utils/spawnHappyCLI.ts +++ b/cli/src/utils/spawnHappyCLI.ts @@ -55,7 +55,7 @@ import { spawn, SpawnOptions, type ChildProcess } from 'child_process'; import { join } from 'node:path'; -import { projectPath } from '@/projectPath'; +import { isBunCompiled, projectPath } from '@/projectPath'; import { logger } from '@/ui/logger'; import { existsSync } from 'node:fs'; @@ -84,12 +84,39 @@ function resolveEntrypointForBun(projectRoot: string): string { throw new Error('No CLI entrypoint found for Bun runtime (expected dist/index.mjs or src/index.ts)'); } -export function spawnHappyCLI(args: string[], options: SpawnOptions = {}): ChildProcess { +export interface HappyCliCommand { + command: string; + args: string[]; +} + +export function getHappyCliCommand(args: string[]): HappyCliCommand { + if (isBunCompiled()) { + return { + command: process.execPath, + args + }; + } + const projectRoot = projectPath(); const distEntrypoint = join(projectRoot, 'dist', 'index.mjs'); const isBunRuntime = Boolean((process.versions as Record).bun); const entrypoint = isBunRuntime ? resolveEntrypointForBun(projectRoot) : distEntrypoint; + const spawnArgs = isBunRuntime ? [entrypoint, ...args] : [ + '--no-warnings', + '--no-deprecation', + entrypoint, + ...args + ]; + + return { + command: process.execPath, + args: spawnArgs + }; +} + +export function spawnHappyCLI(args: string[], options: SpawnOptions = {}): ChildProcess { + let directory: string | URL | undefined; if ('cwd' in options) { directory = options.cwd @@ -104,19 +131,16 @@ export function spawnHappyCLI(args: string[], options: SpawnOptions = {}): Child const fullCommand = `hapi ${args.join(' ')}`; logger.debug(`[SPAWN HAPI CLI] Spawning: ${fullCommand} in ${directory}`); - const spawnCommand = process.execPath; - const spawnArgs = isBunRuntime ? [entrypoint, ...args] : [ - '--no-warnings', - '--no-deprecation', - entrypoint, - ...args - ]; + const { command: spawnCommand, args: spawnArgs } = getHappyCliCommand(args); // Sanity check of the entrypoint path exists - if (!existsSync(entrypoint)) { - const errorMessage = `Entrypoint ${entrypoint} does not exist`; - logger.debug(`[SPAWN HAPI CLI] ${errorMessage}`); - throw new Error(errorMessage); + if (!isBunCompiled()) { + const entrypoint = spawnArgs.find((arg) => arg.endsWith('index.mjs') || arg.endsWith('index.ts')); + if (entrypoint && !existsSync(entrypoint)) { + const errorMessage = `Entrypoint ${entrypoint} does not exist`; + logger.debug(`[SPAWN HAPI CLI] ${errorMessage}`); + throw new Error(errorMessage); + } } return spawn(spawnCommand, spawnArgs, options); diff --git a/cli/tsconfig.json b/cli/tsconfig.json index 68e26d32..86410401 100644 --- a/cli/tsconfig.json +++ b/cli/tsconfig.json @@ -24,6 +24,7 @@ ], "include": [ "src/**/*.ts", - "src/**/*.tsx" + "src/**/*.tsx", + "src/**/*.d.ts" ] } diff --git a/package.json b/package.json index eab416c1..a7a8d445 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,8 @@ "dev": "concurrently \"bun run dev:server\" \"bun run dev:web\" --kill-others-on-exit", "build": "bun run build:cli && bun run build:server && bun run build:web", "build:cli": "cd cli && bun run build", + "build:cli:exe": "cd cli && bun run build:exe", + "build:cli:exe:all": "cd cli && bun run build:exe:all", "build:server": "cd server && bun run build", "build:web": "cd web && bun run build", "dev:server": "cd server && bun run dev",