| 1 | import fs from "node:fs"; |
| 2 | import path from "node:path"; |
| 3 | import crypto from "node:crypto"; |
| 4 | import { spawnSync } from "node:child_process"; |
| 5 | |
| 6 | /** Publish a verified bundle in one rename, retaining the previous install. */ |
| 7 | export function replaceMacBundle(source, destination, { prepare = () => {}, verify = verifySignature } = {}) { |
| 8 | const parent = path.dirname(destination); |
| 9 | fs.mkdirSync(parent, { recursive: true }); |
| 10 | if (fs.existsSync(destination) && !fs.existsSync(path.join(destination, "Contents", "Resources", "plugin", "app", "daemon.mjs"))) throw new Error("The installation destination contains a different application."); |
| 11 | if (fs.existsSync(destination) && fs.lstatSync(destination).isSymbolicLink()) throw new Error("The installation destination must not be a symlink."); |
| 12 | const staging = fs.mkdtempSync(path.join(parent, ".codewhale-cu-update-")); |
| 13 | const next = path.join(staging, path.basename(destination)); |
| 14 | let backup = null; |
| 15 | try { |
| 16 | fs.cpSync(source, next, { recursive: true }); |
| 17 | prepare(next); |
| 18 | verify(next); |
| 19 | if (fs.existsSync(destination)) { |
| 20 | const backups = path.join(parent, ".codewhale-cu-backups"); |
| 21 | fs.mkdirSync(backups, { recursive: true, mode: 0o700 }); |
| 22 | backup = path.join(backups, `${Date.now()}-${crypto.randomUUID()}.app`); |
| 23 | fs.renameSync(destination, backup); |
| 24 | } |
| 25 | try { fs.renameSync(next, destination); } |
| 26 | catch (error) { if (backup) fs.renameSync(backup, destination); throw error; } |
| 27 | return { backup }; |
| 28 | } finally { fs.rmSync(staging, { recursive: true, force: true }); } |
| 29 | } |
| 30 | |
| 31 | export function verifySignature(bundle) { |
| 32 | const result=spawnSync("codesign",["--verify","--deep","--strict",bundle],{encoding:"utf8"}); |
| 33 | if(result.status!==0) throw new Error(`The app signature did not verify: ${result.stderr?.trim() ?? "codesign unavailable"}`); |
| 34 | } |
| 35 | |
| 36 | export function verifyReleaseBundle(bundle) { |
| 37 | verifySignature(bundle); |
| 38 | const requirement='=anchor apple generic and identifier "net.codewhale.computer-use" and certificate leaf[subject.OU] = "5RDNSHA5TY"'; |
| 39 | for(const [command,args] of [["/usr/bin/codesign",["--verify","--strict","-R",requirement,bundle]],["/usr/sbin/spctl",["--assess","--type","execute","--verbose=2",bundle]]]) { |
| 40 | const result=spawnSync(command,args,{encoding:"utf8"}); |
| 41 | // Gatekeeper ships with macOS. Requiring its notarized source also rejects |
| 42 | // local allow-list overrides; consumer Macs do not need Xcode's stapler. |
| 43 | if(result.status!==0 || (command.endsWith("/spctl") && !/^source=Notarized Developer ID\r?$/m.test(result.stderr))) throw new Error("The update is not a valid notarized Codewhale release. Your current app has been kept."); |
| 44 | } |
| 45 | if(!fs.existsSync(path.join(bundle,"Contents","MacOS","node"))) throw new Error("The release is missing its bundled runtime."); |
| 46 | } |
| 47 |