Fix menubar install release lookup

This commit is contained in:
AgentSeal 2026-07-08 21:36:20 +02:00
parent cc6d84a6ca
commit 75b6925749
3 changed files with 150 additions and 31 deletions

View file

@ -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)

View file

@ -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<Record<'HTTPS_PROXY' | 'https_proxy' | 'HTTP_PROXY' | 'http_proxy' | 'NO_PROXY' | 'no_proxy', string>>
type FetchOptions = Parameters<typeof undiciFetch>[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<ResolvedAssets> {
},
})
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<void> {
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<string> {
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<void> {
return new Promise((resolve, reject) => {
const proc = spawn(command, args, { stdio: 'inherit' })
@ -260,7 +339,7 @@ async function killRunningApp(): Promise<void> {
}
}
export async function installMenubarApp(options: { force?: boolean } = {}): Promise<InstallResult> {
export async function installMenubarApp(options: InstallOptions = {}): Promise<InstallResult> {
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

View file

@ -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<string, string> = {
'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')