|
| 1 | +import { type } from "arktype"; |
| 2 | +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; |
| 3 | +import { join } from "node:path"; |
| 4 | + |
| 5 | +const FormulaRenames = type({ "[string]": "string" }); |
| 6 | + |
| 7 | +type Platform = "macos-arm64" | "macos-x64" | "linux-arm64" | "linux-x64"; |
| 8 | + |
| 9 | +export interface HomebrewRelease { |
| 10 | + version: string; |
| 11 | + checksums: Record<Platform, string>; |
| 12 | +} |
| 13 | + |
| 14 | +/** Release facts owned by scripts/release.sh; passed in so they live in one place. */ |
| 15 | +export interface HomebrewPackage { |
| 16 | + repo: string; // GitHub owner/name |
| 17 | + binary: string; // CLI binary, tarball stem, and legacy formula name |
| 18 | + formula: string; // `brew install` name |
| 19 | + description: string; |
| 20 | +} |
| 21 | + |
| 22 | +const formulaClass = (formula: string): string => |
| 23 | + formula.replace(/(?:^|-)([a-z])/g, (_, c: string) => c.toUpperCase()); |
| 24 | + |
| 25 | +function renderFormula(pkg: HomebrewPackage, release: HomebrewRelease): string { |
| 26 | + const source = ( |
| 27 | + platform: Platform, |
| 28 | + ): string => ` url "https://github.com/${pkg.repo}/releases/download/v${release.version}/${pkg.binary}-${release.version}-${platform}.tar.gz" |
| 29 | + sha256 "${release.checksums[platform]}"`; |
| 30 | + |
| 31 | + return `class ${formulaClass(pkg.formula)} < Formula |
| 32 | + desc "${pkg.description}" |
| 33 | + homepage "https://github.com/${pkg.repo}" |
| 34 | + version "${release.version}" |
| 35 | + license "GPL-2.0-only" |
| 36 | +
|
| 37 | + on_macos do |
| 38 | + on_arm do |
| 39 | +${source("macos-arm64")} |
| 40 | + end |
| 41 | + on_intel do |
| 42 | +${source("macos-x64")} |
| 43 | + end |
| 44 | + end |
| 45 | +
|
| 46 | + on_linux do |
| 47 | + on_arm do |
| 48 | +${source("linux-arm64")} |
| 49 | + end |
| 50 | + on_intel do |
| 51 | +${source("linux-x64")} |
| 52 | + end |
| 53 | + end |
| 54 | +
|
| 55 | + def install |
| 56 | + bin.install "${pkg.binary}" |
| 57 | + if File.directory?("plugins") |
| 58 | + (bin/"plugins").mkpath |
| 59 | + cp_r "plugins/.", bin/"plugins" |
| 60 | + end |
| 61 | + end |
| 62 | +
|
| 63 | + test do |
| 64 | + assert_predicate bin/"${pkg.binary}", :executable? |
| 65 | + end |
| 66 | +end |
| 67 | +`; |
| 68 | +} |
| 69 | + |
| 70 | +async function readFormulaRenames(path: string): Promise<Record<string, string>> { |
| 71 | + let raw: string; |
| 72 | + try { |
| 73 | + raw = await readFile(path, "utf8"); |
| 74 | + } catch (cause) { |
| 75 | + if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") return {}; |
| 76 | + throw cause; |
| 77 | + } |
| 78 | + |
| 79 | + const parsed: unknown = JSON.parse(raw); |
| 80 | + if (Array.isArray(parsed)) { |
| 81 | + throw new Error("Invalid formula rename metadata: expected an object"); |
| 82 | + } |
| 83 | + const renames = FormulaRenames(parsed); |
| 84 | + if (renames instanceof type.errors) { |
| 85 | + throw new Error(`Invalid formula rename metadata: ${renames.summary}`); |
| 86 | + } |
| 87 | + return renames; |
| 88 | +} |
| 89 | + |
| 90 | +export async function generateHomebrewTap( |
| 91 | + tapDir: string, |
| 92 | + pkg: HomebrewPackage, |
| 93 | + release: HomebrewRelease, |
| 94 | +): Promise<void> { |
| 95 | + const formulaDir = join(tapDir, "Formula"); |
| 96 | + const renamesPath = join(tapDir, "formula_renames.json"); |
| 97 | + const formula = renderFormula(pkg, release); |
| 98 | + const renames = await readFormulaRenames(renamesPath); |
| 99 | + renames[pkg.binary] = pkg.formula; |
| 100 | + const renameMetadata = `${JSON.stringify(renames, null, 2)}\n`; |
| 101 | + |
| 102 | + await mkdir(formulaDir, { recursive: true }); |
| 103 | + await rm(join(formulaDir, `${pkg.binary}.rb`), { force: true }); |
| 104 | + await writeFile(join(formulaDir, `${pkg.formula}.rb`), formula); |
| 105 | + await writeFile(renamesPath, renameMetadata); |
| 106 | +} |
| 107 | + |
| 108 | +function parseRelease(args: string[]): { tapDir: string; release: HomebrewRelease } { |
| 109 | + if (args.length !== 6) { |
| 110 | + throw new Error( |
| 111 | + "usage: generate-homebrew-tap.ts TAP_DIR VERSION MACOS_ARM64 MACOS_X64 LINUX_ARM64 LINUX_X64", |
| 112 | + ); |
| 113 | + } |
| 114 | + const [tapDir, version, macosArm64, macosX64, linuxArm64, linuxX64] = args; |
| 115 | + if (!tapDir || !version || !/^\d+\.\d+\.\d+$/.test(version)) { |
| 116 | + throw new Error("version must be X.Y.Z"); |
| 117 | + } |
| 118 | + const isChecksum = (value: string | undefined): value is string => |
| 119 | + value !== undefined && /^[0-9a-f]{64}$/.test(value); |
| 120 | + if ( |
| 121 | + !isChecksum(macosArm64) || |
| 122 | + !isChecksum(macosX64) || |
| 123 | + !isChecksum(linuxArm64) || |
| 124 | + !isChecksum(linuxX64) |
| 125 | + ) { |
| 126 | + throw new Error("invalid SHA-256 checksum"); |
| 127 | + } |
| 128 | + |
| 129 | + return { |
| 130 | + tapDir, |
| 131 | + release: { |
| 132 | + version, |
| 133 | + checksums: { |
| 134 | + "macos-arm64": macosArm64, |
| 135 | + "macos-x64": macosX64, |
| 136 | + "linux-arm64": linuxArm64, |
| 137 | + "linux-x64": linuxX64, |
| 138 | + }, |
| 139 | + }, |
| 140 | + }; |
| 141 | +} |
| 142 | + |
| 143 | +function requireEnv(name: string): string { |
| 144 | + const value = process.env[name]; |
| 145 | + if (!value) throw new Error(`missing ${name} (set by scripts/release.sh)`); |
| 146 | + return value; |
| 147 | +} |
| 148 | + |
| 149 | +if (import.meta.main) { |
| 150 | + const { tapDir, release } = parseRelease(process.argv.slice(2)); |
| 151 | + await generateHomebrewTap( |
| 152 | + tapDir, |
| 153 | + { |
| 154 | + repo: requireEnv("MAIN_REPO"), |
| 155 | + binary: requireEnv("BINARY"), |
| 156 | + formula: requireEnv("BREW_FORMULA"), |
| 157 | + description: requireEnv("DESC"), |
| 158 | + }, |
| 159 | + release, |
| 160 | + ); |
| 161 | +} |
0 commit comments