From b380be8617479e90264072212d448ed5fe7012aa Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 9 Apr 2026 09:39:17 +0000 Subject: [PATCH] feat(config): support .sentryclirc config file for per-directory defaults Add backward-compatible support for .sentryclirc INI config files. The CLI walks up from CWD to find config files, merging them (closest wins per-field) with ~/.sentryclirc as a global fallback. Supported fields: - [defaults] org, project, url - [auth] token Token and URL are applied via env shim (SENTRY_AUTH_TOKEN, SENTRY_URL). Org and project are inserted into the resolution chain between env vars and SQLite defaults with source tracking. This enables per-directory project defaults in monorepos and seamless migration from legacy sentry-cli. --- docs/src/content/docs/agent-guidance.md | 4 +- docs/src/content/docs/configuration.md | 62 ++- docs/src/content/docs/features.md | 6 +- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 4 +- src/cli.ts | 37 +- src/lib/dsn/project-root.ts | 117 +++--- src/lib/ini.ts | 105 ++++++ src/lib/resolve-target.ts | 104 +++-- src/lib/sentryclirc.ts | 318 ++++++++++++++++ src/lib/walk-up.ts | 50 +++ test/lib/ini.property.test.ts | 229 +++++++++++ test/lib/ini.test.ts | 172 +++++++++ test/lib/sentryclirc.property.test.ts | 233 ++++++++++++ test/lib/sentryclirc.test.ts | 356 ++++++++++++++++++ 14 files changed, 1713 insertions(+), 84 deletions(-) create mode 100644 src/lib/ini.ts create mode 100644 src/lib/sentryclirc.ts create mode 100644 src/lib/walk-up.ts create mode 100644 test/lib/ini.property.test.ts create mode 100644 test/lib/ini.test.ts create mode 100644 test/lib/sentryclirc.property.test.ts create mode 100644 test/lib/sentryclirc.test.ts diff --git a/docs/src/content/docs/agent-guidance.md b/docs/src/content/docs/agent-guidance.md index aae8658ee..c31391243 100644 --- a/docs/src/content/docs/agent-guidance.md +++ b/docs/src/content/docs/agent-guidance.md @@ -12,7 +12,7 @@ Best practices and operational guidance for AI coding agents using the Sentry CL - **Use `sentry schema` to explore the API** — if you need to discover API endpoints, run `sentry schema` to browse interactively or `sentry schema ` to search. This is faster than fetching OpenAPI specs externally. - **Use `sentry issue view ` to investigate issues** — when asked about a specific issue (e.g., `CLI-G5`, `PROJECT-123`), use `sentry issue view` directly. - **Use `--json` for machine-readable output** — pipe through `jq` for filtering. Human-readable output includes formatting that is hard to parse. -- **The CLI auto-detects org/project** — most commands work without explicit targets by scanning for DSNs in `.env` files, source code, config defaults, and directory names. Only specify `/` when the CLI reports it can't detect the target or detects the wrong one. +- **The CLI auto-detects org/project** — most commands work without explicit targets by checking `.sentryclirc` config files, scanning for DSNs in `.env` files and source code, and matching directory names. Only specify `/` when the CLI reports it can't detect the target or detects the wrong one. ## Design Principles @@ -213,7 +213,7 @@ When querying the Events API (directly or via `sentry api`), valid dataset value - **Wrong issue ID format**: Use `PROJECT-123` (short ID), not the numeric ID `123456789`. The short ID includes the project prefix. - **Pre-authenticating unnecessarily**: Don't run `sentry auth login` before every command. The CLI detects missing/expired auth and prompts automatically. Only run `sentry auth login` if you need to switch accounts. - **Missing `--json` for piping**: Human-readable output includes formatting. Use `--json` when parsing output programmatically. -- **Specifying org/project when not needed**: Auto-detection resolves org/project from DSNs, env vars, config defaults, and directory names. Let it work first — only add `/` if the CLI says it can't detect the target or detects the wrong one. +- **Specifying org/project when not needed**: Auto-detection resolves org/project from `.sentryclirc` config files, DSNs, env vars, and directory names. Let it work first — only add `/` if the CLI says it can't detect the target or detects the wrong one. - **Confusing `--query` syntax**: The `--query` flag uses Sentry search syntax (e.g., `is:unresolved`, `assigned:me`), not free text search. - **Not using `--web`**: View commands support `-w`/`--web` to open the resource in the browser — useful for sharing links. - **Fetching API schemas instead of using the CLI**: Prefer `sentry schema` to browse the API and `sentry api` to make requests — the CLI handles authentication and endpoint resolution, so there's rarely a need to download OpenAPI specs separately. diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md index 6d5278a45..9cfe7807a 100644 --- a/docs/src/content/docs/configuration.md +++ b/docs/src/content/docs/configuration.md @@ -1,9 +1,67 @@ --- title: Configuration -description: Environment variables and configuration options for the Sentry CLI +description: Environment variables, config files, and configuration options for the Sentry CLI --- -The Sentry CLI can be configured through environment variables and a local database. Most users don't need to set any of these — the CLI auto-detects your project from your codebase and stores credentials locally after `sentry auth login`. +The Sentry CLI can be configured through config files, environment variables, and a local database. Most users don't need to set any of these — the CLI auto-detects your project from your codebase and stores credentials locally after `sentry auth login`. + +## Configuration File (`.sentryclirc`) + +The CLI supports a `.sentryclirc` config file using standard INI syntax. This is the same format used by the legacy `sentry-cli` tool, so existing config files are automatically picked up. + +### How It Works + +The CLI looks for `.sentryclirc` files by walking up from your current directory toward the filesystem root. If multiple files are found, values from the closest file take priority, with `~/.sentryclirc` serving as a global fallback. + +```ini +[defaults] +org = my-org +project = my-project + +[auth] +token = sntrys_... +``` + +### Supported Fields + +| Section | Key | Description | +|---------|-----|-------------| +| `[defaults]` | `org` | Default organization slug | +| `[defaults]` | `project` | Default project slug | +| `[defaults]` | `url` | Sentry base URL (for self-hosted) | +| `[auth]` | `token` | Auth token (mapped to `SENTRY_AUTH_TOKEN`) | + +### Monorepo Setup + +In monorepos, place a `.sentryclirc` at the repo root with your org, then add per-package configs with just the project: + +``` +my-monorepo/ + .sentryclirc # [defaults] org = my-company + packages/ + frontend/ + .sentryclirc # [defaults] project = frontend-web + backend/ + .sentryclirc # [defaults] project = backend-api +``` + +When you run a command from `packages/frontend/`, the CLI resolves `org = my-company` from the root and `project = frontend-web` from the closest file. + +### Resolution Priority + +When the CLI needs to determine your org and project, it checks these sources in order: + +1. **Explicit CLI arguments** — `sentry issue list my-org/my-project` +2. **Environment variables** — `SENTRY_ORG` / `SENTRY_PROJECT` +3. **`.sentryclirc` config file** — walked up from CWD, merged with `~/.sentryclirc` +4. **DSN auto-detection** — scans source code and `.env` files +5. **Directory name inference** — matches your directory name against project slugs + +The first source that provides both org and project wins. For org-only commands, only the org is needed. + +### Backward Compatibility + +If you previously used the legacy `sentry-cli` and have a `~/.sentryclirc` file, the new CLI reads it automatically. The `[defaults]` and `[auth]` sections are fully compatible. The `[auth] token` value is mapped to the `SENTRY_AUTH_TOKEN` environment variable internally (only if the env var is not already set). ## Environment Variables diff --git a/docs/src/content/docs/features.md b/docs/src/content/docs/features.md index 158e1139a..01e3ec80b 100644 --- a/docs/src/content/docs/features.md +++ b/docs/src/content/docs/features.md @@ -7,7 +7,7 @@ The Sentry CLI includes several features designed to streamline your workflow, e ## DSN Auto-Detection -The CLI automatically detects your Sentry project from your codebase, eliminating the need to specify the target for every command. +The CLI automatically detects your Sentry project from your codebase, eliminating the need to specify the target for every command. DSN detection is one part of the [resolution priority chain](./configuration/#resolution-priority) — it runs after checking for explicit arguments, environment variables, and `.sentryclirc` config files. ### How It Works @@ -19,6 +19,10 @@ DSN detection follows this priority order (highest first): When a DSN is found, the CLI resolves it to your organization and project, then caches the result for fast subsequent lookups. +:::tip +For monorepos or when DSN detection picks up the wrong project, use a [`.sentryclirc` config file](./configuration/#configuration-file-sentryclirc) to pin your org/project explicitly. +::: + ### Supported Languages The CLI can detect DSNs from source code in these languages: diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index dfe18d2b8..ba9a5a2a2 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -22,7 +22,7 @@ Best practices and operational guidance for AI coding agents using the Sentry CL - **Use `sentry schema` to explore the API** — if you need to discover API endpoints, run `sentry schema` to browse interactively or `sentry schema ` to search. This is faster than fetching OpenAPI specs externally. - **Use `sentry issue view ` to investigate issues** — when asked about a specific issue (e.g., `CLI-G5`, `PROJECT-123`), use `sentry issue view` directly. - **Use `--json` for machine-readable output** — pipe through `jq` for filtering. Human-readable output includes formatting that is hard to parse. -- **The CLI auto-detects org/project** — most commands work without explicit targets by scanning for DSNs in `.env` files, source code, config defaults, and directory names. Only specify `/` when the CLI reports it can't detect the target or detects the wrong one. +- **The CLI auto-detects org/project** — most commands work without explicit targets by checking `.sentryclirc` config files, scanning for DSNs in `.env` files and source code, and matching directory names. Only specify `/` when the CLI reports it can't detect the target or detects the wrong one. ### Design Principles @@ -223,7 +223,7 @@ When querying the Events API (directly or via `sentry api`), valid dataset value - **Wrong issue ID format**: Use `PROJECT-123` (short ID), not the numeric ID `123456789`. The short ID includes the project prefix. - **Pre-authenticating unnecessarily**: Don't run `sentry auth login` before every command. The CLI detects missing/expired auth and prompts automatically. Only run `sentry auth login` if you need to switch accounts. - **Missing `--json` for piping**: Human-readable output includes formatting. Use `--json` when parsing output programmatically. -- **Specifying org/project when not needed**: Auto-detection resolves org/project from DSNs, env vars, config defaults, and directory names. Let it work first — only add `/` if the CLI says it can't detect the target or detects the wrong one. +- **Specifying org/project when not needed**: Auto-detection resolves org/project from `.sentryclirc` config files, DSNs, env vars, and directory names. Let it work first — only add `/` if the CLI says it can't detect the target or detects the wrong one. - **Confusing `--query` syntax**: The `--query` flag uses Sentry search syntax (e.g., `is:unresolved`, `assigned:me`), not free text search. - **Not using `--web`**: View commands support `-w`/`--web` to open the resource in the browser — useful for sharing links. - **Fetching API schemas instead of using the CLI**: Prefer `sentry schema` to browse the API and `sentry api` to make requests — the CLI handles authentication and endpoint resolution, so there's rarely a need to download OpenAPI specs separately. diff --git a/src/cli.ts b/src/cli.ts index 79f8978c6..5c749d8bc 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,6 +11,31 @@ */ import { getEnv } from "./lib/env.js"; +import { applySentryCliRcEnvShim } from "./lib/sentryclirc.js"; + +/** + * Preload project context: walk up from `cwd` once, finding both the + * project root (for DSN detection) and `.sentryclirc` config (for + * org/project defaults and env shim). Caches both results so later calls + * to `findProjectRoot` and `loadSentryCliRc` are cache hits. + */ +async function preloadProjectContext(cwd: string): Promise { + // Dynamic import keeps the heavy DSN/DB modules out of the completion fast-path + const [{ findProjectRoot }, { setCachedProjectRoot }] = await Promise.all([ + import("./lib/dsn/project-root.js"), + import("./lib/db/project-root-cache.js"), + ]); + + const result = await findProjectRoot(cwd); + await setCachedProjectRoot(cwd, { + projectRoot: result.projectRoot, + reason: result.reason, + }); + + // Apply .sentryclirc env shim (token, URL) — sentryclirc cache was + // populated as a side effect of findProjectRoot's walk + await applySentryCliRcEnvShim(cwd); +} /** * Fast-path: shell completion. @@ -401,9 +426,10 @@ export async function runCli(cliArgs: string[]): Promise { * Reads `process.argv`, dispatches to the completion fast-path or the full * CLI runner, and handles fatal errors. Called from `bin.ts`. */ -export function startCli(): Promise { +export async function startCli(): Promise { const args = process.argv.slice(2); + // Completions are a fast-path (~1ms) — skip .sentryclirc I/O. if (args[0] === "__complete") { return runCompletion(args.slice(1)).catch(() => { // Completions should never crash — silently return no results @@ -411,6 +437,15 @@ export function startCli(): Promise { }); } + // Walk up from CWD once to find project root AND .sentryclirc config. + // Caches both so later findProjectRoot / loadSentryCliRc calls are hits. + // Non-fatal — the CLI can still work via env vars and DSN detection. + try { + await preloadProjectContext(process.cwd()); + } catch { + // Gracefully degrade: project context is optional for CLI operation. + } + return runCli(args).catch((err) => { process.stderr.write(`Fatal: ${err}\n`); process.exitCode = 1; diff --git a/src/lib/dsn/project-root.ts b/src/lib/dsn/project-root.ts index e11a4a323..73ad1b877 100644 --- a/src/lib/dsn/project-root.ts +++ b/src/lib/dsn/project-root.ts @@ -15,9 +15,18 @@ import { opendir, stat } from "node:fs/promises"; import { homedir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { join, resolve } from "node:path"; import { anyTrue } from "../promises.js"; +import { + applyGlobalFallbacks, + applySentryCliRcDir, + createSentryCliRcConfig, + CONFIG_FILENAME as SENTRYCLIRC_FILENAME, + type SentryCliRcConfig, + setSentryCliRcCache, +} from "../sentryclirc.js"; import { withFsSpan, withTracingSpan } from "../telemetry.js"; +import { walkUpFrom } from "../walk-up.js"; import { ENV_FILES, extractDsnFromEnvContent } from "./env-file.js"; import { handleFileError } from "./fs-utils.js"; import { createDetectedDsn } from "./parser.js"; @@ -74,6 +83,9 @@ const CI_MARKERS = [ /** Language/package markers - strong project boundary */ const LANGUAGE_MARKERS = [ + // Sentry CLI config — treated as a project boundary (not definitive root, + // so the walk continues past it to find VCS markers in monorepos) + SENTRYCLIRC_FILENAME, // JavaScript/Node ecosystem "package.json", "deno.json", @@ -393,14 +405,6 @@ function selectProjectRoot( return { projectRoot: fallback, reason: "fallback" }; } -/** State tracked during directory walk-up */ -type WalkState = { - currentDir: string; - levelsTraversed: number; - languageMarkerAt: string | null; - buildSystemAt: string | null; -}; - /** * Create result when DSN is found in .env file */ @@ -447,90 +451,97 @@ function createRepoRootResult( }; } +/** + * Finalize accumulated sentryclirc config: apply global fallbacks and cache. + */ +async function finalizeSentryCliRc( + cwd: string, + config: SentryCliRcConfig +): Promise { + await applyGlobalFallbacks(config); + setSentryCliRcCache(cwd, config); +} + /** * Walk up directories searching for project root. * - * Loop logic: - * 1. Always process starting directory (do-while ensures this) - * 2. Stop at stopBoundary AFTER processing it (break before moving to parent) - * 3. Stop at filesystem root (parentDir === currentDir) + * Uses the shared {@link walkUpFrom} generator for directory traversal + * (with symlink cycle detection). Also reads `.sentryclirc` files at each + * level and populates the sentryclirc cache (with global fallbacks applied), + * so that a later `loadSentryCliRc` call for the same `cwd` is a cache hit + * instead of a second walk. + * + * Stops at the `stopBoundary` (home dir) after processing it, or when the + * generator reaches the filesystem root. */ async function walkUpDirectories( resolvedStart: string, stopBoundary: string ): Promise { - const state: WalkState = { - currentDir: resolvedStart, - levelsTraversed: 0, - languageMarkerAt: null, - buildSystemAt: null, - }; + let levelsTraversed = 0; + let languageMarkerAt: string | null = null; + let buildSystemAt: string | null = null; + const rcConfig = createSentryCliRcConfig(); - // do-while ensures starting directory is always checked, - // even when it equals the stop boundary (e.g., user runs from home dir) - do { - state.levelsTraversed += 1; + for await (const currentDir of walkUpFrom(resolvedStart)) { + levelsTraversed += 1; - const { dsnResult, repoRootResult, hasLang, hasBuild } = - await processDirectoryLevel( - state.currentDir, - state.languageMarkerAt, - state.buildSystemAt - ); + // Check project-root markers AND .sentryclirc in parallel + const [{ dsnResult, repoRootResult, hasLang, hasBuild }] = + await Promise.all([ + processDirectoryLevel(currentDir, languageMarkerAt, buildSystemAt), + applySentryCliRcDir(rcConfig, currentDir), + ]); // 1. Check for DSN in .env files - immediate return (unless at/above home directory) // Don't use a .env in the home directory as a project root indicator, // as users may have global configs that shouldn't define project boundaries - if (dsnResult && state.currentDir !== stopBoundary) { - return createDsnFoundResult( - state.currentDir, - dsnResult, - state.levelsTraversed - ); + if (dsnResult && currentDir !== stopBoundary) { + await finalizeSentryCliRc(resolvedStart, rcConfig); + return createDsnFoundResult(currentDir, dsnResult, levelsTraversed); } // 2. Check for VCS/CI markers - definitive root, stop walking if (repoRootResult.found) { + await finalizeSentryCliRc(resolvedStart, rcConfig); return createRepoRootResult( - state.currentDir, + currentDir, repoRootResult.type, - state.levelsTraversed, - state.languageMarkerAt + levelsTraversed, + languageMarkerAt ); } // 3. Remember language marker (closest to cwd wins) - if (!state.languageMarkerAt && hasLang) { - state.languageMarkerAt = state.currentDir; + if (!languageMarkerAt && hasLang) { + languageMarkerAt = currentDir; } // 4. Remember build system marker (last resort) - if (!state.buildSystemAt && hasBuild) { - state.buildSystemAt = state.currentDir; + if (!buildSystemAt && hasBuild) { + buildSystemAt = currentDir; } - // Move to parent directory (or stop if at boundary/root) - const parentDir = dirname(state.currentDir); - const shouldStop = - state.currentDir === stopBoundary || parentDir === state.currentDir; - if (shouldStop) { + // Stop at boundary after processing it (e.g., home dir) + if (currentDir === stopBoundary) { break; } - state.currentDir = parentDir; - // biome-ignore lint/correctness/noConstantCondition: loop exits via break - } while (true); + } + + // Populate sentryclirc cache from accumulated data + setSentryCliRcCache(resolvedStart, rcConfig); // Determine project root from candidates (priority order) const selected = selectProjectRoot( - state.languageMarkerAt, - state.buildSystemAt, + languageMarkerAt, + buildSystemAt, resolvedStart ); return { projectRoot: selected.projectRoot, reason: selected.reason, - levelsTraversed: state.levelsTraversed, + levelsTraversed, }; } diff --git a/src/lib/ini.ts b/src/lib/ini.ts new file mode 100644 index 000000000..99c641d96 --- /dev/null +++ b/src/lib/ini.ts @@ -0,0 +1,105 @@ +/** + * Simple INI file parser. + * + * Supports the subset of INI syntax used by `.sentryclirc` config files: + * `[section]` headers, `key = value` pairs, and `#`/`;` line comments. + * + * Design decisions: + * - Section and key names are lowercased for case-insensitive lookup + * - Inline comments are NOT supported (tokens/URLs may contain `#` and `;`) + * - Duplicate keys: last value wins within a section + * - Duplicate sections: merged (keys accumulate) + * - Malformed lines are silently skipped + * - Quoted values (matching `"` or `'`) are stripped + */ + +import { logger } from "./logger.js"; + +const log = logger.withTag("ini"); + +/** Parsed INI data: section name → key → value. Keys before any section go into `""`. */ +export type IniData = Record>; + +/** UTF-8 BOM character */ +const BOM = "\uFEFF"; + +/** Match `[section]` headers, allowing whitespace inside brackets */ +const SECTION_RE = /^\[([^\]]+)\]$/; + +/** Split on LF or CRLF in one pass */ +const LINE_SPLIT_RE = /\r?\n/; + +/** + * Strip matching outer quotes from a value string. + * + * Only strips when the first and last characters are the same quote type + * (`"` or `'`) and the string is at least 2 characters long. + */ +function stripQuotes(value: string): string { + if (value.length < 2) { + return value; + } + const first = value[0]; + const last = value.at(-1); + if ((first === '"' || first === "'") && first === last) { + return value.slice(1, -1); + } + return value; +} + +/** + * Parse INI-formatted text into a section→key→value map. + * + * @param content - Raw INI file content + * @returns Parsed data keyed by lowercase section name, then lowercase key + */ +export function parseIni(content: string): IniData { + const data: IniData = {}; + let currentSection = ""; + + // Ensure the global section always exists + data[currentSection] = {}; + + // Strip UTF-8 BOM if present + const text = content.startsWith(BOM) ? content.slice(1) : content; + + for (const rawLine of text.split(LINE_SPLIT_RE)) { + const line = rawLine.trim(); + + // Skip empty lines and comments (lines starting with # or ;) + if (line === "" || line[0] === "#" || line[0] === ";") { + continue; + } + + // Check for section header + const sectionMatch = SECTION_RE.exec(line); + if (sectionMatch?.[1]) { + currentSection = sectionMatch[1].trim().toLowerCase(); + if (!(currentSection in data)) { + data[currentSection] = {}; + } + continue; + } + + // Check for key = value pair + const eqIndex = line.indexOf("="); + if (eqIndex === -1) { + log.debug(`Skipping malformed INI line: ${line}`); + continue; + } + + const key = line.slice(0, eqIndex).trim().toLowerCase(); + if (key === "") { + log.debug(`Skipping INI line with empty key: ${line}`); + continue; + } + + const rawValue = line.slice(eqIndex + 1).trim(); + const value = stripQuotes(rawValue); + + // biome-ignore lint/style/noNonNullAssertion: section is always initialized above + data[currentSection]![key] = value; + } + + return data; +} diff --git a/src/lib/resolve-target.ts b/src/lib/resolve-target.ts index 277c8b8a4..aee946213 100644 --- a/src/lib/resolve-target.ts +++ b/src/lib/resolve-target.ts @@ -8,9 +8,10 @@ * Resolution priority (highest to lowest): * 1. Explicit CLI flags * 2. SENTRY_ORG / SENTRY_PROJECT environment variables - * 3. Config defaults - * 4. DSN auto-detection (source code, .env files, environment variables) - * 5. Directory name inference (matches project slugs with word boundaries) + * 3. `.sentryclirc` config file (walked up from CWD, merged with global) + * 4. Config defaults (SQLite) + * 5. DSN auto-detection (source code, .env files, environment variables) + * 6. Directory name inference (matches project slugs with word boundaries) */ import { basename } from "node:path"; @@ -52,6 +53,7 @@ import { import { fuzzyMatch } from "./fuzzy.js"; import { logger } from "./logger.js"; import { resolveEffectiveOrg } from "./region.js"; +import { CONFIG_FILENAME, loadSentryCliRc } from "./sentryclirc.js"; import { setOrgProjectContext, withTracingSpan } from "./telemetry.js"; import { isAllDigits } from "./utils.js"; @@ -755,9 +757,10 @@ async function resolveDsnsWithTimeout( * Resolution priority: * 1. Explicit org and project - returns single target * 2. SENTRY_ORG / SENTRY_PROJECT env vars - returns single target - * 3. Config defaults - returns single target - * 4. DSN auto-detection - may return multiple targets - * 5. Directory name inference - matches project slugs with word boundaries + * 3. `.sentryclirc` config file - returns single target + * 4. Config defaults - returns single target + * 5. DSN auto-detection - may return multiple targets + * 6. Directory name inference - matches project slugs with word boundaries * * @param options - Resolution options with org, project, and cwd * @returns All resolved targets and optional footer message @@ -818,10 +821,30 @@ export async function resolveAllTargets( } log.debug( - "No SENTRY_ORG/SENTRY_PROJECT env vars, trying config defaults" + `No SENTRY_ORG/SENTRY_PROJECT env vars, trying ${CONFIG_FILENAME} config file` ); - // 3. Config defaults + // 3. .sentryclirc config file (walked up from cwd, merged with global) + const rcConfig = await loadSentryCliRc(cwd); + if (rcConfig.org && rcConfig.project) { + span.setAttribute("resolve.method", "sentryclirc"); + setOrgProjectContext([rcConfig.org], [rcConfig.project]); + return { + targets: [ + { + org: rcConfig.org, + project: rcConfig.project, + orgDisplay: rcConfig.org, + projectDisplay: rcConfig.project, + detectedFrom: `${CONFIG_FILENAME} (${rcConfig.sources.project})`, + }, + ], + }; + } + + log.debug(`No ${CONFIG_FILENAME} org/project, trying config defaults`); + + // 4. Config defaults const defaultOrg = getDefaultOrganization(); const defaultProject = getDefaultProject(); if (defaultOrg && defaultProject) { @@ -841,14 +864,14 @@ export async function resolveAllTargets( log.debug("No config defaults set, trying DSN auto-detection"); - // 4. DSN auto-detection (may find multiple in monorepos) + // 5. DSN auto-detection (may find multiple in monorepos) const detection = await detectAllDsns(cwd); if (detection.all.length === 0) { log.debug( "No DSNs found in source code or env files, trying directory name inference" ); - // 5. Fallback: infer from directory name + // 6. Fallback: infer from directory name const result = await inferFromDirectoryName(cwd); if (result.targets.length === 0) { span.setAttribute("resolve.method", "none"); @@ -957,9 +980,10 @@ async function resolveDetectedDsns( * Resolution priority: * 1. Explicit org and project - both must be provided together * 2. SENTRY_ORG / SENTRY_PROJECT env vars - * 3. Config defaults - * 4. DSN auto-detection - * 5. Directory name inference - matches project slugs with word boundaries + * 3. `.sentryclirc` config file + * 4. Config defaults + * 5. DSN auto-detection + * 6. Directory name inference - matches project slugs with word boundaries * * @param options - Resolution options with org, project, and cwd * @returns Resolved target, or null if resolution failed @@ -1007,7 +1031,20 @@ export async function resolveOrgAndProject( }); } - // 3. Config defaults + // 3. .sentryclirc config file + const rcConfig = await loadSentryCliRc(cwd); + if (rcConfig.org && rcConfig.project) { + span.setAttribute("resolve.method", "sentryclirc"); + return withTelemetryContext({ + org: rcConfig.org, + project: rcConfig.project, + orgDisplay: rcConfig.org, + projectDisplay: rcConfig.project, + detectedFrom: `${CONFIG_FILENAME} (${rcConfig.sources.project})`, + }); + } + + // 4. Config defaults const defaultOrg = getDefaultOrganization(); const defaultProject = getDefaultProject(); if (defaultOrg && defaultProject) { @@ -1020,7 +1057,7 @@ export async function resolveOrgAndProject( }); } - // 4. DSN auto-detection + // 5. DSN auto-detection try { const dsnResult = await resolveFromDsn(cwd); if (dsnResult) { @@ -1031,7 +1068,7 @@ export async function resolveOrgAndProject( // Fall through to directory inference } - // 5. Fallback: infer from directory name + // 6. Fallback: infer from directory name const inferred = await inferFromDirectoryName(cwd); const [first] = inferred.targets; if (!first) { @@ -1059,8 +1096,9 @@ export async function resolveOrgAndProject( * Resolution priority: * 1. Positional argument * 2. SENTRY_ORG / SENTRY_PROJECT env vars - * 3. Config defaults - * 4. DSN auto-detection + * 3. `.sentryclirc` config file + * 4. Config defaults + * 5. DSN auto-detection * * @param options - Resolution options with flag and cwd * @returns Resolved org, or null if resolution failed @@ -1083,14 +1121,24 @@ export async function resolveOrg( return { org: envVars.org, detectedFrom: envVars.detectedFrom }; } - // 3. Config defaults + // 3. .sentryclirc config file (org only) + const rcConfig = await loadSentryCliRc(cwd); + if (rcConfig.org) { + setOrgProjectContext([rcConfig.org], []); + return { + org: rcConfig.org, + detectedFrom: `${CONFIG_FILENAME} (${rcConfig.sources.org})`, + }; + } + + // 4. Config defaults const defaultOrg = getDefaultOrganization(); if (defaultOrg) { setOrgProjectContext([defaultOrg], []); return { org: defaultOrg }; } - // 4. DSN auto-detection + // 5. DSN auto-detection try { const result = await resolveOrgFromDsn(cwd); if (result) { @@ -1198,9 +1246,11 @@ export type OrgListResolution = { * * Resolution priority: * 1. Explicit org flag → use that single org - * 2. Config default org → use that org - * 3. DSN auto-detection → extract unique orgs from detected targets - * 4. No context found → empty list (caller must decide to show all orgs or error) + * 2. SENTRY_ORG / SENTRY_PROJECT env vars → use that org + * 3. `.sentryclirc` config file → use org from config + * 4. Config default org → use that org + * 5. DSN auto-detection → extract unique orgs from detected targets + * 6. No context found → empty list (caller must decide to show all orgs or error) * * @param orgFlag - Explicit org slug from CLI positional arg, or undefined * @param cwd - Current working directory for DSN detection @@ -1222,6 +1272,14 @@ export async function resolveOrgsForListing( return { orgs: [envVars.org] }; } + // 3. .sentryclirc config file + const rcConfig = await loadSentryCliRc(cwd); + if (rcConfig.org) { + setOrgProjectContext([rcConfig.org], []); + return { orgs: [rcConfig.org] }; + } + + // 4. Config defaults const defaultOrg = getDefaultOrganization(); if (defaultOrg) { setOrgProjectContext([defaultOrg], []); diff --git a/src/lib/sentryclirc.ts b/src/lib/sentryclirc.ts new file mode 100644 index 000000000..d54ca7a43 --- /dev/null +++ b/src/lib/sentryclirc.ts @@ -0,0 +1,318 @@ +/** + * `.sentryclirc` Configuration File Reader + * + * Provides backward compatibility with the old `sentry-cli` INI config file. + * Walks up from `cwd` toward the filesystem root looking for `.sentryclirc` + * files, merging them with a global fallback (`~/.sentryclirc` or + * `$SENTRY_CONFIG_DIR/.sentryclirc`). + * + * Supported fields: + * - `[defaults]` section: `org`, `project`, `url` + * - `[auth]` section: `token` + * + * The env shim ({@link applySentryCliRcEnvShim}) maps `token` → `SENTRY_AUTH_TOKEN` + * and `url` → `SENTRY_URL` so existing code picks them up without changes. + * Org and project are consumed directly by the resolution chain in + * `resolve-target.ts`. + */ + +import { homedir } from "node:os"; +import { join } from "node:path"; +import { getConfigDir } from "./db/index.js"; +import { getEnv } from "./env.js"; +import { parseIni } from "./ini.js"; +import { logger } from "./logger.js"; +import { walkUpFrom } from "./walk-up.js"; + +const log = logger.withTag("sentryclirc"); + +/** Config file name matching the old `sentry-cli` convention */ +export const CONFIG_FILENAME = ".sentryclirc"; + +/** Parsed `.sentryclirc` config with provenance tracking */ +export type SentryCliRcConfig = { + /** Organization slug from `[defaults]` section */ + org?: string; + /** Project slug from `[defaults]` section */ + project?: string; + /** Sentry base URL from `[defaults]` section */ + url?: string; + /** Auth token from `[auth]` section */ + token?: string; + /** + * Source file path for each resolved field. + * Useful for debug logging and error messages. + */ + sources: { + org?: string; + project?: string; + url?: string; + token?: string; + }; +}; + +/** + * Process-lifetime cache keyed by cwd. + * Stores promises (not resolved values) so concurrent callers share the same load. + */ +const cache = new Map>(); + +/** + * Read a file's text content, returning null for expected I/O errors. + * ENOENT (missing) and EACCES (permission denied) return null. + * All other errors propagate. + */ +async function tryReadFile(filePath: string): Promise { + try { + return await Bun.file(filePath).text(); + } catch (error: unknown) { + if (error instanceof Error && "code" in error) { + const { code } = error as NodeJS.ErrnoException; + if (code === "ENOENT" || code === "EACCES") { + return null; + } + } + throw error; + } +} + +/** + * Fields we extract from an INI config, keyed by section.field. + * + * Each entry maps to a field on {@link SentryCliRcConfig}. + */ +const FIELD_MAP: ReadonlyArray<{ + section: string; + key: string; + field: keyof Omit; +}> = [ + { section: "defaults", key: "org", field: "org" }, + { section: "defaults", key: "project", field: "project" }, + { section: "defaults", key: "url", field: "url" }, + { section: "auth", key: "token", field: "token" }, +]; + +/** + * Apply values from a parsed INI file to the result, filling gaps only. + * Fields already set in `result` are not overwritten (closest-file-wins). + */ +function applyConfig( + result: SentryCliRcConfig, + iniData: ReturnType, + filePath: string +): void { + for (const { section, key, field } of FIELD_MAP) { + if (result[field] !== undefined) { + continue; + } + const value = iniData[section]?.[key]?.trim(); + if (value) { + result[field] = value; + result.sources[field] = filePath; + } + } +} + +/** + * Check if all config fields are populated (early exit optimization). + */ +function isComplete(result: SentryCliRcConfig): boolean { + return ( + result.org !== undefined && + result.project !== undefined && + result.url !== undefined && + result.token !== undefined + ); +} + +/** + * Try to read and apply a `.sentryclirc` file to the result. + * No-op if the file doesn't exist or can't be read. + */ +async function tryApplyFile( + result: SentryCliRcConfig, + filePath: string, + isGlobal: boolean +): Promise { + const content = await tryReadFile(filePath); + if (content !== null) { + log.debug( + `Found ${isGlobal ? "global" : "local"} ${CONFIG_FILENAME} at ${filePath}` + ); + applyConfig(result, parseIni(content), filePath); + } +} + +/** Lazy-cached set of global `.sentryclirc` paths (stable for the process lifetime) */ +let globalPaths: Set | null = null; + +/** Global paths checked as fallback after the walk-up */ +function getGlobalPaths(): Set { + if (!globalPaths) { + globalPaths = new Set([ + join(getConfigDir(), CONFIG_FILENAME), + join(homedir(), CONFIG_FILENAME), + ]); + } + return globalPaths; +} + +/** + * Apply global `.sentryclirc` fallbacks to fill any remaining gaps. + * Checks `$SENTRY_CONFIG_DIR/.sentryclirc`, then `~/.sentryclirc`. + * + * Must be called after the walk-up and before caching the result, + * so that global values are included in the cached config. + */ +export async function applyGlobalFallbacks( + result: SentryCliRcConfig +): Promise { + for (const globalPath of getGlobalPaths()) { + if (isComplete(result)) { + break; + } + await tryApplyFile(result, globalPath, true); + } +} + +/** + * Try to apply a `.sentryclirc` file from `dir` to `result`. + * + * Skips global paths (handled separately by {@link applyGlobalFallbacks}). + * Returns true if all fields are now populated (caller can stop walking). + * + * Exported so the project-root walk in `dsn/project-root.ts` can read + * `.sentryclirc` files during its own walk-up, avoiding a second traversal. + */ +export async function applySentryCliRcDir( + result: SentryCliRcConfig, + dir: string +): Promise { + if (isComplete(result)) { + return true; + } + const rcPath = join(dir, CONFIG_FILENAME); + // Skip global paths — they're applied as fallback after the walk + if (!getGlobalPaths().has(rcPath)) { + await tryApplyFile(result, rcPath, false); + } + return isComplete(result); +} + +/** + * Store a pre-built config in the cache. + * + * Called by `findProjectRoot` after it has walked the directory tree + * and accumulated `.sentryclirc` data, so that a later `loadSentryCliRc` + * call for the same `cwd` is a cache hit instead of a second walk. + */ +export function setSentryCliRcCache( + cwd: string, + config: SentryCliRcConfig +): void { + cache.set(cwd, Promise.resolve(config)); +} + +/** + * Create an empty accumulator for building a config during a walk. + */ +export function createSentryCliRcConfig(): SentryCliRcConfig { + return { sources: {} }; +} + +/** + * Perform the actual load: walk up from `cwd`, then check global paths. + */ +async function doLoad(cwd: string): Promise { + const result = createSentryCliRcConfig(); + + // Walk up from cwd, applying local .sentryclirc files (closest-first) + for await (const dir of walkUpFrom(cwd)) { + if (await applySentryCliRcDir(result, dir)) { + break; + } + } + + await applyGlobalFallbacks(result); + return result; +} + +/** + * Load `.sentryclirc` config by walking up from `cwd` and merging with global. + * + * Walk-up behavior: + * 1. Start at `cwd`, walk toward filesystem root + * 2. At each directory, check for `.sentryclirc` + * 3. Closest file's values win per-field + * 4. Always check global location as fallback: + * `$SENTRY_CONFIG_DIR/.sentryclirc`, then `~/.sentryclirc` + * + * Results are cached for the process lifetime (keyed by `cwd`). + * Concurrent callers for the same `cwd` share the same promise. + * + * @param cwd - Starting directory for walk-up search + * @returns Merged config (empty fields if no files found) + */ +export function loadSentryCliRc(cwd: string): Promise { + const cached = cache.get(cwd); + if (cached) { + return cached; + } + + // Evict from cache on failure so subsequent calls retry instead of + // permanently returning a rejected promise. + const promise = doLoad(cwd).catch((error) => { + cache.delete(cwd); + throw error; + }); + cache.set(cwd, promise); + return promise; +} + +/** + * Apply env shim for `.sentryclirc` token and URL fields. + * + * Maps config file values to environment variables so the existing + * auth and URL resolution code picks them up without changes: + * - `[auth] token` → `SENTRY_AUTH_TOKEN` (if neither `SENTRY_AUTH_TOKEN` nor `SENTRY_TOKEN` is set) + * - `[defaults] url` → `SENTRY_URL` (if both `SENTRY_HOST` and `SENTRY_URL` are unset) + * + * Call this once, early in the CLI boot process (before any auth or API calls). + * + * @param cwd - Current working directory for config file lookup + */ +export async function applySentryCliRcEnvShim(cwd: string): Promise { + const config = await loadSentryCliRc(cwd); + const env = getEnv(); + + // Only set token if neither SENTRY_AUTH_TOKEN nor SENTRY_TOKEN is set, + // since both env vars rank above .sentryclirc in the auth chain. + if ( + config.token && + !env.SENTRY_AUTH_TOKEN?.trim() && + !env.SENTRY_TOKEN?.trim() + ) { + log.debug( + `Setting SENTRY_AUTH_TOKEN from ${CONFIG_FILENAME} (${config.sources.token})` + ); + env.SENTRY_AUTH_TOKEN = config.token; + } + + if (config.url && !env.SENTRY_HOST?.trim() && !env.SENTRY_URL?.trim()) { + log.debug( + `Setting SENTRY_URL from ${CONFIG_FILENAME} (${config.sources.url})` + ); + env.SENTRY_URL = config.url; + } +} + +/** + * Clear the process-lifetime cache. + * + * @internal Exported for testing only + */ +export function clearSentryCliRcCache(): void { + cache.clear(); + // Reset global paths — tests change SENTRY_CONFIG_DIR between runs + globalPaths = null; +} diff --git a/src/lib/walk-up.ts b/src/lib/walk-up.ts new file mode 100644 index 000000000..d28054f7c --- /dev/null +++ b/src/lib/walk-up.ts @@ -0,0 +1,50 @@ +/** + * Shared async generator for walking up a directory tree. + * + * Yields each directory from `startDir` up toward the filesystem root. + * Resolves symlinks via `realpath` to detect cycles (e.g., a symlink + * pointing back down the tree). + * + * Used by `.sentryclirc` config loading and project-root detection. + */ + +import { realpath } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; + +/** + * Walk up from `startDir` toward the filesystem root, yielding each + * directory path along the way. + * + * Stops at the filesystem root or on a symlink cycle. The caller can + * `break` out of the loop early (e.g., when all needed data is found, + * or when a stop boundary like `homedir()` is reached). + * + * @param startDir - Directory to start walking from + * @yields Absolute directory paths, starting with `startDir` + */ +export async function* walkUpFrom(startDir: string): AsyncGenerator { + const seen = new Set(); + let current = resolve(startDir); + + while (true) { + let real: string; + try { + real = await realpath(current); + } catch { + // Can't resolve (broken symlink, permission denied) — stop walking + break; + } + if (seen.has(real)) { + break; + } + seen.add(real); + + yield current; + + const parent = dirname(current); + if (parent === current) { + break; + } + current = parent; + } +} diff --git a/test/lib/ini.property.test.ts b/test/lib/ini.property.test.ts new file mode 100644 index 000000000..713114132 --- /dev/null +++ b/test/lib/ini.property.test.ts @@ -0,0 +1,229 @@ +/** + * Property-Based Tests for INI Parser + * + * Uses fast-check to verify properties that should always hold true + * for parseIni/serializeIni, regardless of input. + */ + +import { describe, expect, test } from "bun:test"; +import { + array, + constantFrom, + dictionary, + assert as fcAssert, + property, +} from "fast-check"; +import { type IniData, parseIni } from "../../src/lib/ini.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +/** + * Serialize IniData to INI string for round-trip testing. + * Global (empty-key) section is emitted first without a header. + */ +function serializeIni(data: IniData): string { + const lines: string[] = []; + const sections = Object.keys(data).sort((a, b) => { + if (a === "") { + return -1; + } + if (b === "") { + return 1; + } + return a.localeCompare(b); + }); + + for (const section of sections) { + const entries = data[section] ?? {}; + if (section !== "") { + if (lines.length > 0) { + lines.push(""); + } + lines.push(`[${section}]`); + } + for (const [key, value] of Object.entries(entries)) { + lines.push(`${key} = ${value}`); + } + } + + return lines.join("\n"); +} + +// Arbitraries + +/** Valid INI section names: lowercase alpha + digits + hyphens/underscores */ +const sectionNameChars = "abcdefghijklmnopqrstuvwxyz0123456789-_"; +const sectionNameArb = array(constantFrom(...sectionNameChars.split("")), { + minLength: 1, + maxLength: 20, +}).map((chars) => chars.join("")); + +/** Valid INI key names: lowercase alpha + digits + underscores/hyphens */ +const keyChars = "abcdefghijklmnopqrstuvwxyz0123456789_-"; +const keyNameArb = array(constantFrom(...keyChars.split("")), { + minLength: 1, + maxLength: 20, +}).map((chars) => chars.join("")); + +/** + * Valid INI values: printable characters that don't start with quotes + * and don't contain newlines. Avoids leading `"` or `'` to prevent + * round-trip issues with quote stripping. + */ +const safeValueChars = + "abcdefghijklmnopqrstuvwxyz0123456789 !@#$%^&*()-_=+[]{}|:/<>.?~`"; +const valueArb = array(constantFrom(...safeValueChars.split("")), { + minLength: 0, + maxLength: 50, +}).map((chars) => chars.join("").trim()); + +/** Generate a section as a dict of key→value pairs */ +const sectionArb = dictionary(keyNameArb, valueArb, { + minKeys: 0, + maxKeys: 5, +}); + +/** Generate valid IniData (only named sections, no global) for round-trip */ +const iniDataArb = dictionary(sectionNameArb, sectionArb, { + minKeys: 1, + maxKeys: 5, +}); + +/** Generate a comment line */ +const commentArb = constantFrom( + "# this is a comment", + "; another comment", + "# key = value", + "; [section]" +); + +describe("property: parseIni", () => { + test("round-trip: serialize then parse recovers all data", () => { + fcAssert( + property(iniDataArb, (data) => { + const serialized = serializeIni(data); + const parsed = parseIni(serialized); + + // All sections and keys from input should be in output + for (const [section, entries] of Object.entries(data)) { + for (const [key, value] of Object.entries(entries)) { + // Keys and sections are lowercased by parseIni, serializeIni outputs lowercase + const normalizedSection = section.toLowerCase(); + const normalizedKey = key.toLowerCase(); + expect(parsed[normalizedSection]?.[normalizedKey]).toBe(value); + } + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("idempotency: parse(serialize(parse(x))) === parse(x)", () => { + fcAssert( + property(iniDataArb, (data) => { + const serialized1 = serializeIni(data); + const parsed1 = parseIni(serialized1); + const serialized2 = serializeIni(parsed1); + const parsed2 = parseIni(serialized2); + + // Remove empty global section for comparison + const clean1 = { ...parsed1 }; + const clean2 = { ...parsed2 }; + if (clean1[""] && Object.keys(clean1[""]).length === 0) { + delete clean1[""]; + } + if (clean2[""] && Object.keys(clean2[""]).length === 0) { + delete clean2[""]; + } + + expect(clean2).toEqual(clean1); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("inserting comment lines does not change parsed values", () => { + fcAssert( + property(iniDataArb, commentArb, (data, comment) => { + const serialized = serializeIni(data); + const lines = serialized.split("\n"); + + // Insert comment at random positions + const withComments = lines.flatMap((line) => [comment, line]); + const withCommentsStr = withComments.join("\n"); + + const original = parseIni(serialized); + const withCommentsData = parseIni(withCommentsStr); + + // All non-empty-global sections should match + for (const [section, entries] of Object.entries(original)) { + if (section === "" && Object.keys(entries).length === 0) { + continue; + } + for (const [key, value] of Object.entries(entries)) { + expect(withCommentsData[section]?.[key]).toBe(value); + } + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("key names are case-insensitive", () => { + fcAssert( + property(sectionNameArb, keyNameArb, valueArb, (section, key, value) => { + const upper = `[${section}]\n${key.toUpperCase()} = ${value}`; + const lower = `[${section}]\n${key.toLowerCase()} = ${value}`; + + const parsedUpper = parseIni(upper); + const parsedLower = parseIni(lower); + + const normalizedSection = section.toLowerCase(); + const normalizedKey = key.toLowerCase(); + + expect(parsedUpper[normalizedSection]?.[normalizedKey]).toBe( + parsedLower[normalizedSection]?.[normalizedKey] + ); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("section names are case-insensitive", () => { + fcAssert( + property(sectionNameArb, keyNameArb, valueArb, (section, key, value) => { + const upper = `[${section.toUpperCase()}]\n${key} = ${value}`; + const lower = `[${section.toLowerCase()}]\n${key} = ${value}`; + + const parsedUpper = parseIni(upper); + const parsedLower = parseIni(lower); + + const normalizedSection = section.toLowerCase(); + const normalizedKey = key.toLowerCase(); + + expect(parsedUpper[normalizedSection]?.[normalizedKey]).toBe( + parsedLower[normalizedSection]?.[normalizedKey] + ); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("last-write-wins for duplicate keys in same section", () => { + fcAssert( + property( + sectionNameArb, + keyNameArb, + valueArb, + valueArb, + (section, key, value1, value2) => { + const content = `[${section}]\n${key} = ${value1}\n${key} = ${value2}`; + const parsed = parseIni(content); + const normalizedSection = section.toLowerCase(); + const normalizedKey = key.toLowerCase(); + expect(parsed[normalizedSection]?.[normalizedKey]).toBe(value2); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/test/lib/ini.test.ts b/test/lib/ini.test.ts new file mode 100644 index 000000000..c812c785f --- /dev/null +++ b/test/lib/ini.test.ts @@ -0,0 +1,172 @@ +/** + * Unit Tests for INI Parser + * + * Note: Core invariants (round-trips, case insensitivity, duplicate handling) + * are tested via property-based tests in ini.property.test.ts. These tests + * focus on edge cases and specific formatting not covered by property generators. + */ + +import { describe, expect, test } from "bun:test"; +import { parseIni } from "../../src/lib/ini.js"; + +describe("parseIni", () => { + test("empty string returns empty global section", () => { + const result = parseIni(""); + expect(result).toEqual({ "": {} }); + }); + + test("comments-only file returns empty global section", () => { + const result = parseIni("# comment\n; another comment\n"); + expect(result).toEqual({ "": {} }); + }); + + test("strips UTF-8 BOM", () => { + const result = parseIni("\uFEFF[defaults]\norg = my-org"); + expect(result.defaults?.org).toBe("my-org"); + }); + + test("handles Windows line endings (CRLF)", () => { + const result = parseIni( + "[defaults]\r\norg = my-org\r\nproject = my-proj\r\n" + ); + expect(result.defaults?.org).toBe("my-org"); + expect(result.defaults?.project).toBe("my-proj"); + }); + + test("global keys (before any section header)", () => { + const result = parseIni("key = value\n[section]\nother = data"); + expect(result[""]?.key).toBe("value"); + expect(result.section?.other).toBe("data"); + }); + + test("section names are lowercased", () => { + const result = parseIni("[Defaults]\nOrg = my-org"); + expect(result.defaults?.org).toBe("my-org"); + expect(result.Defaults).toBeUndefined(); + }); + + test("key names are lowercased", () => { + const result = parseIni("[auth]\nToken = secret123"); + expect(result.auth?.token).toBe("secret123"); + expect(result.auth?.Token).toBeUndefined(); + }); + + test("strips matching double quotes from values", () => { + const result = parseIni('[auth]\ntoken = "my-secret-token"'); + expect(result.auth?.token).toBe("my-secret-token"); + }); + + test("strips matching single quotes from values", () => { + const result = parseIni("[auth]\ntoken = 'my-secret-token'"); + expect(result.auth?.token).toBe("my-secret-token"); + }); + + test("does not strip mismatched quotes", () => { + const result = parseIni("[auth]\ntoken = \"my-secret-token'"); + expect(result.auth?.token).toBe("\"my-secret-token'"); + }); + + test("preserves quotes in middle of value", () => { + const result = parseIni( + '[defaults]\nurl = https://example.com/path?foo="bar"' + ); + expect(result.defaults?.url).toBe('https://example.com/path?foo="bar"'); + }); + + test("empty value after = sign", () => { + const result = parseIni("[defaults]\norg =\n"); + expect(result.defaults?.org).toBe(""); + }); + + test("value with leading/trailing spaces is trimmed", () => { + const result = parseIni("[defaults]\norg = my-org "); + expect(result.defaults?.org).toBe("my-org"); + }); + + test("duplicate keys: last value wins", () => { + const result = parseIni("[defaults]\norg = first\norg = second"); + expect(result.defaults?.org).toBe("second"); + }); + + test("duplicate sections: merged", () => { + const result = parseIni( + "[defaults]\norg = my-org\n[defaults]\nproject = my-proj" + ); + expect(result.defaults?.org).toBe("my-org"); + expect(result.defaults?.project).toBe("my-proj"); + }); + + test("duplicate section with duplicate key: last wins", () => { + const result = parseIni( + "[defaults]\norg = first\n[defaults]\norg = second" + ); + expect(result.defaults?.org).toBe("second"); + }); + + test("malformed line (no = sign) is skipped", () => { + const result = parseIni("[defaults]\nthis is not valid\norg = works"); + expect(result.defaults?.org).toBe("works"); + expect(Object.keys(result.defaults ?? {})).toEqual(["org"]); + }); + + test("line with = but empty key is skipped", () => { + const result = parseIni("[defaults]\n = value\norg = works"); + expect(result.defaults?.org).toBe("works"); + expect(Object.keys(result.defaults ?? {})).toEqual(["org"]); + }); + + test("unclosed section header is skipped", () => { + const result = parseIni("[defaults\norg = my-org"); + // Falls through as a malformed line, org goes into global section + expect(result[""]?.org).toBe("my-org"); + }); + + test("section header with whitespace inside", () => { + const result = parseIni("[ defaults ]\norg = my-org"); + expect(result.defaults?.org).toBe("my-org"); + }); + + test("inline # in value is preserved (not treated as comment)", () => { + const result = parseIni("[auth]\ntoken = abc#def"); + expect(result.auth?.token).toBe("abc#def"); + }); + + test("inline ; in value is preserved (not treated as comment)", () => { + const result = parseIni("[auth]\ntoken = abc;def"); + expect(result.auth?.token).toBe("abc;def"); + }); + + test("real-world .sentryclirc from old sentry-cli", () => { + const content = `[defaults] +url = https://sentry.io/ +org = my-org +project = my-project + +[auth] +token = sntrys_eyJpYXQiOjE3MTkzODM1MjQuNjQ0OTgyfQ==_abc123 +`; + + const result = parseIni(content); + expect(result.defaults?.url).toBe("https://sentry.io/"); + expect(result.defaults?.org).toBe("my-org"); + expect(result.defaults?.project).toBe("my-project"); + expect(result.auth?.token).toBe( + "sntrys_eyJpYXQiOjE3MTkzODM1MjQuNjQ0OTgyfQ==_abc123" + ); + }); + + test("key with no spaces around = sign", () => { + const result = parseIni("[defaults]\norg=my-org"); + expect(result.defaults?.org).toBe("my-org"); + }); + + test("key with extra spaces around = sign", () => { + const result = parseIni("[defaults]\norg = my-org"); + expect(result.defaults?.org).toBe("my-org"); + }); + + test("value containing = sign", () => { + const result = parseIni("[auth]\ntoken = abc=def=ghi"); + expect(result.auth?.token).toBe("abc=def=ghi"); + }); +}); diff --git a/test/lib/sentryclirc.property.test.ts b/test/lib/sentryclirc.property.test.ts new file mode 100644 index 000000000..fd396b5fb --- /dev/null +++ b/test/lib/sentryclirc.property.test.ts @@ -0,0 +1,233 @@ +/** + * Property-Based Tests for .sentryclirc Config Reader + * + * Verifies merge properties that should hold for any valid config: + * - Monotonicity: adding files never removes resolved fields + * - Closest-wins: closest file always takes priority per-field + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { + array, + asyncProperty, + constantFrom, + assert as fcAssert, + record, +} from "fast-check"; +import { + CONFIG_FILENAME, + clearSentryCliRcCache, + loadSentryCliRc, +} from "../../src/lib/sentryclirc.js"; +import { cleanupTestDir, createTestConfigDir } from "../helpers.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +let testDir: string; +let savedConfigDir: string | undefined; + +beforeEach(async () => { + clearSentryCliRcCache(); + savedConfigDir = process.env.SENTRY_CONFIG_DIR; + testDir = await createTestConfigDir("sentryclirc-prop-", { + isolateProjectRoot: true, + }); + process.env.SENTRY_CONFIG_DIR = testDir; +}); + +afterEach(async () => { + clearSentryCliRcCache(); + if (savedConfigDir !== undefined) { + process.env.SENTRY_CONFIG_DIR = savedConfigDir; + } + await cleanupTestDir(testDir); +}); + +// Arbitraries + +const slugChars = "abcdefghijklmnopqrstuvwxyz0123456789"; +const slugArb = array(constantFrom(...slugChars.split("")), { + minLength: 1, + maxLength: 15, +}).map((chars) => chars.join("")); + +const tokenArb = array( + constantFrom(..."abcdefghijklmnopqrstuvwxyz0123456789".split("")), + { + minLength: 5, + maxLength: 20, + } +).map((chars) => `sntrys_${chars.join("")}`); + +/** Generate a partial config (each field may or may not be present) */ +const partialConfigArb = record( + { + org: slugArb, + project: slugArb, + token: tokenArb, + }, + { requiredKeys: [] } +); + +type PartialConfig = { org?: string; project?: string; token?: string }; + +/** Serialize a partial config to .sentryclirc INI format */ +function serializeConfig(config: PartialConfig): string { + const lines: string[] = []; + if (config.org || config.project) { + lines.push("[defaults]"); + if (config.org) { + lines.push(`org = ${config.org}`); + } + if (config.project) { + lines.push(`project = ${config.project}`); + } + } + if (config.token) { + lines.push("[auth]"); + lines.push(`token = ${config.token}`); + } + return lines.join("\n"); +} + +/** Counter for creating unique subdirectories per property iteration */ +let iterCounter = 0; + +/** Create an isolated dir tree for one property test iteration */ +function createIterDirs(): { parentDir: string; childDir: string } { + iterCounter += 1; + const parentDir = join(testDir, `iter-${iterCounter}`); + const childDir = join(parentDir, "child"); + mkdirSync(childDir, { recursive: true }); + return { parentDir, childDir }; +} + +describe("property: loadSentryCliRc", () => { + test("monotonicity: adding a parent config never removes resolved fields", async () => { + await fcAssert( + asyncProperty( + partialConfigArb, + partialConfigArb, + async (childConfig, parentConfig) => { + clearSentryCliRcCache(); + const { parentDir, childDir } = createIterDirs(); + + // Create child with its config + writeFileSync( + join(childDir, CONFIG_FILENAME), + serializeConfig(childConfig), + "utf-8" + ); + + // Load with child only + const resultChildOnly = await loadSentryCliRc(childDir); + const fieldsChildOnly: string[] = []; + if (resultChildOnly.org) fieldsChildOnly.push("org"); + if (resultChildOnly.project) fieldsChildOnly.push("project"); + if (resultChildOnly.token) fieldsChildOnly.push("token"); + + clearSentryCliRcCache(); + + // Now add parent config + writeFileSync( + join(parentDir, CONFIG_FILENAME), + serializeConfig(parentConfig), + "utf-8" + ); + + // Load again with parent also present + const resultBoth = await loadSentryCliRc(childDir); + + // All fields that were set with child-only should still be set + for (const field of fieldsChildOnly) { + expect(resultBoth[field as keyof typeof resultBoth]).toBeDefined(); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("closest-wins: child config values always take priority over parent", async () => { + await fcAssert( + asyncProperty( + partialConfigArb, + partialConfigArb, + async (childConfig, parentConfig) => { + clearSentryCliRcCache(); + const { parentDir, childDir } = createIterDirs(); + + // Write parent config + writeFileSync( + join(parentDir, CONFIG_FILENAME), + serializeConfig(parentConfig), + "utf-8" + ); + + // Write child config + writeFileSync( + join(childDir, CONFIG_FILENAME), + serializeConfig(childConfig), + "utf-8" + ); + + const result = await loadSentryCliRc(childDir); + + // For every field set in child config, the result should match the child's value + if (childConfig.org) { + expect(result.org).toBe(childConfig.org); + } + if (childConfig.project) { + expect(result.project).toBe(childConfig.project); + } + if (childConfig.token) { + expect(result.token).toBe(childConfig.token); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("parent fills gaps: fields not in child come from parent", async () => { + await fcAssert( + asyncProperty( + partialConfigArb, + partialConfigArb, + async (childConfig, parentConfig) => { + clearSentryCliRcCache(); + const { parentDir, childDir } = createIterDirs(); + + // Write parent config + writeFileSync( + join(parentDir, CONFIG_FILENAME), + serializeConfig(parentConfig), + "utf-8" + ); + + // Write child config + writeFileSync( + join(childDir, CONFIG_FILENAME), + serializeConfig(childConfig), + "utf-8" + ); + + const result = await loadSentryCliRc(childDir); + + // For fields NOT in child config, they should come from parent + if (!childConfig.org && parentConfig.org) { + expect(result.org).toBe(parentConfig.org); + } + if (!childConfig.project && parentConfig.project) { + expect(result.project).toBe(parentConfig.project); + } + if (!childConfig.token && parentConfig.token) { + expect(result.token).toBe(parentConfig.token); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/test/lib/sentryclirc.test.ts b/test/lib/sentryclirc.test.ts new file mode 100644 index 000000000..4a3a17f72 --- /dev/null +++ b/test/lib/sentryclirc.test.ts @@ -0,0 +1,356 @@ +/** + * Unit Tests for .sentryclirc Config File Reader + * + * Tests the walk-up discovery, merging, env shim, and caching behavior. + * Uses real temp directories with actual .sentryclirc files. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { closeDatabase } from "../../src/lib/db/index.js"; +import { + applySentryCliRcEnvShim, + CONFIG_FILENAME, + clearSentryCliRcCache, + loadSentryCliRc, +} from "../../src/lib/sentryclirc.js"; +import { cleanupTestDir, createTestConfigDir } from "../helpers.js"; + +const ENV_KEYS = [ + "SENTRY_AUTH_TOKEN", + "SENTRY_TOKEN", + "SENTRY_HOST", + "SENTRY_URL", + "SENTRY_CONFIG_DIR", +] as const; + +let testDir: string; +let savedEnv: Record; + +beforeEach(async () => { + clearSentryCliRcCache(); + closeDatabase(); + // Save env vars we'll modify + savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); + testDir = await createTestConfigDir("sentryclirc-test-", { + isolateProjectRoot: true, + }); + // Point config dir at the test dir so getConfigDir() returns a predictable path + process.env.SENTRY_CONFIG_DIR = testDir; +}); + +afterEach(async () => { + clearSentryCliRcCache(); + closeDatabase(); + // Restore all saved env vars to their exact pre-test state. + // Vars that were undefined before must be deleted — otherwise values + // set during tests leak into subsequent test files in the suite. + for (const key of ENV_KEYS) { + const saved = savedEnv[key]; + if (saved !== undefined) { + process.env[key] = saved; + } else { + delete process.env[key]; + } + } + await cleanupTestDir(testDir); +}); + +/** Helper: write a .sentryclirc file */ +function writeRcFile(dir: string, content: string): void { + writeFileSync(join(dir, CONFIG_FILENAME), content, "utf-8"); +} + +/** Read an env var without TypeScript narrowing issues after `delete` */ +function readEnv(key: string): string | undefined { + return process.env[key]; +} + +describe("loadSentryCliRc", () => { + test("returns empty config when no .sentryclirc files exist", async () => { + const result = await loadSentryCliRc(testDir); + expect(result.org).toBeUndefined(); + expect(result.project).toBeUndefined(); + expect(result.url).toBeUndefined(); + expect(result.token).toBeUndefined(); + expect(result.sources).toEqual({}); + }); + + test("reads org and project from local .sentryclirc", async () => { + writeRcFile(testDir, "[defaults]\norg = my-org\nproject = my-project\n"); + + const result = await loadSentryCliRc(testDir); + expect(result.org).toBe("my-org"); + expect(result.project).toBe("my-project"); + expect(result.sources.org).toBe(join(testDir, CONFIG_FILENAME)); + expect(result.sources.project).toBe(join(testDir, CONFIG_FILENAME)); + }); + + test("reads auth token from local .sentryclirc", async () => { + writeRcFile(testDir, "[auth]\ntoken = sntrys_abc123\n"); + + const result = await loadSentryCliRc(testDir); + expect(result.token).toBe("sntrys_abc123"); + expect(result.sources.token).toBe(join(testDir, CONFIG_FILENAME)); + }); + + test("reads url from local .sentryclirc", async () => { + writeRcFile(testDir, "[defaults]\nurl = https://sentry.example.com\n"); + + const result = await loadSentryCliRc(testDir); + expect(result.url).toBe("https://sentry.example.com"); + }); + + test("reads all fields from a complete .sentryclirc", async () => { + writeRcFile( + testDir, + `[defaults] +org = my-org +project = my-project +url = https://sentry.io/ + +[auth] +token = sntrys_test +` + ); + + const result = await loadSentryCliRc(testDir); + expect(result.org).toBe("my-org"); + expect(result.project).toBe("my-project"); + expect(result.url).toBe("https://sentry.io/"); + expect(result.token).toBe("sntrys_test"); + }); + + test("walks up from subdirectory to find .sentryclirc", async () => { + writeRcFile(testDir, "[defaults]\norg = parent-org\nproject = parent-proj"); + + const subDir = join(testDir, "packages", "frontend", "src"); + mkdirSync(subDir, { recursive: true }); + + const result = await loadSentryCliRc(subDir); + expect(result.org).toBe("parent-org"); + expect(result.project).toBe("parent-proj"); + expect(result.sources.org).toBe(join(testDir, CONFIG_FILENAME)); + }); + + test("closest file wins for overlapping fields", async () => { + // Parent has org + project + writeRcFile(testDir, "[defaults]\norg = parent-org\nproject = parent-proj"); + + // Child overrides project only + const childDir = join(testDir, "packages", "frontend"); + mkdirSync(childDir, { recursive: true }); + writeRcFile(childDir, "[defaults]\nproject = child-proj"); + + const result = await loadSentryCliRc(childDir); + expect(result.org).toBe("parent-org"); + expect(result.project).toBe("child-proj"); + expect(result.sources.org).toBe(join(testDir, CONFIG_FILENAME)); + expect(result.sources.project).toBe(join(childDir, CONFIG_FILENAME)); + }); + + test("closest file wins: token from child, org from parent", async () => { + writeRcFile( + testDir, + "[defaults]\norg = parent-org\n[auth]\ntoken = parent-token" + ); + + const childDir = join(testDir, "sub"); + mkdirSync(childDir, { recursive: true }); + writeRcFile(childDir, "[auth]\ntoken = child-token"); + + const result = await loadSentryCliRc(childDir); + expect(result.org).toBe("parent-org"); + expect(result.token).toBe("child-token"); + }); + + test("partial config is valid (only org, no project)", async () => { + writeRcFile(testDir, "[defaults]\norg = only-org\n"); + + const result = await loadSentryCliRc(testDir); + expect(result.org).toBe("only-org"); + expect(result.project).toBeUndefined(); + }); + + test("empty values are treated as unset", async () => { + writeRcFile(testDir, "[defaults]\norg =\nproject = real-proj\n"); + + const result = await loadSentryCliRc(testDir); + // Empty string after trim is falsy, so org should not be set + expect(result.org).toBeUndefined(); + expect(result.project).toBe("real-proj"); + }); + + test("cache returns same object on repeated calls", async () => { + writeRcFile(testDir, "[defaults]\norg = cached-org\n"); + + const promise1 = loadSentryCliRc(testDir); + const promise2 = loadSentryCliRc(testDir); + // Same promise reference (concurrent callers share the load) + expect(promise1).toBe(promise2); + }); + + test("clearSentryCliRcCache invalidates the cache", async () => { + writeRcFile(testDir, "[defaults]\norg = first\n"); + const result1 = await loadSentryCliRc(testDir); + + clearSentryCliRcCache(); + + // Modify the file + writeRcFile(testDir, "[defaults]\norg = second\n"); + const result2 = await loadSentryCliRc(testDir); + + expect(result1.org).toBe("first"); + expect(result2.org).toBe("second"); + expect(result1).not.toBe(result2); + }); + + test("invalid INI content is gracefully handled", async () => { + writeRcFile(testDir, "this is not ini format at all\n===\nmore garbage"); + + const result = await loadSentryCliRc(testDir); + expect(result.org).toBeUndefined(); + expect(result.project).toBeUndefined(); + }); +}); + +describe("applySentryCliRcEnvShim", () => { + test("sets SENTRY_AUTH_TOKEN when not already set", async () => { + delete process.env.SENTRY_AUTH_TOKEN; + delete process.env.SENTRY_TOKEN; + writeRcFile(testDir, "[auth]\ntoken = rc-token\n"); + + await applySentryCliRcEnvShim(testDir); + expect(readEnv("SENTRY_AUTH_TOKEN")).toBe("rc-token"); + }); + + test("does not override existing SENTRY_AUTH_TOKEN", async () => { + process.env.SENTRY_AUTH_TOKEN = "existing-token"; + writeRcFile(testDir, "[auth]\ntoken = rc-token\n"); + + await applySentryCliRcEnvShim(testDir); + expect(readEnv("SENTRY_AUTH_TOKEN")).toBe("existing-token"); + }); + + test("does not override non-empty whitespace SENTRY_AUTH_TOKEN", async () => { + process.env.SENTRY_AUTH_TOKEN = " real-token "; + writeRcFile(testDir, "[auth]\ntoken = rc-token\n"); + + await applySentryCliRcEnvShim(testDir); + expect(readEnv("SENTRY_AUTH_TOKEN")).toBe(" real-token "); + }); + + test("overrides empty/whitespace-only SENTRY_AUTH_TOKEN", async () => { + process.env.SENTRY_AUTH_TOKEN = " "; + writeRcFile(testDir, "[auth]\ntoken = rc-token\n"); + + await applySentryCliRcEnvShim(testDir); + expect(readEnv("SENTRY_AUTH_TOKEN")).toBe("rc-token"); + }); + + test("does not override existing SENTRY_TOKEN (fallback env var)", async () => { + delete process.env.SENTRY_AUTH_TOKEN; + process.env.SENTRY_TOKEN = "env-fallback-token"; + writeRcFile(testDir, "[auth]\ntoken = rc-token\n"); + + await applySentryCliRcEnvShim(testDir); + // SENTRY_AUTH_TOKEN should NOT be set — SENTRY_TOKEN already provides auth + expect(readEnv("SENTRY_AUTH_TOKEN")).toBeUndefined(); + expect(readEnv("SENTRY_TOKEN")).toBe("env-fallback-token"); + }); + + test("sets SENTRY_URL when neither SENTRY_HOST nor SENTRY_URL is set", async () => { + delete process.env.SENTRY_HOST; + delete process.env.SENTRY_URL; + writeRcFile(testDir, "[defaults]\nurl = https://sentry.example.com\n"); + + await applySentryCliRcEnvShim(testDir); + expect(readEnv("SENTRY_URL")).toBe("https://sentry.example.com"); + }); + + test("does not set SENTRY_URL when SENTRY_HOST is set", async () => { + process.env.SENTRY_HOST = "sentry.other.com"; + delete process.env.SENTRY_URL; + writeRcFile(testDir, "[defaults]\nurl = https://sentry.example.com\n"); + + await applySentryCliRcEnvShim(testDir); + expect(readEnv("SENTRY_URL")).toBeUndefined(); + }); + + test("does not set SENTRY_URL when SENTRY_URL is already set", async () => { + delete process.env.SENTRY_HOST; + process.env.SENTRY_URL = "https://existing.sentry.io"; + writeRcFile(testDir, "[defaults]\nurl = https://sentry.example.com\n"); + + await applySentryCliRcEnvShim(testDir); + expect(readEnv("SENTRY_URL")).toBe("https://existing.sentry.io"); + }); + + test("does nothing when no .sentryclirc exists", async () => { + delete process.env.SENTRY_AUTH_TOKEN; + delete process.env.SENTRY_HOST; + delete process.env.SENTRY_URL; + + await applySentryCliRcEnvShim(testDir); + expect(readEnv("SENTRY_AUTH_TOKEN")).toBeUndefined(); + expect(readEnv("SENTRY_URL")).toBeUndefined(); + }); + + test("does not set org/project as env vars (only token and url)", async () => { + writeRcFile(testDir, "[defaults]\norg = my-org\nproject = my-proj\n"); + + const orgBefore = readEnv("SENTRY_ORG"); + const projBefore = readEnv("SENTRY_PROJECT"); + + await applySentryCliRcEnvShim(testDir); + + // Org and project should NOT be set as env vars + // (they're handled in the resolution chain, not via env shim) + expect(readEnv("SENTRY_ORG")).toBe(orgBefore); + expect(readEnv("SENTRY_PROJECT")).toBe(projBefore); + }); +}); + +describe("monorepo scenario", () => { + test("root has org, each package has project", async () => { + // Root: org only + writeRcFile(testDir, "[defaults]\norg = acme-corp\n"); + + // Frontend package: project override + const frontendDir = join(testDir, "packages", "frontend"); + mkdirSync(frontendDir, { recursive: true }); + writeRcFile(frontendDir, "[defaults]\nproject = frontend-web\n"); + + // Backend package: project override + const backendDir = join(testDir, "packages", "backend"); + mkdirSync(backendDir, { recursive: true }); + writeRcFile(backendDir, "[defaults]\nproject = backend-api\n"); + + const frontendResult = await loadSentryCliRc(frontendDir); + expect(frontendResult.org).toBe("acme-corp"); + expect(frontendResult.project).toBe("frontend-web"); + + clearSentryCliRcCache(); + + const backendResult = await loadSentryCliRc(backendDir); + expect(backendResult.org).toBe("acme-corp"); + expect(backendResult.project).toBe("backend-api"); + }); + + test("deep nesting: child of child inherits from multiple ancestors", async () => { + writeRcFile(testDir, "[auth]\ntoken = root-token\n"); + + const pkgDir = join(testDir, "packages", "web"); + mkdirSync(pkgDir, { recursive: true }); + writeRcFile(pkgDir, "[defaults]\norg = web-org\nproject = web-proj\n"); + + const srcDir = join(pkgDir, "src", "components"); + mkdirSync(srcDir, { recursive: true }); + + const result = await loadSentryCliRc(srcDir); + expect(result.org).toBe("web-org"); + expect(result.project).toBe("web-proj"); + expect(result.token).toBe("root-token"); + }); +});