mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-09 17:19:15 +00:00
Fix menubar install release lookup
This commit is contained in:
commit
74b1f1e14d
3 changed files with 150 additions and 31 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue