77 */
88
99import { spawn } from "node:child_process" ;
10- import { chmodSync , unlinkSync } from "node:fs" ;
10+ import { chmodSync , realpathSync , unlinkSync } from "node:fs" ;
1111import { homedir } from "node:os" ;
1212import { join , sep } from "node:path" ;
1313import {
@@ -29,6 +29,7 @@ import { UpgradeError } from "./errors.js";
2929
3030export type InstallationMethod =
3131 | "curl"
32+ | "brew"
3233 | "npm"
3334 | "pnpm"
3435 | "bun"
@@ -162,6 +163,26 @@ async function isInstalledWith(pm: PackageManager): Promise<boolean> {
162163 }
163164}
164165
166+ /**
167+ * Detect if the CLI binary is running from a Homebrew Cellar.
168+ *
169+ * Homebrew places the real binary deep in the Cellar
170+ * (e.g. `/opt/homebrew/Cellar/sentry/1.2.3/bin/sentry`) and exposes it
171+ * via a symlink at the prefix bin dir (e.g. `/opt/homebrew/bin/sentry`).
172+ * `process.execPath` typically reflects the symlink, not the realpath, so
173+ * we resolve symlinks first before checking for `/Cellar/`. Falls back to
174+ * the unresolved path if `realpathSync` throws (e.g. binary was deleted).
175+ */
176+ function isHomebrewInstall ( ) : boolean {
177+ let execPath = process . execPath ;
178+ try {
179+ execPath = realpathSync ( execPath ) ;
180+ } catch {
181+ // Binary may have been deleted or moved; use the original path
182+ }
183+ return execPath . includes ( "/Cellar/" ) ;
184+ }
185+
165186/**
166187 * Legacy detection for existing installs that don't have stored install info.
167188 * Checks known curl install paths and package managers.
@@ -199,7 +220,14 @@ async function detectLegacyInstallationMethod(): Promise<InstallationMethod> {
199220 * @returns Detected installation method, or "unknown" if unable to determine
200221 */
201222export async function detectInstallationMethod ( ) : Promise < InstallationMethod > {
202- // 1. Check stored info (fast path)
223+ // Always check for Homebrew first — the stored install info may be stale
224+ // (e.g. user previously had a curl install recorded, then switched to
225+ // Homebrew). The realpath check is cheap and authoritative.
226+ if ( isHomebrewInstall ( ) ) {
227+ return "brew" ;
228+ }
229+
230+ // 1. Check stored info (fast path for non-Homebrew installs)
203231 const stored = getInstallInfo ( ) ;
204232 if ( stored ?. method ) {
205233 return stored . method ;
@@ -289,7 +317,7 @@ export async function fetchLatestFromNpm(): Promise<string> {
289317
290318/**
291319 * Fetch the latest available version based on installation method.
292- * curl installations check GitHub releases; package managers check npm.
320+ * curl and brew installations check GitHub releases; package managers check npm.
293321 *
294322 * @param method - How the CLI was installed
295323 * @returns Latest version string (without 'v' prefix)
@@ -298,7 +326,9 @@ export async function fetchLatestFromNpm(): Promise<string> {
298326export function fetchLatestVersion (
299327 method : InstallationMethod
300328) : Promise < string > {
301- return method === "curl" ? fetchLatestFromGitHub ( ) : fetchLatestFromNpm ( ) ;
329+ return method === "curl" || method === "brew"
330+ ? fetchLatestFromGitHub ( )
331+ : fetchLatestFromNpm ( ) ;
302332}
303333
304334/**
@@ -314,7 +344,7 @@ export async function versionExists(
314344 method : InstallationMethod ,
315345 version : string
316346) : Promise < boolean > {
317- if ( method === "curl" ) {
347+ if ( method === "curl" || method === "brew" ) {
318348 const response = await fetchWithUpgradeError (
319349 `${ GITHUB_RELEASES_URL } /tags/${ version } ` ,
320350 { method : "HEAD" , headers : getGitHubHeaders ( ) } ,
@@ -453,6 +483,43 @@ export async function downloadBinaryToTemp(
453483 }
454484}
455485
486+ /**
487+ * Execute upgrade via Homebrew.
488+ *
489+ * Runs `brew upgrade getsentry/tools/sentry` which fetches the latest
490+ * formula from the tap and installs the new version. The version argument
491+ * is intentionally ignored: Homebrew manages versioning through the formula
492+ * file in the tap and does not support pinning to an arbitrary release.
493+ *
494+ * @throws {UpgradeError } When brew upgrade fails
495+ */
496+ function executeUpgradeHomebrew ( ) : Promise < void > {
497+ return new Promise ( ( resolve , reject ) => {
498+ const proc = spawn ( "brew" , [ "upgrade" , "getsentry/tools/sentry" ] , {
499+ stdio : "inherit" ,
500+ } ) ;
501+
502+ proc . on ( "close" , ( code ) => {
503+ if ( code === 0 ) {
504+ resolve ( ) ;
505+ } else {
506+ reject (
507+ new UpgradeError (
508+ "execution_failed" ,
509+ `brew upgrade failed with exit code ${ code } `
510+ )
511+ ) ;
512+ }
513+ } ) ;
514+
515+ proc . on ( "error" , ( err ) => {
516+ reject (
517+ new UpgradeError ( "execution_failed" , `brew failed: ${ err . message } ` )
518+ ) ;
519+ } ) ;
520+ } ) ;
521+ }
522+
456523/**
457524 * Execute upgrade via package manager global install.
458525 *
@@ -516,6 +583,9 @@ export async function executeUpgrade(
516583 switch ( method ) {
517584 case "curl" :
518585 return downloadBinaryToTemp ( version ) ;
586+ case "brew" :
587+ await executeUpgradeHomebrew ( ) ;
588+ return null ;
519589 case "npm" :
520590 case "pnpm" :
521591 case "bun" :
@@ -530,6 +600,7 @@ export async function executeUpgrade(
530600/** Valid methods that can be specified via --method flag */
531601const VALID_METHODS : InstallationMethod [ ] = [
532602 "curl" ,
603+ "brew" ,
533604 "npm" ,
534605 "pnpm" ,
535606 "bun" ,
0 commit comments