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.
This commit is contained in:
weishu
2025-12-20 21:41:11 +08:00
parent bf18224d05
commit 17eeba10d6
28 changed files with 605 additions and 238 deletions
+1
View File
@@ -3,6 +3,7 @@
# Build output
**/dist/
**/dist-exe/
**/*.tsbuildinfo
# Env files (can contain secrets)
+1 -1
View File
@@ -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/`) |
+3 -1
View File
@@ -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
```
+5 -3
View File
@@ -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=="],
+22 -6
View File
@@ -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
+4 -6
View File
@@ -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));
}
+3 -1
View File
@@ -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": {
+197
View File
@@ -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<void> {
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<void> {
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 <bun-platform[-arch]>] [--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);
});
+4
View File
@@ -0,0 +1,4 @@
// Disable Ink devtools in compiled binaries to avoid optional dependencies.
process.env.DEV = 'false';
await import('./index');
+3 -2
View File
@@ -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
+1 -1
View File
@@ -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'));
+6 -4
View File
@@ -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
}
}
+71 -73
View File
@@ -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 <http://127.0.0.1:PORT>\n'
);
process.exit(2);
}
let httpClient: Client | null = null;
async function ensureHttpClient(): Promise<Client> {
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<void> {
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 <http://127.0.0.1:PORT>\n'
);
process.exit(2);
}
let httpClient: Client | null = null;
async function ensureHttpClient(): Promise<Client> {
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);
}
}
}
+4 -3
View File
@@ -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;
+3 -1
View File
@@ -21,10 +21,12 @@ export async function findAllHappyProcesses(): Promise<Array<{ pid: number, comm
const name = proc.name || '';
// Check if it's a Happy process
const isHappyBinary = name === 'hapi' || name === 'hapi.exe' || /\bhapi(\.exe)?\b/.test(cmd);
const isHappy = name.includes('happy') ||
name === 'node' && (cmd.includes('happy-cli') || cmd.includes('dist/index.mjs')) ||
cmd.includes('happy.mjs') ||
cmd.includes('happy-coder') ||
isHappyBinary ||
(cmd.includes('tsx') && cmd.includes('src/index.ts') && cmd.includes('happy-cli'));
if (!isHappy) continue;
@@ -120,4 +122,4 @@ export async function killRunawayHappyProcesses(): Promise<{ killed: number, err
}
return { killed, errors };
}
}
+3 -4
View File
@@ -1,6 +1,5 @@
import fs from 'fs/promises';
import os from 'os';
import * as tmp from 'tmp';
import { ApiClient } from '@/api/api';
import { TrackedSession } from './types';
@@ -233,14 +232,14 @@ export async function startDaemon(): Promise<void> {
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 = {
+48 -8
View File
@@ -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')
+3 -1
View File
@@ -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<RipgrepRe
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [runnerPath, JSON.stringify(args)], {
stdio: ['pipe', 'pipe', 'pipe'],
cwd: options?.cwd
cwd: options?.cwd,
env: withBunRuntimeEnv()
});
let stdout = '';
+3 -1
View File
@@ -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, '..');
+29 -16
View File
@@ -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<ArrayBuffer> } };
}).Bun;
async function copyAssetFile(asset: EmbeddedAsset, targetPath: string): Promise<void> {
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<void> {
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);
+100 -86
View File
@@ -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<EmbeddedAsset[]> {
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<EmbeddedAsset[]> {
return selectEmbeddedAssets();
}
+14
View File
@@ -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;
}
+11
View File
@@ -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';
}
}
+11 -6
View File
@@ -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<void>
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
+14
View File
@@ -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'
};
}
+37 -13
View File
@@ -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<string, string | undefined>).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);
+2 -1
View File
@@ -24,6 +24,7 @@
],
"include": [
"src/**/*.ts",
"src/**/*.tsx"
"src/**/*.tsx",
"src/**/*.d.ts"
]
}
+2
View File
@@ -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",