From 85c2dc10ea4cc4415787c96790d791043adedef7 Mon Sep 17 00:00:00 2001 From: weishu Date: Wed, 24 Dec 2025 14:38:24 +0800 Subject: [PATCH] feat: add npx/bunx binary distribution support Adds infrastructure for distributing Hapi via npm as a scoped package (@twsxtd/hapi) with platform-specific optional dependencies. This enables installation via `npx @twsxtd/hapi` or `bunx @twsxtd/hapi`. - Add bin/hapi.js wrapper script for platform detection and binary selection - Create npm/ directory structure for platform-specific packages (darwin-{arm64,x64}, linux-{arm64,x64}, win32-x64) - Add scripts/prepare-npm-packages.ts to generate platform-specific package.json files and copy binaries to npm packages - Add scripts/publish-npm.ts for automated publishing of platform packages - Update package.json with new scoped name, bin entry, optionalDependencies, and prepack/publish scripts - Update .gitignore to ignore generated npm packages and build outputs - Update .npmignore to only include bin/hapi.js wrapper, excluding source and build files --- bun.lock | 15 ++- cli/.gitignore | 8 ++ cli/.npmignore | 30 ++++- cli/bin/hapi.js | 48 ++++++++ cli/npm/darwin-arm64/.gitkeep | 0 cli/npm/darwin-x64/.gitkeep | 0 cli/npm/linux-arm64/.gitkeep | 0 cli/npm/linux-x64/.gitkeep | 0 cli/npm/win32-x64/.gitkeep | 0 cli/package.json | 18 ++- cli/scripts/prepare-npm-packages.ts | 177 ++++++++++++++++++++++++++++ cli/scripts/publish-npm.ts | 144 ++++++++++++++++++++++ 12 files changed, 429 insertions(+), 11 deletions(-) create mode 100644 cli/bin/hapi.js create mode 100644 cli/npm/darwin-arm64/.gitkeep create mode 100644 cli/npm/darwin-x64/.gitkeep create mode 100644 cli/npm/linux-arm64/.gitkeep create mode 100644 cli/npm/linux-x64/.gitkeep create mode 100644 cli/npm/win32-x64/.gitkeep create mode 100644 cli/scripts/prepare-npm-packages.ts create mode 100644 cli/scripts/publish-npm.ts diff --git a/bun.lock b/bun.lock index 93cdd841..4e1c2756 100644 --- a/bun.lock +++ b/bun.lock @@ -11,10 +11,10 @@ }, }, "cli": { - "name": "hapi", + "name": "@twsxtd/hapi", "version": "0.12.0-1", "bin": { - "hapi": "./src/index.ts", + "hapi": "bin/hapi.js", }, "dependencies": { "@modelcontextprotocol/sdk": "^1.22.0", @@ -49,6 +49,13 @@ "typescript": "^5", "vitest": "^3.2.4", }, + "optionalDependencies": { + "@twsxtd/hapi-darwin-arm64": "0.0.0-placeholder", + "@twsxtd/hapi-darwin-x64": "0.0.0-placeholder", + "@twsxtd/hapi-linux-arm64": "0.0.0-placeholder", + "@twsxtd/hapi-linux-x64": "0.0.0-placeholder", + "@twsxtd/hapi-win32-x64": "0.0.0-placeholder", + }, }, "server": { "name": "hapi-server", @@ -588,6 +595,8 @@ "@tsconfig/node16": ["@tsconfig/node16@1.0.4", "", {}, "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA=="], + "@twsxtd/hapi": ["@twsxtd/hapi@workspace:cli"], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], @@ -1062,8 +1071,6 @@ "grammy": ["grammy@1.38.4", "", { "dependencies": { "@grammyjs/types": "3.22.2", "abort-controller": "^3.0.0", "debug": "^4.4.3", "node-fetch": "^2.7.0" } }, "sha512-z07Kin3HgRwMdy40KUs+c9fmNBvGlSxGwcqY8NAH0a8KULGFYEMQaFAo3ge0V5tvmgr02Jgubkf54KjHLAMCbw=="], - "hapi": ["hapi@workspace:cli"], - "hapi-server": ["hapi-server@workspace:server"], "hapi-web": ["hapi-web@workspace:web"], diff --git a/cli/.gitignore b/cli/.gitignore index f33d308c..4ac01477 100644 --- a/cli/.gitignore +++ b/cli/.gitignore @@ -10,6 +10,14 @@ oclif.manifest.json # Unpacked binaries (keep archives) /tools/unpacked/ +# Build outputs +/dist-exe/ + +# Generated npm platform packages (created by prepare-npm-packages.ts) +/npm/*/package.json +/npm/*/bin/hapi +/npm/*/bin/hapi.exe + pnpm-lock.yaml diff --git a/cli/.npmignore b/cli/.npmignore index 9cf0bb00..5167352a 100644 --- a/cli/.npmignore +++ b/cli/.npmignore @@ -1,6 +1,26 @@ -# Exclude unpacked binaries from npm package -tools/unpacked/ +# npm package only needs bin/hapi.js (defined in package.json "files" field) +# This file is kept for documentation purposes -# Keep these for npm package -# tools/archives/ -# tools/licenses/ \ No newline at end of file +# Source code (not needed - binary distribution) +src/ +scripts/ +tools/ +demo-project/ + +# Build outputs +dist-exe/ +npm/ + +# Config files +*.md +!README.md +.github/ +.release-it.* +bunfig.toml +tsconfig.json +.env* + +# Test files +**/*.test.ts +**/__fixtures__/ +**/__tests__/ diff --git a/cli/bin/hapi.js b/cli/bin/hapi.js new file mode 100644 index 00000000..d71fa437 --- /dev/null +++ b/cli/bin/hapi.js @@ -0,0 +1,48 @@ +#!/usr/bin/env node + +const { execFileSync } = require('child_process'); +const path = require('path'); + +const platform = process.platform; +const arch = process.arch; +const pkgName = `@twsxtd/hapi-${platform}-${arch}`; + +function getBinaryPath() { + try { + // Try to find the platform-specific package + const pkgPath = require.resolve(`${pkgName}/package.json`); + const binName = platform === 'win32' ? 'hapi.exe' : 'hapi'; + return path.join(path.dirname(pkgPath), 'bin', binName); + } catch (e) { + return null; + } +} + +const binPath = getBinaryPath(); + +if (!binPath) { + console.error(`Unsupported platform: ${platform}-${arch}`); + console.error(''); + console.error('Supported platforms:'); + console.error(' - darwin-arm64 (macOS Apple Silicon)'); + console.error(' - darwin-x64 (macOS Intel)'); + console.error(' - linux-arm64'); + console.error(' - linux-x64'); + console.error(' - win32-x64'); + console.error(''); + console.error('You can download the binary manually from:'); + console.error(' https://github.com/anthropics/hapi/releases'); + process.exit(1); +} + +try { + execFileSync(binPath, process.argv.slice(2), { stdio: 'inherit' }); +} catch (e) { + // If the binary execution fails, exit with the same code + if (e.status !== undefined) { + process.exit(e.status); + } + // For other errors (e.g., binary not found), print and exit + console.error(`Failed to execute ${binPath}:`, e.message); + process.exit(1); +} diff --git a/cli/npm/darwin-arm64/.gitkeep b/cli/npm/darwin-arm64/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/cli/npm/darwin-x64/.gitkeep b/cli/npm/darwin-x64/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/cli/npm/linux-arm64/.gitkeep b/cli/npm/linux-arm64/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/cli/npm/linux-x64/.gitkeep b/cli/npm/linux-x64/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/cli/npm/win32-x64/.gitkeep b/cli/npm/win32-x64/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/cli/package.json b/cli/package.json index 0d02f2ce..840173b8 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,5 +1,5 @@ { - "name": "hapi", + "name": "@twsxtd/hapi", "version": "0.12.0-1", "description": "Mobile and Web client for Claude Code and Codex", "author": "Kirill Dubovitskiy", @@ -13,20 +13,34 @@ "directory": "cli" }, "bin": { - "hapi": "./src/index.ts" + "hapi": "bin/hapi.js" }, + "files": [ + "bin/hapi.js" + ], "imports": { "#embedded-assets": { "bun": "./src/runtime/embeddedAssets.bun.ts", "default": "./src/runtime/embeddedAssets.stub.ts" } }, + "optionalDependencies": { + "@twsxtd/hapi-darwin-arm64": "0.0.0-placeholder", + "@twsxtd/hapi-darwin-x64": "0.0.0-placeholder", + "@twsxtd/hapi-linux-arm64": "0.0.0-placeholder", + "@twsxtd/hapi-linux-x64": "0.0.0-placeholder", + "@twsxtd/hapi-win32-x64": "0.0.0-placeholder" + }, "scripts": { "typecheck": "tsc --noEmit", "build:exe": "bun run scripts/build-executable.ts", "build:exe:all": "bun run scripts/build-executable.ts --all", "build:exe:allinone": "bun run scripts/build-executable.ts --with-web-assets", "build:exe:allinone:all": "bun run scripts/build-executable.ts --with-web-assets --all", + "prepare-npm-packages": "bun run scripts/prepare-npm-packages.ts", + "prepack": "bun run prepare-npm-packages", + "publish-npm": "bun run scripts/publish-npm.ts", + "publish-npm:dry-run": "bun run scripts/publish-npm.ts --dry-run", "tools:unpack": "bun run scripts/unpack-tools.ts", "test": "tsx --env-file .env.integration-test node_modules/.bin/vitest run", "test:win": "vitest run", diff --git a/cli/scripts/prepare-npm-packages.ts b/cli/scripts/prepare-npm-packages.ts new file mode 100644 index 00000000..6505448a --- /dev/null +++ b/cli/scripts/prepare-npm-packages.ts @@ -0,0 +1,177 @@ +/** + * Prepare npm platform packages for publishing. + * + * This script: + * 1. Reads the version from cli/package.json + * 2. Generates package.json for each platform package + * 3. Copies binaries from dist-exe to npm package directories + * 4. Updates optionalDependencies versions in main package.json + * + * Run after `bun run build:exe:all` + */ + +import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const projectRoot = join(__dirname, '..'); + +// Platform configurations +// Maps npm platform name to build target info +const PLATFORMS = [ + { + name: 'darwin-arm64', + os: 'darwin', + cpu: 'arm64', + buildTarget: 'bun-darwin-arm64', + binName: 'hapi' + }, + { + name: 'darwin-x64', + os: 'darwin', + cpu: 'x64', + buildTarget: 'bun-darwin-x64', + binName: 'hapi' + }, + { + name: 'linux-arm64', + os: 'linux', + cpu: 'arm64', + buildTarget: 'bun-linux-arm64', + binName: 'hapi' + }, + { + name: 'linux-x64', + os: 'linux', + cpu: 'x64', + buildTarget: 'bun-linux-x64', + binName: 'hapi' + }, + { + name: 'win32-x64', + os: 'win32', + cpu: 'x64', + buildTarget: 'bun-windows-x64', + binName: 'hapi.exe' + } +] as const; + +interface MainPackageJson { + version: string; + license?: string; + repository?: { + type: string; + url: string; + }; +} + +async function readMainPackageJson(): Promise { + const pkgPath = join(projectRoot, 'package.json'); + const content = await Bun.file(pkgPath).text(); + return JSON.parse(content); +} + +function generatePlatformPackageJson( + platform: typeof PLATFORMS[number], + mainPkg: MainPackageJson +): object { + return { + name: `@twsxtd/hapi-${platform.name}`, + version: mainPkg.version, + description: `hapi binary for ${platform.os} ${platform.cpu}`, + os: [platform.os], + cpu: [platform.cpu], + bin: { + hapi: `bin/${platform.binName}` + }, + files: [`bin/${platform.binName}`], + license: mainPkg.license ?? 'MIT', + repository: mainPkg.repository + }; +} + +async function preparePlatform( + platform: typeof PLATFORMS[number], + mainPkg: MainPackageJson, + distExeDir: string, + npmDir: string +): Promise { + const platformDir = join(npmDir, platform.name); + const binDir = join(platformDir, 'bin'); + + // Ensure bin directory exists + mkdirSync(binDir, { recursive: true }); + + // Generate package.json + const pkgJson = generatePlatformPackageJson(platform, mainPkg); + const pkgJsonPath = join(platformDir, 'package.json'); + writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 4) + '\n'); + console.log(`Generated: ${pkgJsonPath}`); + + // Copy binary + const srcBin = join(distExeDir, platform.buildTarget, platform.binName); + const destBin = join(binDir, platform.binName); + + if (!existsSync(srcBin)) { + console.warn(`Warning: Binary not found: ${srcBin}`); + console.warn(` Run 'bun run build:exe:all' first to build binaries.`); + return; + } + + copyFileSync(srcBin, destBin); + console.log(`Copied: ${srcBin} -> ${destBin}`); +} + +function updateMainPackageOptionalDeps(version: string): void { + const pkgPath = join(projectRoot, 'package.json'); + const content = readFileSync(pkgPath, 'utf-8'); + const pkg = JSON.parse(content); + + // Update optionalDependencies versions + if (!pkg.optionalDependencies) { + pkg.optionalDependencies = {}; + } + + for (const platform of PLATFORMS) { + pkg.optionalDependencies[`@twsxtd/hapi-${platform.name}`] = version; + } + + writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); + console.log(`Updated optionalDependencies in package.json to version ${version}`); +} + +async function main(): Promise { + console.log('Preparing npm platform packages...\n'); + + const mainPkg = await readMainPackageJson(); + console.log(`Version: ${mainPkg.version}\n`); + + // Update optionalDependencies in main package.json + updateMainPackageOptionalDeps(mainPkg.version); + + const distExeDir = join(projectRoot, 'dist-exe'); + const npmDir = join(projectRoot, 'npm'); + + let hasErrors = false; + + for (const platform of PLATFORMS) { + try { + await preparePlatform(platform, mainPkg, distExeDir, npmDir); + } catch (error) { + console.error(`Error preparing ${platform.name}:`, error); + hasErrors = true; + } + } + + console.log('\nDone!'); + + if (hasErrors) { + process.exit(1); + } +} + +main().catch((error) => { + console.error('Fatal error:', error); + process.exit(1); +}); diff --git a/cli/scripts/publish-npm.ts b/cli/scripts/publish-npm.ts new file mode 100644 index 00000000..6711fc77 --- /dev/null +++ b/cli/scripts/publish-npm.ts @@ -0,0 +1,144 @@ +/** + * Publish all npm packages (platform packages + main package). + * + * Usage: + * bun run scripts/publish-npm.ts # Publish all packages + * bun run scripts/publish-npm.ts --dry-run # Preview without publishing + * bun run scripts/publish-npm.ts --skip-build # Skip building binaries + * + * Prerequisites: + * - npm login (must be logged in to npm) + * - For scoped packages: npm access must be set to public + */ + +import { execSync, spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const projectRoot = join(__dirname, '..'); + +const PLATFORMS = [ + 'darwin-arm64', + 'darwin-x64', + 'linux-arm64', + 'linux-x64', + 'win32-x64' +]; + +function parseArgs(): { dryRun: boolean; skipBuild: boolean } { + const args = process.argv.slice(2); + return { + dryRun: args.includes('--dry-run'), + skipBuild: args.includes('--skip-build') + }; +} + +function run(cmd: string, cwd: string = projectRoot): void { + console.log(`\n$ ${cmd}`); + execSync(cmd, { cwd, stdio: 'inherit' }); +} + +function checkNpmLogin(): boolean { + try { + const result = spawnSync('npm', ['whoami'], { encoding: 'utf-8' }); + if (result.status === 0) { + console.log(`Logged in as: ${result.stdout.trim()}`); + return true; + } + } catch { + // ignore + } + return false; +} + +function checkBinariesExist(): boolean { + const binaries = [ + join(projectRoot, 'dist-exe', 'bun-darwin-arm64', 'hapi'), + join(projectRoot, 'dist-exe', 'bun-darwin-x64', 'hapi'), + join(projectRoot, 'dist-exe', 'bun-linux-arm64', 'hapi'), + join(projectRoot, 'dist-exe', 'bun-linux-x64', 'hapi'), + join(projectRoot, 'dist-exe', 'bun-windows-x64', 'hapi.exe') + ]; + + for (const bin of binaries) { + if (!existsSync(bin)) { + return false; + } + } + return true; +} + +function publishPackage(pkgDir: string, dryRun: boolean): void { + const cmd = dryRun + ? 'npm publish --access public --dry-run' + : 'npm publish --access public'; + run(cmd, pkgDir); +} + +async function main(): Promise { + const { dryRun, skipBuild } = parseArgs(); + + console.log('='.repeat(60)); + console.log(dryRun ? ' DRY RUN - No packages will be published' : ' PUBLISHING PACKAGES'); + console.log('='.repeat(60)); + + // Check npm login + console.log('\n[1/5] Checking npm login...'); + if (!checkNpmLogin()) { + console.error('Error: Not logged in to npm. Run `npm login` first.'); + process.exit(1); + } + + // Build binaries + if (!skipBuild) { + console.log('\n[2/5] Building binaries for all platforms...'); + run('bun run build:exe:all'); + } else { + console.log('\n[2/5] Skipping build (--skip-build)'); + if (!checkBinariesExist()) { + console.error('Error: Binaries not found. Run without --skip-build first.'); + process.exit(1); + } + } + + // Prepare npm packages + console.log('\n[3/5] Preparing npm packages...'); + run('bun run prepare-npm-packages'); + + // Publish platform packages + console.log('\n[4/5] Publishing platform packages...'); + for (const platform of PLATFORMS) { + const pkgDir = join(projectRoot, 'npm', platform); + console.log(`\nPublishing @twsxtd/hapi-${platform}...`); + try { + publishPackage(pkgDir, dryRun); + } catch (error) { + console.error(`Failed to publish @twsxtd/hapi-${platform}`); + throw error; + } + } + + // Publish main package + console.log('\n[5/5] Publishing main package...'); + console.log('\nPublishing @twsxtd/hapi...'); + publishPackage(projectRoot, dryRun); + + console.log('\n' + '='.repeat(60)); + console.log(dryRun ? ' DRY RUN COMPLETE' : ' ALL PACKAGES PUBLISHED SUCCESSFULLY'); + console.log('='.repeat(60)); + + if (!dryRun) { + const pkg = await Bun.file(join(projectRoot, 'package.json')).json(); + console.log(`\nVersion ${pkg.version} published!`); + console.log('\nUsers can now run:'); + console.log(' npx @twsxtd/hapi'); + console.log(' bunx @twsxtd/hapi'); + } +} + +main().catch((error) => { + console.error('\nPublish failed:', error.message); + process.exit(1); +});