From 75b6925749d9f174fe961da75d2dbee1939a406b Mon Sep 17 00:00:00 2001 From: AgentSeal Date: Wed, 8 Jul 2026 21:36:20 +0200 Subject: [PATCH] Fix menubar install release lookup --- src/main.ts | 2 +- src/menubar-installer.ts | 133 +++++++++++++++++++++++++------- tests/menubar-installer.test.ts | 46 +++++++++++ 3 files changed, 150 insertions(+), 31 deletions(-) diff --git a/src/main.ts b/src/main.ts index a5378fd..cc99267 100644 --- a/src/main.ts +++ b/src/main.ts @@ -865,7 +865,7 @@ program .option('--force', 'Reinstall even if an older copy is already in ~/Applications') .action(async (opts: { force?: boolean }) => { try { - const result = await installMenubarApp({ force: opts.force }) + const result = await installMenubarApp({ force: opts.force, cliVersion: version }) console.log(`\n Ready. ${result.installedPath}\n`) } catch (err) { const message = err instanceof Error ? err.message : String(err) diff --git a/src/menubar-installer.ts b/src/menubar-installer.ts index fbbe600..7dbed16 100644 --- a/src/menubar-installer.ts +++ b/src/menubar-installer.ts @@ -13,10 +13,10 @@ import { resolvePersistentCodeburnPathFromWhichOutput, } from './persistent-codeburn.js' -/// Public GitHub repo that hosts macOS release builds. CLI and menubar releases share -/// the repository, so we scan recent releases and choose the newest `mac-v*` release -/// that actually contains the menubar zip. +/// Public GitHub repo that hosts macOS release builds. Normal installs use direct +/// versioned release asset URLs; the API scan is only a fallback for missing assets. const RELEASE_API = 'https://api.github.com/repos/getagentseal/codeburn/releases?per_page=20' +const RELEASE_DOWNLOAD_BASE = 'https://github.com/getagentseal/codeburn/releases/download' const APP_BUNDLE_NAME = 'CodeBurnMenubar.app' const EXPECTED_BUNDLE_ID = 'org.agentseal.codeburn-menubar' const VERSIONED_ASSET_PATTERN = /^CodeBurnMenubar-v.+\.zip$/ @@ -32,8 +32,17 @@ export type InstallResult = { installedPath: string; launched: boolean } export type ReleaseAsset = { name: string; browser_download_url: string } export type ReleaseResponse = { tag_name: string; assets: ReleaseAsset[] } export type ResolvedAssets = { release: ReleaseResponse; zip: ReleaseAsset; checksum: ReleaseAsset } +export type InstallOptions = { force?: boolean; cliVersion?: string } type ProxyEnv = Partial> type FetchOptions = Parameters[1] +type HeaderGetter = { get(name: string): string | null } + +class HttpStatusError extends Error { + constructor(message: string, readonly status: number) { + super(message) + this.name = 'HttpStatusError' + } +} export function resolveProxyUrlForUrl(url: string, env: ProxyEnv = process.env): string | undefined { const target = new URL(url) @@ -88,6 +97,48 @@ export function resolveLatestMenubarReleaseAssets(releases: ReleaseResponse[]): throw new Error('No mac-v* release with a CodeBurnMenubar-v*.zip and checksum was found.') } +function normalizeCliVersion(cliVersion: string): string { + return cliVersion.trim().replace(/^v/, '') +} + +export function resolveVersionedMenubarReleaseAssets(cliVersion: string): ResolvedAssets { + const version = normalizeCliVersion(cliVersion) + if (!version) throw new Error('Cannot resolve CodeBurn Menubar release without a CLI version.') + + const tagName = `mac-v${version}` + const zipName = `CodeBurnMenubar-v${version}.zip` + const checksumName = `${zipName}.sha256` + const releaseBase = `${RELEASE_DOWNLOAD_BASE}/${tagName}` + const zip = { name: zipName, browser_download_url: `${releaseBase}/${zipName}` } + const checksum = { name: checksumName, browser_download_url: `${releaseBase}/${checksumName}` } + + return { + release: { tag_name: tagName, assets: [zip, checksum] }, + zip, + checksum, + } +} + +export function shouldFallbackToReleaseApi(status: number): boolean { + return status === 404 || status === 410 +} + +export function formatGitHubReleaseLookupError(status: number, headers?: HeaderGetter): string { + const base = `GitHub release lookup failed: HTTP ${status}` + if (status !== 403 && status !== 429) return base + + const details = ['GitHub may be rate limiting unauthenticated release API requests.'] + const retryAfter = headers?.get('retry-after') + const rateLimitReset = headers?.get('x-ratelimit-reset') + if (retryAfter) details.push(`retry-after=${retryAfter}`) + if (rateLimitReset) details.push(`x-ratelimit-reset=${rateLimitReset}`) + return `${base}. ${details.join(' ')}` +} + +function isMissingDirectAssetError(err: unknown): boolean { + return err instanceof HttpStatusError && shouldFallbackToReleaseApi(err.status) +} + export { buildPersistentCodeburnLookupPath, resolvePersistentCodeburnPathFromWhichOutput, @@ -138,7 +189,7 @@ async function fetchLatestReleaseAssets(): Promise { }, }) if (!response.ok) { - throw new Error(`GitHub release lookup failed: HTTP ${response.status}`) + throw new HttpStatusError(formatGitHubReleaseLookupError(response.status, response.headers), response.status) } const body = await response.json() as ReleaseResponse[] return resolveLatestMenubarReleaseAssets(body) @@ -150,7 +201,7 @@ async function verifyChecksum(archivePath: string, checksumUrl: string): Promise redirect: 'follow', }) if (!response.ok) { - throw new Error(`Checksum download failed: HTTP ${response.status}`) + throw new HttpStatusError(`Checksum download failed: HTTP ${response.status}`, response.status) } const text = await response.text() const expected = text.trim().split(/\s+/)[0]!.toLowerCase() @@ -172,13 +223,41 @@ async function downloadToFile(url: string, destPath: string): Promise { redirect: 'follow', }) if (!response.ok || response.body === null) { - throw new Error(`Download failed: HTTP ${response.status}`) + throw new HttpStatusError(`Download failed: HTTP ${response.status}`, response.status) } // fetch's ReadableStream needs to be wrapped for Node streams. const nodeStream = Readable.fromWeb(response.body as never) await pipeline(nodeStream, createWriteStream(destPath)) } +async function stageMenubarApp(assets: ResolvedAssets, stagingDir: string): Promise { + const { zip, checksum } = assets + const archivePath = join(stagingDir, zip.name) + console.log(`Downloading ${zip.name}...`) + await downloadToFile(zip.browser_download_url, archivePath) + + console.log('Verifying checksum...') + await verifyChecksum(archivePath, checksum.browser_download_url) + + console.log('Unpacking...') + await runCommand('/usr/bin/ditto', ['-x', '-k', archivePath, stagingDir]) + + const unpackedApp = join(stagingDir, APP_BUNDLE_NAME) + if (!(await exists(unpackedApp))) { + throw new Error(`Archive did not contain ${APP_BUNDLE_NAME}.`) + } + + console.log('Verifying app bundle...') + await verifyBundleIdentity(unpackedApp) + + // Clear Gatekeeper's quarantine xattr. Without this, the first launch shows the + // "cannot verify developer" prompt even for a signed + notarized app when the bundle + // was delivered via curl/fetch instead of the Mac App Store. + await runCommand('/usr/bin/xattr', ['-dr', 'com.apple.quarantine', unpackedApp]).catch(() => {}) + + return unpackedApp +} + async function runCommand(command: string, args: string[]): Promise { return new Promise((resolve, reject) => { const proc = spawn(command, args, { stdio: 'inherit' }) @@ -260,7 +339,7 @@ async function killRunningApp(): Promise { } } -export async function installMenubarApp(options: { force?: boolean } = {}): Promise { +export async function installMenubarApp(options: InstallOptions = {}): Promise { await ensureSupportedPlatform() await persistCodeburnPath() @@ -275,34 +354,28 @@ export async function installMenubarApp(options: { force?: boolean } = {}): Prom return { installedPath: targetPath, launched: true } } - console.log('Looking up the latest CodeBurn Menubar release...') - const { zip, checksum } = await fetchLatestReleaseAssets() + const cliVersion = options.cliVersion ? normalizeCliVersion(options.cliVersion) : '' + let assets: ResolvedAssets + if (cliVersion) { + console.log(`Resolving CodeBurn Menubar v${cliVersion}...`) + assets = resolveVersionedMenubarReleaseAssets(cliVersion) + } else { + console.log('Looking up the latest CodeBurn Menubar release...') + assets = await fetchLatestReleaseAssets() + } const stagingDir = await mkdtemp(join(tmpdir(), 'codeburn-menubar-')) try { - const archivePath = join(stagingDir, zip.name) - console.log(`Downloading ${zip.name}...`) - await downloadToFile(zip.browser_download_url, archivePath) - - console.log('Verifying checksum...') - await verifyChecksum(archivePath, checksum.browser_download_url) - - console.log('Unpacking...') - await runCommand('/usr/bin/ditto', ['-x', '-k', archivePath, stagingDir]) - - const unpackedApp = join(stagingDir, APP_BUNDLE_NAME) - if (!(await exists(unpackedApp))) { - throw new Error(`Archive did not contain ${APP_BUNDLE_NAME}.`) + let unpackedApp: string + try { + unpackedApp = await stageMenubarApp(assets, stagingDir) + } catch (err) { + if (!cliVersion || !isMissingDirectAssetError(err)) throw err + console.log(`CodeBurn Menubar v${cliVersion} assets were not found. Looking up the latest CodeBurn Menubar release...`) + assets = await fetchLatestReleaseAssets() + unpackedApp = await stageMenubarApp(assets, stagingDir) } - console.log('Verifying app bundle...') - await verifyBundleIdentity(unpackedApp) - - // Clear Gatekeeper's quarantine xattr. Without this, the first launch shows the - // "cannot verify developer" prompt even for a signed + notarized app when the bundle - // was delivered via curl/fetch instead of the Mac App Store. - await runCommand('/usr/bin/xattr', ['-dr', 'com.apple.quarantine', unpackedApp]).catch(() => {}) - await mkdir(appsDir, { recursive: true }) if (alreadyInstalled) { // Kill the running copy before replacing its bundle so `mv` can proceed cleanly and the diff --git a/tests/menubar-installer.test.ts b/tests/menubar-installer.test.ts index 911145e..129fbac 100644 --- a/tests/menubar-installer.test.ts +++ b/tests/menubar-installer.test.ts @@ -1,10 +1,13 @@ import { describe, expect, it } from 'vitest' import { buildPersistentCodeburnLookupPath, + formatGitHubReleaseLookupError, resolveLatestMenubarReleaseAssets, resolveMenubarReleaseAssets, resolvePersistentCodeburnPathFromWhichOutput, resolveProxyUrlForUrl, + resolveVersionedMenubarReleaseAssets, + shouldFallbackToReleaseApi, type ReleaseResponse, } from '../src/menubar-installer.js' @@ -76,6 +79,49 @@ describe('resolveMenubarReleaseAssets', () => { expect(resolved.zip.name).toBe('CodeBurnMenubar-v0.9.8.zip') }) + it('builds direct release asset URLs from the CLI version', () => { + const resolved = resolveVersionedMenubarReleaseAssets('0.9.15') + + expect(resolved.release.tag_name).toBe('mac-v0.9.15') + expect(resolved.zip.name).toBe('CodeBurnMenubar-v0.9.15.zip') + expect(resolved.zip.browser_download_url).toBe( + 'https://github.com/getagentseal/codeburn/releases/download/mac-v0.9.15/CodeBurnMenubar-v0.9.15.zip' + ) + expect(resolved.checksum.name).toBe('CodeBurnMenubar-v0.9.15.zip.sha256') + expect(resolved.checksum.browser_download_url).toBe( + 'https://github.com/getagentseal/codeburn/releases/download/mac-v0.9.15/CodeBurnMenubar-v0.9.15.zip.sha256' + ) + }) + + it('normalizes a leading v when building direct release URLs', () => { + const resolved = resolveVersionedMenubarReleaseAssets('v0.9.15') + + expect(resolved.release.tag_name).toBe('mac-v0.9.15') + expect(resolved.zip.name).toBe('CodeBurnMenubar-v0.9.15.zip') + }) + + it('falls back to the release API only for missing direct assets', () => { + expect(shouldFallbackToReleaseApi(404)).toBe(true) + expect(shouldFallbackToReleaseApi(410)).toBe(true) + expect(shouldFallbackToReleaseApi(403)).toBe(false) + expect(shouldFallbackToReleaseApi(429)).toBe(false) + expect(shouldFallbackToReleaseApi(500)).toBe(false) + }) + + it('explains likely rate limiting for GitHub API 403 and 429 errors', () => { + const headerValues: Record = { + 'retry-after': '120', + 'x-ratelimit-reset': '1783539204', + } + const headers = { get: (name: string) => headerValues[name] ?? null } + + expect(formatGitHubReleaseLookupError(403, headers)).toContain( + 'GitHub may be rate limiting unauthenticated release API requests' + ) + expect(formatGitHubReleaseLookupError(403, headers)).toContain('retry-after=120') + expect(formatGitHubReleaseLookupError(429, headers)).toContain('x-ratelimit-reset=1783539204') + }) + it('preserves the caller PATH when building the persistent CLI lookup PATH', () => { const lookupPath = buildPersistentCodeburnLookupPath('/Users/me/.nvm/versions/node/v22.13.0/bin:/usr/bin')