mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -3,6 +3,7 @@
|
||||
|
||||
# Build output
|
||||
**/dist/
|
||||
**/dist-exe/
|
||||
**/*.tsbuildinfo
|
||||
|
||||
# Env files (can contain secrets)
|
||||
|
||||
@@ -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/`) |
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
// Disable Ink devtools in compiled binaries to avoid optional dependencies.
|
||||
process.env.DEV = 'false';
|
||||
|
||||
await import('./index');
|
||||
@@ -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
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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')
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Vendored
+14
@@ -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;
|
||||
}
|
||||
Vendored
+11
@@ -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
@@ -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
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -24,6 +24,7 @@
|
||||
],
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx"
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user