From 90cb7dae0523d755006565b46e4c8a2b6ce5019d Mon Sep 17 00:00:00 2001 From: weishu Date: Thu, 25 Dec 2025 10:23:16 +0800 Subject: [PATCH] feat: add Homebrew installation support for hapi CLI - Update CI workflow to package release artifacts with correct naming (hapi-* prefix) - Generate SHA256 checksums for all artifacts - Auto-update homebrew-tap repository on release - Add Installation section to README with Homebrew, npm, and binary options - Simplify .release-it.json config as CI now handles release process - Add update-homebrew-formula script to update formula in homebrew-tap - Add release-artifacts directory to .gitignore Users can now install via: brew install tiann/tap/hapi --- .github/workflows/release.yml | 29 +++- README.md | 38 +++- cli/.gitignore | 1 + cli/.release-it.json | 8 +- cli/package.json | 1 + cli/scripts/update-homebrew-formula.ts | 231 +++++++++++++++++++++++++ 6 files changed, 289 insertions(+), 19 deletions(-) create mode 100644 cli/scripts/update-homebrew-formula.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b10502f5..76eabda7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,17 +16,26 @@ jobs: - uses: oven-sh/setup-bun@v2 - run: bun install - run: bun run build:single-exe:all - - name: Package binaries + - name: Package release artifacts run: | cd cli/dist-exe + mkdir -p ../release-artifacts + for dir in bun-*/; do - name="${dir%/}" - if [[ "$name" == *"windows"* ]]; then - zip -r "${name}.zip" "$dir" + target="${dir%/}" + # bun-darwin-arm64 -> hapi-darwin-arm64 + name="hapi-${target#bun-}" + name="${name/windows/win32}" + + if [[ "$target" == *"windows"* ]]; then + zip -j "../release-artifacts/${name}.zip" "$target"/hapi.exe else - tar -czvf "${name}.tar.gz" "$dir" + tar -czvf "../release-artifacts/${name}.tar.gz" -C "$target" hapi fi done + + cd ../release-artifacts + sha256sum * > checksums.txt - name: Create Release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -34,5 +43,11 @@ jobs: gh release create "${GITHUB_REF#refs/tags/}" \ --title "Release ${GITHUB_REF#refs/tags/}" \ --generate-notes \ - cli/dist-exe/*.tar.gz \ - cli/dist-exe/*.zip + cli/release-artifacts/* + - name: Update Homebrew formula + continue-on-error: true + env: + GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + run: | + VERSION="${GITHUB_REF#refs/tags/v}" + cd cli && bun run update-homebrew-formula --version "$VERSION" --push diff --git a/README.md b/README.md index d47c87d0..1a172cbf 100644 --- a/README.md +++ b/README.md @@ -13,16 +13,39 @@ Run Claude Code / Codex / Gemini sessions locally and control them remotely thro - Track session progress with todo lists. - Supports multiple AI backends: Claude Code, Codex, and Gemini. +## Installation + +### Homebrew (macOS/Linux) + +```bash +brew install tiann/tap/hapi +``` + +### npm/npx + +```bash +npx @twsxtd/hapi +``` + +Or install globally: + +```bash +npm install -g @twsxtd/hapi +``` + +### Prebuilt binary + +Download from [Releases](https://github.com/tiann/hapi/releases). + ## Quickstart 1. Start the server on a machine you control: ```bash -npx @twsxtd/hapi server +hapi server +# or: npx @twsxtd/hapi server ``` -> Alternatively, download the prebuilt binary from [Releases](https://github.com/tiann/hapi/releases) and run `hapi server`. - 2. If the server has no public IP, expose it over HTTPS: - Cloudflare Tunnel: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/ - Tailscale: https://tailscale.com/kb/ @@ -33,7 +56,8 @@ npx @twsxtd/hapi server # If the server is not on localhost:3006 export HAPI_SERVER_URL="https://your-domain.example" -npx @twsxtd/hapi +hapi +# or: npx @twsxtd/hapi ``` 4. Open the UI in a browser at the server URL and log in with `CLI_API_TOKEN`. @@ -65,9 +89,9 @@ ALLOWED_CHAT_IDS="12345678" ## Multi-agent support -- `npx @twsxtd/hapi` - Start a Claude Code session. -- `npx @twsxtd/hapi codex` - Start an OpenAI Codex session. -- `npx @twsxtd/hapi gemini` - Start a Google Gemini session. +- `hapi` - Start a Claude Code session. +- `hapi codex` - Start an OpenAI Codex session. +- `hapi gemini` - Start a Google Gemini session. ## CLI config file diff --git a/cli/.gitignore b/cli/.gitignore index 4ac01477..25292f19 100644 --- a/cli/.gitignore +++ b/cli/.gitignore @@ -12,6 +12,7 @@ oclif.manifest.json # Build outputs /dist-exe/ +/release-artifacts/ # Generated npm platform packages (created by prepare-npm-packages.ts) /npm/*/package.json diff --git a/cli/.release-it.json b/cli/.release-it.json index a63c2431..63d699e9 100644 --- a/cli/.release-it.json +++ b/cli/.release-it.json @@ -8,12 +8,10 @@ "addUntrackedFiles": false }, "github": { - "release": true, - "releaseName": "v${version}", - "releaseNotes": "node .release-it.notes.js ${latestTag} ${version}" + "release": false }, "npm": { - "publish": true + "publish": false }, "hooks": {} -} \ No newline at end of file +} diff --git a/cli/package.json b/cli/package.json index 6538e1c1..129e442b 100644 --- a/cli/package.json +++ b/cli/package.json @@ -42,6 +42,7 @@ "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", + "update-homebrew-formula": "bun run scripts/update-homebrew-formula.ts", "test": "tsx --env-file .env.integration-test node_modules/.bin/vitest run", "test:win": "vitest run", "dev": "tsx src/index.ts", diff --git a/cli/scripts/update-homebrew-formula.ts b/cli/scripts/update-homebrew-formula.ts new file mode 100644 index 00000000..53b28d45 --- /dev/null +++ b/cli/scripts/update-homebrew-formula.ts @@ -0,0 +1,231 @@ +/** + * Update the Homebrew formula for hapi. + * + * This script: + * 1. Reads checksums from release-artifacts/checksums.txt + * 2. Generates an updated hapi.rb formula + * 3. Optionally clones the tap repo, commits, and pushes + * + * Usage: + * # Generate formula locally (for review) + * bun run scripts/update-homebrew-formula.ts --version 0.1.0 + * + * # Generate and push to tap repository + * bun run scripts/update-homebrew-formula.ts --version 0.1.0 --push + * + * Environment: + * HOMEBREW_TAP_REPO - Git URL of the tap repository (default: https://github.com/tiann/homebrew-tap.git) + */ + +import { execSync } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { tmpdir } from 'node:os'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const projectRoot = join(__dirname, '..'); + +interface PlatformSha { + darwinArm64: string; + darwinX64: string; + linuxArm64: string; + linuxX64: string; +} + +function parseChecksums(checksumsPath: string): PlatformSha { + const content = readFileSync(checksumsPath, 'utf-8'); + const lines = content.trim().split('\n'); + + const shas: Partial = {}; + + for (const line of lines) { + const [sha, filename] = line.split(' '); + if (!sha || !filename) continue; + + if (filename.includes('darwin-arm64')) shas.darwinArm64 = sha; + else if (filename.includes('darwin-x64')) shas.darwinX64 = sha; + else if (filename.includes('linux-arm64')) shas.linuxArm64 = sha; + else if (filename.includes('linux-x64')) shas.linuxX64 = sha; + } + + const missing: string[] = []; + if (!shas.darwinArm64) missing.push('darwin-arm64'); + if (!shas.darwinX64) missing.push('darwin-x64'); + if (!shas.linuxArm64) missing.push('linux-arm64'); + if (!shas.linuxX64) missing.push('linux-x64'); + + if (missing.length > 0) { + throw new Error(`Missing SHA256 checksums for: ${missing.join(', ')}`); + } + + return shas as PlatformSha; +} + +function generateFormula(version: string, shas: PlatformSha): string { + return `# typed: false +# frozen_string_literal: true + +class Hapi < Formula + desc "App for agentic coding - access coding agent anywhere" + homepage "https://github.com/tiann/hapi" + version "${version}" + license "MIT" + + on_macos do + if Hardware::CPU.arm? + url "https://github.com/tiann/hapi/releases/download/v#{version}/hapi-darwin-arm64.tar.gz" + sha256 "${shas.darwinArm64}" + else + url "https://github.com/tiann/hapi/releases/download/v#{version}/hapi-darwin-x64.tar.gz" + sha256 "${shas.darwinX64}" + end + end + + on_linux do + if Hardware::CPU.arm? + url "https://github.com/tiann/hapi/releases/download/v#{version}/hapi-linux-arm64.tar.gz" + sha256 "${shas.linuxArm64}" + else + url "https://github.com/tiann/hapi/releases/download/v#{version}/hapi-linux-x64.tar.gz" + sha256 "${shas.linuxX64}" + end + end + + def install + bin.install "hapi" + end + + test do + assert_match version.to_s, shell_output("#{bin}/hapi --version") + end +end +`; +} + +function printUsage(): void { + console.log(`Usage: + bun run scripts/update-homebrew-formula.ts --version [--push] + +Options: + --version Version to update to (required) + --push Clone tap repo, commit and push changes + --help Show this help message + +Examples: + # Generate formula locally + bun run scripts/update-homebrew-formula.ts --version 0.1.0 + + # Push to tap repository + bun run scripts/update-homebrew-formula.ts --version 0.1.0 --push +`); +} + +async function main(): Promise { + const args = process.argv.slice(2); + + if (args.includes('--help') || args.includes('-h')) { + printUsage(); + process.exit(0); + } + + const versionIdx = args.indexOf('--version'); + if (versionIdx === -1 || !args[versionIdx + 1]) { + console.error('Error: --version is required\n'); + printUsage(); + process.exit(1); + } + + const version = args[versionIdx + 1]; + const shouldPush = args.includes('--push'); + const tapRepo = process.env.HOMEBREW_TAP_REPO || 'https://github.com/tiann/homebrew-tap.git'; + const checksumsPath = join(projectRoot, 'release-artifacts', 'checksums.txt'); + + if (!existsSync(checksumsPath)) { + console.error(`Error: Checksums file not found: ${checksumsPath}`); + console.error('This file is generated by the CI workflow during release.'); + process.exit(1); + } + + console.log(`Generating Homebrew formula for v${version}...\n`); + + // Parse checksums + const shas = parseChecksums(checksumsPath); + console.log('SHA256 checksums:'); + console.log(` darwin-arm64: ${shas.darwinArm64}`); + console.log(` darwin-x64: ${shas.darwinX64}`); + console.log(` linux-arm64: ${shas.linuxArm64}`); + console.log(` linux-x64: ${shas.linuxX64}\n`); + + // Generate formula content + const formulaContent = generateFormula(version, shas); + + if (!shouldPush) { + // Just output the formula locally + const localFormulaDir = join(projectRoot, 'release-artifacts', 'Formula'); + mkdirSync(localFormulaDir, { recursive: true }); + + const localFormulaPath = join(localFormulaDir, 'hapi.rb'); + writeFileSync(localFormulaPath, formulaContent); + + console.log(`Formula generated: ${localFormulaPath}\n`); + console.log('To push to the tap repository, run with --push flag.'); + console.log(`Or manually copy to your homebrew-tap repo's Formula/ directory.`); + return; + } + + // Clone and push to tap repository + const githubToken = process.env.GITHUB_TOKEN; + let cloneUrl = tapRepo; + + // Use token-authenticated URL in CI + if (githubToken && tapRepo.includes('github.com')) { + cloneUrl = tapRepo.replace('https://github.com/', `https://x-access-token:${githubToken}@github.com/`); + } + + console.log(`Cloning ${tapRepo}...`); + + const tempDir = join(tmpdir(), `homebrew-tap-${Date.now()}`); + mkdirSync(tempDir, { recursive: true }); + + try { + execSync(`git clone --depth 1 "${cloneUrl}" "${tempDir}"`, { stdio: 'pipe' }); + + // Ensure Formula directory exists + const formulaDir = join(tempDir, 'Formula'); + mkdirSync(formulaDir, { recursive: true }); + + // Write formula + const formulaPath = join(formulaDir, 'hapi.rb'); + writeFileSync(formulaPath, formulaContent); + console.log(`Updated: ${formulaPath}`); + + // Configure git user for CI + if (githubToken) { + execSync('git config user.name "github-actions[bot]"', { cwd: tempDir, stdio: 'pipe' }); + execSync('git config user.email "github-actions[bot]@users.noreply.github.com"', { cwd: tempDir, stdio: 'pipe' }); + } + + // Commit and push + execSync('git add Formula/hapi.rb', { cwd: tempDir, stdio: 'pipe' }); + + try { + execSync(`git commit -m "Update hapi to v${version}"`, { cwd: tempDir, stdio: 'pipe' }); + execSync('git push origin main', { cwd: tempDir, stdio: 'pipe' }); + console.log(`\nSuccessfully pushed hapi v${version} to homebrew-tap`); + } catch { + console.log('\nNo changes to commit (formula already up to date)'); + } + + console.log('\nUsers can now install via:'); + console.log(' brew install tiann/tap/hapi'); + } finally { + // Cleanup + rmSync(tempDir, { recursive: true, force: true }); + } +} + +main().catch((error) => { + console.error('Error:', error.message || error); + process.exit(1); +});