forked from getagentseal/codeburn
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmenubar-installer.ts
More file actions
292 lines (258 loc) · 10.5 KB
/
Copy pathmenubar-installer.ts
File metadata and controls
292 lines (258 loc) · 10.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
import { spawn } from 'node:child_process'
import { createHash } from 'node:crypto'
import { createWriteStream } from 'node:fs'
import { chmod, mkdir, mkdtemp, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
import { homedir, platform, tmpdir } from 'node:os'
import { join } from 'node:path'
import { pipeline } from 'node:stream/promises'
import { Readable } from 'node:stream'
import {
buildPersistentCodeburnLookupPath,
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.
const RELEASE_API = 'https://api.github.com/repos/getagentseal/codeburn/releases?per_page=20'
const APP_BUNDLE_NAME = 'CodeBurnMenubar.app'
const EXPECTED_BUNDLE_ID = 'org.agentseal.codeburn-menubar'
const VERSIONED_ASSET_PATTERN = /^CodeBurnMenubar-v.+\.zip$/
const APP_PROCESS_NAME = 'CodeBurnMenubar'
const SUPPORTED_OS = 'darwin'
const MIN_MACOS_MAJOR = 14
const PERSISTED_CLI_PATH = join(homedir(), 'Library', 'Application Support', 'CodeBurn', 'codeburn-cli-path.v1')
const PERSISTENT_CLI_REQUIRED_MESSAGE =
'The menubar app needs a persistent codeburn command. Install CodeBurn globally first: npm install -g codeburn'
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 function resolveMenubarReleaseAssets(release: ReleaseResponse): ResolvedAssets {
const zip = release.assets.find(a => VERSIONED_ASSET_PATTERN.test(a.name))
if (!zip) {
throw new Error(
`No ${APP_BUNDLE_NAME} versioned zip found in release ${release.tag_name}. ` +
`Check https://github.com/getagentseal/codeburn/releases.`
)
}
const checksum = release.assets.find(a => a.name === `${zip.name}.sha256`)
if (!checksum) {
throw new Error(`Missing checksum asset ${zip.name}.sha256 in release ${release.tag_name}.`)
}
return { release, zip, checksum }
}
export function resolveLatestMenubarReleaseAssets(releases: ReleaseResponse[]): ResolvedAssets {
for (const release of releases) {
if (!release.tag_name.startsWith('mac-v')) continue
try {
return resolveMenubarReleaseAssets(release)
} catch {
continue
}
}
throw new Error('No mac-v* release with a CodeBurnMenubar-v*.zip and checksum was found.')
}
export {
buildPersistentCodeburnLookupPath,
resolvePersistentCodeburnPathFromWhichOutput,
} from './persistent-codeburn.js'
function userApplicationsDir(): string {
return join(homedir(), 'Applications')
}
async function exists(path: string): Promise<boolean> {
try {
await stat(path)
return true
} catch {
return false
}
}
async function ensureSupportedPlatform(): Promise<void> {
if (platform() !== SUPPORTED_OS) {
throw new Error(`The menubar app is macOS only (detected: ${platform()}).`)
}
const major = Number((process.env.CODEBURN_FORCE_MACOS_MAJOR ?? '')
|| (await sysProductVersion()).split('.')[0])
if (!Number.isFinite(major) || major < MIN_MACOS_MAJOR) {
throw new Error(`macOS ${MIN_MACOS_MAJOR}+ required (detected ${major}).`)
}
}
async function sysProductVersion(): Promise<string> {
return new Promise((resolve, reject) => {
const proc = spawn('/usr/bin/sw_vers', ['-productVersion'])
let out = ''
proc.stdout.on('data', (chunk: Buffer) => { out += chunk.toString() })
proc.on('error', reject)
proc.on('close', (code) => {
if (code !== 0) reject(new Error(`sw_vers exited with ${code}`))
else resolve(out.trim())
})
})
}
async function fetchLatestReleaseAssets(): Promise<ResolvedAssets> {
const response = await fetch(RELEASE_API, {
headers: {
'User-Agent': 'codeburn-menubar-installer',
Accept: 'application/vnd.github+json',
},
})
if (!response.ok) {
throw new Error(`GitHub release lookup failed: HTTP ${response.status}`)
}
const body = await response.json() as ReleaseResponse[]
return resolveLatestMenubarReleaseAssets(body)
}
async function verifyChecksum(archivePath: string, checksumUrl: string): Promise<void> {
const response = await fetch(checksumUrl, {
headers: { 'User-Agent': 'codeburn-menubar-installer' },
redirect: 'follow',
})
if (!response.ok) {
throw new Error(`Checksum download failed: HTTP ${response.status}`)
}
const text = await response.text()
const expected = text.trim().split(/\s+/)[0]!.toLowerCase()
const fileBytes = await readFile(archivePath)
const actual = createHash('sha256').update(fileBytes).digest('hex')
if (actual !== expected) {
throw new Error(
`Checksum mismatch for ${archivePath}.\n` +
` Expected: ${expected}\n` +
` Got: ${actual}\n` +
`The download may be corrupted or tampered with.`
)
}
}
async function downloadToFile(url: string, destPath: string): Promise<void> {
const response = await fetch(url, {
headers: { 'User-Agent': 'codeburn-menubar-installer' },
redirect: 'follow',
})
if (!response.ok || response.body === null) {
throw new Error(`Download failed: HTTP ${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 runCommand(command: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const proc = spawn(command, args, { stdio: 'inherit' })
proc.on('error', reject)
proc.on('close', (code) => {
if (code === 0) resolve()
else reject(new Error(`${command} exited with status ${code}`))
})
})
}
async function captureCommand(command: string, args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
const proc = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] })
let out = ''
let err = ''
proc.stdout.on('data', (chunk: Buffer) => { out += chunk.toString() })
proc.stderr.on('data', (chunk: Buffer) => { err += chunk.toString() })
proc.on('error', reject)
proc.on('close', (code) => {
if (code === 0) resolve(out.trim())
else reject(new Error(`${command} exited with status ${code}${err ? `: ${err.trim()}` : ''}`))
})
})
}
async function verifyBundleIdentity(appPath: string): Promise<void> {
const bundleID = await captureCommand('/usr/libexec/PlistBuddy', [
'-c',
'Print :CFBundleIdentifier',
join(appPath, 'Contents', 'Info.plist'),
])
if (bundleID !== EXPECTED_BUNDLE_ID) {
throw new Error(`Unexpected menubar bundle id ${bundleID}; expected ${EXPECTED_BUNDLE_ID}.`)
}
await runCommand('/usr/bin/codesign', ['--verify', '--deep', '--strict', appPath])
}
async function resolvePersistentCodeburnPath(): Promise<string> {
let output = ''
try {
output = await captureCommand('/usr/bin/env', [
`PATH=${buildPersistentCodeburnLookupPath()}`,
'which',
'-a',
'codeburn',
])
} catch {
throw new Error(PERSISTENT_CLI_REQUIRED_MESSAGE)
}
return resolvePersistentCodeburnPathFromWhichOutput(output, PERSISTENT_CLI_REQUIRED_MESSAGE)
}
async function persistCodeburnPath(): Promise<void> {
const cliPath = await resolvePersistentCodeburnPath()
await mkdir(join(homedir(), 'Library', 'Application Support', 'CodeBurn'), { recursive: true, mode: 0o700 })
await writeFile(PERSISTED_CLI_PATH, `${cliPath}\n`, { mode: 0o600 })
await chmod(PERSISTED_CLI_PATH, 0o600)
}
async function isAppRunning(): Promise<boolean> {
return new Promise((resolve) => {
const proc = spawn('/usr/bin/pgrep', ['-f', APP_PROCESS_NAME])
proc.on('close', (code) => resolve(code === 0))
proc.on('error', () => resolve(false))
})
}
async function killRunningApp(): Promise<void> {
await new Promise<void>((resolve) => {
const proc = spawn('/usr/bin/pkill', ['-f', APP_PROCESS_NAME])
proc.on('close', () => resolve())
proc.on('error', () => resolve())
})
for (let i = 0; i < 10; i++) {
if (!(await isAppRunning())) return
await new Promise(r => setTimeout(r, 500))
}
}
export async function installMenubarApp(options: { force?: boolean } = {}): Promise<InstallResult> {
await ensureSupportedPlatform()
await persistCodeburnPath()
const appsDir = userApplicationsDir()
const targetPath = join(appsDir, APP_BUNDLE_NAME)
const alreadyInstalled = await exists(targetPath)
if (alreadyInstalled && !options.force) {
if (!(await isAppRunning())) {
await runCommand('/usr/bin/open', [targetPath])
}
return { installedPath: targetPath, launched: true }
}
console.log('Looking up the latest CodeBurn Menubar release...')
const { zip, checksum } = 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}.`)
}
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
// user ends up on the new version.
await killRunningApp()
await rm(targetPath, { recursive: true, force: true })
}
await rename(unpackedApp, targetPath)
console.log('Launching CodeBurn Menubar...')
await runCommand('/usr/bin/open', [targetPath])
return { installedPath: targetPath, launched: true }
} finally {
await rm(stagingDir, { recursive: true, force: true })
}
}