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
+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);
});