chore: migrate cli distribution from npm to bun executable

Migrate HAPI CLI from npm-based distribution to Bun single-executable format:
- Remove npm bin wrappers (bin/happy.mjs, bin/happy-mcp.mjs)
- Simplify package.json: remove npm publish config (exports, main, module, types, files, publishConfig)
- Update bin entry to point to TypeScript source (src/index.ts)
- Migrate shebang from Node to Bun (#!/usr/bin/env bun)
- Simplify build scripts: remove npm-specific steps (pkgroll, prepublishOnly, release-it)
- Update spawnHappyCLI to support compiled binaries and development TypeScript mode
- Update daemon/doctor diagnostics for new process detection logic
- Production: use `bun build --compile` for single-executable releases
- Development: run TypeScript directly via `bun src/index.ts` or `tsx src/index.ts`
This commit is contained in:
weishu
2025-12-22 16:44:20 +08:00
parent fd94a684dc
commit d057daabfe
11 changed files with 94 additions and 714 deletions
+1 -1
View File
@@ -112,7 +112,7 @@ Local HTTP server (127.0.0.1 only) provides:
### Doctor Command
`hapi doctor` uses `ps aux | grep` to find all HAPI processes:
- Production: matches `happy.mjs`, `happy-coder`, `dist/index.mjs`
- Production: matches `hapi` binary, `happy-coder`
- Development: matches `tsx.*src/index.ts`
- Categorizes by command args: daemon, daemon-spawned, user-session, doctor
+2 -2
View File
@@ -407,7 +407,7 @@ describe.skipIf(!await isServerHealthy())('Daemon Integration Tests', { timeout:
*
* Critical timing constraints:
* - Heartbeat must be long enough (30s) for yarn build to complete before daemon tries to spawn
* - If heartbeat fires during rebuild, spawn fails (dist/index.mjs missing) and test fails
* - If heartbeat fires during rebuild, spawn fails (entrypoint missing) and test fails
* - pkgroll doesn't reliably update compiled version, must use full yarn build
* - Test modifies package.json BEFORE rebuild to ensure new version is compiled in
*
@@ -472,6 +472,6 @@ describe.skipIf(!await isServerHealthy())('Daemon Integration Tests', { timeout:
// TODO: Add a test to see if a corrupted file will work
// TODO: Test npm uninstall scenario - daemon should gracefully handle when hapi is uninstalled
// Current behavior: daemon tries to spawn new daemon on version mismatch but dist/index.mjs is gone
// Current behavior: daemon tries to spawn new daemon on version mismatch but entrypoint is gone
// Expected: daemon should detect missing entrypoint and either exit cleanly or at minimum not respawn infinitely
});
+4 -4
View File
@@ -22,12 +22,12 @@ export async function findAllHappyProcesses(): Promise<Array<{ pid: number, comm
// Check if it's a HAPI 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') ||
const isHappy = name.includes('happy') ||
name === 'node' && cmd.includes('happy-cli') ||
cmd.includes('happy-coder') ||
isHappyBinary ||
(cmd.includes('tsx') && cmd.includes('src/index.ts') && cmd.includes('happy-cli'));
(cmd.includes('tsx') && cmd.includes('src/index.ts') && cmd.includes('happy-cli')) ||
(cmd.includes('bun') && cmd.includes('src/index.ts') && cmd.includes('happy-cli'));
if (!isHappy) continue;
Regular → Executable
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env node
#!/usr/bin/env bun
/**
* CLI entry point for hapi command
+2 -5
View File
@@ -92,17 +92,14 @@ export async function runDoctorCommand(filter?: 'all' | 'daemon'): Promise<void>
// Daemon spawn diagnostics
console.log(chalk.bold('🔧 Daemon Spawn Diagnostics'));
const projectRoot = projectPath();
const wrapperPath = join(projectRoot, 'bin', 'happy.mjs');
const cliEntrypoint = join(projectRoot, 'dist', 'index.mjs');
const cliEntrypoint = join(projectRoot, 'src', 'index.ts');
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('');
+35 -83
View File
@@ -1,56 +1,28 @@
/**
* Cross-platform HAPI CLI spawning utility
*
* ## Background
*
* We built a command-line JavaScript program with the entrypoint at `dist/index.mjs`.
* This needs to be run with a JS runtime (Node.js or Bun). For Node we want to hide
* deprecation warnings and other
* noise from end users by passing specific flags: `--no-warnings --no-deprecation`.
*
* Users don't care about these technical details - they just want a clean experience
* with no warning output when using HAPI.
*
* ## The Wrapper Strategy
*
* We created a wrapper script `bin/happy.mjs` with a shebang `#!/usr/bin/env node`.
* This allows direct execution on Unix systems and NPM automatically generates
* Windows-specific wrapper scripts (`hapi.cmd` and `hapi.ps1`) when it sees
* the `bin` field in package.json pointing to a JavaScript file with a shebang.
*
* The wrapper script either directly execs `dist/index.mjs` with the flags we want,
* or imports it directly if Node.js already has the right flags.
*
* ## Execution Chains
*
* **Unix/Linux/macOS:**
* 1. User runs `hapi` command
* 2. Shell directly executes `bin/happy.mjs` (shebang: `#!/usr/bin/env node`)
* 3. `bin/happy.mjs` either execs `node --no-warnings --no-deprecation dist/index.mjs` or imports `dist/index.mjs` directly
*
* **Windows:**
* 1. User runs `hapi` command
* 2. NPM wrapper (`hapi.cmd`) calls `node bin/happy.mjs`
* 3. `bin/happy.mjs` either execs `node --no-warnings --no-deprecation dist/index.mjs` or imports `dist/index.mjs` directly
*
* ## The Spawning Problem
*
* When our code needs to spawn HAPI CLI as a subprocess (for daemon processes),
* we were trying to execute `bin/happy.mjs` directly. This fails on Windows
* because Windows doesn't understand shebangs - you get an `EFTYPE` error.
*
* ## The Solution
*
* Since we know exactly what needs to happen (run `dist/index.mjs` with the current
* runtime), we can bypass all the wrapper layers and do it directly:
*
* `spawn(process.execPath, ['--no-warnings', '--no-deprecation', 'dist/index.mjs', ...args])`
*
* When running under Bun, we spawn the Bun executable with the entrypoint and
* omit Node-specific flags.
*
* This works on all platforms and achieves the same result without any of the
* middleman steps that were providing workarounds for Windows vs Linux differences.
* ## Background
*
* HAPI CLI runs in two modes:
* 1. **Compiled binary**: A single executable built with `bun build --compile`
* 2. **Development mode**: Running TypeScript directly via `tsx` or `bun`
*
* ## Execution Modes
*
* **Compiled Binary (Production):**
* - The executable is self-contained and runs directly
* - `process.execPath` points to the compiled binary itself
* - No additional entrypoint needed - just pass args to `process.execPath`
*
* **Development Mode:**
* - Running via `tsx src/index.ts` or `bun src/index.ts`
* - Spawn child processes using the same runtime with `src/index.ts` entrypoint
*
* ## Cross-Platform Support
*
* This utility handles spawning HAPI CLI subprocesses (for daemon processes)
* in a cross-platform way, detecting the current runtime mode and using
* the appropriate command and arguments.
*/
import { spawn, SpawnOptions, type ChildProcess } from 'child_process';
@@ -60,28 +32,15 @@ import { logger } from '@/ui/logger';
import { existsSync } from 'node:fs';
/**
* Spawn the HAPI CLI with the given arguments in a cross-platform way.
*
* This function bypasses the wrapper script (bin/happy.mjs) and spawns the
* actual CLI entrypoint (dist/index.mjs) directly with the current runtime
* (Node.js or Bun), ensuring compatibility across all platforms including Windows.
*
* @param args - Arguments to pass to the HAPI CLI
* @param options - Spawn options (same as child_process.spawn)
* @returns ChildProcess instance
* Resolve the TypeScript entrypoint for development mode.
*/
function resolveEntrypointForBun(projectRoot: string): string {
const distEntrypoint = join(projectRoot, 'dist', 'index.mjs');
if (existsSync(distEntrypoint)) {
return distEntrypoint;
}
function resolveEntrypoint(projectRoot: string): string {
const srcEntrypoint = join(projectRoot, 'src', 'index.ts');
if (existsSync(srcEntrypoint)) {
return srcEntrypoint;
}
throw new Error('No CLI entrypoint found for Bun runtime (expected dist/index.mjs or src/index.ts)');
throw new Error('No CLI entrypoint found (expected src/index.ts)');
}
export interface HappyCliCommand {
@@ -90,6 +49,7 @@ export interface HappyCliCommand {
}
export function getHappyCliCommand(args: string[]): HappyCliCommand {
// Compiled binary mode: just use the executable directly
if (isBunCompiled()) {
return {
command: process.execPath,
@@ -97,31 +57,23 @@ export function getHappyCliCommand(args: string[]): HappyCliCommand {
};
}
// Development mode: spawn with TypeScript entrypoint
const projectRoot = projectPath();
const distEntrypoint = join(projectRoot, 'dist', 'index.mjs');
const entrypoint = resolveEntrypoint(projectRoot);
const isBunRuntime = Boolean((process.versions as Record<string, string | undefined>).bun);
const entrypoint = isBunRuntime ? resolveEntrypointForBun(projectRoot) : distEntrypoint;
const argv1 = process.argv[1] ?? '';
const runningFromSource = argv1.endsWith(join('src', 'index.ts')) || process.execArgv.some((arg) => arg.includes('tsx'));
const srcEntrypoint = join(projectRoot, 'src', 'index.ts');
if (!isBunRuntime && runningFromSource && existsSync(srcEntrypoint)) {
if (isBunRuntime) {
// Bun can run TypeScript directly
return {
command: process.execPath,
args: [...process.execArgv, srcEntrypoint, ...args]
args: [entrypoint, ...args]
};
}
const spawnArgs = isBunRuntime ? [entrypoint, ...args] : [
'--no-warnings',
'--no-deprecation',
entrypoint,
...args
];
// Node.js with tsx: preserve execArgv (which includes tsx loader)
return {
command: process.execPath,
args: spawnArgs
args: [...process.execArgv, entrypoint, ...args]
};
}
@@ -143,9 +95,9 @@ export function spawnHappyCLI(args: string[], options: SpawnOptions = {}): Child
const { command: spawnCommand, args: spawnArgs } = getHappyCliCommand(args);
// Sanity check of the entrypoint path exists
// Sanity check that the entrypoint path exists
if (!isBunCompiled()) {
const entrypoint = spawnArgs.find((arg) => arg.endsWith('index.mjs') || arg.endsWith('index.ts'));
const entrypoint = spawnArgs.find((arg) => arg.endsWith('index.ts'));
if (entrypoint && !existsSync(entrypoint)) {
const errorMessage = `Entrypoint ${entrypoint} does not exist`;
logger.debug(`[SPAWN HAPI CLI] ${errorMessage}`);