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
This commit is contained in:
weishu
2025-12-25 10:23:16 +08:00
parent 5a3f10078e
commit 90cb7dae05
6 changed files with 289 additions and 19 deletions
+22 -7
View File
@@ -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
+31 -7
View File
@@ -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
+1
View File
@@ -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
+3 -5
View File
@@ -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": {}
}
}
+1
View File
@@ -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",
+231
View File
@@ -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<PlatformSha> = {};
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 <version> [--push]
Options:
--version <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<void> {
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);
});