diff --git a/apps/cli-docs/src/content/docs/getting-started.mdx b/apps/cli-docs/src/content/docs/getting-started.mdx index 101bb2fe6..6449b7601 100644 --- a/apps/cli-docs/src/content/docs/getting-started.mdx +++ b/apps/cli-docs/src/content/docs/getting-started.mdx @@ -112,8 +112,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 @@ -156,7 +157,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 diff --git a/apps/cli-docs/src/fragments/configuration.md b/apps/cli-docs/src/fragments/configuration.md index f28662cce..41716d6df 100644 --- a/apps/cli-docs/src/fragments/configuration.md +++ b/apps/cli-docs/src/fragments/configuration.md @@ -103,7 +103,7 @@ 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 diff --git a/packages/cli/README.md b/packages/cli/README.md index 515665c53..e3d41cce4 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -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 diff --git a/packages/cli/src/lib/db/index.ts b/packages/cli/src/lib/db/index.ts index 46cfc7ca1..938fb89f0 100644 --- a/packages/cli/src/lib/db/index.ts +++ b/packages/cli/src/lib/db/index.ts @@ -4,10 +4,10 @@ * bundled WASM driver (`node-sqlite3-wasm`, Node < 22.15) behind one API. */ -import { chmodSync, mkdirSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync } from "node:fs"; import { createRequire } from "node:module"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { isAbsolute, join } from "node:path"; import { getEnv } from "../env.js"; import { logger } from "../logger.js"; @@ -21,7 +21,11 @@ import { Database } from "./sqlite.js"; export const CONFIG_DIR_ENV_VAR = "SENTRY_CONFIG_DIR"; -const DEFAULT_CONFIG_DIR_NAME = ".sentry"; +/** Legacy config directory name under the user's home directory (`~/.sentry`). */ +const LEGACY_CONFIG_DIR_NAME = ".sentry"; + +/** Sub-directory used under the XDG config base directory. */ +const XDG_CONFIG_SUBDIR = "sentry"; const DB_FILENAME = "cli.db"; @@ -69,10 +73,47 @@ function registerExitHandler(): void { }); } +/** + * Resolve the config directory from an environment and home directory. + * + * Precedence: + * 1. `SENTRY_CONFIG_DIR` — explicit override, always wins. + * 2. Legacy `~/.sentry` — used when it already exists, so existing installs + * keep working without migration. + * 3. XDG base directory — `$XDG_CONFIG_HOME/sentry`, falling back to + * `~/.config/sentry`. Per the XDG spec, a non-absolute `XDG_CONFIG_HOME` + * is ignored. + * + * Pure and side-effect free so it can be unit-tested directly. + */ +export function resolveConfigDir(env: NodeJS.ProcessEnv, home: string): string { + const override = env[CONFIG_DIR_ENV_VAR]; + if (override) { + return override; + } + + const legacyDir = join(home, LEGACY_CONFIG_DIR_NAME); + // Only treat the legacy directory as a prior config install when it + // contains the actual database or the old JSON config. A bare + // `~/.sentry/bin` created by the curl installer should not block XDG. + if ( + existsSync(legacyDir) && + (existsSync(join(legacyDir, DB_FILENAME)) || + existsSync(join(legacyDir, "config.json"))) + ) { + return legacyDir; + } + + const xdgConfigHome = env.XDG_CONFIG_HOME; + const configHome = + xdgConfigHome && isAbsolute(xdgConfigHome) + ? xdgConfigHome + : join(home, ".config"); + return join(configHome, XDG_CONFIG_SUBDIR); +} + export function getConfigDir(): string { - return ( - getEnv()[CONFIG_DIR_ENV_VAR] || join(homedir(), DEFAULT_CONFIG_DIR_NAME) - ); + return resolveConfigDir(getEnv(), homedir()); } export function getDbPath(): string { diff --git a/packages/cli/test/lib/config.test.ts b/packages/cli/test/lib/config.test.ts index 496d2bb08..31bd445f4 100644 --- a/packages/cli/test/lib/config.test.ts +++ b/packages/cli/test/lib/config.test.ts @@ -4,8 +4,9 @@ * Integration tests for SQLite-based config storage. */ -import { writeFileSync } from "node:fs"; -import { access } from "node:fs/promises"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { access, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { @@ -26,6 +27,7 @@ import { CONFIG_DIR_ENV_VAR, closeDatabase, getDbPath, + resolveConfigDir, } from "../../src/lib/db/index.js"; import { clearProjectAliases, @@ -561,6 +563,62 @@ describe("getDbPath", () => { }); }); +describe("resolveConfigDir", () => { + let home: string; + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), "resolve-config-home-")); + }); + + afterEach(async () => { + await rm(home, { recursive: true, force: true }); + }); + + test("prefers the SENTRY_CONFIG_DIR override over everything", () => { + const override = join(home, "custom-config"); + mkdirSync(join(home, ".sentry")); + expect( + resolveConfigDir( + { + [CONFIG_DIR_ENV_VAR]: override, + XDG_CONFIG_HOME: join(home, "xdg"), + }, + home + ) + ).toBe(override); + }); + + test("uses the legacy ~/.sentry directory when it already exists", () => { + const legacy = join(home, ".sentry"); + mkdirSync(legacy); + writeFileSync(join(legacy, "cli.db"), ""); // simulate a prior config install + expect(resolveConfigDir({}, home)).toBe(legacy); + }); + + test("ignores a bare ~/.sentry/bin (installer artifact) and falls back to XDG", () => { + const legacy = join(home, ".sentry"); + mkdirSync(join(legacy, "bin"), { recursive: true }); + expect(resolveConfigDir({}, home)).toBe(join(home, ".config", "sentry")); + }); + + test("uses XDG_CONFIG_HOME/sentry when set to an absolute path", () => { + const xdg = join(home, "xdg-config"); + expect(resolveConfigDir({ XDG_CONFIG_HOME: xdg }, home)).toBe( + join(xdg, "sentry") + ); + }); + + test("falls back to ~/.config/sentry when XDG_CONFIG_HOME is unset", () => { + expect(resolveConfigDir({}, home)).toBe(join(home, ".config", "sentry")); + }); + + test("ignores a non-absolute XDG_CONFIG_HOME per the XDG spec", () => { + expect(resolveConfigDir({ XDG_CONFIG_HOME: "relative/path" }, home)).toBe( + join(home, ".config", "sentry") + ); + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // JSON Migration // ─────────────────────────────────────────────────────────────────────────────