Skip to content

Commit 1e7b397

Browse files
feat(release): add Homebrew install support (#277)
Closes #228 ## What Adds `brew install getsentry/tools/sentry` as a supported install method. ## Changes **`.craft.yml`** — new `brew` target that publishes `Formula/sentry.rb` to the `getsentry/homebrew-tools` tap on each stable release. Uses `includeNames` to filter to the 4 macOS/Linux binaries only (no `.gz`, no Windows, no npm tarball). Checksum template keys are the raw filenames since they contain no dots or version strings. **`src/lib/upgrade.ts`** — adds `"brew"` as an `InstallationMethod`. Detection checks for `/Cellar/` in `process.execPath` (works for both Apple Silicon and Intel Homebrew prefixes). Upgrade runs `brew upgrade getsentry/tools/sentry`. Version pinning is intentionally unsupported since Homebrew manages versioning through the formula. **Docs** — `getting-started.mdx` and `commands/cli/upgrade.md` updated to document the new install and upgrade paths. --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 25fe2f4 commit 1e7b397

File tree

12 files changed

+389
-14
lines changed

12 files changed

+389
-14
lines changed

.craft.yml

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,51 @@ targets:
1717
- name: npm
1818
- name: github
1919
- name: gh-pages
20+
- name: brew
21+
tap: getsentry/tools
22+
formula: sentry
23+
path: Formula
24+
includeNames: '/^sentry-(darwin|linux)-(arm64|x64)\.gz$/'
25+
template: |
26+
class Sentry < Formula
27+
desc "Sentry command-line tool for error monitoring and debugging"
28+
homepage "https://cli.sentry.dev"
29+
version "{{version}}"
30+
license "FSL-1.1-MIT"
31+
32+
if OS.mac?
33+
if Hardware::CPU.arm?
34+
url "https://github.com/getsentry/cli/releases/download/{{version}}/sentry-darwin-arm64.gz"
35+
sha256 "{{checksums.sentry-darwin-arm64__gz}}"
36+
elsif Hardware::CPU.intel?
37+
url "https://github.com/getsentry/cli/releases/download/{{version}}/sentry-darwin-x64.gz"
38+
sha256 "{{checksums.sentry-darwin-x64__gz}}"
39+
else
40+
raise "Unsupported macOS CPU architecture: #{Hardware::CPU.type}"
41+
end
42+
elsif OS.linux?
43+
if Hardware::CPU.arm? && Hardware::CPU.is_64_bit?
44+
url "https://github.com/getsentry/cli/releases/download/{{version}}/sentry-linux-arm64.gz"
45+
sha256 "{{checksums.sentry-linux-arm64__gz}}"
46+
elsif Hardware::CPU.intel? && Hardware::CPU.is_64_bit?
47+
url "https://github.com/getsentry/cli/releases/download/{{version}}/sentry-linux-x64.gz"
48+
sha256 "{{checksums.sentry-linux-x64__gz}}"
49+
else
50+
raise "Unsupported Linux CPU architecture: #{Hardware::CPU.type} (only 64-bit arm and x86_64 are supported)"
51+
end
52+
else
53+
raise "Unsupported operating system"
54+
end
55+
56+
def install
57+
bin.install Dir["sentry-*"].first => "sentry"
58+
end
59+
60+
def post_install
61+
system bin/"sentry", "cli", "setup", "--method", "brew", "--no-modify-path"
62+
end
63+
64+
test do
65+
assert_match version.to_s, shell_output("#{bin}/sentry --version").chomp
66+
end
67+
end

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@
2222
curl -fsSL https://cli.sentry.dev/install | bash
2323
```
2424

25+
### Homebrew
26+
27+
```bash
28+
brew install getsentry/tools/sentry
29+
```
30+
2531
### Package Managers
2632

2733
```bash

docs/src/content/docs/commands/cli/upgrade.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ sentry cli upgrade --method npm # Force using npm to upgrade
2020
|--------|-------------|
2121
| `<version>` | Target version to install (defaults to latest) |
2222
| `--check` | Check for updates without installing |
23-
| `--method <method>` | Force installation method: curl, npm, pnpm, bun, yarn |
23+
| `--method <method>` | Force installation method: curl, brew, npm, pnpm, bun, yarn |
2424

2525
## Installation Detection
2626

@@ -29,6 +29,7 @@ The CLI auto-detects how it was installed and uses the same method to upgrade:
2929
| Method | Detection |
3030
|--------|-----------|
3131
| curl | Binary located in `~/.sentry/bin` (installed via cli.sentry.dev) |
32+
| brew | Binary located in a Homebrew Cellar (installed via `brew install getsentry/tools/sentry`) |
3233
| npm | Globally installed via `npm install -g sentry` |
3334
| pnpm | Globally installed via `pnpm add -g sentry` |
3435
| bun | Globally installed via `bun install -g sentry` |

docs/src/content/docs/getting-started.mdx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ Install the Sentry CLI using the install script:
1515
curl https://cli.sentry.dev/install -fsS | bash
1616
```
1717

18+
### Homebrew
19+
20+
```bash
21+
brew install getsentry/tools/sentry
22+
```
23+
1824
### Package Managers
1925

2026
Install globally with your preferred package manager:

docs/src/content/docs/index.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export const base = import.meta.env.BASE_URL.endsWith('/') ? import.meta.env.BAS
2222
<div class="hero-command">
2323
<InstallSelector options={[
2424
{ label: "curl", command: "curl https://cli.sentry.dev/install -fsS | bash" },
25+
{ label: "brew", command: "brew install getsentry/tools/sentry" },
2526
{ label: "npx", command: "npx sentry@latest" },
2627
{ label: "npm", command: "npm install -g sentry" },
2728
{ label: "pnpm", command: "pnpm add -g sentry" },

plugins/sentry-cli/skills/sentry-cli/SKILL.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ The CLI must be installed and authenticated before use.
1515

1616
```bash
1717
curl https://cli.sentry.dev/install -fsS | bash
18+
brew install getsentry/tools/sentry
1819

1920
# Or install via npm/pnpm/bun
2021
npm install -g sentry
@@ -435,7 +436,7 @@ Update the Sentry CLI to the latest version
435436

436437
**Flags:**
437438
- `--check - Check for updates without installing`
438-
- `--method <value> - Installation method to use (curl, npm, pnpm, bun, yarn)`
439+
- `--method <value> - Installation method to use (curl, brew, npm, pnpm, bun, yarn)`
439440

440441
### Repo
441442

src/commands/cli/upgrade.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ export const upgradeCommand = buildCommand({
157157
method: {
158158
kind: "parsed",
159159
parse: parseInstallationMethod,
160-
brief: "Installation method to use (curl, npm, pnpm, bun, yarn)",
160+
brief: "Installation method to use (curl, brew, npm, pnpm, bun, yarn)",
161161
optional: true,
162162
placeholder: "method",
163163
},
@@ -177,6 +177,15 @@ export const upgradeCommand = buildCommand({
177177
throw new UpgradeError("unknown_method");
178178
}
179179

180+
// Homebrew manages versioning through the formula in the tap — the installed
181+
// version is always whatever the formula specifies, not an arbitrary release.
182+
if (method === "brew" && version) {
183+
throw new UpgradeError(
184+
"unsupported_operation",
185+
"Homebrew does not support installing a specific version. Run 'brew upgrade getsentry/tools/sentry' to upgrade to the latest formula version."
186+
);
187+
}
188+
180189
stdout.write(`Installation method: ${method}\n`);
181190
stdout.write(`Current version: ${CLI_VERSION}\n`);
182191

@@ -213,10 +222,9 @@ export const upgradeCommand = buildCommand({
213222
} finally {
214223
releaseLock(downloadResult.lockPath);
215224
}
216-
} else {
225+
} else if (method !== "brew") {
217226
// Package manager: binary already in place, just run setup.
218-
// Always use execPath — storedInfo?.path could reference a stale
219-
// binary from a different installation method.
227+
// Skip brew — Homebrew's post_install hook already runs setup.
220228
await runSetupOnNewBinary(this.process.execPath, method, false);
221229
}
222230

src/lib/errors.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,7 @@ export class DeviceFlowError extends CliError {
232232

233233
export type UpgradeErrorReason =
234234
| "unknown_method"
235+
| "unsupported_operation"
235236
| "network_error"
236237
| "execution_failed"
237238
| "version_not_found";
@@ -249,6 +250,8 @@ export class UpgradeError extends CliError {
249250
const defaultMessages: Record<UpgradeErrorReason, string> = {
250251
unknown_method:
251252
"Could not detect installation method. Use --method to specify.",
253+
unsupported_operation:
254+
"This operation is not supported for this installation method.",
252255
network_error: "Failed to fetch version information.",
253256
execution_failed: "Upgrade command failed.",
254257
version_not_found: "The specified version was not found.",

src/lib/upgrade.ts

Lines changed: 76 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
import { spawn } from "node:child_process";
10-
import { chmodSync, unlinkSync } from "node:fs";
10+
import { chmodSync, realpathSync, unlinkSync } from "node:fs";
1111
import { homedir } from "node:os";
1212
import { join, sep } from "node:path";
1313
import {
@@ -29,6 +29,7 @@ import { UpgradeError } from "./errors.js";
2929

3030
export 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
*/
201222
export 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> {
298326
export 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 */
531601
const VALID_METHODS: InstallationMethod[] = [
532602
"curl",
603+
"brew",
533604
"npm",
534605
"pnpm",
535606
"bun",

test/commands/cli/upgrade.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,39 @@ describe("sentry cli upgrade", () => {
216216
});
217217
});
218218

219+
describe("brew method", () => {
220+
test("errors immediately when specific version requested with brew", async () => {
221+
// No fetch mock needed — error is thrown before any network call
222+
const { context, output, errors } = createMockContext({
223+
homeDir: testDir,
224+
});
225+
226+
await run(app, ["cli", "upgrade", "--method", "brew", "1.2.3"], context);
227+
228+
const allOutput = [...output, ...errors].join("");
229+
expect(allOutput).toContain(
230+
"Homebrew does not support installing a specific version"
231+
);
232+
});
233+
234+
test("check mode works for brew method", async () => {
235+
mockGitHubVersion("99.99.99");
236+
237+
const { context, output } = createMockContext({ homeDir: testDir });
238+
239+
await run(
240+
app,
241+
["cli", "upgrade", "--check", "--method", "brew"],
242+
context
243+
);
244+
245+
const combined = output.join("");
246+
expect(combined).toContain("Installation method: brew");
247+
expect(combined).toContain("Latest version: 99.99.99");
248+
expect(combined).toContain("Run 'sentry cli upgrade' to update.");
249+
});
250+
});
251+
219252
describe("version validation", () => {
220253
test("reports error for non-existent version", async () => {
221254
// Mock: latest is 99.99.99, but 0.0.1 doesn't exist

0 commit comments

Comments
 (0)