diff --git a/apps/cli-docs/src/content/docs/getting-started.mdx b/apps/cli-docs/src/content/docs/getting-started.mdx index ca55e7a46..461d4d1da 100644 --- a/apps/cli-docs/src/content/docs/getting-started.mdx +++ b/apps/cli-docs/src/content/docs/getting-started.mdx @@ -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 @@ -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 diff --git a/apps/cli-docs/src/fragments/configuration.md b/apps/cli-docs/src/fragments/configuration.md index f28662cce..b9673a933 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 @@ -111,3 +111,16 @@ We store credentials and caches in a SQLite database (`cli.db`) inside the confi - 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. 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/install b/packages/cli/install index 467b13b3d..e05cef201 100755 --- a/packages/cli/install +++ b/packages/cli/install @@ -337,7 +337,15 @@ 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 + # Only honor XDG_BIN_HOME when absolute, per the XDG spec (matches the Node + # determineInstallDir logic); a relative value is ignored. Accept both POSIX + # (/…) and Windows drive-letter (C:\… or C:/…) absolute paths so a Windows + # XDG_BIN_HOME isn't dropped while Node still installs there. + xdg_bin_home="${XDG_BIN_HOME:-}" + if [[ "$xdg_bin_home" != /* && ! "$xdg_bin_home" =~ ^[A-Za-z]:[\\/] ]]; then + xdg_bin_home="" + fi + 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" diff --git a/packages/cli/src/commands/cli/setup.ts b/packages/cli/src/commands/cli/setup.ts index 5288be295..fc1491964 100644 --- a/packages/cli/src/commands/cli/setup.ts +++ b/packages/cli/src/commands/cli/setup.ts @@ -7,6 +7,7 @@ */ import { existsSync, unlinkSync } from "node:fs"; +import { chmod, copyFile, mkdir, rename, unlink } from "node:fs/promises"; import { dirname, join } from "node:path"; import { captureException } from "@sentry/node-core/light"; import type { SentryContext } from "../../context.js"; @@ -14,9 +15,11 @@ import { installAgentSkills } from "../../lib/agent-skills.js"; import { determineInstallDir, getBinaryFilename, + getLegacyInstallDirs, type InstallationMethod, installBinary, parseInstallationMethod, + samePath, } from "../../lib/binary.js"; import { buildCommand } from "../../lib/command.js"; import { @@ -28,6 +31,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, @@ -80,6 +84,112 @@ 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. + */ +async function migrateLegacyConfig( + homeDir: string, + env: NodeJS.ProcessEnv, + emit: Logger +): Promise { + 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 (samePath(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(); + await mkdir(targetConfigDir, { recursive: true, mode: 0o700 }); + for (const name of configFiles) { + const from = join(legacyDir, name); + if (existsSync(from)) { + await rename(from, join(targetConfigDir, name)); + } + } + emit(`Config: Migrated ${legacyDir} → ${targetConfigDir}`); +} + +/** + * Find a binary in a *legacy* install directory (see {@link getLegacyInstallDirs}) + * that should be migrated into the resolved install target. Only genuinely + * pre-XDG locations are considered — `~/.local/bin` and `~/bin` are valid + * current targets and must never be treated as migration sources, or a working + * binary could be relocated out of an active directory. + */ +function findMigratableBinary( + homeDir: string, + targetDir: string, + filename: string +): string | undefined { + for (const dir of getLegacyInstallDirs(homeDir)) { + if (samePath(dir, targetDir)) { + continue; + } + const candidate = join(dir, filename); + if (existsSync(candidate)) { + return candidate; + } + } + return; +} + +/** + * Migrate an existing binary out of a known legacy install directory 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. + */ +async function migrateLegacyBinary( + homeDir: string, + env: NodeJS.ProcessEnv, + emit: Logger +): Promise { + const filename = getBinaryFilename(); + const targetDir = determineInstallDir(homeDir, env); + const targetBin = join(targetDir, filename); + if (existsSync(targetBin)) { + return; + } + + const legacyBin = findMigratableBinary(homeDir, targetDir, filename); + if (!legacyBin) { + return; + } + + await mkdir(targetDir, { recursive: true, mode: 0o755 }); + await copyFile(legacyBin, targetBin); + // copyFile 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. + await chmod(targetBin, 0o755); + try { + await unlink(legacyBin); + } catch (error) { + // Leave the old binary in place if it can't be removed — the new copy + // 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. * @@ -556,7 +666,35 @@ 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, and both are + // best-effort: warnings surface to the user and errors are reported to + // Sentry, but a failure never aborts setup. + await bestEffort( + "Legacy config migration", + () => migrateLegacyConfig(homeDir, process.env, emit), + warn + ); + await bestEffort( + "Legacy binary migration", + async () => { + const migratedBinary = await 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); + } + }, + warn + ); + + // 1. Install binary from temp location (when --install is set) if (flags.install) { const result = await handleInstall( process.execPath, diff --git a/packages/cli/src/commands/cli/upgrade.ts b/packages/cli/src/commands/cli/upgrade.ts index f760f80ba..8d8282368 100644 --- a/packages/cli/src/commands/cli/upgrade.ts +++ b/packages/cli/src/commands/cli/upgrade.ts @@ -16,13 +16,15 @@ 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 { determineInstallDir, isDowngrade, + LEGACY_INSTALL_SUBDIR, releaseLock, + samePath, } from "../../lib/binary.js"; import { buildCommand } from "../../lib/command.js"; import { CLI_VERSION } from "../../lib/constants.js"; @@ -42,6 +44,7 @@ import { type ChangelogSummary, fetchChangelog, } from "../../lib/release-notes.js"; +import { isInPath } from "../../lib/shell.js"; import { detectInstallationMethod, executeUpgrade, @@ -564,6 +567,41 @@ function resolveUpdatedCliPath( return whichSync("sentry", { PATH: pathEnv }) ?? entryPath ?? execPath; } +/** + * 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(), LEGACY_INSTALL_SUBDIR); + 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. @@ -615,17 +653,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, }); diff --git a/packages/cli/src/lib/binary.ts b/packages/cli/src/lib/binary.ts index 89717e8f1..75582612a 100644 --- a/packages/cli/src/lib/binary.ts +++ b/packages/cli/src/lib/binary.ts @@ -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, sep } from "node:path"; import { compare as semverCompare } from "semver"; import { getUserAgent } from "./constants.js"; import { @@ -29,6 +29,62 @@ import { isProcessRunning } from "./process-utils.js"; /** Known directories where the curl installer may place the binary */ export const KNOWN_CURL_DIRS = [".local/bin", "bin", ".sentry/bin"]; +/** + * Whether the current platform's filesystem is case-insensitive by default + * (Windows, macOS). Resolved once at module load — `process.platform` never + * changes at runtime. + */ +const IS_CASE_INSENSITIVE_FS = + process.platform === "win32" || process.platform === "darwin"; + +/** + * Legacy install directory (relative to home) that predates the XDG layout. + * The curl installer used to drop the binary here; migration moves it out. + */ +export const LEGACY_INSTALL_SUBDIR = join(".sentry", "bin"); + +/** + * Legacy install sub-directories (relative to home) that predate the XDG + * layout and that migration is allowed to move a binary out of. Deliberately + * limited to the pre-XDG `~/.sentry/bin`: `~/.local/bin` and `~/bin` (also in + * {@link KNOWN_CURL_DIRS}) are valid *current* XDG install targets, so treating + * them as migration sources would relocate a working binary out of an active + * directory. An array so more legacy locations can be added if they ever exist. + */ +export const LEGACY_INSTALL_SUBDIRS = [LEGACY_INSTALL_SUBDIR]; + +/** + * Strip a trailing path separator (but never from a bare root like `/`) so a + * PATH entry such as `~/.local/bin/` compares equal to `~/.local/bin`. + */ +function stripTrailingSep(p: string): string { + return p.length > 1 && p.endsWith(sep) ? p.slice(0, -1) : p; +} + +/** + * Compare two filesystem paths for equality. Tolerates a trailing separator on + * either side, and is case-insensitive on case-insensitive filesystems + * (Windows, macOS) — a stored path can differ in casing from a freshly computed + * one (e.g. `C:\Users\User` vs `C:\Users\user`) yet point at the same location, + * so a strict `===` would wrongly differ. + * + * The implementation is chosen once at module load from + * {@link IS_CASE_INSENSITIVE_FS} so there is no per-call platform check. + */ +export const samePath: (a: string, b: string) => boolean = + IS_CASE_INSENSITIVE_FS + ? (a, b) => + stripTrailingSep(a).toLowerCase() === stripTrailingSep(b).toLowerCase() + : (a, b) => stripTrailingSep(a) === stripTrailingSep(b); + +/** + * Absolute legacy install directories for the given home. See + * {@link LEGACY_INSTALL_SUBDIRS} for why this is scoped to pre-XDG locations. + */ +export function getLegacyInstallDirs(homeDir: string): string[] { + return LEGACY_INSTALL_SUBDIRS.map((dir) => join(homeDir, dir)); +} + /** * How the CLI was installed. Determines the upgrade strategy. * @@ -226,10 +282,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 @@ -246,17 +303,25 @@ 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. samePath keeps + // the membership check case-insensitive on Windows/macOS, where a PATH entry + // can differ in casing from the computed directory yet be the same dir. const candidates = [join(homeDir, ".local", "bin"), join(homeDir, "bin")]; for (const dir of candidates) { - if (existsSync(dir) && pathDirs.includes(dir)) { + if (existsSync(dir) && pathDirs.some((p) => samePath(p, dir))) { return dir; } } - // 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"); } /** diff --git a/packages/cli/src/lib/db/index.ts b/packages/cli/src/lib/db/index.ts index 7aa4af95d..2c7b66c0a 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,66 @@ 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; + } + + return resolveXdgConfigDir(env, home); +} + +/** + * Resolve the XDG-compliant config directory, ignoring any legacy `~/.sentry` + * install. This is the migration *target*: `resolveConfigDir` keeps returning + * the legacy dir while it holds `cli.db`, so migration must compute the new + * location directly. Honors `SENTRY_CONFIG_DIR` and an absolute + * `XDG_CONFIG_HOME`, otherwise defaults to `~/.config/sentry`. + */ +export function resolveXdgConfigDir( + env: NodeJS.ProcessEnv, + home: string +): string { + const override = env[CONFIG_DIR_ENV_VAR]; + if (override) { + return override; + } + + 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/src/lib/shell.ts b/packages/cli/src/lib/shell.ts index a53603c77..2e9b971c3 100644 --- a/packages/cli/src/lib/shell.ts +++ b/packages/cli/src/lib/shell.ts @@ -8,6 +8,7 @@ import { existsSync } from "node:fs"; import { access, readFile, writeFile } from "node:fs/promises"; import { basename, delimiter, join } from "node:path"; +import { samePath } from "./binary.js"; import { logger } from "./logger.js"; import { whichSync } from "./which.js"; @@ -171,8 +172,9 @@ export function isInPath( if (!pathEnv) { return false; } - const paths = pathEnv.split(delimiter); - return paths.includes(directory); + // samePath handles case-insensitive filesystems (Windows, macOS), where a + // PATH entry can differ in casing from a computed directory yet be the same. + return pathEnv.split(delimiter).some((p) => samePath(p, directory)); } /** diff --git a/packages/cli/src/lib/upgrade.ts b/packages/cli/src/lib/upgrade.ts index afe930f89..945adc48d 100644 --- a/packages/cli/src/lib/upgrade.ts +++ b/packages/cli/src/lib/upgrade.ts @@ -19,11 +19,12 @@ import { } from "node:fs"; import { writeFile } from "node:fs/promises"; import { homedir } from "node:os"; -import { dirname, join, sep } from "node:path"; +import { dirname, isAbsolute, join, sep } from "node:path"; import { setTimeout } from "node:timers/promises"; import { acquireLock, cleanupOldBinary, + determineInstallDir, fetchWithUpgradeError, GITHUB_RELEASES_URL, getBinaryDownloadUrl, @@ -89,18 +90,34 @@ export const VERSION_PREFIX_REGEX = /^v/; // Curl Binary Helpers /** - * Known directories where the curl installer may place the binary. - * Resolved at runtime against the user's home directory. - * Used for legacy detection (when no install info is stored). - * Trailing separator ensures startsWith matches a directory boundary - * (e.g. ~/.local/bin/ won't match ~/.local/binaries/). - * - * Computed lazily (not at module load) to avoid TDZ issues from circular - * imports — `KNOWN_CURL_DIRS` must be fully initialized before access. + * Build the list of known curl install directories the binary may live in, + * each with a trailing separator so `startsWith` matches a directory boundary + * (e.g. `~/.local/bin/` won't match `~/.local/binaries/`). Pure — takes home + * and env — so it can be unit-tested; `getKnownCurlPaths` memoizes the result. + */ +export function buildKnownCurlPaths( + homeDir: string, + env: NodeJS.ProcessEnv +): string[] { + const paths = KNOWN_CURL_DIRS.map((dir) => join(homeDir, dir) + sep); + // Honor an absolute XDG_BIN_HOME, matching determineInstallDir's precedence. + const xdgBinHome = env.XDG_BIN_HOME; + if (xdgBinHome && isAbsolute(xdgBinHome)) { + // join(dir, ".") strips any trailing separator so we don't emit a double + // separator (e.g. `/custom/bin//`) that would break the startsWith checks. + paths.push(join(xdgBinHome, ".") + sep); + } + return paths; +} + +/** + * Memoized known curl paths. Computed lazily (not at module load) to avoid TDZ + * issues from circular imports — `KNOWN_CURL_DIRS` must be fully initialized + * before access. */ let _knownCurlPaths: string[] | undefined; function getKnownCurlPaths(): string[] { - _knownCurlPaths ??= KNOWN_CURL_DIRS.map((dir) => join(homedir(), dir) + sep); + _knownCurlPaths ??= buildKnownCurlPaths(homedir(), process.env); return _knownCurlPaths; } @@ -111,7 +128,7 @@ function getKnownCurlPaths(): string[] { * 1. Stored install path from DB (if method is curl AND its directory still * exists — a stale path whose directory was purged is skipped) * 2. process.execPath if it's in a known curl install location - * 3. Default to ~/.sentry/bin/sentry (fallback for fresh installs) + * 3. Default to the XDG-aware install dir (fallback for fresh installs) * * @returns Object with install, temp, old, and lock file paths */ @@ -128,7 +145,7 @@ export function getCurlInstallPaths(): { // `ENOENT ... open '.../sentry.lock'` (reported in #discuss-cli). // // existsSync also returns false on EACCES / a transiently-unmounted parent, - // in which case we fall through to execPath / the ~/.sentry/bin fallback + // in which case we fall through to execPath / the default-install fallback // rather than erroring. That tradeoff is acceptable: the running binary's // own directory (execPath) is by definition accessible, so a genuine install // is still found; only an unreadable *stored hint* is ignored. @@ -149,7 +166,10 @@ export function getCurlInstallPaths(): { } // Fallback to default path (for fresh installs or non-curl runs like tests) - const defaultPath = join(homedir(), ".sentry", "bin", getBinaryFilename()); + const defaultPath = join( + determineInstallDir(homedir(), process.env), + getBinaryFilename() + ); return getBinaryPaths(defaultPath); } diff --git a/packages/cli/test/commands/cli/setup.test.ts b/packages/cli/test/commands/cli/setup.test.ts index ade184939..82ce6ba2b 100644 --- a/packages/cli/test/commands/cli/setup.test.ts +++ b/packages/cli/test/commands/cli/setup.test.ts @@ -7,7 +7,14 @@ * via a spy on process.stderr.write and assert on the collected output. */ -import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { + accessSync, + constants, + existsSync, + mkdirSync, + rmSync, + writeFileSync, +} from "node:fs"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { run } from "@stricli/core"; @@ -33,6 +40,10 @@ import { getAgentSkillsPreference, setAgentSkillsPreference, } from "../../../src/lib/db/defaults.js"; +import { + clearInstallInfo, + getInstallInfo, +} from "../../../src/lib/db/install-info.js"; import { getReleaseChannel } from "../../../src/lib/db/release-channel.js"; // biome-ignore lint/performance/noNamespaceImport: dynamic setup imports are mocked at the module boundary import * as interactiveLogin from "../../../src/lib/interactive-login.js"; @@ -1015,6 +1026,199 @@ describe("sentry cli setup", () => { }); }); +describe("sentry cli setup — legacy migration", () => { + let testHome: string; + let restoreStderr: (() => void) | undefined; + + beforeEach(() => { + testHome = join( + "/tmp", + `setup-mig-home-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + mkdirSync(testHome, { recursive: true }); + }); + + afterEach(() => { + restoreStderr?.(); + restoreStderr = undefined; + rmSync(testHome, { recursive: true, force: true }); + }); + + const setupArgs = [ + "cli", + "setup", + "--quiet", + "--no-modify-path", + "--no-completions", + "--no-agent-skills", + ]; + + test("migrates legacy ~/.sentry config into the XDG config dir", async () => { + const configDir = join(testHome, "config", "sentry"); + const legacyDir = join(testHome, ".sentry"); + mkdirSync(legacyDir, { recursive: true }); + writeFileSync(join(legacyDir, "cli.db"), "legacy-db"); + + const { context, restore } = createMockContext({ + homeDir: testHome, + env: { SENTRY_CONFIG_DIR: configDir }, + }); + restoreStderr = restore; + + await run(app, setupArgs, context); + + const moved = join(configDir, "cli.db"); + expect(existsSync(moved)).toBe(true); + expect(await readFile(moved, "utf8")).toBe("legacy-db"); + expect(existsSync(join(legacyDir, "cli.db"))).toBe(false); + }); + + test("migrates a legacy ~/.sentry/bin binary to the install dir", async () => { + const installDir = join(testHome, "install", "bin"); + const legacyBinDir = join(testHome, ".sentry", "bin"); + mkdirSync(legacyBinDir, { recursive: true }); + writeFileSync(join(legacyBinDir, "sentry"), "legacy-binary"); + + const { context, restore } = createMockContext({ + homeDir: testHome, + env: { SENTRY_INSTALL_DIR: installDir }, + }); + restoreStderr = restore; + + await run(app, setupArgs, context); + + const moved = join(installDir, "sentry"); + expect(existsSync(moved)).toBe(true); + expect(await readFile(moved, "utf8")).toBe("legacy-binary"); + expect(existsSync(join(legacyBinDir, "sentry"))).toBe(false); + // The migrated binary must remain executable. + if (process.platform !== "win32") { + expect(() => accessSync(moved, constants.X_OK)).not.toThrow(); + } + }); + + test("does not overwrite an existing binary at the target", async () => { + const installDir = join(testHome, "install", "bin"); + mkdirSync(installDir, { recursive: true }); + writeFileSync(join(installDir, "sentry"), "current-binary"); + + const legacyBinDir = join(testHome, ".sentry", "bin"); + mkdirSync(legacyBinDir, { recursive: true }); + writeFileSync(join(legacyBinDir, "sentry"), "legacy-binary"); + + const { context, restore } = createMockContext({ + homeDir: testHome, + env: { SENTRY_INSTALL_DIR: installDir }, + }); + restoreStderr = restore; + + await run(app, setupArgs, context); + + expect(await readFile(join(installDir, "sentry"), "utf8")).toBe( + "current-binary" + ); + }); + + test("does not migrate a binary out of ~/.local/bin (a valid target)", async () => { + // ~/.local/bin is a current XDG install target, not a legacy source: a + // binary there must never be relocated, even if it isn't the resolved dir. + const installDir = join(testHome, "install", "bin"); + const localBin = join(testHome, ".local", "bin"); + mkdirSync(localBin, { recursive: true }); + writeFileSync(join(localBin, "sentry"), "local-binary"); + + const { context, restore } = createMockContext({ + homeDir: testHome, + env: { SENTRY_INSTALL_DIR: installDir }, + }); + restoreStderr = restore; + + await run(app, setupArgs, context); + + // The ~/.local/bin binary stays put; nothing is copied to the target. + expect(existsSync(join(localBin, "sentry"))).toBe(true); + expect(await readFile(join(localBin, "sentry"), "utf8")).toBe( + "local-binary" + ); + expect(existsSync(join(installDir, "sentry"))).toBe(false); + }); + + test("does not migrate a binary out of ~/bin (a valid target)", async () => { + const installDir = join(testHome, "install", "bin"); + const homeBin = join(testHome, "bin"); + mkdirSync(homeBin, { recursive: true }); + writeFileSync(join(homeBin, "sentry"), "home-bin-binary"); + + const { context, restore } = createMockContext({ + homeDir: testHome, + env: { SENTRY_INSTALL_DIR: installDir }, + }); + restoreStderr = restore; + + await run(app, setupArgs, context); + + expect(existsSync(join(homeBin, "sentry"))).toBe(true); + expect(existsSync(join(installDir, "sentry"))).toBe(false); + }); +}); + +describe("sentry cli setup — legacy migration records new path", () => { + // Isolate the DB so getInstallInfo() reflects this test's writes. + useTestConfigDir("test-setup-migration-info-"); + + let testHome: string; + let restoreStderr: (() => void) | undefined; + + beforeEach(() => { + testHome = join( + "/tmp", + `setup-mig-info-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + mkdirSync(testHome, { recursive: true }); + }); + + afterEach(() => { + restoreStderr?.(); + restoreStderr = undefined; + clearInstallInfo(); + rmSync(testHome, { recursive: true, force: true }); + }); + + test("records the migrated binary path, not the legacy location", async () => { + const installDir = join(testHome, "install", "bin"); + const legacyBinDir = join(testHome, ".sentry", "bin"); + mkdirSync(legacyBinDir, { recursive: true }); + writeFileSync(join(legacyBinDir, "sentry"), "legacy-binary"); + + const { context, restore } = createMockContext({ + homeDir: testHome, + env: { + SENTRY_INSTALL_DIR: installDir, + SENTRY_CONFIG_DIR: process.env.SENTRY_CONFIG_DIR, + }, + }); + restoreStderr = restore; + + await run( + app, + [ + "cli", + "setup", + "--quiet", + "--method", + "curl", + "--no-modify-path", + "--no-completions", + "--no-agent-skills", + ], + context + ); + + const recorded = getInstallInfo(); + expect(recorded?.path).toBe(join(installDir, "sentry")); + }); +}); + describe("sentry cli setup — --channel flag", () => { useTestConfigDir("test-setup-channel-"); diff --git a/packages/cli/test/commands/cli/upgrade.test.ts b/packages/cli/test/commands/cli/upgrade.test.ts index 47f4f372f..ca9688e46 100644 --- a/packages/cli/test/commands/cli/upgrade.test.ts +++ b/packages/cli/test/commands/cli/upgrade.test.ts @@ -13,7 +13,8 @@ import * as child_process from "node:child_process"; import { chmodSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { unlink } from "node:fs/promises"; -import { join } from "node:path"; +import { homedir } from "node:os"; +import { delimiter, join } from "node:path"; import { gzipSync } from "node:zlib"; import { run } from "@stricli/core"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; @@ -25,7 +26,10 @@ vi.mock("node:child_process", async (importOriginal) => { }); import { app } from "../../../src/app.js"; -import { isEbusyError } from "../../../src/commands/cli/upgrade.js"; +import { + isEbusyError, + resolveUpgradeInstallDir, +} from "../../../src/commands/cli/upgrade.js"; import type { SentryContext } from "../../../src/context.js"; import { CLI_VERSION } from "../../../src/lib/constants.js"; import { @@ -1129,3 +1133,70 @@ describe("isEbusyError", () => { expect(isEbusyError(new Error("some error"))).toBe(false); }); }); + +describe("resolveUpgradeInstallDir", () => { + const home = homedir(); + const legacyBinDir = join(home, ".sentry", "bin"); + const xdgBinDir = join(home, ".local", "bin"); + let savedInstallDir: string | undefined; + let savedXdgBinHome: string | undefined; + + beforeEach(() => { + savedInstallDir = process.env.SENTRY_INSTALL_DIR; + savedXdgBinHome = process.env.XDG_BIN_HOME; + delete process.env.SENTRY_INSTALL_DIR; + delete process.env.XDG_BIN_HOME; + }); + + afterEach(() => { + if (savedInstallDir === undefined) { + delete process.env.SENTRY_INSTALL_DIR; + } else { + process.env.SENTRY_INSTALL_DIR = savedInstallDir; + } + if (savedXdgBinHome === undefined) { + delete process.env.XDG_BIN_HOME; + } else { + process.env.XDG_BIN_HOME = savedXdgBinHome; + } + }); + + test("keeps a non-legacy install dir unchanged", () => { + const current = join(home, "bin"); + expect( + resolveUpgradeInstallDir(current, `${current}${delimiter}/usr/bin`) + ).toBe(current); + }); + + test("relocates a legacy ~/.sentry/bin install when the XDG dir is on PATH", () => { + const pathEnv = `${xdgBinDir}${delimiter}/usr/bin`; + expect(resolveUpgradeInstallDir(legacyBinDir, pathEnv)).toBe(xdgBinDir); + }); + + test("keeps the legacy dir when the XDG dir is not on PATH", () => { + expect(resolveUpgradeInstallDir(legacyBinDir, "/usr/bin:/bin")).toBe( + legacyBinDir + ); + }); + + test("keeps the legacy dir when PATH is undefined", () => { + expect(resolveUpgradeInstallDir(legacyBinDir, undefined)).toBe( + legacyBinDir + ); + }); + + test("treats a differently-cased legacy dir as legacy on case-insensitive filesystems", () => { + // On Windows/macOS a stored install path can differ only in casing from + // the freshly computed legacy dir yet point at the same directory; it must + // still be recognized as the legacy install so relocation can trigger. + const mixedCaseLegacy = legacyBinDir.toUpperCase(); + const pathEnv = `${xdgBinDir}${delimiter}/usr/bin`; + const result = resolveUpgradeInstallDir(mixedCaseLegacy, pathEnv); + if (process.platform === "win32" || process.platform === "darwin") { + expect(result).toBe(xdgBinDir); + } else { + // Case-sensitive filesystem: a different-cased path is a different dir. + expect(result).toBe(mixedCaseLegacy); + } + }); +}); diff --git a/packages/cli/test/e2e/migration.test.ts b/packages/cli/test/e2e/migration.test.ts new file mode 100644 index 000000000..d9ef3bb89 --- /dev/null +++ b/packages/cli/test/e2e/migration.test.ts @@ -0,0 +1,116 @@ +/** + * Legacy Layout Migration E2E Tests + * + * Spawns the real CLI to verify that `sentry cli setup` migrates an existing + * `~/.sentry` layout — the SQLite config DB and a curl-installed binary — into + * the XDG-compliant locations. Exercises the full startup + migration path + * (DB open, close, file moves) as a user would hit it, not just the unit-level + * helpers. + */ + +import { + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { getBinaryFilename } from "../../src/lib/binary.js"; +import { runCli } from "../fixture.js"; + +const binName = getBinaryFilename(); + +let home: string; + +/** Env that isolates a spawned CLI to a throwaway home directory. */ +function homeEnv(extra: Record = {}): Record { + return { + HOME: home, + USERPROFILE: home, + // Drop any config-dir pin from the parent (preload sets it) so the CLI + // resolves paths from HOME like a real install. + SENTRY_CONFIG_DIR: "", + XDG_CONFIG_HOME: "", + XDG_BIN_HOME: "", + SENTRY_CLI_NO_TELEMETRY: "1", + ...extra, + }; +} + +/** Args that keep setup non-interactive and side-effect free beyond migration. */ +const setupArgs = [ + "cli", + "setup", + "--quiet", + "--no-modify-path", + "--no-completions", + "--no-agent-skills", +]; + +describe("e2e: legacy ~/.sentry migration", () => { + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "sentry-migrate-e2e-")); + }); + + afterEach(() => { + rmSync(home, { recursive: true, force: true }); + }); + + test( + "migrates the config DB from ~/.sentry to ~/.config/sentry", + { timeout: 60_000 }, + async () => { + // Seed a real SQLite config DB in the legacy location by running the CLI + // once pinned at ~/.sentry (this creates cli.db via getDatabase()). + const legacyDir = join(home, ".sentry"); + mkdirSync(legacyDir, { recursive: true, mode: 0o700 }); + // `auth status` opens (and thus creates) cli.db; a non-zero "not logged + // in" exit is fine — we only need the DB file to exist. + await runCli(["auth", "status"], { + env: homeEnv({ SENTRY_CONFIG_DIR: legacyDir }), + }); + expect(existsSync(join(legacyDir, "cli.db"))).toBe(true); + + // Now run setup with no config pin — it should migrate ~/.sentry/cli.db + // into the XDG config dir (~/.config/sentry). + const result = await runCli(setupArgs, { env: homeEnv() }); + expect(result.exitCode).toBe(0); + + const xdgDb = join(home, ".config", "sentry", "cli.db"); + expect(existsSync(xdgDb)).toBe(true); + expect(existsSync(join(legacyDir, "cli.db"))).toBe(false); + } + ); + + test( + "migrates a legacy ~/.sentry/bin binary onto the XDG install dir", + { timeout: 60_000 }, + async () => { + // Legacy curl layout: binary under ~/.sentry/bin, and ~/.local/bin on PATH + // so the resolved install dir is the XDG location. + const legacyBinDir = join(home, ".sentry", "bin"); + mkdirSync(legacyBinDir, { recursive: true }); + const legacyBin = join(legacyBinDir, binName); + writeFileSync(legacyBin, "#!/bin/sh\necho legacy\n", { mode: 0o755 }); + + const xdgBinDir = join(home, ".local", "bin"); + mkdirSync(xdgBinDir, { recursive: true }); + + const result = await runCli(setupArgs, { + env: homeEnv({ + PATH: `${xdgBinDir}:${process.env.PATH ?? ""}`, + }), + }); + expect(result.exitCode).toBe(0); + + const movedBin = join(xdgBinDir, binName); + expect(existsSync(movedBin)).toBe(true); + expect(await readFile(movedBin, "utf8")).toBe("#!/bin/sh\necho legacy\n"); + expect(existsSync(legacyBin)).toBe(false); + } + ); +}); diff --git a/packages/cli/test/lib/binary.test.ts b/packages/cli/test/lib/binary.test.ts index 8254eac7b..6fcfb3b1a 100644 --- a/packages/cli/test/lib/binary.test.ts +++ b/packages/cli/test/lib/binary.test.ts @@ -15,7 +15,7 @@ import { writeFileSync, } from "node:fs"; import { access, readFile, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { join, sep } from "node:path"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { acquireLock, @@ -25,12 +25,14 @@ import { getBinaryDownloadUrl, getBinaryFilename, getBinaryPaths, + getLegacyInstallDirs, getPlatformBinaryName, installBinary, isDowngrade, isMusl, releaseLock, replaceBinarySync, + samePath, } from "../../src/lib/binary.js"; import { UpgradeError } from "../../src/lib/errors.js"; @@ -78,6 +80,52 @@ describe("getBinaryPaths", () => { }); }); +describe("samePath", () => { + test("matches identical paths", () => { + expect(samePath("/home/user/.local/bin", "/home/user/.local/bin")).toBe( + true + ); + }); + + test("distinguishes genuinely different paths", () => { + expect(samePath("/home/user/.local/bin", "/home/user/.sentry/bin")).toBe( + false + ); + }); + + test("case sensitivity follows the platform", () => { + const result = samePath("/Home/User/bin", "/home/user/bin"); + if (process.platform === "win32" || process.platform === "darwin") { + expect(result).toBe(true); + } else { + expect(result).toBe(false); + } + }); + + test("tolerates a trailing separator on either side", () => { + const dir = join("/home/user", ".local", "bin"); + expect(samePath(dir + sep, dir)).toBe(true); + expect(samePath(dir, dir + sep)).toBe(true); + expect(samePath(dir + sep, dir + sep)).toBe(true); + }); + + test("does not treat root as equal to empty after stripping", () => { + // A bare root separator must not be stripped to "". + expect(samePath(sep, sep)).toBe(true); + expect(samePath(sep, "")).toBe(false); + }); +}); + +describe("getLegacyInstallDirs", () => { + test("returns only the pre-XDG ~/.sentry/bin, not current XDG targets", () => { + const dirs = getLegacyInstallDirs("/home/user"); + expect(dirs).toEqual([join("/home/user", ".sentry", "bin")]); + // ~/.local/bin and ~/bin are valid current targets, never migration sources + expect(dirs).not.toContain(join("/home/user", ".local", "bin")); + expect(dirs).not.toContain(join("/home/user", "bin")); + }); +}); + describe("determineInstallDir", () => { let testDir: string; @@ -116,6 +164,24 @@ describe("determineInstallDir", () => { expect(result).toBe(localBin); }); + test("matches a PATH entry case-insensitively on Windows/macOS", () => { + // Use ~/bin so the result is distinguishable from the ~/.local/bin fallback. + const homeBin = join(testDir, "bin"); + mkdirSync(homeBin, { recursive: true }); + + const result = determineInstallDir(testDir, { + PATH: `/usr/bin:${homeBin.toUpperCase()}`, + }); + + if (process.platform === "win32" || process.platform === "darwin") { + // Case-insensitive FS: the upper-cased PATH entry still matches ~/bin. + expect(result).toBe(homeBin); + } else { + // Case-sensitive FS: no match, so it falls back to the XDG default. + expect(result).toBe(join(testDir, ".local", "bin")); + } + }); + test("uses ~/bin when it exists and is in PATH but ~/.local/bin is not", () => { const homeBin = join(testDir, "bin"); mkdirSync(homeBin, { recursive: true }); @@ -127,15 +193,15 @@ describe("determineInstallDir", () => { expect(result).toBe(homeBin); }); - test("falls back to ~/.sentry/bin when no candidates are in PATH", () => { + test("falls back to ~/.local/bin when no candidates are in PATH", () => { const result = determineInstallDir(testDir, { PATH: "/usr/bin:/bin", }); - expect(result).toBe(join(testDir, ".sentry", "bin")); + expect(result).toBe(join(testDir, ".local", "bin")); }); - test("skips ~/.local/bin when it exists but is not in PATH", () => { + test("falls back to ~/.local/bin when it exists but is not in PATH", () => { const localBin = join(testDir, ".local", "bin"); mkdirSync(localBin, { recursive: true }); @@ -143,8 +209,7 @@ describe("determineInstallDir", () => { PATH: "/usr/bin:/bin", }); - // Should fall back to ~/.sentry/bin, not use ~/.local/bin - expect(result).toBe(join(testDir, ".sentry", "bin")); + expect(result).toBe(localBin); }); test("handles empty PATH", () => { @@ -152,13 +217,60 @@ describe("determineInstallDir", () => { PATH: "", }); - expect(result).toBe(join(testDir, ".sentry", "bin")); + expect(result).toBe(join(testDir, ".local", "bin")); }); test("handles undefined PATH", () => { const result = determineInstallDir(testDir, {}); - expect(result).toBe(join(testDir, ".sentry", "bin")); + expect(result).toBe(join(testDir, ".local", "bin")); + }); + + test("uses XDG_BIN_HOME when set to an absolute path", () => { + const xdgBin = join(testDir, "xdg", "bin"); + + const result = determineInstallDir(testDir, { + XDG_BIN_HOME: xdgBin, + PATH: "/usr/bin", + }); + + expect(result).toBe(xdgBin); + }); + + test("ignores a non-absolute XDG_BIN_HOME per the XDG spec", () => { + const result = determineInstallDir(testDir, { + XDG_BIN_HOME: "relative/bin", + PATH: "/usr/bin", + }); + + expect(result).toBe(join(testDir, ".local", "bin")); + }); + + test("XDG_BIN_HOME takes priority over ~/.local/bin in PATH", () => { + const localBin = join(testDir, ".local", "bin"); + mkdirSync(localBin, { recursive: true }); + const xdgBin = join(testDir, "xdg", "bin"); + + const result = determineInstallDir(testDir, { + XDG_BIN_HOME: xdgBin, + PATH: `/usr/bin:${localBin}`, + }); + + expect(result).toBe(xdgBin); + }); + + test("SENTRY_INSTALL_DIR takes priority over XDG_BIN_HOME", () => { + const xdgBin = join(testDir, "xdg", "bin"); + const customDir = join(testDir, "custom"); + mkdirSync(customDir, { recursive: true }); + + const result = determineInstallDir(testDir, { + SENTRY_INSTALL_DIR: customDir, + XDG_BIN_HOME: xdgBin, + PATH: "/usr/bin", + }); + + expect(result).toBe(customDir); }); test("SENTRY_INSTALL_DIR takes priority over ~/.local/bin", () => { 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 // ───────────────────────────────────────────────────────────────────────────── diff --git a/packages/cli/test/lib/shell.test.ts b/packages/cli/test/lib/shell.test.ts index 6ddc6d0b6..a5e0571db 100644 --- a/packages/cli/test/lib/shell.test.ts +++ b/packages/cli/test/lib/shell.test.ts @@ -17,6 +17,7 @@ import { findExistingConfigFile, getConfigCandidates, isBashAvailable, + isInPath, } from "../../src/lib/shell.js"; import { whichSync } from "../../src/lib/which.js"; @@ -359,6 +360,34 @@ describe("shell utilities", () => { }); }); +describe("isInPath", () => { + const sep = process.platform === "win32" ? ";" : ":"; + + test("returns true for an exact match", () => { + expect(isInPath("/usr/local/bin", `/usr/bin${sep}/usr/local/bin`)).toBe( + true + ); + }); + + test("returns false when the directory is absent", () => { + expect(isInPath("/opt/bin", `/usr/bin${sep}/usr/local/bin`)).toBe(false); + }); + + test("returns false for undefined or empty PATH", () => { + expect(isInPath("/usr/bin", undefined)).toBe(false); + expect(isInPath("/usr/bin", "")).toBe(false); + }); + + test("case sensitivity follows the platform", () => { + const result = isInPath("/Users/User/.local/bin", "/users/user/.local/bin"); + if (process.platform === "win32" || process.platform === "darwin") { + expect(result).toBe(true); + } else { + expect(result).toBe(false); + } + }); +}); + describe("isBashAvailable", () => { test("returns true when bash is in PATH", () => { // Point PATH at the directory containing bash diff --git a/packages/cli/test/lib/upgrade.test.ts b/packages/cli/test/lib/upgrade.test.ts index 7ea0520dd..7c6f63a4b 100644 --- a/packages/cli/test/lib/upgrade.test.ts +++ b/packages/cli/test/lib/upgrade.test.ts @@ -19,7 +19,7 @@ import { } from "node:fs"; import { access, readFile, unlink, writeFile } from "node:fs/promises"; import { homedir, platform } from "node:os"; -import { join } from "node:path"; +import { join, sep } from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; import { gzipSync } from "node:zlib"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; @@ -129,6 +129,7 @@ import { UpgradeError } from "../../src/lib/errors.js"; import { isProcessRunning } from "../../src/lib/process-utils.js"; const { + buildKnownCurlPaths, detectInstallationMethod, detectPackageManagerFromPath, downloadBinaryToTemp, @@ -986,6 +987,40 @@ describe("getBinaryDownloadUrl", () => { }); }); +describe("buildKnownCurlPaths", () => { + test("appends a trailing separator to each known dir", () => { + const paths = buildKnownCurlPaths("/home/user", {}); + expect(paths).toContain(join("/home/user", ".local", "bin") + sep); + expect(paths).toContain(join("/home/user", ".sentry", "bin") + sep); + expect(paths.every((p) => p.endsWith(sep))).toBe(true); + }); + + test("includes an absolute XDG_BIN_HOME", () => { + const xdgBin = join(homedir(), "custom", "bin"); + const paths = buildKnownCurlPaths("/home/user", { XDG_BIN_HOME: xdgBin }); + expect(paths).toContain(xdgBin + sep); + }); + + test("normalizes a trailing slash on XDG_BIN_HOME (no double separator)", () => { + const xdgBin = join(homedir(), "custom", "bin"); + const paths = buildKnownCurlPaths("/home/user", { + // Trailing separator on the configured dir must be normalized away. + XDG_BIN_HOME: xdgBin + sep, + }); + // Must end with a single sep, never a double sep which would break + // process.execPath.startsWith() directory-boundary checks. + expect(paths).toContain(xdgBin + sep); + expect(paths.some((p) => p.includes(sep + sep))).toBe(false); + }); + + test("ignores a non-absolute XDG_BIN_HOME", () => { + const paths = buildKnownCurlPaths("/home/user", { + XDG_BIN_HOME: join("relative", "bin"), + }); + expect(paths.some((p) => p.includes(`relative${sep}bin`))).toBe(false); + }); +}); + describe("getCurlInstallPaths", () => { test("returns all required paths", () => { const paths = getCurlInstallPaths();