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",