Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions apps/cli-docs/src/content/docs/getting-started.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,9 @@ sentry auth
You'll be given a URL and a code to enter. Once you authorize the application
in your browser, the CLI stores the OAuth credentials. When the server provides
a refresh token, the CLI refreshes the access token automatically. Persist the
Sentry CLI configuration directory (`~/.sentry/` by default, overridable with
`SENTRY_CONFIG_DIR`) across runs to keep automatic refresh working.
Sentry CLI configuration directory (`$XDG_CONFIG_HOME/sentry/`, defaulting to
`~/.config/sentry/`, overridable with `SENTRY_CONFIG_DIR`) across runs to keep
automatic refresh working.

### API Token

Expand Down Expand Up @@ -177,7 +178,7 @@ See the [Self-Hosted](../self-hosted/) guide for full setup details.

## Configuration

Credentials are stored in a SQLite database at `~/.sentry/` with restricted file permissions (mode 600) for security. See [Configuration](../configuration/) for environment variables and customization options.
Credentials are stored in a SQLite database under `$XDG_CONFIG_HOME/sentry/` (defaulting to `~/.config/sentry/`) with restricted file permissions (mode 600) for security. See [Configuration](../configuration/) for environment variables and customization options.

## Next Steps

Expand Down
15 changes: 14 additions & 1 deletion apps/cli-docs/src/fragments/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,24 @@ The `sentry api` command also uses `--verbose` to show full HTTP request/respons

## Credential Storage

We store credentials and caches in a SQLite database (`cli.db`) inside the config directory (`~/.sentry/` by default, overridable via `SENTRY_CONFIG_DIR`). The database file and its WAL side-files are created with restricted permissions (mode 600) so that only the current user can read them. The database also caches:
We store credentials and caches in a SQLite database (`cli.db`) inside the config directory. The location follows the [XDG Base Directory specification](https://specifications.freedesktop.org/basedir/latest/): by default the CLI uses `$XDG_CONFIG_HOME/sentry` (i.e. `~/.config/sentry/` when `XDG_CONFIG_HOME` is unset), and you can override it with `SENTRY_CONFIG_DIR`. For backward compatibility, if a legacy `~/.sentry/` directory already exists it continues to be used. The database file and its WAL side-files are created with restricted permissions (mode 600) so that only the current user can read them. The database also caches:

- Organization and project defaults
- DSN resolution results
- Region URL mappings
- Project aliases (for monorepo support)

See [Credential Storage](./commands/auth/#credential-storage) in the auth command docs for more details.

## Binary Install Location

When installed via the install script, the CLI binary is placed in an XDG-aligned directory. `sentry cli setup` resolves the location in this order:

1. `SENTRY_INSTALL_DIR` — explicit override
2. `$XDG_BIN_HOME` — used when set to an absolute path, per the XDG spec
3. `~/.local/bin` or `~/bin` — when either already exists and is on your `PATH`
4. `~/.local/bin` — default fallback

Older installs placed the binary in `~/.sentry/bin`. Running `sentry cli setup` moves an existing `~/.sentry/bin` binary into the resolved install directory (updating your `PATH` and recorded install metadata to match) and migrates any legacy `~/.sentry` config data (`cli.db`, `config.json`) into the XDG config directory. Both migrations are skipped when a binary or config already exists at the target.

`sentry upgrade` runs `setup` on the new binary, so it migrates too — but conservatively, because upgrade never edits your `PATH`. A legacy `~/.sentry/bin` binary is relocated to the XDG install directory **only when that directory is already on your `PATH`**, so the moved binary stays discoverable. If the XDG directory isn't on `PATH`, upgrade leaves the binary in place (a mislocated binary that vanished from `PATH` would break the command); run `sentry cli setup` explicitly to relocate it and update `PATH`. Legacy config data is migrated on upgrade regardless.
2 changes: 1 addition & 1 deletion packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ Run `sentry --help` to see all available commands, or browse the [command refere

## Configuration

Credentials are stored in `~/.sentry/` with restricted permissions (mode 600).
Credentials are stored in `$XDG_CONFIG_HOME/sentry/` (defaulting to `~/.config/sentry/`) with restricted permissions (mode 600). A pre-existing legacy `~/.sentry/` directory is still honored, and the location can be overridden with `SENTRY_CONFIG_DIR`.

## Library Usage

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/install
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,7 @@ trap - EXIT
# interactively — when piped (curl | bash), stdin is the pipe.
if [[ "${SENTRY_INIT:-}" == "1" ]]; then
sentry_bin=""
for dir in "${SENTRY_INSTALL_DIR:-}" "$HOME/.local/bin" "$HOME/bin" "$HOME/.sentry/bin"; do
for dir in "${SENTRY_INSTALL_DIR:-}" "${XDG_BIN_HOME:-}" "$HOME/.local/bin" "$HOME/bin" "$HOME/.sentry/bin"; do
[[ -z "$dir" ]] && continue
if [[ -x "${dir}/sentry" ]]; then
sentry_bin="${dir}/sentry"
Expand Down
115 changes: 113 additions & 2 deletions packages/cli/src/commands/cli/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,14 @@
* and the upgrade command for curl-based installs).
*/

import { existsSync, unlinkSync } from "node:fs";
import {
chmodSync,
copyFileSync,
existsSync,
mkdirSync,
renameSync,
unlinkSync,
} from "node:fs";
import { dirname, join } from "node:path";
import { captureException } from "@sentry/node-core/light";
import type { SentryContext } from "../../context.js";
Expand All @@ -28,6 +35,7 @@ import {
getAgentSkillsPreference,
setAgentSkillsPreference,
} from "../../lib/db/defaults.js";
import { closeDatabase, resolveXdgConfigDir } from "../../lib/db/index.js";
import { setInstallInfo } from "../../lib/db/install-info.js";
import {
parseReleaseChannel,
Expand Down Expand Up @@ -80,6 +88,89 @@ function formatSetupResult(result: SetupResult): string {
return result.messages.join("\n");
}

/**
* Migrate `cli.db` (+ WAL sidecars) and the old `config.json` out of the legacy
* `~/.sentry` directory into the XDG config directory.
*
* The database is opened at CLI startup (cleanup-old-binary reads install
* info), so it must be closed before the files are moved — an open SQLite file
* cannot be renamed on Windows. Closing also invalidates the cached handle, so
* the next `getDatabase()` reopens at the new path.
*/
function migrateLegacyConfig(
homeDir: string,
env: NodeJS.ProcessEnv,
emit: Logger
): void {
const legacyDir = join(homeDir, ".sentry");
// Target the XDG location directly — resolveConfigDir keeps returning the
// legacy dir while it still holds cli.db, which would make migration a no-op.
const targetConfigDir = resolveXdgConfigDir(env, homeDir);
if (targetConfigDir === legacyDir) {
return;
}

const configFiles = ["cli.db", "cli.db-wal", "cli.db-shm", "config.json"];
const hasLegacyConfig = configFiles.some((name) =>
existsSync(join(legacyDir, name))
);
if (!hasLegacyConfig || existsSync(join(targetConfigDir, "cli.db"))) {
return;
}

closeDatabase();
mkdirSync(targetConfigDir, { recursive: true, mode: 0o700 });
for (const name of configFiles) {
const from = join(legacyDir, name);
if (existsSync(from)) {
renameSync(from, join(targetConfigDir, name));
}
Comment thread
cursor[bot] marked this conversation as resolved.
}
emit(`Config: Migrated ${legacyDir} → ${targetConfigDir}`);
}

/**
* Migrate the binary out of the legacy `~/.sentry/bin` into the XDG-aware
* install dir. Returns the new binary path when a move happened, so the caller
* can point PATH setup and recorded install info at the new location instead of
* the now-deleted legacy path.
*/
function migrateLegacyBinary(
homeDir: string,
env: NodeJS.ProcessEnv,
emit: Logger
): string | undefined {
const legacyDir = join(homeDir, ".sentry");
const filename = getBinaryFilename();
const legacyBin = join(legacyDir, "bin", filename);
const targetDir = determineInstallDir(homeDir, env);
const targetBin = join(targetDir, filename);
if (
!existsSync(legacyBin) ||
existsSync(targetBin) ||
targetDir === join(legacyDir, "bin")
) {
Comment thread
cursor[bot] marked this conversation as resolved.
return;
Comment thread
jared-outpost[bot] marked this conversation as resolved.
}

mkdirSync(targetDir, { recursive: true, mode: 0o755 });
copyFileSync(legacyBin, targetBin);
// copyFileSync already preserves the source mode, but assert the exec bit
// explicitly — mirrors installBinary — so the migrated binary is runnable
// even if the legacy copy's permissions were somehow stripped.
chmodSync(targetBin, 0o755);
try {
unlinkSync(legacyBin);
} catch (error) {
// Leave the old binary in place if it can't be removed — the new copy
Comment thread
jared-outpost[bot] marked this conversation as resolved.
// is authoritative and setInstallInfo points upgrades at it.
logger.withTag("cli.setup").debug("Failed to remove legacy binary", error);
}
setInstallInfo({ method: "curl", path: targetBin, version: CLI_VERSION });
emit(`Binary: Migrated ${legacyBin} → ${targetBin}`);
return targetBin;
}

/**
* Handle binary installation from a temp location.
*
Expand Down Expand Up @@ -556,7 +647,27 @@ export const setupCommand = buildCommand({
let binaryDir = dirname(binaryPath);
let freshInstall = false;

// 0. Install binary from temp location (when --install is set)
// 0. Migrate any legacy ~/.sentry config/binary into XDG locations first,
// so the steps below operate on the new paths. Config and binary migrations
// are independent — a failure in one must not skip the other.
try {
migrateLegacyConfig(homeDir, process.env, emit);
} catch (error) {
warn("Legacy config migration", error);
}
try {
const migratedBinary = migrateLegacyBinary(homeDir, process.env, emit);
// Adopt the new location so PATH setup and recorded install info point at
// the migrated binary rather than the deleted legacy path.
if (migratedBinary) {
binaryPath = migratedBinary;
binaryDir = dirname(migratedBinary);
}
} catch (error) {
warn("Legacy binary migration", error);
}

// 1. Install binary from temp location (when --install is set)
Comment thread
cursor[bot] marked this conversation as resolved.
if (flags.install) {
const result = await handleInstall(
process.execPath,
Expand Down
61 changes: 58 additions & 3 deletions packages/cli/src/commands/cli/upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

import { spawn } from "node:child_process";
import { homedir } from "node:os";
import { dirname } from "node:path";
import { dirname, join } from "node:path";
import { setTimeout } from "node:timers/promises";
import type { SentryContext } from "../../context.js";
import {
Expand All @@ -42,6 +42,7 @@ import {
type ChangelogSummary,
fetchChangelog,
} from "../../lib/release-notes.js";
import { isInPath } from "../../lib/shell.js";
import {
detectInstallationMethod,
executeUpgrade,
Expand Down Expand Up @@ -564,6 +565,55 @@ function resolveUpdatedCliPath(
return whichSync("sentry", { PATH: pathEnv }) ?? entryPath ?? execPath;
}

/**
* Compare two directory paths for equality, case-insensitively on
* case-insensitive filesystems (Windows, macOS). A stored install path can
* differ in casing from a freshly computed one (e.g. `C:\Users\User` vs
* `C:\Users\user`) yet point at the same directory, so a strict `===` would
* wrongly treat them as different.
*/
function samePath(a: string, b: string): boolean {
if (process.platform === "win32" || process.platform === "darwin") {
return a.toLowerCase() === b.toLowerCase();
}
return a === b;
}

/**
* Decide which directory a curl upgrade should install into.
*
* Normally the binary stays where it currently lives — pinning the install
* dir keeps an in-place update from relocating a binary that is already on
* the user's `PATH` (upgrade runs setup with `--no-modify-path`, so it can't
* add a new directory to `PATH`).
*
* The one exception is a legacy `~/.sentry/bin` install: those should move to
* the XDG-aligned location so users actually migrate off `~/.sentry`. We only
* relocate when the XDG target directory is *already* on `PATH`, so the moved
* binary stays discoverable without any `PATH` edit. When it isn't, we keep
* the binary in place and leave relocation to an explicit `sentry cli setup`.
*/
export function resolveUpgradeInstallDir(
currentInstallDir: string,
pathEnv: string | undefined
): string {
const legacyBinDir = join(homedir(), ".sentry", "bin");
if (!samePath(currentInstallDir, legacyBinDir)) {
return currentInstallDir;
}

// determineInstallDir with the legacy pin removed yields the XDG target.
const { SENTRY_INSTALL_DIR: _pinned, ...envWithoutPin } = process.env;
const xdgInstallDir = determineInstallDir(homedir(), envWithoutPin);
if (
!samePath(xdgInstallDir, legacyBinDir) &&
isInPath(xdgInstallDir, pathEnv)
) {
return xdgInstallDir;
}
return currentInstallDir;
}

/**
* Execute the standard upgrade path: download via curl or package manager,
* then run setup on the new binary.
Expand Down Expand Up @@ -615,17 +665,22 @@ async function executeStandardUpgrade(opts: {
if (downloadResult) {
// Curl: new binary is at temp path, setup --install will place it.
// Pin the install directory via SENTRY_INSTALL_DIR so the child's
// determineInstallDir() doesn't relocate to a different directory.
// determineInstallDir() doesn't relocate to a directory that isn't on
// PATH. A legacy ~/.sentry/bin install is relocated to the XDG dir when
// that dir is already on PATH (see resolveUpgradeInstallDir); setup's
// legacy-binary migration then moves the old binary and removes it before
// --install writes the new one.
// Release the download lock after the child exits — if the child used
// the same lock path (ppid takeover), this is a harmless no-op.
const currentInstallDir = dirname(getCurlInstallPaths().installPath);
const installDir = resolveUpgradeInstallDir(currentInstallDir, pathEnv);
try {
await runSetupOnNewBinary({
binaryPath: downloadResult.tempBinaryPath,
method,
channel,
install: true,
installDir: currentInstallDir,
installDir,
ensureAuthScopes: !json,
noAgentSkills,
});
Expand Down
23 changes: 15 additions & 8 deletions packages/cli/src/lib/binary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
writeFileSync,
} from "node:fs";
import { chmod, copyFile, mkdir, realpath, unlink } from "node:fs/promises";
import { delimiter, dirname, join, resolve } from "node:path";
import { delimiter, dirname, isAbsolute, join, resolve } from "node:path";
import { compare as semverCompare } from "semver";
import { getUserAgent } from "./constants.js";
import {
Expand Down Expand Up @@ -226,10 +226,11 @@ export function getBinaryPaths(installPath: string): {
* Determine the install directory for a curl-installed binary.
*
* Priority:
* 1. $SENTRY_INSTALL_DIR environment variable (if set and writable)
* 2. ~/.local/bin (if exists AND in $PATH)
* 3. ~/bin (if exists AND in $PATH)
* 4. ~/.sentry/bin (fallback; setup will handle PATH modification)
* 1. $SENTRY_INSTALL_DIR environment variable
* 2. $XDG_BIN_HOME (if set to an absolute path, per the XDG spec)
* 3. ~/.local/bin (if exists AND in $PATH)
* 4. ~/bin (if exists AND in $PATH)
* 5. ~/.local/bin (XDG-aligned fallback; setup handles PATH modification)
*
* @param homeDir - User's home directory
* @param env - Process environment variables
Expand All @@ -246,7 +247,13 @@ export function determineInstallDir(
return env.SENTRY_INSTALL_DIR;
}

// 2-3. Check well-known directories that are already in PATH
// 2. XDG_BIN_HOME override — honored only when absolute, per the XDG spec
const xdgBinHome = env.XDG_BIN_HOME;
if (xdgBinHome && isAbsolute(xdgBinHome)) {
return xdgBinHome;
}

// 3-4. Check well-known directories that are already in PATH
const candidates = [join(homeDir, ".local", "bin"), join(homeDir, "bin")];

for (const dir of candidates) {
Expand All @@ -255,8 +262,8 @@ export function determineInstallDir(
}
}

// 4. Fallback — setup will handle adding this to PATH
return join(homeDir, ".sentry", "bin");
// 5. XDG-aligned fallback — setup will handle adding this to PATH
return join(homeDir, ".local", "bin");
}

/**
Expand Down
Loading
Loading