diff --git a/.lore.md b/.lore.md index de0fcbdca..287f972d4 100644 --- a/.lore.md +++ b/.lore.md @@ -4,14 +4,8 @@ ### Architecture - -* **@sentry/api SDK integration: type wrapping pattern and pagination helpers**: (architecture) @sentry/api SDK integration: wrap SDK types at \`src/lib/api/\*.ts\` with \`as unknown as SentryX\` casts; never leak to commands. \`src/types/sentry.ts\` uses \`Partial\ & RequiredCore\`. Avoid circular deps by importing SDK functions directly in \`src/lib/region.ts\`. \`unwrapResult\`/\`unwrapPaginatedResult\` must stay CLI-owned (SDK versions throw plain \`Error\`). Body-shape casts use \`Parameters\\[0]\["body"]\`. \`apiRequestToRegion/rawApiRequest\` expose only \`{ method, body, params, schema }\` (typed API); \`rawApiRequest\` adds \`headers?\`. Neither exposes \`timeout\`/\`signal\`. \`apiRequestToRegion\` auto-sets JSON Content-Type; \`rawApiRequest\` preserves strings. 204/205 throw \`ApiError\` not \`TypeError\`. - -* **Auth token env var override pattern: SENTRY\_AUTH\_TOKEN > SENTRY\_TOKEN > SQLite**: Auth in \`src/lib/db/auth.ts\` follows layered precedence: \`SENTRY\_AUTH\_TOKEN\` > \`SENTRY\_TOKEN\` > SQLite OAuth token. \`getEnvToken()\` trims env vars (empty/whitespace = unset). \`AuthSource\` tracks provenance. \`ENV\_SOURCE\_PREFIX = "env:"\` — use \`.length\` not hardcoded 4. Env tokens bypass refresh/expiry. \`isEnvTokenActive()\` guards auth commands. Logout must NOT clear stored auth when env token active. These functions stay in \`db/auth.ts\` despite not touching DB because they're tightly coupled with token retrieval. - - -* **Completion fast-path skips Sentry SDK via SENTRY\_CLI\_NO\_TELEMETRY and SQLite telemetry queue**: (architecture) Completion fast-path + Sentry SDK setup: Shell completions set \`SENTRY\_CLI\_NO\_TELEMETRY=1\` in \`bin.ts\` before imports, skipping \`createTracedDatabase\` and \`@sentry/node-core/light\` load (~85ms). Timing queued to \`completion\_telemetry\_queue\` SQLite table; normal runs drain via \`DELETE ... RETURNING\`. Achieves ~60ms dev / ~140ms CI within 200ms budget. SDK uses \`@sentry/node-core/light\` (not \`@sentry/bun\`) to avoid OpenTelemetry overhead (~150ms, 24MB). \`@sentry/core\` barrel patched via \`bun patch\`. \`LightNodeClient\` hardcodes \`runtime:{name:'node'}\` AFTER spreading options — fix by patching \`client.getOptions().runtime\` post-init. Transport uses Node \`http\`. Always import from \`@sentry/node-core/light\`; root barrel pulls uninstalled @opentelemetry/instrumentation. When bumping SDK: remove patches, install, patch, edit, commit. +* **Auth token env var override pattern: SENTRY\_AUTH\_TOKEN > SENTRY\_TOKEN > SQLite**: Auth token precedence in \`src/lib/db/auth.ts\`: \`SENTRY\_AUTH\_TOKEN\` > \`SENTRY\_TOKEN\` > SQLite OAuth token. \`getEnvToken()\` trims env vars (empty/whitespace = unset). \`AuthSource\` tracks provenance. \`ENV\_SOURCE\_PREFIX = "env:"\` — use \`.length\` not hardcoded 4. Env tokens bypass refresh/expiry. \`isEnvTokenActive()\` guards auth commands. Logout must NOT clear stored auth when env token active. Auth functions stay in \`db/auth.ts\` despite not touching DB — tightly coupled with token retrieval. \`runInteractiveLogin\` catches OAuth flow errors internally and returns falsy on failure; login command sets \`process.exitCode = 1\` and returns normally (does NOT reject). Tests expecting \`rejects.toThrow()\` will fail — assert via fetch-call inspection instead. \`requestDeviceCode\` requires \`SENTRY\_CLIENT\_ID\` env var. * **Consola chosen as CLI logger with Sentry createConsolaReporter integration**: Consola is the CLI logger with Sentry \`createConsolaReporter\` integration. Two reporters: FancyReporter (stderr) + Sentry structured logs. Level via \`SENTRY\_LOG\_LEVEL\`. \`buildCommand\` injects hidden \`--log-level\`/\`--verbose\` flags. \`withTag()\` creates independent instances; \`setLogLevel()\` propagates via registry. All user-facing output must use consola, not raw stderr. \`HandlerContext\` intentionally omits stderr. @@ -23,17 +17,11 @@ * **Grep worker pool: binary-transferable matches + streaming dispatch in src/lib/scan/**: Grep worker pool (\`src/lib/scan/worker-pool.ts\` + \`grep-worker.js\`): lazy singleton, size \`min(8, max(2, availableParallelism()))\`. Matches encoded as \`Uint32Array\` quads \`\[pathIdx, lineNum, lineOffset, lineLength]\` + \`linePool\` string, transferred via \`postMessage(msg, \[ints.buffer])\` (~40% faster than structuredClone). Worker imported via \`with { type: 'text' }\` → \`Blob\` + \`URL.createObjectURL\`; \`new Worker(new URL(...))\` HANGS in \`bun build --compile\` binaries. FIFO \`pending\` queue per worker — per-dispatch \`addEventListener\` causes wrong-request resolution. \`ref()\`/\`unref()\` idempotent booleans, NOT refcounted — only unref when \`inflight\` drops to 0; spawn unref'd. Disable via \`SENTRY\_SCAN\_DISABLE\_WORKERS=1\`. Track dispatched/failed batches with \`Promise.allSettled\`; throw if all failed so DSN cache doesn't persist false-negatives. -* **Host-scoped token model: auth.host column + three-layer enforcement**: Host-scoped token model (PR #844): every token bound to issuing host via \`auth.host\` column (schema v16), lazy-migrated from boot-env. Trust established ONLY via \`sentry auth login --url\` or shell-exported \`SENTRY\_HOST\`/\`SENTRY\_URL\` at boot — \`.sentryclirc\` URL never a trust source (mtime-based freshness doesn't work: git clone resets, \`touch -t\` backdates). Three enforcement layers: (1) \`applySentryUrlContext\` throws on URL-arg mismatch; (2) \`applySentryCliRcEnvShim\` throws on rc-url mismatch (auth login/logout bypass via \`skipUrlTrustCheck\`); (3) fetch-layer \`isRequestOriginTrusted\`. Region trust: in-process Set in \`db/regions.ts\`, auto-synced by \`setOrgRegion(s)\`. \`clearTrustedHostState\` must NOT clear login anchor (breaks IAP re-auth). Login refusal scoped to \`--token\`. \`HostScopeError\` (\`src/lib/errors.ts\`) is canonical formatter with overloads \`(message)\` and \`(source, destinationUrl, tokenHost)\`; used by rc-shim, URL-arg, fetch bearer, sntrys\_ claim, OAuth refresh. E2E: pass \`--url ${ctx.serverUrl}\` to \`auth login --token\`; child \`SENTRY\_URL\` alone doesn't anchor. +* **Host-scoped token model: auth.host column + three-layer enforcement**: Host-scoped token model (schema v16): every token bound to issuing host via \`auth.host\` column, lazy-migrated from boot-env. Trust established ONLY via \`sentry auth login --url\` or shell-exported \`SENTRY\_HOST\`/\`SENTRY\_URL\` at boot — \`.sentryclirc\` URL never a trust source. Three enforcement layers: (1) \`applySentryUrlContext\` throws on URL-arg mismatch; (2) \`applySentryCliRcEnvShim\` throws on rc-url mismatch (auth login/logout bypass via \`skipUrlTrustCheck\`); (3) fetch-layer \`isRequestOriginTrusted\`. Region trust: in-process Set in \`db/regions.ts\`, auto-synced by \`setOrgRegion(s)\`. \`clearTrustedHostState\` must NOT clear login anchor (breaks IAP re-auth). \`HostScopeError\` (\`src/lib/errors.ts\`) canonical formatter with overloads \`(message)\` and \`(source, destinationUrl, tokenHost)\`. Test helpers: \`resetHostScopingState()\` bundles \`resetEnvTokenHostForTesting\` + \`resetLoginTrustAnchorForTesting\` + \`resetTrustedRegionUrlsForTesting\`. \`mintSntrysToken(payload)\` produces test tokens. E2E: pass \`--url ${ctx.serverUrl}\` to \`auth login --token\`; child \`SENTRY\_URL\` alone doesn't anchor. Multi-region tests need \`registerTrustedRegionUrls\`. * **isSentrySaasUrl vs isSaaSTrustOrigin: two intentional SaaS checks**: \`src/lib/sentry-urls.ts\` exports two SaaS-detection helpers with intentional split: (1) \`isSentrySaasUrl(url)\` — hostname-only check (\`sentry.io\` or \`\*.sentry.io\`), accepts any protocol/port. Used for routing/UX: custom-headers warning, \`getSentryBaseUrl\`/\`isSelfHosted\`, region resolution skip, telemetry \`is\_self\_hosted\` tag. (2) \`isSaaSTrustOrigin(url)\` — stricter: additionally requires \`https:\` and default port. Used for security decisions: token-host trust comparison, sentryclirc URL trust check, URL-arg trust, login refusal. Rule: hostname-only for routing/UX (don't break users behind TLS-terminating proxies with \`http://sentry.io\`); strict for credential scoping. JSDoc on \`isSentrySaasUrl\` points callers to \`isSaaSTrustOrigin\` for security contexts. Keep both implementations in sync re: hostname matching. - -* **Issue resolve --in grammar: release + @next + @commit sentinels**: \`sentry issue resolve --in\` grammar: (a) omitted→immediate resolve, (b) \`\\`→\`inRelease\` (monorepo \`spotlight@1.2.3\` pass-through), (c) \`@next\`→\`inNextRelease\`, (d) \`@commit\`→auto-detect git HEAD + match Sentry repos, (e) \`@commit:\@\\`→explicit. Sentinel matching case-insensitive; unknown \`@\`-prefixed tokens throw \`ValidationError\`. \`parseResolveSpec\` splits on LAST \`@\` to handle scoped names like \`@acme/web\`. \`resolveCommitSpec\` uses \`getHeadCommit\`/\`getRepositoryName\` from \`src/lib/git.ts\`, matching Sentry repo \`externalSlug\` or \`name\` via \`listRepositoriesCached\`. API requires \`statusDetails.inCommit: {commit, repository}\` — not bare SHA. - - -* **Magic @ selectors resolve issues dynamically via sort-based list API queries**: Magic @ selectors resolve issues dynamically: \`@latest\`, \`@most\_frequent\` in \`parseIssueArg\` detected before \`validateResourceId\` (@ not in forbidden charset). \`SELECTOR\_MAP\` provides case-insensitive matching. \`resolveSelector\` maps to \`IssueSort\` values, calls \`listIssuesPaginated\` with \`perPage: 1\`, \`query: 'is:unresolved'\`. Supports org-prefixed: \`sentry/@latest\`. Unrecognized \`@\`-prefixed strings fall through. \`ParsedIssueArg\` union includes \`{ type: 'selector' }\`. - * **Project cache is org-scoped with three key formats and three population paths**: Project cache (\`project\_cache\` table) uses three key shapes: \`{orgId}:{projectId}\` (DSN), \`dsn:{publicKey}\` (DSN-only), \`list:{orgSlug}/{projectSlug}\` (batch). \`getCachedProjectBySlug\` queries all three for hot-path lookups. Population: DSN resolution, \`listProjects()\` batch, \`fetchProjectId\` seed. Resolution errors use live API via \`findSimilarProjectsAcrossOrgs\` — no cross-org cache search. @@ -44,19 +32,19 @@ * **Seer trial prompt uses middleware layering in bin.ts error handling chain**: Seer trial prompt via error middleware layering: \`bin.ts\` chain is \`main() → executeWithAutoAuth() → executeWithSeerTrialPrompt() → runCommand()\`. Seer trial prompts (\`no\_budget\`/\`not\_enabled\`) caught by inner wrapper; auth errors bubble to outer. Trial API: \`GET /api/0/customers/{org}/\` → \`productTrials\[]\` (prefer \`seerUsers\`, fallback \`seerAutofix\`). Start: \`PUT /api/0/customers/{org}/product-trial/\`. SaaS-only; self-hosted 404s gracefully. \`ai\_disabled\` excluded. \`startSeerTrial\` accepts \`category\` from trial object — don't hardcode. -* **Sentry API: events require org+project, issues have legacy global endpoint**: Sentry API quirks: (1) Events need org+project (\`/projects/{org}/{project}/events/{id}/\`); issues use legacy global \`/api/0/issues/{id}/\`; traces need org only. (2) \`/users/me/\` returns 403 for OAuth — use \`/auth/\` instead via \`getControlSiloUrl()\`. (3) Chunk upload endpoint returns camelCase (\`chunkSize\`, etc.) — exception to snake\_case convention. +* **Sentry API: events require org+project, issues have legacy global endpoint**: Sentry API quirks: (1) Events need org+project (\`/projects/{org}/{project}/events/{id}/\`); issues use legacy global \`/api/0/issues/{id}/\`; traces need org only. (2) \`/users/me/\` returns 403 for OAuth — use \`/auth/\` instead via \`getControlSiloUrl()\`. (3) Chunk upload endpoint returns camelCase (\`chunkSize\`, etc.) — exception to snake\_case convention. (4) 204/205 responses throw \`ApiError\` not \`TypeError\` from \`rawApiRequest\`. (5) Magic @ selectors: \`@latest\`, \`@most\_frequent\` in \`parseIssueArg\` detected before \`validateResourceId\`. \`SELECTOR\_MAP\` case-insensitive; \`resolveSelector\` calls \`listIssuesPaginated\` with \`perPage: 1\`. Supports org-prefixed: \`sentry/@latest\`. (6) \`issue resolve --in\` grammar: omitted→immediate, \`\\`→\`inRelease\`, \`@next\`→\`inNextRelease\`, \`@commit\`→auto-detect git HEAD. \`parseResolveSpec\` splits on LAST \`@\` for scoped names. API requires \`statusDetails.inCommit: {commit, repository}\` — not bare SHA. -* **Sentry CLI authenticated fetch architecture with response caching**: (architecture) Authenticated fetch + response cache: \`createAuthenticatedFetch\`: auth headers, 30s timeout, max 2 retries, 401 refresh, span tracing. \`buildAttemptFactory\` clones \`Request\`; do NOT materialize FormData (strips boundary). Per-endpoint timeout overrides (e.g. \`/autofix/\` 120s). Response cache RFC 7234 at \`~/.sentry/cache/responses/\`, GET 2xx only. TTL tiers: stable=5min, volatile=60s, immutable=24h. \`@sentry/api\` SDK passes Request with no init — undefined init → empty headers stripping Content-Type (HTTP 415); fall back to \`input.headers\` when init undefined. Guard \`Array.isArray(data)\` before \`.map()\` (SDK returns \`{}\` for 204/empty; \`ReadableStream\` when Content-Type missing). GET response cache checked BEFORE fetch — tests asserting call counts see 0 calls if prior test cached same URL. Tests mocking fetch MUST call \`useTestConfigDir()\` + \`setAuthToken()\` + \`resetCacheState()\` + \`disableResponseCache()\` + \`resetAuthenticatedFetch()\` in beforeEach. +* **Sentry CLI authenticated fetch architecture with response caching**: Authenticated fetch + response cache: \`createAuthenticatedFetch\`: auth headers, 30s timeout, max 2 retries, 401 refresh, span tracing. \`buildAttemptFactory\` clones \`Request\`; do NOT materialize FormData (strips boundary). Per-endpoint timeout overrides (e.g. \`/autofix/\` 120s). Response cache RFC 7234 at \`~/.sentry/cache/responses/\`, GET 2xx only. TTL tiers: stable=5min, volatile=60s, immutable=24h. \`@sentry/api\` SDK passes Request with no init — undefined init → empty headers stripping Content-Type (HTTP 415); fall back to \`input.headers\` when init undefined. Guard \`Array.isArray(data)\` before \`.map()\` (SDK returns \`{}\` for 204/empty). GET response cache checked BEFORE fetch — tests asserting call counts see 0 calls if prior test cached same URL. Tests mocking fetch MUST call \`useTestConfigDir()\` + \`setAuthToken()\` + \`resetCacheState()\` + \`disableResponseCache()\` + \`resetAuthenticatedFetch()\` in beforeEach. -* **Sentry CLI resolve-target cascade has 5 priority levels with env var support**: (architecture) Resolve-target cascade: (1) CLI flags, (2) SENTRY\_ORG/SENTRY\_PROJECT env vars, (3) SQLite defaults, (4) DSN auto-detection, (5) directory name inference. SENTRY\_PROJECT supports \`org/project\` combo — SENTRY\_ORG ignored if set. Malformed combos discarded. \`resolveFromEnvVars()\` injected into all four resolution functions. Schema v13 merged \`defaults\` table into \`metadata\` KV with keys \`defaults.{org,project,telemetry,url}\`; getters/setters in \`src/lib/db/defaults.ts\`. Prefer dedicated SQLite tables + migrations over \`metadata\` KV for non-trivial caches — dedicated tables give clearer schema, proper indexes, simpler bulk-clear. \`metadata\` KV fine for small scalars. Example: \`issue\_org\_cache\` (v15) replaced \`metadata\` keys. Hidden global \`--org\`/\`--project\` flags: \`mergeGlobalFlags()\` in command.ts injects hidden flag shapes, \`applyOrgProjectFlags()\` writes to \`SENTRY\_ORG\`/\`SENTRY\_PROJECT\` before auth guard. No short aliases (\`-p\` conflicts). +* **Sentry CLI resolve-target cascade has 5 priority levels with env var support**: Resolve-target cascade: (1) CLI flags, (2) SENTRY\_ORG/SENTRY\_PROJECT env vars, (3) SQLite defaults, (4) DSN auto-detection, (5) directory name inference. SENTRY\_PROJECT supports \`org/project\` combo — SENTRY\_ORG ignored if set. Schema v13 merged \`defaults\` table into \`metadata\` KV with keys \`defaults.{org,project,telemetry,url}\`; getters/setters in \`src/lib/db/defaults.ts\`. Prefer dedicated SQLite tables + migrations over \`metadata\` KV for non-trivial caches. \`metadata\` KV fine for small scalars. Hidden global \`--org\`/\`--project\` flags: \`mergeGlobalFlags()\` in command.ts injects hidden flag shapes, \`applyOrgProjectFlags()\` writes to \`SENTRY\_ORG\`/\`SENTRY\_PROJECT\` before auth guard. No short aliases (\`-p\` conflicts). \`@sentry/api\` SDK: wrap types at \`src/lib/api/\*.ts\` with \`as unknown as SentryX\` casts; never leak to commands. \`unwrapResult\`/\`unwrapPaginatedResult\` must stay CLI-owned. \`apiRequestToRegion\` auto-sets JSON Content-Type; \`rawApiRequest\` preserves strings. * **Sentry log IDs are UUIDv7 — enables deterministic retention checks**: (architecture) Sentry log IDs are UUIDv7 — enables deterministic retention checks. \`decodeUuidV7Timestamp()\` and \`ageInDaysFromUuidV7()\` in \`src/lib/hex-id.ts\` return null for non-v7, safe to call unconditionally. \`RETENTION\_DAYS.log = 90\` in \`src/lib/retention.ts\`; traces/events are \`null\` (plan-dependent). \`LOG\_RETENTION\_PERIOD\` is DERIVED as \`\` \`${RETENTION\_DAYS.log}d\` \`\` — never hardcode \`'90d'\`. Shared hex primitives (\`HEX\_ID\_RE\`, \`SPAN\_ID\_RE\`, \`UUID\_DASH\_RE\`) live in \`hex-id.ts\`. Three Sentry span APIs: (1) \`/trace/{traceId}/\` — hierarchical tree with \`additional\_attributes\`. (2) \`/projects/{org}/{project}/trace-items/{itemId}/\` — single span with ALL attributes. (3) \`/events/?dataset=spans\` — list/search. \`meta.fields\` order is non-deterministic — derive column order from user's \`--field\` list via \`orderFieldNames()\` in \`explore.ts\`. -* **Sentry token formats: only sntrys\_ embeds host claim, and it's unsigned**: (architecture) Sentry token formats: \`sntryu\_\\` (user auth, no claims); \`sntrys\_\\_\\` (org auth, \*\*unsigned\*\*/plaintext — UX hint only, not verifiable); \`sntrya\_\`/\`sntryi\_\` — random hex; OAuth — random, no prefix. \`classifySentryToken()\` returns \`'org-auth-token'\`/\`'user-auth-token'\`/\`'oauth-or-legacy'\`. \`parseSntrysClaim\` requires exactly 2 underscores, 2KB cap, fail-open. Two consumers: (1) \`captureEnvTokenHost\` — claim url first for \`sntrys\_\` (defends against \`$GITHUB\_ENV\` poisoning); env wins for \`sntryu\_\`/OAuth. (2) \`prepareHeaders\` — refuses bearer attach if request origin doesn't match claim url. \`auth.host\` column \[\[019dc168-adb2-7bed-900e-cab5d3716099]] is strictly stronger than token claims. +* **Sentry token formats: only sntrys\_ embeds host claim, and it's unsigned**: Sentry token formats: \`sntryu\_\\` (user auth); \`sntrys\_\\_\\` (org auth, \*\*unsigned\*\*/plaintext — UX hint only, not verifiable); \`sntrya\_\`/\`sntryi\_\` — random hex; OAuth — random, no prefix. \`classifySentryToken()\` returns \`'org-auth-token'\`/\`'user-auth-token'\`/\`'oauth-or-legacy'\`. \`parseSntrysClaim\` requires exactly 2 underscores, 2KB cap, fail-open. Two consumers: (1) \`captureEnvTokenHost\` — claim url first for \`sntrys\_\`; env wins for \`sntryu\_\`/OAuth. (2) \`prepareHeaders\` — refuses bearer attach if request origin doesn't match claim url. \`auth.host\` column is strictly stronger than token claims. \`getAuthToken()\` from \`db/auth\` returns effective token. Non-essential DB cache writes (e.g. \`setUserInfo()\`) must wrap in try-catch. Exception: \`getUserRegions()\` failure should \`clearAuth()\` and fail hard. Use \`classifySentryToken\` to short-circuit commands where a token type is semantically invalid (e.g. \`whoami\` with org token). * **Zod schema on OutputConfig enables self-documenting JSON fields in help and SKILL.md**: Zod schema on OutputConfig enables self-documenting JSON fields: List commands register \`schema?: ZodType\` on \`OutputConfig\\`. \`extractSchemaFields()\` produces \`SchemaFieldInfo\[]\` from Zod shapes. \`buildFieldsFlag()\` enriches \`--fields\` brief; \`enrichDocsWithSchema()\` appends fields to \`fullDescription\`. Schema exposed as \`\_\_jsonSchema\` on built commands — \`introspect.ts\` reads it into \`CommandInfo.jsonFields\`, \`help.ts\` and \`generate-skill.ts\` render it. For \`buildOrgListCommand\`/\`dispatchOrgScopedList\`, pass \`schema\` via \`OrgListConfig\`. @@ -72,10 +60,10 @@ ### Gotcha -* **Biome noUselessUndefined also rejects () => {} empty arrow callbacks**: (gotcha) Biome lint traps (run \`bun run lint\` not \`lint:fix\` before pushing): (1) \`noUselessUndefined\`+\`noEmptyBlockStatements\` reject \`()=>undefined\` and \`()=>{}\` — use \`function noop():void{}\`. (2) \`noExcessiveCognitiveComplexity\` caps at 15 — extract helpers. (3) \`noPrecisionLoss\` on int >2^53 — use \`Number(string)\`. (4) \`noIncrementDecrement\` — use \`i+=1\`. (5) \`useYield\` on \`async \*func()\` needs \`biome-ignore\`. (6) Plugin forbids raw \`metadata\` table queries — use \`getMetadata\`/\`setMetadata\`/\`clearMetadata\`. (7) \`noMisplacedAssertion\` fires on helper functions — use inline \`biome-ignore\` above each \`expect()\`, NOT file-level. (8) \`AuthError(reason, message?)\` — easy to swap args; correct: \`new AuthError("expired", "Token expired")\`. Tests aren't type-checked but ARE lint-checked. Node polyfill (\`script/node-polyfills.ts\`) is INCOMPLETE — prefer \`node:fs/promises\` for file ops; \`execSync\` for shell. +* **Biome noUselessUndefined also rejects () => {} empty arrow callbacks**: Biome lint traps (run \`bun run lint\` not \`lint:fix\` before pushing): (1) \`noUselessUndefined\`+\`noEmptyBlockStatements\` reject \`()=>undefined\` and \`()=>{}\` — use \`function noop():void{}\`. (2) \`noExcessiveCognitiveComplexity\` caps at 15 — extract helpers. (3) \`noPrecisionLoss\` on int >2^53 — use \`Number(string)\`. (4) \`noIncrementDecrement\` — use \`i+=1\`. (5) \`useYield\` on \`async \*func()\` needs \`biome-ignore\`. (6) Plugin forbids raw \`metadata\` table queries — use \`getMetadata\`/\`setMetadata\`/\`clearMetadata\`. (7) \`noMisplacedAssertion\` fires on helper functions — use inline \`biome-ignore\` above each \`expect()\`, NOT file-level. (8) \`AuthError(reason, message?)\` — correct: \`new AuthError("expired", "Token expired")\`. Tests aren't type-checked but ARE lint-checked. -* **dist/bin.cjs runtime Node version check must match engines.node**: dist/bin.cjs runtime Node check: engines.node >=22.12 matches Astro 6 floor. Node 20 is EOL — do not add feature-detection workarounds for it. CI builds \`\["22","24"]\`; E2E job must use \`actions/setup-node\` with Node 22 (ubuntu-latest defaults to Node 20 which is EOL and unsupported). Don't use \`parseInt(node\_version) < 22\` — it misses 22.0.0–22.11.x. Use: \`let v=process.versions.node.split('.').map(Number);if(v\[0]<22||(v\[0]===22&\&v\[1]<12))\`. Update BIN\_WRAPPER in lockstep. +* **dist/bin.cjs runtime Node version check must match engines.node**: dist/bin.cjs Node version check: \`engines.node >=22.15\` (bumped from 22.12 — zstd requires 22.15+). Node 20 is EOL — never add workarounds for it. CI builds \`\["22","24"]\`; E2E jobs MUST use \`actions/setup-node\` with \`node-version: 22\` — \`ubuntu-latest\` defaults to Node 20. Don't use \`parseInt(node\_version) < 22\` (misses 22.0.0–22.14.x). Use: \`let v=process.versions.node.split('.').map(Number);if(v\[0]<22||(v\[0]===22&\&v\[1]<15))\`. Update BIN\_WRAPPER in lockstep. Node 22.15+ guaranteed: remove feature-detection dances for \`zstdCompress\` — use direct \`import { zstdCompress } from 'node:zlib'\`; \`hasZstdSupport\` always returns \`true\`. * **MastraClient has no dispose API — use AbortController for cleanup**: MastraClient has no \`close()\`/\`dispose()\` API — cleanup via \`ClientOptions.abortSignal\` (constructor) or per-prompt \`signal\`. Without explicit abort, Bun's fetch dispatcher keep-alive sockets hold the event loop alive past natural exit. Pattern in \`src/lib/init/wizard-runner.ts\`: create \`AbortController\` per \`runWizard\`, pass \`abortSignal: controller.signal\` to \`new MastraClient(...)\`, abort via \`using \_ = { \[Symbol.dispose]: () => controller.abort() }\`. Custom \`fetch\` wrapper must preserve \`init.signal\` via spread. Tests capture \`ClientOptions\` via \`spyOn(MastraClient.prototype, 'getWorkflow').mockImplementation(function() { capturedOpts.push(this.options); ... })\`. @@ -83,17 +71,11 @@ * **process.stdin.isTTY unreliable in Bun — use isatty(0) and backfill for clack**: \`process.stdin.isTTY\` unreliable in Bun — use \`isatty(0)\` from \`node:tty\`. Bun's single-file binary can leave \`process.stdin.isTTY === undefined\` on TTY fds inherited via redirects like \`exec … \ -* **runInteractiveLogin swallows errors and sets process.exitCode = 1**: \`runInteractiveLogin\` in \`src/lib/interactive-login.ts\` catches OAuth flow errors internally (device-code fetch failures, timeout, etc.) and returns falsy on failure. The login command then sets \`process.exitCode = 1\` and returns normally — the wrapped command function resolves, NOT rejects. Tests that mock fetch to throw and expect \`rejects.toThrow()\` will fail with \`resolved: Promise { \ }\`. Assert behavior via fetch-call inspection (\`fetchCalls.length > 0\`, header content) instead. \`requestDeviceCode\` requires \`SENTRY\_CLIENT\_ID\` env var — unset in tests means it throws \`ConfigError\` before any fetch fires. - * **Stricli rejects unknown flags — pre-parsed global flags must be consumed from argv**: Stricli flag parsing traps: (1) Unknown \`--flag\`s rejected — global flags parsed in \`bin.ts\` MUST be spliced from argv (check both \`--flag value\` and \`--flag=value\`). (2) \`FLAG\_NAME\_PATTERN\` requires 2+ chars after \`--\`; single-char flags like \`--x\` silently become positionals — use aliases (\`-x\` → longer name). Bit \`dashboard widget --x\`/\`--y\`. (3) \`FlagDef.hidden\` is propagated by \`extractFlags\` so \`generateCommandDoc\` filters hidden flags alongside \`help\`/\`helpAll\`; hidden \`--log-level\`/\`--verbose\` appear only in global options docs. - -* **ubuntu-latest defaults to Node 20 (EOL) — E2E jobs must use actions/setup-node**: Trap: \`ubuntu-latest\` GitHub Actions runner ships Node 20 (EOL). E2E jobs that run the npm bundle via \`node -e "..."\` silently use Node 20 unless \`actions/setup-node\` is explicitly added. This looks fine because the job succeeds, but it violates \`engines.node >=22.12\`. Fix: add \`actions/setup-node\` with \`node-version: 22\` to any job that invokes \`node\` directly. The \`build-npm\` job already does this correctly; E2E jobs need the same treatment. Never add feature-detection workarounds for Node 20 — it's EOL and unsupported. - -* **Bun.which polyfill uses 'command -v' with 5s timeout for PATH-restricted lookups**: (pattern) Bun→Node.js API replacements live in \`script/node-polyfills.ts\` (injected at bundle time via esbuild). \`Bun.which(cmd, {PATH})\` polyfill uses \`command -v\` shell builtin (NOT \`which\` binary — \`which\` fails when PATH is restricted to a test dir) with \`timeout: 5000\` to prevent indefinite blocking. Windows uses \`where\`. \`Bun.spawn\` → \`spawn\` from \`node:child\_process\`; wrap \`.exited\` as \`new Promise(r => proc.on('close', code => r(code ?? 1)))\`. \*\*CRITICAL: always attach \`proc.on('error', () => {})\` — Node crashes on unhandled spawn errors; Bun did not.\*\* \`Bun.spawnSync\` → \`spawnSync\`. \`Bun.sleep(ms)\` → \`setTimeout\` promise. \`new Bun.Glob(p).match(i)\` → \`picomatch(p, { dot: true })(i)\` — pre-compile matchers outside hot loops. \`Bun.randomUUIDv7()\` → \`uuidv7()\`. \`Bun.semver.order()\` → \`compare()\` from \`semver\`. SQLite handled by \`src/lib/db/sqlite.ts\` (not the polyfill). Only change actual API call sites — never comments. Update type annotations. +* **whichSync must use 'command -v' not 'which' for PATH-restricted lookups**: Bun→Node.js API replacements: \`Bun.which(cmd,{PATH})\` → \`whichSync()\` from \`src/lib/which.ts\` (uses \`command -v\`). \`Bun.spawn\` → \`spawn\` from \`node:child\_process\`; wrap \`.exited\` as \`new Promise(r=>proc.on('close',code=>r(code??1)))\`. \*\*CRITICAL: always attach \`proc.on('error',()=>{})\` — Node crashes on unhandled spawn errors.\*\* \`Bun.spawnSync\` → \`spawnSync\`. \`Bun.sleep(ms)\` → \`import {setTimeout as sleepMs} from 'node:timers/promises'\`. \`new Bun.Glob(p).match(i)\` → \`picomatch(p,{dot:true})(i)\`. \`Bun.randomUUIDv7()\` → \`uuidv7()\`. \`Bun.semver.order()\` → \`compare()\` from \`semver\`. \`Bun.file().writer()\` → \`createWriteStream\` — \*\*CRITICAL: never pass \`resolve\` directly to \`writer.end()\`\*\* (Node uses error-first callback). Fix: \`writer.end((err?)=>err?reject(err):resolve())\`. Register \`writer.on('error',reject)\` BEFORE the write loop. When reviewing async/stream code: flag callbacks passed where error-first callbacks expected, missing \`'error'\` event listeners, and listeners attached too late. * **Whole-buffer matchAll slower than split+test when aggregated over many files**: Grep/scan traps in \`src/lib/scan/\`: (1) Whole-buffer \`regex.exec\` 12× faster per-file but ~1.6× SLOWER over 10k files — early-exit at \`maxResults\` via \`mapFilesConcurrent.onResult\` wins. (2) Literal prefilter is FILE-LEVEL gate (\`indexOf\`→skip); per-line verify breaks cross-newline patterns and Unicode length-changing \`toLowerCase\` (Turkish \`İ\`→\`i̇\`). (3) Extractor \`hasTopLevelAlternation\`+\`skipGroup\` must call \`skipCharacterClass\` (PCRE \`\[]abc]\` ≠ JS empty class). (4) Wake-latch race: naive \`let notify=null; await new Promise(r=>notify=r)\` loses signals — use latched \`pendingWake\` flag. (5) \`mapFilesConcurrent\` filters \`null\` but NOT \`\[]\` — return \`null\` for no-op files. (6) \`collectGlob\`/\`collectGrep\` must NOT forward \`maxResults\` to iterator; drain uncapped, set \`truncated=true\`. @@ -104,30 +86,18 @@ * **Grouped widget --limit auto-default via applyGroupLimitAutoDefault helper**: Dashboard widget flag normalization: (1) Dataset aliases (errors→error-events) normalize ONCE at top of \`func()\` via \`normalizeDataset()\` in \`src/commands/dashboard/resolve.ts\`. In \`edit.ts\`, pass \`normalizedFlags\` to \`buildReplacement\` — \`validateAggregateNames\` reads \`flags.dataset\` and rejects valid aggregates like \`failure\_rate\` if it sees raw alias. (2) Grouped widgets need \`limit\` (API rejects). \`applyGroupLimitAutoDefault\` defaults to \`DEFAULT\_GROUP\_BY\_LIMIT=5\` only when user passed \`--group-by\` without \`--limit\`; skip for auto-defaulted columns like \`\["issue"]\`. (3) Tests asserting \`--limit\` >10 survives into PUT body must use \`display: "line"\` — \`prepareWidgetQueries\` clamps bar/table to max=10. -* **Merging mock.module() test files with static-import counterparts**: (pattern) Bun test mocking traps: (1) \`mock.module()\` for CJS built-ins needs \`default\` re-export + named exports, declared top-level BEFORE \`await import()\`. (2) Convert code-under-test to \`await import()\` when merging mocks — static imports won't re-bind. (3) \`Bun.mmap()\` always PROT\_WRITE — use \`new Uint8Array(await Bun.file(path).arrayBuffer())\` for read-only. (4) \`mock.module()\` pollutes registry — use \`test/isolated/\`. (5) \`buildCommand\` wrapper: \`cmd.loader()\` returns wrapped async fn; call \`func.call(ctx, flags, ...args)\` as a promise. Auth guard runs first; \`test/preload.ts\` sets fake \`SENTRY\_AUTH\_TOKEN\`. (6) Test glob \`test:unit\` only picks up \`test/lib\`, \`test/commands\`, \`test/types\` — tests under \`test/fixtures/\`, \`test/scripts/\`, \`test/script/\` NOT run by CI. (7) Tests mocking fetch MUST call \`useTestConfigDir()\` + \`setAuthToken()\` + \`resetCacheState()\` + \`disableResponseCache()\` + \`resetAuthenticatedFetch()\` in beforeEach — filesystem cache will serve prior test responses otherwise. Symptom: test expects fresh mock value, receives prior test's value. TTL tiers: stable=5min, volatile=60s, immutable=24h. +* **Merging mock.module() test files with static-import counterparts**: Bun test mocking traps: (1) \`mock.module()\` for CJS built-ins needs \`default\` re-export + named exports, declared top-level BEFORE \`await import()\`. Convert code-under-test to \`await import()\` when merging mocks — static imports won't re-bind. (2) \`Bun.mmap()\` always PROT\_WRITE — use \`new Uint8Array(await Bun.file(path).arrayBuffer())\` for read-only. (3) \`mock.module()\` pollutes registry — use \`test/isolated/\`. (4) \`buildCommand\` wrapper: \`cmd.loader()\` returns wrapped async fn; call \`func.call(ctx,flags,...args)\` as a promise. Auth guard runs first; \`test/preload.ts\` sets fake \`SENTRY\_AUTH\_TOKEN\`. (5) Test glob \`test:unit\` only picks up \`test/lib\`, \`test/commands\`, \`test/types\` — \`test/fixtures/\`, \`test/scripts/\`, \`test/script/\` NOT run by CI. (6) Tests mocking fetch MUST call \`useTestConfigDir()\` + \`setAuthToken()\` + \`resetCacheState()\` + \`disableResponseCache()\` + \`resetAuthenticatedFetch()\` in beforeEach — filesystem cache will serve prior test responses otherwise. \`useEnvSandbox(keys)\` saves+clears+restores env keys (do NOT use in tests depending on preload's \`SENTRY\_AUTH\_TOKEN\`). -* **Shared pagination infrastructure: buildPaginationContextKey and parseCursorFlag**: (pattern) Pagination infrastructure + org flag injection: Bidirectional pagination via cursor stack in \`src/lib/db/pagination.ts\`. \`resolveCursor(flag, key, contextKey)\` maps keywords (next/prev/first/last) to \`{cursor, direction}\`. \`advancePaginationState\` manages stack — back-then-forward truncates stale entries. \`paginationHint()\` builds nav strings. Critical: \`resolveCursor()\` must be called INSIDE \`org-all\` override closures, not before \`dispatchOrgScopedList\`. \`issue list --limit\` is global total: \`fetchWithBudget\` Phase 1 divides evenly, Phase 2 redistributes surplus. \`trimWithProjectGuarantee\` ensures ≥1 issue per project. Compound cursor (pipe-separated) enables \`-c last\` for multi-target pagination. JSON output wraps in \`{ data, hasMore }\` with optional \`errors\` array. +* **Shared pagination infrastructure: buildPaginationContextKey and parseCursorFlag**: Pagination infrastructure + org flag injection: Bidirectional pagination via cursor stack in \`src/lib/db/pagination.ts\`. \`resolveCursor(flag, key, contextKey)\` maps keywords (next/prev/first/last) to \`{cursor, direction}\`. \`advancePaginationState\` manages stack — back-then-forward truncates stale entries. Critical: \`resolveCursor()\` must be called INSIDE \`org-all\` override closures, not before \`dispatchOrgScopedList\`. \`issue list --limit\` is global total: \`fetchWithBudget\` Phase 1 divides evenly, Phase 2 redistributes surplus. \`trimWithProjectGuarantee\` ensures ≥1 issue per project. Compound cursor (pipe-separated) enables \`-c last\` for multi-target pagination. JSON output wraps in \`{ data, hasMore }\` with optional \`errors\` array. -* **Telemetry instrumentation pattern: withTracingSpan + captureException for handled errors**: (pattern) Telemetry instrumentation + command bypass: Use \`withTracingSpan\` from \`src/lib/telemetry.ts\` for child spans (\`onlyIfParent: true\`) and \`captureException\` from \`@sentry/bun\` (named import) with \`level: 'warning'\` for non-fatal errors. Several commands bypass telemetry by importing \`buildCommand\` from \`@stricli/core\` directly. \`ENV\_VAR\_REGISTRY\` in \`src/lib/env-registry.ts\` is the single source for all honored env vars; \`topLevel: true\` + \`briefDescription\` surfaces in \`--help\`. Add new env vars with \`installOnly: true\` if install-script-only. Opt-out priority: (1) \`SENTRY\_CLI\_NO\_TELEMETRY=1\`, (2) \`DO\_NOT\_TRACK=1\`, (3) \`metadata.defaults.telemetry\`, (4) default on. \`computeTelemetryEffective()\` returns resolved source for display. - - -* **Test helpers for host-scoping security tests**: (pattern) Test helpers for host-scoping security tests: \`useEnvSandbox(keys)\` saves+clears+restores env keys (do NOT use in tests depending on preload's \`SENTRY\_AUTH\_TOKEN\`). \`resetHostScopingState()\` bundles \`resetEnvTokenHostForTesting\` + \`resetLoginTrustAnchorForTesting\` + \`resetTrustedRegionUrlsForTesting\` (always reset together). \`mintSntrysToken(payload)\` produces \`sntrys\_\\_\\` test tokens (rstrip \`=\`). \`extractFetchUrl(input)\` for fetch-mock assertions. Tests mocking fetch with non-SaaS URLs must pass \`{host}\` to \`setAuthToken\`. For \`assertRcUrlTrusted\`: sequence is \`resetEnvTokenHostForTesting()\` → delete env vars → \`captureEnvTokenHost()\` → \`applySentryCliRcEnvShim()\` → \`assertRcUrlTrusted()\`. E2E: \`createE2EContext\` parent must \`setAuthToken(token, ttl, {host: serverUrl})\`; multi-region tests need \`registerTrustedRegionUrls\`. - - -* **Token-type classification via literal prefix match (classifySentryToken)**: (pattern) Token-type classification: \`src/lib/token-type.ts\` \`classifySentryToken(token)\` returns \`'org-auth-token'\` (\`sntrys\_\` prefix), \`'user-auth-token'\` (\`sntryu\_\` prefix), or \`'oauth-or-legacy'\`. Case-sensitive \`startsWith\`. Use to short-circuit commands where a token type is semantically invalid (e.g. \`whoami\` with org token — \`/auth/\` rejects \`sntrys\_\`). \`getAuthToken()\` from \`db/auth\` returns the effective token. Non-essential DB cache writes (e.g., \`setUserInfo()\`, \`setInstallInfo()\`) must wrap in try-catch so broken/read-only DB doesn't crash a successful command. Pattern: \`try { setInstallInfo(...) } catch { log.debug(...) }\`. Exception: \`getUserRegions()\` failure should \`clearAuth()\` and fail hard. +* **Telemetry instrumentation pattern: withTracingSpan + captureException for handled errors**: Telemetry instrumentation + command bypass: Use \`withTracingSpan\` from \`src/lib/telemetry.ts\` for child spans (\`onlyIfParent: true\`) and \`captureException\` from \`@sentry/bun\` (named import) with \`level: 'warning'\` for non-fatal errors. Several commands bypass telemetry by importing \`buildCommand\` from \`@stricli/core\` directly. \`ENV\_VAR\_REGISTRY\` in \`src/lib/env-registry.ts\` is the single source for all honored env vars; \`topLevel: true\` + \`briefDescription\` surfaces in \`--help\`. Add new env vars with \`installOnly: true\` if install-script-only. Opt-out priority: (1) \`SENTRY\_CLI\_NO\_TELEMETRY=1\`, (2) \`DO\_NOT\_TRACK=1\`, (3) \`metadata.defaults.telemetry\`, (4) default on. \`computeTelemetryEffective()\` returns resolved source for display. Shell completions set \`SENTRY\_CLI\_NO\_TELEMETRY=1\` in \`bin.ts\` before imports, skipping \`createTracedDatabase\` and SDK load (~85ms). Timing queued to \`completion\_telemetry\_queue\` SQLite table; normal runs drain via \`DELETE ... RETURNING\`. ### Preference - -* **Always investigate multiple related files thoroughly before proposing or implementing changes**: Deep investigation before implementation: Always investigate multiple related files thoroughly before proposing changes — read complete files, check SDK/type definitions, grep for existing usage, map all call sites. Surface all findings (including what is NOT present) before moving to a fix. Before merging any PR, perform a critical self-review categorizing findings by severity (CRITICAL, MEDIUM, LOW); fix all CRITICAL and MEDIUM issues before pushing. When reporting issues, provide exact file paths, function names, and line number ranges. Write migration plans to \`.opencode/plans/\` with full codebase exploration first; call \`plan\_exit\` after writing. - - -* **Always provide explicit replacement rules before migrating APIs**: When requesting API migrations (e.g., Bun → Node.js), the user always specifies exact replacement mappings upfront: which source API maps to which target API, which files to modify, what to skip, and any special-case handling rules (e.g., 'do NOT modify comments', 'check existing imports before adding new ones', 'this call has special stdout/stderr handling — read carefully'). Follow these rules precisely without improvising alternatives. Never modify excluded items. Always extend existing imports rather than duplicating them. Treat the user's explicit mapping list as the authoritative spec for the entire migration. After migration, always check for missing \`proc.on('error')\` listeners on every spawn site — Node crashes on unhandled spawn errors. - - -* **Always provide root cause context and specific file/line locations when requesting code review or investigation**: When asking for code review or bug investigation, the user consistently pre-loads the conversation with: (1) the root cause already identified or strongly suspected, (2) exact file paths and line numbers to examine, (3) the specific symptoms and expected vs. actual behavior, and (4) any relevant constraints (SDK type limitations, API behavior, etc.). The assistant should treat these upfront details as authoritative ground truth, skip re-investigating already-stated facts, and focus review/analysis effort on the specific locations and concerns the user has already scoped. Do not re-derive what the user has already explained. + +* **Always request adversarial/critical PR review before merging**: After implementing a feature and creating a PR, the user consistently requests a thorough adversarial self-review before merging. This applies even to code the user or assistant just wrote. The review should be structured with severity levels (CRITICAL, MEDIUM, MINOR), cover security invariants explicitly, check for edge cases in error handling (silent catch blocks, unhandled errors), verify flag/guard logic correctness, and validate that the PR description matches the implementation. The user expects the reviewer to actively try to find bugs, not just confirm correctness. - -* **Always request critical PR reviews before merging, focusing on correctness, error handling, and edge cases**: When working on PRs (especially large refactors or migrations), the user consistently requests thorough code reviews before merging. Reviews should focus on: correctness of API replacements, error handling gaps (e.g., missing event listeners on spawned processes), type safety, security issues (e.g., shell injection), edge cases, and accuracy of PR descriptions/comments. The user expects the reviewer to read critical files in full, identify CRITICAL vs MEDIUM severity findings, and then immediately proceed to fix all identified issues — not just report them. After fixes, all tests and lint must pass before the PR is pushed. + +* **Always review PRs for error handling correctness in async/stream code before approving**: PR review priorities: (1) Async/stream error handling — flag callbacks passed where error-first callbacks expected (e.g. \`stream.end(resolve)\`), missing \`'error'\` event listeners on EventEmitters/streams, listeners attached too late. (2) Security trust models — enumerate concrete attack vectors (env var hijacking, planted files, CI pipeline writes) for each trust boundary; reject blanket trust assumptions; probe homedir/config-dir/project-local individually under adversarial CI conditions. (3) Always wait for Sentry Seer and Cursor BugBot CI jobs before merging. Use: \`gh run view --log-failed --job $(gh pr checks $PR\_NO --json state,link -q '.\[] | select(.state == "FAILURE").link | split("/")\[-1]')\`. (4) Perform thorough self-review (consider subagent for objectivity) checking correctness, security, lint/type errors, and PR description accuracy before merge. diff --git a/docs/src/content/docs/contributing.md b/docs/src/content/docs/contributing.md index 203900de7..6365b31a4 100644 --- a/docs/src/content/docs/contributing.md +++ b/docs/src/content/docs/contributing.md @@ -52,7 +52,7 @@ cli/ │ ├── context.ts # Dependency injection context │ ├── commands/ # CLI commands │ │ ├── auth/ # login, logout, refresh, status, token, whoami -│ │ ├── cli/ # defaults, feedback, fix, setup, upgrade +│ │ ├── cli/ # defaults, feedback, fix, import, setup, upgrade │ │ ├── dashboard/ # list, view, create, add, edit, delete, revisions, restore │ │ ├── event/ # view, list │ │ ├── issue/ # list, events, explain, plan, view, resolve, unresolve, archive, merge diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index df00799cf..978aeebec 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -345,6 +345,7 @@ CLI-related commands - `sentry cli defaults ` — View and manage default settings - `sentry cli feedback ` — Send feedback about the CLI - `sentry cli fix` — Diagnose and repair CLI database issues +- `sentry cli import` — Import settings from legacy .sentryclirc files - `sentry cli setup` — Configure shell integration - `sentry cli upgrade ` — Update the Sentry CLI to the latest version diff --git a/plugins/sentry-cli/skills/sentry-cli/references/cli.md b/plugins/sentry-cli/skills/sentry-cli/references/cli.md index a029e08c0..4ba56845d 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/cli.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/cli.md @@ -47,6 +47,16 @@ Diagnose and repair CLI database issues sentry cli fix ``` +### `sentry cli import` + +Import settings from legacy .sentryclirc files + +**Flags:** +- `-y, --yes - Skip confirmation prompt` +- `-n, --dry-run - Show what would happen without making changes` +- `--url - Explicitly trust this URL (bypasses same-file trust check)` +- `--skip-validation - Skip token validation against the Sentry API` + ### `sentry cli setup` Configure shell integration diff --git a/src/cli.ts b/src/cli.ts index 3c5d4574d..34ccaf1f6 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -218,6 +218,143 @@ export async function runCli(cliArgs: string[]): Promise { } }; + /** + * Attempt to import `.sentryclirc` settings when the user is unauthenticated. + * + * Returns `"imported"` if a trusted token was found, imported, and validated. + * Returns `"declined"` if the user said no (marked as declined). + * Returns `"skip"` if no eligible files, trust gate fails, or any error. + */ + /** + * Build a trusted import plan from non-project-local .sentryclirc files, + * or return null if no eligible import is available. + */ + async function buildEligibleImportPlan() { + const { discoverRcFiles, buildImportPlan, isImportNeededAsync } = + await import("./lib/sentryclirc-import.js"); + + if (!(await isImportNeededAsync())) { + return null; + } + const files = await discoverRcFiles(process.cwd()); + const eligible = files.filter((f) => f.location !== "project-local"); + if (eligible.length === 0 || !eligible.some((f) => f.token)) { + return null; + } + const plan = buildImportPlan(eligible); + if ( + !( + plan.trusted && + plan.effective.token && + plan.newFields.includes("token") + ) + ) { + return null; + } + return plan; + } + + async function tryRcImport(): Promise<"imported" | "declined" | "skip"> { + const plan = await buildEligibleImportPlan(); + if (!plan) { + return "skip"; + } + + const source = plan.sources.find((s) => s.token)?.path ?? "~/.sentryclirc"; + process.stderr.write( + `\nFound auth token in ${source}\n` + + "Import settings to the new CLI? This stores your token with proper host scoping.\n\n" + ); + + const consent = await promptImportConsent(); + if (consent === "declined") { + const { markImportDeclined } = await import( + "./lib/sentryclirc-import.js" + ); + markImportDeclined(plan.sources); + return "declined"; + } + if (consent !== "accepted") { + return "skip"; + } + + const { executeImport } = await import("./lib/sentryclirc-import.js"); + const result = await executeImport(plan, { validateToken: true }); + return result.imported && result.tokenValid !== false ? "imported" : "skip"; + } + + /** + * Prompt the user to accept/decline the import. + * Returns "accepted", "declined" (explicit no), or "cancelled" (Ctrl+C). + * Only "declined" permanently suppresses future prompts. + */ + async function promptImportConsent(): Promise< + "accepted" | "declined" | "cancelled" + > { + const { logger: logModule } = await import("./lib/logger.js"); + const confirmed = await logModule + .withTag("import") + .prompt("Import from .sentryclirc?", { type: "confirm", initial: true }); + if (confirmed === true) { + return "accepted"; + } + // false = explicit "no"; Symbol(clack:cancel) = Ctrl+C + return confirmed === false ? "declined" : "cancelled"; + } + + /** Log import middleware errors at an appropriate level */ + async function logImportError(importErr: unknown): Promise { + const { logger: logModule } = await import("./lib/logger.js"); + const { HostScopeError: HSE } = await import("./lib/errors.js"); + const importLog = logModule.withTag("import"); + if (importErr instanceof HSE) { + importLog.warn("Import middleware error", importErr); + } else { + importLog.debug("Import middleware error", importErr); + } + } + + /** + * `.sentryclirc` import middleware. + * + * When a command fails with `not_authenticated` and a non-project-local + * `.sentryclirc` file has a token that passes the same-file trust gate, + * offers to import it into the new CLI's SQLite store. On success, retries + * the command. On decline, marks as declined (never asks again) and + * re-throws so the auto-auth middleware can offer OAuth login instead. + * + * Only fires in interactive TTYs (disabled in CI). Project-local files + * are excluded to avoid prompting in every cloned repo. + */ + const rcImportMiddleware: ErrorMiddleware = async (next, argv) => { + try { + await next(argv); + } catch (err) { + let imported = false; + if ( + err instanceof AuthError && + err.reason === "not_authenticated" && + !err.skipAutoAuth && + isatty(0) + ) { + try { + imported = (await tryRcImport()) === "imported"; + } catch (importErr) { + await logImportError(importErr); + } + } + if (imported) { + // Retry outside the import try/catch so retry errors propagate + // naturally instead of being swallowed and re-throwing the + // original AuthError. + process.stderr.write("Import successful! Retrying command...\n\n"); + await next(argv); + return; + } + throw err; + } + }; + /** * Auto-authentication middleware. * @@ -269,6 +406,7 @@ export async function runCli(cliArgs: string[]): Promise { */ const errorMiddlewares: ErrorMiddleware[] = [ seerTrialMiddleware, + rcImportMiddleware, autoAuthMiddleware, ]; diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index d277740db..03d4830fe 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -207,7 +207,8 @@ export function rcTokenHint( : ` --url ${effectiveHost}`; return ( `Found a token in .sentryclirc (${rcConfig.sources.token}). ` + - `To skip OAuth next time: sentry auth login --token ${urlHint}` + "To import it: sentry cli import | " + + `To pass it directly: sentry auth login --token ${urlHint}` ); } @@ -374,6 +375,7 @@ export const loginCommand = buildCommand({ refuseLoginToUntrustedHost(flags, effectiveHost, urlFromRc); + // Check if already authenticated and handle re-authentication if (isAuthenticated()) { const shouldProceed = await handleExistingAuth(flags.force); if (!shouldProceed) { diff --git a/src/commands/cli/import.ts b/src/commands/cli/import.ts new file mode 100644 index 000000000..fcb93555a --- /dev/null +++ b/src/commands/cli/import.ts @@ -0,0 +1,384 @@ +/** + * `sentry cli import` — Import settings from legacy `.sentryclirc` files. + * + * Scans for `.sentryclirc` config files (used by the old Rust-based sentry-cli) + * and imports their settings into the new CLI's SQLite store with proper host + * scoping. + * + * Security: Trust is content-based (same-file rule), not path-based. Token and + * URL must originate from the same file for the import to proceed without + * explicit `--url` confirmation. See `sentryclirc-import.ts` for details. + */ + +import { isatty } from "node:tty"; +import type { SentryContext } from "../../context.js"; +import { buildCommand } from "../../lib/command.js"; +import { getDefaultUrl } from "../../lib/db/defaults.js"; +import { HostScopeError, ValidationError } from "../../lib/errors.js"; +import { success, warning } from "../../lib/formatters/colors.js"; +import { renderMarkdown } from "../../lib/formatters/markdown.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; +import { logger } from "../../lib/logger.js"; +import { DRY_RUN_FLAG } from "../../lib/mutate-command.js"; +import { + isSaaSTrustOrigin, + normalizeUserInputToOrigin, +} from "../../lib/sentry-urls.js"; +import type { + DiscoveredRcFile, + ImportPlan, + ImportResult, +} from "../../lib/sentryclirc-import.js"; +import { + buildImportPlan, + checkSntrysClaim, + clearImportDecline, + discoverRcFiles, + executeImport, + markImportCompleted, + maskToken, +} from "../../lib/sentryclirc-import.js"; + +const log = logger.withTag("cli.import"); + +// --------------------------------------------------------------------------- +// Formatters +// --------------------------------------------------------------------------- + +/** Format a discovered file's fields for human-readable preview */ +function formatFilePreview(file: DiscoveredRcFile): string { + const lines: string[] = []; + const locationLabel = + file.location === "project-local" ? " (project-level)" : ""; + lines.push(` **${file.path}**${locationLabel}`); + if (file.token) { + lines.push(` Token: \`${maskToken(file.token)}\``); + } + if (file.url) { + lines.push(` URL: ${file.url}`); + } + if (file.org) { + lines.push(` Org: ${file.org}`); + } + if (file.project) { + lines.push(` Project: ${file.project}`); + } + return lines.join("\n"); +} + +/** Format the planned actions for preview */ +function formatPlanActions(plan: ImportPlan): string { + const lines: string[] = []; + if (plan.newFields.includes("token")) { + const host = plan.effective.url ?? "https://sentry.io"; + lines.push(` + Store auth token (host scope: ${host})`); + } + if (plan.newFields.includes("url") && plan.effective.url) { + lines.push(` + Set default URL: ${plan.effective.url}`); + } + if (plan.newFields.includes("org") && plan.effective.org) { + lines.push(` + Set default org: ${plan.effective.org}`); + } + if (plan.newFields.includes("project") && plan.effective.project) { + lines.push(` + Set default project: ${plan.effective.project}`); + } + return lines.join("\n"); +} + +/** Format the full preview shown before confirmation */ +function formatPreview(plan: ImportPlan): string { + const sections: string[] = ["Found .sentryclirc settings:\n"]; + + for (const file of plan.sources) { + if (file.token || file.url || file.org || file.project) { + sections.push(formatFilePreview(file)); + } + } + + if (plan.newFields.length > 0) { + sections.push("\nWill import:"); + sections.push(formatPlanActions(plan)); + } + + for (const warn of plan.warnings) { + sections.push(`\n${warning(`Warning: ${warn}`)}`); + } + + return sections.join("\n"); +} + +/** Format stored fields list for result display */ +function formatStoredFields(result: ImportResult): string { + const stored: string[] = []; + if (result.stored.token) { + stored.push("auth token"); + } + if (result.stored.url) { + stored.push("default URL"); + } + if (result.stored.org) { + stored.push("default org"); + } + if (result.stored.project) { + stored.push("default project"); + } + return stored.join(", "); +} + +/** Format the import result for human output */ +function formatImportResult(result: ImportResult): string { + // Check if anything was partially stored (defaults may persist even + // when token validation fails) + const stored = formatStoredFields(result); + const hasPartialResults = !result.imported && stored; + + if (!(result.imported || hasPartialResults)) { + return result.warnings.join("\n") || "Import was not performed."; + } + + const lines: string[] = result.imported + ? [success("Import successful!")] + : [warning("Import partially completed:")]; + + if (result.user?.name || result.user?.email) { + const parts = [ + result.user.name, + result.user.email ? `<${result.user.email}>` : "", + ].filter(Boolean); + lines.push(` Logged in as: ${parts.join(" ")}`); + } + + if (stored) { + lines.push(` Stored: ${stored}`); + } + + if (result.tokenValid === false) { + lines.push(warning(" Token validation failed.")); + } + + for (const warn of result.warnings) { + lines.push(warning(` ${warn}`)); + } + + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** An empty ImportResult for early-exit cases */ +function emptyResult(warnings: string[] = []): ImportResult { + return { + imported: false, + stored: { token: false, url: false, org: false, project: false }, + warnings, + }; +} + +/** + * Apply --url override to the plan, marking it trusted and recalculating isSaas. + * Ensures newFields matches what executeImport will actually do (no preview/execution mismatch). + */ +function applyUrlOverride(plan: ImportPlan, url: string): void { + plan.effective.url = url; + plan.effectiveSources.url = plan.effectiveSources.token; + plan.trusted = true; + plan.isSaas = isSaaSTrustOrigin(url); + + // Re-check sntrys_ claim against the overridden URL + if (plan.effective.token) { + const claimWarning = checkSntrysClaim(plan.effective.token, url); + if (claimWarning && !plan.warnings.includes(claimWarning)) { + plan.warnings.push(claimWarning); + } + } + + // Remove stale "url" entry if the override makes it SaaS (storeDefaults skips SaaS URLs) + if (plan.isSaas) { + plan.newFields = plan.newFields.filter((f) => f !== "url"); + return; + } + // Add "url" only if not already listed and no default URL is stored + if (!plan.newFields.includes("url")) { + try { + if (!getDefaultUrl()) { + plan.newFields.push("url"); + } + } catch (error) { + log.debug("Failed to check default URL", error); + plan.newFields.push("url"); + } + } +} + +/** Enforce the same-file trust gate; throws HostScopeError on violation */ +function enforceTrustGate(plan: ImportPlan): void { + if (plan.trusted) { + return; + } + const tokenSource = plan.effectiveSources.token ?? "unknown"; + const urlSource = plan.effectiveSources.url ?? "unknown"; + throw new HostScopeError( + `Token (from ${tokenSource}) and URL (from ${urlSource}) come from different files.\n` + + "To confirm you trust this URL, pass it explicitly:\n" + + ` sentry cli import --url ${plan.effective.url ?? ""}` + ); +} + +/** Prompt for confirmation in interactive mode. Returns true to proceed. */ +async function confirmImport(): Promise { + if (!isatty(0)) { + return false; + } + const confirmed = await log.prompt("Import these settings to the new CLI?", { + type: "confirm", + initial: true, + }); + // Symbol(clack:cancel) is truthy — strict equality check + return confirmed === true; +} + +// --------------------------------------------------------------------------- +// Command +// --------------------------------------------------------------------------- + +type ImportFlags = { + readonly yes: boolean; + readonly "dry-run": boolean; + readonly url?: string; + readonly "skip-validation": boolean; +}; + +/** Parse and normalize the --url flag value */ +function parseImportUrl(raw: string): string { + const origin = normalizeUserInputToOrigin(raw); + if (!origin) { + throw new ValidationError(`Invalid URL: ${raw}`, "url"); + } + return origin; +} + +export const importCommand = buildCommand({ + auth: false, + skipRcUrlCheck: true, + docs: { + brief: "Import settings from legacy .sentryclirc files", + fullDescription: + "Scan for .sentryclirc config files (used by the old Rust-based sentry-cli) " + + "and import their settings into the new CLI.\n\n" + + "Imported settings:\n" + + " - Auth token -> stored credentials (with proper host scoping)\n" + + " - URL -> default Sentry instance URL\n" + + " - Organization -> default organization\n" + + " - Project -> default project\n\n" + + "Security: token and URL must come from the same file to be trusted.\n" + + "Cross-file URL requires explicit --url confirmation.\n\n" + + "Examples:\n" + + " sentry cli import # Scan and import interactively\n" + + " sentry cli import --yes # Auto-confirm (CI-safe)\n" + + " sentry cli import --dry-run # Preview without changes\n" + + " sentry cli import --url # Trust a specific URL", + }, + parameters: { + flags: { + yes: { + kind: "boolean", + brief: "Skip confirmation prompt", + default: false, + }, + "dry-run": DRY_RUN_FLAG, + url: { + kind: "parsed", + parse: parseImportUrl, + brief: "Explicitly trust this URL (bypasses same-file trust check)", + optional: true, + }, + "skip-validation": { + kind: "boolean", + brief: "Skip token validation against the Sentry API", + default: false, + }, + }, + aliases: { y: "yes", n: "dry-run" }, + }, + output: { human: formatImportResult }, + async *func(this: SentryContext, flags: ImportFlags) { + // 1. Discover .sentryclirc files + const files = await discoverRcFiles(this.cwd); + if (files.length === 0) { + yield new CommandOutput(emptyResult(["No .sentryclirc files found."])); + return; + } + + // 2. Build import plan (with optional --url override) + const plan = buildImportPlan(files); + if (flags.url) { + applyUrlOverride(plan, flags.url); + } + + // 3. Nothing to import? + if (plan.newFields.length === 0) { + markImportCompleted(plan); + yield new CommandOutput( + emptyResult(["All settings from .sentryclirc are already configured."]) + ); + return; + } + + // 4. Trust gate — only enforce when importing a token with a non-SaaS URL. + // Org/project/URL-only defaults are harmless and don't need cross-file trust. + // The trust gate protects against redirecting a token to a malicious host, + // so it only matters when a token is actually being stored. + if (!flags.url && plan.newFields.includes("token")) { + enforceTrustGate(plan); + } + + // 5. Show preview + log.info(renderMarkdown(formatPreview(plan))); + + // 6. Dry-run: stop here + if (flags["dry-run"]) { + yield new CommandOutput(emptyResult()); + return { hint: "Dry run — no changes made." }; + } + + // 7. Confirm (unless --yes) + if (!flags.yes) { + if (!isatty(0)) { + yield new CommandOutput( + emptyResult([ + "Import requires confirmation. Use --yes in non-interactive mode.", + ]) + ); + process.exitCode = 1; + return; + } + if (!(await confirmImport())) { + log.info("Cancelled."); + return; + } + } + + // 8. Execute import + const result = await executeImport(plan, { + validateToken: !flags["skip-validation"], + }); + // Always clear the decline flag when the user explicitly runs import — + // even if validation fails. This lets the auto-prompt re-offer on the + // next auth error (with fresh hash evaluation). + clearImportDecline(); + yield new CommandOutput(result); + + if (result.tokenValid === false) { + return { + hint: "Token validation failed. Check your token and try: sentry auth login", + }; + } + if (result.imported) { + return { + hint: "Your .sentryclirc file is still active via the env shim. You can remove it once you've verified everything works.", + }; + } + }, +}); diff --git a/src/commands/cli/index.ts b/src/commands/cli/index.ts index 61b4567b6..8d15043c8 100644 --- a/src/commands/cli/index.ts +++ b/src/commands/cli/index.ts @@ -2,6 +2,7 @@ import { buildRouteMap } from "../../lib/route-map.js"; import { defaultsCommand } from "./defaults.js"; import { feedbackCommand } from "./feedback.js"; import { fixCommand } from "./fix.js"; +import { importCommand } from "./import.js"; import { setupCommand } from "./setup.js"; import { upgradeCommand } from "./upgrade.js"; @@ -10,6 +11,7 @@ export const cliRoute = buildRouteMap({ defaults: defaultsCommand, feedback: feedbackCommand, fix: fixCommand, + import: importCommand, setup: setupCommand, upgrade: upgradeCommand, }, diff --git a/src/lib/sentryclirc-import.ts b/src/lib/sentryclirc-import.ts new file mode 100644 index 000000000..fe31ffcf5 --- /dev/null +++ b/src/lib/sentryclirc-import.ts @@ -0,0 +1,939 @@ +/** + * `.sentryclirc` Import Engine + * + * Scans for `.sentryclirc` files, classifies them, builds an import plan, + * and executes it by storing credentials and defaults in SQLite. + * + * ## Security: Same-File Rule + * + * Trust is determined by file content, not file path. No location + * (including `~/.sentryclirc`) is inherently trusted — on CI, any path + * can be planted by an attacker. + * + * The core invariant: **the effective token and URL must originate from + * the same `.sentryclirc` file** for the import to be trusted. This + * "co-presence" rule means an attacker who planted both values already + * has the token — there is nothing to steal via a URL redirect. + * + * When token and URL come from different files (cross-file merge), the + * URL may have been injected by an attacker with write access to one + * file but not the other. This requires explicit `--url` confirmation. + * + * ## Hash-Based Change Detection + * + * At import time, SHA-256 hashes of imported files are stored. On + * subsequent runs, a hash mismatch clears the import record and + * triggers re-evaluation — catching post-import tampering such as + * an attacker appending a malicious URL to a token-only file. + */ + +import { createHash } from "node:crypto"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { DEFAULT_SENTRY_URL, normalizeUrl } from "./constants.js"; +import { + clearAuth, + hasStoredAuthCredentials, + setAuthToken, +} from "./db/auth.js"; +import { + getDefaultOrganization, + getDefaultProject, + getDefaultUrl, + setDefaultOrganization, + setDefaultProject, + setDefaultUrl, +} from "./db/defaults.js"; +import { getConfigDir, getDatabase } from "./db/index.js"; +import { setUserInfo } from "./db/user.js"; +import { clearMetadata, getMetadata, setMetadata } from "./db/utils.js"; +import { parseIni } from "./ini.js"; +import { logger } from "./logger.js"; +import { isSaaSTrustOrigin, normalizeOrigin } from "./sentry-urls.js"; +import { + CONFIG_FILENAME, + getGlobalPaths, + tryReadSentryCliRc, +} from "./sentryclirc.js"; +import { parseSntrysClaim } from "./token-claims.js"; +import { walkUpFrom } from "./walk-up.js"; + +const log = logger.withTag("import"); + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Where the file was found — used for auto-prompt eligibility, NOT for trust */ +export type RcFileLocation = "homedir" | "config-dir" | "project-local"; + +/** A discovered .sentryclirc file with parsed content, location, and content hash */ +export type DiscoveredRcFile = { + /** Absolute path to the file */ + path: string; + /** Where the file was found (homedir, config-dir, or project-local) */ + location: RcFileLocation; + /** SHA-256 hex digest of the raw file content */ + contentHash: string; + /** Auth token from [auth] section */ + token?: string; + /** Sentry URL from [defaults] section */ + url?: string; + /** Organization slug from [defaults] section */ + org?: string; + /** Project slug from [defaults] section */ + project?: string; +}; + +/** Fields that can be imported from .sentryclirc */ +type ImportableField = "token" | "url" | "org" | "project"; + +/** Preview of what the import will do */ +export type ImportPlan = { + /** All discovered .sentryclirc files */ + sources: DiscoveredRcFile[]; + /** Effective merged values (closest-wins + global-fallback) */ + effective: { token?: string; url?: string; org?: string; project?: string }; + /** Provenance: which file path contributed each effective field */ + effectiveSources: { + token?: string; + url?: string; + org?: string; + project?: string; + }; + /** Which fields would be new (not already in SQLite) */ + newFields: ImportableField[]; + /** Whether usable OAuth credentials already exist in SQLite */ + hasExistingAuth: boolean; + /** Whether the effective URL is SaaS (or absent = SaaS default) */ + isSaas: boolean; + /** + * Whether the effective token+URL pass the same-file trust gate. + * true when: (a) token and URL from same file, or (b) no URL (SaaS default). + */ + trusted: boolean; + /** Security warnings for display */ + warnings: string[]; +}; + +/** Result after executing an import */ +export type ImportResult = { + /** Whether the import was executed successfully */ + imported: boolean; + /** Which fields were stored */ + stored: { token: boolean; url: boolean; org: boolean; project: boolean }; + /** Whether the token was validated against the API (undefined if skipped) */ + tokenValid?: boolean; + /** User identity from token validation */ + user?: { name?: string; email?: string; username?: string }; + /** Warnings emitted during import */ + warnings: string[]; +}; + +// --------------------------------------------------------------------------- +// Metadata keys +// --------------------------------------------------------------------------- + +const IMPORT_COMPLETED_KEY = "import.sentryclirc"; +const IMPORT_DECLINED_KEY = "import.sentryclirc_declined"; + +/** Persisted record of a completed import, including file content hashes */ +type ImportRecord = { + completedAt: number; + sources: Array<{ path: string; contentHash: string }>; +}; + +/** Options for {@link executeImport} */ +export type ExecuteImportOptions = { + /** Validate the token against the Sentry API before committing (default: true) */ + validateToken?: boolean; +}; + +// --------------------------------------------------------------------------- +// Classification +// --------------------------------------------------------------------------- + +/** + * Classify a `.sentryclirc` file path by its location. + * + * Used for auto-prompt eligibility (project-local files are excluded), + * NOT for trust decisions (trust uses the same-file rule instead). + */ +export function classifyRcFileLocation(filePath: string): RcFileLocation { + const homedirPath = join(homedir(), CONFIG_FILENAME); + if (filePath === homedirPath) { + return "homedir"; + } + const configDirPath = join(getConfigDir(), CONFIG_FILENAME); + if (filePath === configDirPath) { + return "config-dir"; + } + return "project-local"; +} + +// --------------------------------------------------------------------------- +// Discovery helpers +// --------------------------------------------------------------------------- + +/** Compute SHA-256 hex digest of a string */ +function sha256(content: string): string { + return createHash("sha256").update(content).digest("hex"); +} + +/** Extract fields from parsed INI data */ +function extractFields(iniData: ReturnType): { + token?: string; + url?: string; + org?: string; + project?: string; +} { + const result: { + token?: string; + url?: string; + org?: string; + project?: string; + } = {}; + const token = iniData.auth?.token?.trim(); + if (token) { + result.token = token; + } + const url = iniData.defaults?.url?.trim(); + if (url) { + result.url = url; + } + const org = iniData.defaults?.org?.trim(); + if (org) { + result.org = org; + } + const project = iniData.defaults?.project?.trim(); + if (project) { + result.project = project; + } + return result; +} + +/** Read and parse a single .sentryclirc file into a DiscoveredRcFile */ +async function readRcFile( + rcPath: string, + location: RcFileLocation +): Promise { + const content = await tryReadSentryCliRc(rcPath); + if (content === null) { + return null; + } + return { + path: rcPath, + location, + contentHash: sha256(content), + ...extractFields(parseIni(content)), + }; +} + +// --------------------------------------------------------------------------- +// Discovery +// --------------------------------------------------------------------------- + +/** + * Discover all `.sentryclirc` files by walking up from `cwd` and + * checking global fallback paths. + * + * Returns per-file granularity (not merged) with provenance tracking, + * content hashes, and location classification. + */ +export async function discoverRcFiles( + cwd: string +): Promise { + const files: DiscoveredRcFile[] = []; + const globalPathSet = getGlobalPaths(); + const seen = new Set(); + + // Walk up from CWD (closest-first), skip global paths (handled below) + for await (const dir of walkUpFrom(cwd)) { + const rcPath = join(dir, CONFIG_FILENAME); + if (globalPathSet.has(rcPath) || seen.has(rcPath)) { + continue; + } + seen.add(rcPath); + const file = await readRcFile(rcPath, "project-local"); + if (file) { + files.push(file); + } + } + + // Check global fallback paths (config-dir first, then homedir) + for (const globalPath of globalPathSet) { + if (seen.has(globalPath)) { + continue; + } + seen.add(globalPath); + const file = await readRcFile( + globalPath, + classifyRcFileLocation(globalPath) + ); + if (file) { + files.push(file); + } + } + + return files; +} + +// --------------------------------------------------------------------------- +// Trust Gate +// --------------------------------------------------------------------------- + +/** + * Check whether the effective token and URL satisfy the same-file rule. + * + * Trusted when: + * - No URL at all -> SaaS default, no redirect vector + * - Effective URL is SaaS and has no explicit source -> safe regardless + * - Token and URL come from the same file -> co-presence + * + * Not trusted when token and URL come from different files (cross-file + * merge -- URL may have been injected). + */ +export function isSameFileOrigin(plan: ImportPlan): boolean { + if (!plan.effective.url) { + return true; + } + if (isSaaSTrustOrigin(plan.effective.url) && !plan.effectiveSources.url) { + return true; + } + return plan.effectiveSources.token === plan.effectiveSources.url; +} + +// --------------------------------------------------------------------------- +// Plan Building helpers +// --------------------------------------------------------------------------- + +/** Merge files into effective values using closest-wins order */ +function mergeEffectiveValues(files: DiscoveredRcFile[]): { + effective: ImportPlan["effective"]; + effectiveSources: ImportPlan["effectiveSources"]; +} { + const effective: ImportPlan["effective"] = {}; + const effectiveSources: ImportPlan["effectiveSources"] = {}; + + for (const file of files) { + for (const field of ["token", "url", "org", "project"] as const) { + if (effective[field] === undefined && file[field]) { + effective[field] = file[field]; + effectiveSources[field] = file.path; + } + } + } + + // Normalize URL + if (effective.url) { + const normalized = normalizeUrl(effective.url); + if (normalized) { + effective.url = normalizeOrigin(normalized) ?? normalized; + } + } + + return { effective, effectiveSources }; +} + +/** + * Check if a default value is already set, returning false on DB errors. + * Used to determine which fields would be "new" in an import. + */ +function isDefaultSet(getter: () => string | null): boolean { + try { + return getter() !== null; + } catch (error) { + log.debug("Failed to check default value", error); + return false; + } +} + +/** Determine which effective fields would be new (not already in SQLite) */ +function computeNewFields( + effective: ImportPlan["effective"], + isSaas: boolean +): ImportableField[] { + const fields: ImportableField[] = []; + + if (effective.token) { + let hasAuth = false; + try { + hasAuth = hasStoredAuthCredentials(); + } catch (error) { + log.debug("Failed to check stored auth credentials", error); + } + if (!hasAuth) { + fields.push("token"); + } + } + + if (effective.url && !isSaas && !isDefaultSet(getDefaultUrl)) { + fields.push("url"); + } + if (effective.org && !isDefaultSet(getDefaultOrganization)) { + fields.push("org"); + } + if (effective.project && !isDefaultSet(getDefaultProject)) { + fields.push("project"); + } + + return fields; +} + +/** + * Check if a `sntrys_` token's embedded URL claim mismatches a given URL. + * Returns a warning string if there's a mismatch, or undefined if OK. + * + * @internal Exported for use by the import command's `--url` override + */ +export function checkSntrysClaim( + token: string, + url: string +): string | undefined { + const claim = parseSntrysClaim(token); + if (!claim?.url) { + return; + } + const claimOrigin = normalizeOrigin(claim.url); + if (claimOrigin && url !== claimOrigin) { + return ( + `Token's embedded URL claim (${claimOrigin}) doesn't match ` + + `the config URL (${url}).` + ); + } + return; +} + +/** Build security warnings based on provenance analysis */ +function buildSecurityWarnings( + effectiveSources: ImportPlan["effectiveSources"], + effective: ImportPlan["effective"], + isSaas: boolean +): string[] { + const warnings: string[] = []; + + // Cross-file token+URL warning + if ( + effectiveSources.token && + effectiveSources.url && + effectiveSources.token !== effectiveSources.url && + !isSaas + ) { + warnings.push( + "Token and URL come from different files — URL may have been injected.\n" + + ` Token: ${effectiveSources.token}\n` + + ` URL: ${effectiveSources.url}` + ); + } + + // sntrys_ claim mismatch + if (effective.token && effective.url) { + const claimWarning = checkSntrysClaim(effective.token, effective.url); + if (claimWarning) { + warnings.push(claimWarning); + } + } + + return warnings; +} + +// --------------------------------------------------------------------------- +// Plan Building +// --------------------------------------------------------------------------- + +/** + * Build an import plan by merging discovered files and comparing + * against current SQLite state. + * + * Uses closest-wins merge order (matching {@link loadSentryCliRc} behavior): + * project-local files first, then config-dir, then homedir. + */ +export function buildImportPlan(files: DiscoveredRcFile[]): ImportPlan { + const { effective, effectiveSources } = mergeEffectiveValues(files); + const isSaas = effective.url ? isSaaSTrustOrigin(effective.url) : true; + const newFields = computeNewFields(effective, isSaas); + const warnings = buildSecurityWarnings(effectiveSources, effective, isSaas); + + let hasExistingAuth = false; + try { + hasExistingAuth = hasStoredAuthCredentials(); + } catch (error) { + log.debug("Failed to check stored auth credentials for plan", error); + } + + const plan: ImportPlan = { + sources: files, + effective, + effectiveSources, + newFields, + hasExistingAuth, + isSaas, + trusted: true, + warnings, + }; + + plan.trusted = isSameFileOrigin(plan); + return plan; +} + +// --------------------------------------------------------------------------- +// Execution helpers +// --------------------------------------------------------------------------- + +/** + * Check if an error is an authentication failure (invalid/expired token) + * vs a transient network error (DNS, timeout, 5xx) or a permission issue (403). + * + * Only 401 Unauthorized means the token is invalid. 403 Forbidden means the + * token is valid but lacks the required scope — clearing it would destroy a + * working token that's useful for other API operations. + */ +function isAuthFailure(error: unknown): boolean { + if ( + error instanceof Error && + "status" in error && + typeof (error as { status: unknown }).status === "number" + ) { + return (error as { status: number }).status === 401; + } + return false; +} + +/** + * Validate token against the Sentry API and fetch user info. + * + * On auth failure (401): clears the stored token and returns false. + * On transient network/permission error (403, 5xx, DNS, timeout): keeps + * the token stored but warns the user. + */ +async function validateAndFetchUser(result: ImportResult): Promise { + try { + const { getUserRegions } = await import("./api-client.js"); + await getUserRegions(); + result.tokenValid = true; + } catch (error) { + if (isAuthFailure(error)) { + await clearAuth(); + result.stored.token = false; + result.tokenValid = false; + result.warnings.push( + "Token validation failed (invalid credentials) — the token was not stored." + ); + return false; + } + // Transient network error — keep the token, warn the user + log.debug("Token validation failed with transient error", error); + result.warnings.push( + "Could not validate token (network error). Token was stored — run 'sentry auth status' to verify." + ); + return true; + } + + // Fetch and cache user info (best-effort) + try { + const { getCurrentUser } = await import("./api-client.js"); + const user = await getCurrentUser(); + try { + setUserInfo({ + userId: user.id, + email: user.email ?? undefined, + username: user.username ?? undefined, + name: user.name ?? undefined, + }); + } catch (dbError) { + log.debug("Failed to cache user info", dbError); + } + result.user = { + name: user.name ?? undefined, + email: user.email ?? undefined, + username: user.username ?? undefined, + }; + } catch (userError) { + log.debug("Failed to fetch user info", userError); + } + + return true; +} + +/** + * Try to set a default value if not already set. Returns true if stored. + * Catches and logs DB errors (non-fatal). + */ +function trySetDefault( + getter: () => string | null, + setter: (v: string) => void, + value: string, + label: string +): boolean { + try { + if (!getter()) { + setter(value); + return true; + } + } catch (error) { + log.debug(`Failed to store default ${label}`, error); + } + return false; +} + +/** Store default values that are not already set */ +function storeDefaults( + effective: ImportPlan["effective"], + result: ImportResult +): void { + if (effective.url && !isSaaSTrustOrigin(effective.url)) { + result.stored.url = trySetDefault( + getDefaultUrl, + setDefaultUrl, + effective.url, + "URL" + ); + } + if (effective.org) { + result.stored.org = trySetDefault( + getDefaultOrganization, + setDefaultOrganization, + effective.org, + "org" + ); + } + if (effective.project) { + result.stored.project = trySetDefault( + getDefaultProject, + setDefaultProject, + effective.project, + "project" + ); + } +} + +// --------------------------------------------------------------------------- +// Execution +// --------------------------------------------------------------------------- + +/** + * Execute the import: store credentials and defaults in SQLite. + * + * Token validation is optional (the explicit command always validates; + * the auto-prompt validates by default). + */ +export async function executeImport( + plan: ImportPlan, + options: ExecuteImportOptions = {} +): Promise { + const { validateToken = true } = options; + const { effective } = plan; + const result: ImportResult = { + imported: false, + stored: { token: false, url: false, org: false, project: false }, + warnings: [], + }; + + // 1. Store defaults (URL, org, project) before token validation + // so they're persisted even if the token is invalid. + storeDefaults(effective, result); + + // 2. Store token with host scoping (only if token is new — re-check + // hasStoredAuthCredentials to guard against TOCTOU if user acquired + // OAuth credentials during the confirmation prompt) + if (effective.token && plan.newFields.includes("token")) { + let authAcquiredDuringPrompt = false; + try { + authAcquiredDuringPrompt = hasStoredAuthCredentials(); + } catch (error) { + log.debug("Failed to re-check auth state before import", error); + } + + if (!authAcquiredDuringPrompt) { + const host = effective.url ?? DEFAULT_SENTRY_URL; + setAuthToken(effective.token, undefined, undefined, { host }); + result.stored.token = true; + + if (validateToken && !(await validateAndFetchUser(result))) { + // Mark as declined (not completed) to prevent infinite re-prompting. + // Using "declined" because: markImportCompleted + hasStoredAuth()=false + // (auth cleared by validation failure) would re-trigger on next run. + // "Declined" is checked before hasStoredAuth, breaking the loop. + // The user can re-run `sentry cli import` explicitly (which ignores + // the declined flag), or updating the .sentryclirc file will trigger + // hash-change detection on the completed record. + markImportDeclined(plan.sources); + return result; + } + } + } + + // 3. Mark import as completed + markImportCompleted(plan); + + result.imported = true; + return result; +} + +// --------------------------------------------------------------------------- +// Import State Tracking helpers +// --------------------------------------------------------------------------- + +/** Parse a stored import record from JSON. Returns null on parse failure. */ +function parseImportRecord(raw: string): ImportRecord | null { + try { + const parsed = JSON.parse(raw) as ImportRecord; + if (!Array.isArray(parsed?.sources)) { + log.debug("Import record has invalid sources field"); + return null; + } + return parsed; + } catch (error) { + log.debug("Failed to parse import record", error); + return null; + } +} + +/** + * Check if the user has declined the auto-prompt. + * Returns the decline record (for hash verification) or null if not declined. + */ +function getDeclineRecord(): ImportRecord | null { + try { + const db = getDatabase(); + const raw = getMetadata(db, [IMPORT_DECLINED_KEY]).get(IMPORT_DECLINED_KEY); + if (!raw) { + return null; + } + // Support both old format (bare timestamp) and new format (JSON with hashes) + const parsed = parseImportRecord(raw); + if (parsed) { + return parsed; + } + // Legacy bare timestamp — treat as declined with no hash info + return { completedAt: Number(raw) || 0, sources: [] }; + } catch (error) { + log.debug("Failed to check import decline state", error); + return null; + } +} + +/** Get the stored import record, or null if none exists */ +function getImportRecord(): ImportRecord | null { + try { + const db = getDatabase(); + const raw = getMetadata(db, [IMPORT_COMPLETED_KEY]).get( + IMPORT_COMPLETED_KEY + ); + if (!raw) { + return null; + } + return parseImportRecord(raw); + } catch (error) { + log.debug("Failed to read import record", error); + return null; + } +} + +/** Clear the stored decline flag (file hash changed — give user fresh chance) */ +function clearImportDeclineRecord(): void { + try { + const db = getDatabase(); + clearMetadata(db, [IMPORT_DECLINED_KEY]); + } catch (error) { + log.debug("Failed to clear import decline record", error); + } +} + +/** Clear the stored import record (hash mismatch detected) */ +function clearImportRecord(): void { + try { + const db = getDatabase(); + clearMetadata(db, [IMPORT_COMPLETED_KEY]); + } catch (error) { + log.debug("Failed to clear import record", error); + } +} + +// --------------------------------------------------------------------------- +// Import State Tracking +// --------------------------------------------------------------------------- + +/** Verify all source files in a record still match their stored hashes */ +async function verifyRecordHashes(record: ImportRecord): Promise { + for (const source of record.sources) { + if (!(await verifyFileHash(source.path, source.contentHash))) { + return false; + } + } + return true; +} + +/** + * Async check whether an import is needed, with file hash verification. + * + * Returns `false` when: + * - The user declined the auto-prompt (metadata key exists) + * - A previous import completed AND stored auth still exists AND all + * source file hashes still match + * + * Returns `true` when: + * - No import has been done + * - A previous import's source files have changed (hash mismatch) + * - Auth was cleared since the last import (logout, token expiry) + */ +export async function isImportNeededAsync(): Promise { + // Check for a completed import first — if the file changed since import, + // clear BOTH the import record AND the decline flag so the user gets + // re-prompted with the new file content. + const record = getImportRecord(); + if (record) { + if (!(await verifyRecordHashes(record))) { + clearImportRecord(); + clearImportDeclineRecord(); + return true; + } + // If auth was cleared since the import (e.g., logout), re-offer import + if (!hasStoredAuth()) { + return true; + } + return false; + } + + // No import record — check if previously declined + const declineRecord = getDeclineRecord(); + if (declineRecord) { + // If the decline has hashes and the files changed, clear the decline + // so the user gets re-prompted with the new content + if ( + declineRecord.sources.length > 0 && + !(await verifyRecordHashes(declineRecord)) + ) { + clearImportDeclineRecord(); + return true; + } + return false; + } + + return true; +} + +/** + * Check if any stored auth credentials exist, catching DB errors. + * Used to detect logout since last import. + */ +function hasStoredAuth(): boolean { + try { + return hasStoredAuthCredentials(); + } catch (error) { + log.debug("Failed to check auth state for import", error); + return false; + } +} + +/** + * Check if a file's current content matches the expected hash. + * + * Uses {@link tryReadSentryCliRc} for FIFO/socket/device protection — + * `Bun.file().text()` would block indefinitely on non-regular files. + * A null result (file absent, unreadable, or non-regular) is treated + * as a hash mismatch. + */ +async function verifyFileHash( + filePath: string, + expectedHash: string +): Promise { + try { + const content = await tryReadSentryCliRc(filePath); + if (content === null) { + return false; + } + return sha256(content) === expectedHash; + } catch (error) { + log.debug(`Failed to verify hash for ${filePath}`, error); + return false; + } +} + +/** Record a completed import with file content hashes */ +export function markImportCompleted(plan: ImportPlan): void { + const record: ImportRecord = { + completedAt: Date.now(), + sources: plan.sources + .filter((s) => s.token || s.url || s.org || s.project) + .map((s) => ({ path: s.path, contentHash: s.contentHash })), + }; + try { + const db = getDatabase(); + setMetadata(db, { [IMPORT_COMPLETED_KEY]: JSON.stringify(record) }); + } catch (error) { + log.debug("Failed to record import state", error); + } +} + +/** + * Clear any previous decline state so the auto-prompt can fire again. + * Called by the explicit `sentry cli import` command after a successful + * import — not by `markImportCompleted` to avoid clearing a decline + * on no-op imports. + */ +export function clearImportDecline(): void { + try { + const db = getDatabase(); + clearMetadata(db, [IMPORT_DECLINED_KEY]); + } catch (error) { + log.debug("Failed to clear import decline", error); + } +} + +/** + * Record that the user declined the auto-prompt, including file hashes + * so we can re-prompt if the files change. + * + * @param sources - Discovered files at time of decline (for hash tracking) + */ +export function markImportDeclined(sources?: DiscoveredRcFile[]): void { + try { + const record: ImportRecord = { + completedAt: Date.now(), + sources: (sources ?? []) + .filter((s) => s.token || s.url || s.org || s.project) + .map((s) => ({ path: s.path, contentHash: s.contentHash })), + }; + const db = getDatabase(); + setMetadata(db, { + [IMPORT_DECLINED_KEY]: JSON.stringify(record), + }); + } catch (error) { + log.debug("Failed to record import decline", error); + } +} + +// --------------------------------------------------------------------------- +// Display helpers +// --------------------------------------------------------------------------- + +/** + * Mask a token for display: show first 4 and last 4 characters. + * + * @internal Exported for use by the import command's formatter + */ +export function maskToken(token: string): string { + if (token.length <= 12) { + return "*".repeat(token.length); + } + return `${token.slice(0, 4)}${"*".repeat(token.length - 8)}${token.slice(-4)}`; +} + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +/** + * Clear import state (for testing). + * + * @internal Exported for testing only + */ +export function clearImportState(): void { + try { + const db = getDatabase(); + clearMetadata(db, [IMPORT_COMPLETED_KEY, IMPORT_DECLINED_KEY]); + } catch (error) { + log.debug("Failed to clear import state", error); + } +} diff --git a/src/lib/sentryclirc.ts b/src/lib/sentryclirc.ts index 83406f94c..78b46a2cb 100644 --- a/src/lib/sentryclirc.ts +++ b/src/lib/sentryclirc.ts @@ -147,7 +147,9 @@ function isNarrowAbsenceError(error: unknown): boolean { * That broader policy is correct for opportunistic DSN scans; not * for this committed config load. */ -async function tryReadSentryCliRc(filePath: string): Promise { +export async function tryReadSentryCliRc( + filePath: string +): Promise { let statResult: Awaited>; try { statResult = await stat(filePath); @@ -193,8 +195,13 @@ async function tryApplyFile( /** 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 { +/** + * Global paths checked as fallback after the walk-up. + * + * Returns `$SENTRY_CONFIG_DIR/.sentryclirc` and `~/.sentryclirc`. + * Used by the import engine to classify files by location. + */ +export function getGlobalPaths(): Set { if (!globalPaths) { globalPaths = new Set([ join(getConfigDir(), CONFIG_FILENAME), diff --git a/test/lib/sentryclirc-import.property.test.ts b/test/lib/sentryclirc-import.property.test.ts new file mode 100644 index 000000000..7d1e42fe3 --- /dev/null +++ b/test/lib/sentryclirc-import.property.test.ts @@ -0,0 +1,209 @@ +/** + * Property-based tests for the `.sentryclirc` import engine. + * + * Tests core invariants that must hold for any valid input: + * - Same-file rule: co-present token+URL in one file → always trusted + * - Cross-file rule: token and URL from different files (non-SaaS) → never trusted + * - Merge order: closest-wins for project-local, then config-dir, then homedir + * - Hash determinism: same content → same hash + * - maskToken: output always contains last 4 chars + */ + +import { describe, expect, test } from "bun:test"; +import { + array, + constantFrom, + assert as fcAssert, + property, + string, +} from "fast-check"; +import type { + DiscoveredRcFile, + ImportPlan, +} from "../../src/lib/sentryclirc-import.js"; +import { + buildImportPlan, + isSameFileOrigin, + maskToken, +} from "../../src/lib/sentryclirc-import.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +// --------------------------------------------------------------------------- +// Arbitraries +// --------------------------------------------------------------------------- + +const slugArb = array( + constantFrom(..."abcdefghijklmnopqrstuvwxyz0123456789-".split("")), + { minLength: 1, maxLength: 12 } +).map((chars) => chars.join("")); + +const tokenArb = string({ minLength: 8, maxLength: 40 }); + +const nonSaasUrlArb = slugArb.map((slug) => `https://${slug}.example.com`); + +const filePathArb = slugArb.map((slug) => `/${slug}/.sentryclirc`); + +// --------------------------------------------------------------------------- +// Same-File Rule +// --------------------------------------------------------------------------- + +describe("property: isSameFileOrigin", () => { + test("single file with both token and non-SaaS URL → always trusted", () => { + fcAssert( + property(filePathArb, tokenArb, nonSaasUrlArb, (path, token, url) => { + const plan: ImportPlan = { + sources: [], + effective: { token, url }, + effectiveSources: { token: path, url: path }, + newFields: [], + hasExistingAuth: false, + isSaas: false, + trusted: true, + warnings: [], + }; + expect(isSameFileOrigin(plan)).toBe(true); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("token and URL from different files (non-SaaS) → never trusted", () => { + fcAssert( + property( + filePathArb, + filePathArb, + tokenArb, + nonSaasUrlArb, + (pathA, pathB, token, url) => { + // Ensure paths are actually different + if (pathA === pathB) { + return; + } + const plan: ImportPlan = { + sources: [], + effective: { token, url }, + effectiveSources: { token: pathA, url: pathB }, + newFields: [], + hasExistingAuth: false, + isSaas: false, + trusted: true, + warnings: [], + }; + expect(isSameFileOrigin(plan)).toBe(false); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("token only (no URL) → always trusted", () => { + fcAssert( + property(filePathArb, tokenArb, (path, token) => { + const plan: ImportPlan = { + sources: [], + effective: { token }, + effectiveSources: { token: path }, + newFields: [], + hasExistingAuth: false, + isSaas: true, + trusted: true, + warnings: [], + }; + expect(isSameFileOrigin(plan)).toBe(true); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +// --------------------------------------------------------------------------- +// maskToken +// --------------------------------------------------------------------------- + +describe("property: maskToken", () => { + test("output always contains at least one asterisk", () => { + fcAssert( + property(string({ minLength: 1, maxLength: 100 }), (token) => { + const masked = maskToken(token); + expect(masked).toContain("*"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output never equals the original input", () => { + fcAssert( + property(string({ minLength: 1, maxLength: 100 }), (token) => { + const masked = maskToken(token); + expect(masked).not.toBe(token); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("short tokens (<=12) are fully masked", () => { + fcAssert( + property(string({ minLength: 1, maxLength: 12 }), (token) => { + const masked = maskToken(token); + // Every character should be an asterisk + expect(masked).toBe("*".repeat(token.length)); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +// --------------------------------------------------------------------------- +// buildImportPlan merge order +// --------------------------------------------------------------------------- + +describe("property: buildImportPlan merge order", () => { + test("first file's values win over later files", () => { + fcAssert( + property(slugArb, slugArb, (orgA, orgB) => { + // Ensure different values to test precedence + if (orgA === orgB) { + return; + } + const fileA: DiscoveredRcFile = { + path: "/a/.sentryclirc", + location: "project-local", + contentHash: "aaa", + org: orgA, + }; + const fileB: DiscoveredRcFile = { + path: "/b/.sentryclirc", + location: "homedir", + contentHash: "bbb", + org: orgB, + }; + const plan = buildImportPlan([fileA, fileB]); + expect(plan.effective.org).toBe(orgA); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("later file fills gaps from earlier file", () => { + fcAssert( + property(slugArb, tokenArb, (org, token) => { + const fileA: DiscoveredRcFile = { + path: "/a/.sentryclirc", + location: "project-local", + contentHash: "aaa", + org, + }; + const fileB: DiscoveredRcFile = { + path: "/b/.sentryclirc", + location: "homedir", + contentHash: "bbb", + token, + }; + const plan = buildImportPlan([fileA, fileB]); + expect(plan.effective.org).toBe(org); + expect(plan.effective.token).toBe(token); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/test/lib/sentryclirc-import.test.ts b/test/lib/sentryclirc-import.test.ts new file mode 100644 index 000000000..f9b732724 --- /dev/null +++ b/test/lib/sentryclirc-import.test.ts @@ -0,0 +1,843 @@ +/** + * Unit tests for the `.sentryclirc` import engine. + * + * Core invariants (same-file rule, hash verification, merge order) are tested + * via property-based tests in sentryclirc-import.property.test.ts. These tests + * focus on specific scenarios, edge cases, and SQLite integration. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { + getAuthToken, + resetAuthTokenCache, + setAuthToken, +} from "../../src/lib/db/auth.js"; +import { + getDefaultOrganization, + getDefaultProject, + getDefaultUrl, + setDefaultOrganization, + setDefaultProject, + setDefaultUrl, +} from "../../src/lib/db/defaults.js"; +import { clearSentryCliRcCache } from "../../src/lib/sentryclirc.js"; +import type { + DiscoveredRcFile, + ImportPlan, +} from "../../src/lib/sentryclirc-import.js"; +import { + buildImportPlan, + classifyRcFileLocation, + clearImportState, + discoverRcFiles, + executeImport, + isImportNeededAsync, + isSameFileOrigin, + markImportCompleted, + markImportDeclined, + maskToken, +} from "../../src/lib/sentryclirc-import.js"; +import { useTestConfigDir } from "../helpers.js"; + +const getConfigDir = useTestConfigDir("import-test-"); + +beforeEach(() => { + clearSentryCliRcCache(); +}); + +afterEach(() => { + clearSentryCliRcCache(); +}); + +// --------------------------------------------------------------------------- +// classifyRcFileLocation +// --------------------------------------------------------------------------- + +describe("classifyRcFileLocation", () => { + test("homedir path returns 'homedir'", () => { + const path = join(homedir(), ".sentryclirc"); + expect(classifyRcFileLocation(path)).toBe("homedir"); + }); + + test("config-dir path returns 'config-dir'", () => { + const path = join(getConfigDir(), ".sentryclirc"); + expect(classifyRcFileLocation(path)).toBe("config-dir"); + }); + + test("arbitrary path returns 'project-local'", () => { + expect(classifyRcFileLocation("/tmp/some/project/.sentryclirc")).toBe( + "project-local" + ); + }); +}); + +// --------------------------------------------------------------------------- +// isSameFileOrigin +// --------------------------------------------------------------------------- + +describe("isSameFileOrigin", () => { + function makePlan(overrides: Partial = {}): ImportPlan { + return { + sources: [], + effective: {}, + effectiveSources: {}, + newFields: [], + hasExistingAuth: false, + isSaas: true, + trusted: true, + warnings: [], + ...overrides, + }; + } + + test("no URL → trusted", () => { + const plan = makePlan({ effective: { token: "tok" } }); + expect(isSameFileOrigin(plan)).toBe(true); + }); + + test("token and URL from same file → trusted", () => { + const plan = makePlan({ + effective: { token: "tok", url: "https://sentry.example.com" }, + effectiveSources: { token: "/a/.sentryclirc", url: "/a/.sentryclirc" }, + isSaas: false, + }); + expect(isSameFileOrigin(plan)).toBe(true); + }); + + test("token and URL from different files → not trusted", () => { + const plan = makePlan({ + effective: { token: "tok", url: "https://sentry.example.com" }, + effectiveSources: { token: "/a/.sentryclirc", url: "/b/.sentryclirc" }, + isSaas: false, + }); + expect(isSameFileOrigin(plan)).toBe(false); + }); + + test("SaaS URL without explicit source → trusted", () => { + const plan = makePlan({ + effective: { token: "tok", url: "https://sentry.io" }, + effectiveSources: { token: "/a/.sentryclirc" }, + }); + expect(isSameFileOrigin(plan)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// buildImportPlan +// --------------------------------------------------------------------------- + +describe("buildImportPlan", () => { + function makeFile( + overrides: Partial = {} + ): DiscoveredRcFile { + return { + path: "/test/.sentryclirc", + location: "homedir", + contentHash: "abc123", + ...overrides, + }; + } + + test("single file with token+URL is trusted", () => { + const file = makeFile({ + token: "test-token", + url: "https://sentry.example.com", + }); + const plan = buildImportPlan([file]); + expect(plan.trusted).toBe(true); + expect(plan.effective.token).toBe("test-token"); + expect(plan.warnings).toHaveLength(0); + }); + + test("token only, no URL → trusted (SaaS default)", () => { + const file = makeFile({ token: "test-token" }); + const plan = buildImportPlan([file]); + expect(plan.trusted).toBe(true); + expect(plan.isSaas).toBe(true); + }); + + test("cross-file token+URL with non-SaaS → not trusted + warning", () => { + const fileA = makeFile({ + path: "/a/.sentryclirc", + token: "test-token", + }); + const fileB = makeFile({ + path: "/b/.sentryclirc", + url: "https://sentry.example.com", + }); + const plan = buildImportPlan([fileA, fileB]); + expect(plan.trusted).toBe(false); + expect(plan.warnings.length).toBeGreaterThan(0); + expect(plan.warnings[0]).toContain("different files"); + }); + + test("closest-wins merge order", () => { + const close = makeFile({ + path: "/a/b/.sentryclirc", + org: "close-org", + project: "close-proj", + }); + const far = makeFile({ + path: "/a/.sentryclirc", + org: "far-org", + token: "far-token", + }); + const plan = buildImportPlan([close, far]); + expect(plan.effective.org).toBe("close-org"); + expect(plan.effective.project).toBe("close-proj"); + expect(plan.effective.token).toBe("far-token"); + }); + + test("newFields detects what would be new", () => { + const file = makeFile({ + token: "test-token", + org: "my-org", + project: "my-proj", + }); + const plan = buildImportPlan([file]); + expect(plan.newFields).toContain("token"); + expect(plan.newFields).toContain("org"); + expect(plan.newFields).toContain("project"); + }); + + test("newFields excludes already-set defaults", () => { + setDefaultOrganization("existing-org"); + const file = makeFile({ + token: "test-token", + org: "my-org", + }); + const plan = buildImportPlan([file]); + expect(plan.newFields).toContain("token"); + expect(plan.newFields).not.toContain("org"); + }); + + test("hasExistingAuth true when stored credentials exist", () => { + setAuthToken("existing-token", undefined, undefined, { + host: "https://sentry.io", + }); + const file = makeFile({ token: "new-token" }); + const plan = buildImportPlan([file]); + expect(plan.hasExistingAuth).toBe(true); + // Token already stored — should NOT be in newFields + expect(plan.newFields).not.toContain("token"); + }); +}); + +// --------------------------------------------------------------------------- +// executeImport +// --------------------------------------------------------------------------- + +describe("executeImport", () => { + function makePlan(overrides: Partial = {}): ImportPlan { + return { + sources: [ + { + path: "/test/.sentryclirc", + location: "homedir", + contentHash: "abc", + token: "test-token", + }, + ], + effective: { token: "test-token" }, + effectiveSources: { token: "/test/.sentryclirc" }, + newFields: ["token"], + hasExistingAuth: false, + isSaas: true, + trusted: true, + warnings: [], + ...overrides, + }; + } + + test("stores token with SaaS host by default", async () => { + const plan = makePlan(); + const result = await executeImport(plan, { validateToken: false }); + expect(result.imported).toBe(true); + expect(result.stored.token).toBe(true); + }); + + test("stores token with custom host", async () => { + const plan = makePlan({ + effective: { + token: "test-token", + url: "https://sentry.example.com", + }, + }); + const result = await executeImport(plan, { validateToken: false }); + expect(result.imported).toBe(true); + expect(result.stored.token).toBe(true); + }); + + test("stores org/project defaults when not already set", async () => { + const plan = makePlan({ + effective: { + token: "test-token", + org: "my-org", + project: "my-proj", + }, + }); + const result = await executeImport(plan, { validateToken: false }); + expect(result.stored.org).toBe(true); + expect(result.stored.project).toBe(true); + expect(getDefaultOrganization()).toBe("my-org"); + expect(getDefaultProject()).toBe("my-proj"); + }); + + test("does not overwrite existing defaults", async () => { + setDefaultOrganization("existing-org"); + const plan = makePlan({ + effective: { token: "test-token", org: "new-org" }, + }); + const result = await executeImport(plan, { validateToken: false }); + expect(result.stored.org).toBe(false); + expect(getDefaultOrganization()).toBe("existing-org"); + }); + + test("stores non-SaaS URL default", async () => { + const plan = makePlan({ + effective: { + token: "test-token", + url: "https://sentry.example.com", + }, + isSaas: false, + }); + const result = await executeImport(plan, { validateToken: false }); + expect(result.stored.url).toBe(true); + expect(getDefaultUrl()).toBe("https://sentry.example.com"); + }); + + test("does not store SaaS URL as default", async () => { + const plan = makePlan({ + effective: { token: "test-token", url: "https://sentry.io" }, + isSaas: true, + }); + const result = await executeImport(plan, { validateToken: false }); + expect(result.stored.url).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Import state tracking +// --------------------------------------------------------------------------- + +describe("import state tracking", () => { + test("isImportNeededAsync returns true initially", async () => { + expect(await isImportNeededAsync()).toBe(true); + }); + + test("isImportNeededAsync returns false after completion", async () => { + const configDir = getConfigDir(); + const rcPath = join(configDir, ".sentryclirc"); + writeFileSync(rcPath, "[auth]\ntoken = test-token\n"); + + // Simulate a real import: store auth + mark completed + setAuthToken("test-token", undefined, undefined, { + host: "https://sentry.io", + }); + resetAuthTokenCache(); + + const files = await discoverRcFiles(configDir); + const plan = buildImportPlan(files); + markImportCompleted(plan); + + clearSentryCliRcCache(); + expect(await isImportNeededAsync()).toBe(false); + }); + + test("isImportNeededAsync returns false after decline", async () => { + markImportDeclined(); + expect(await isImportNeededAsync()).toBe(false); + }); + + test("isImportNeededAsync returns true after file mutation", async () => { + const configDir = getConfigDir(); + const rcPath = join(configDir, ".sentryclirc"); + writeFileSync(rcPath, "[auth]\ntoken = test-token\n"); + + // Simulate a real import + setAuthToken("test-token", undefined, undefined, { + host: "https://sentry.io", + }); + resetAuthTokenCache(); + + const files = await discoverRcFiles(configDir); + const plan = buildImportPlan(files); + markImportCompleted(plan); + + // Mutate the file + writeFileSync(rcPath, "[auth]\ntoken = different-token\n"); + clearSentryCliRcCache(); + + expect(await isImportNeededAsync()).toBe(true); + }); + + test("markImportCompleted clears previous decline", async () => { + markImportDeclined(); + expect(await isImportNeededAsync()).toBe(false); + + const configDir = getConfigDir(); + const rcPath = join(configDir, ".sentryclirc"); + writeFileSync(rcPath, "[auth]\ntoken = test-token\n"); + + const files = await discoverRcFiles(configDir); + const plan = buildImportPlan(files); + markImportCompleted(plan); + clearSentryCliRcCache(); + + // After marking completed, the decline is cleared + clearImportState(); + expect(await isImportNeededAsync()).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// discoverRcFiles +// --------------------------------------------------------------------------- + +describe("discoverRcFiles", () => { + test("finds .sentryclirc in config dir", async () => { + const configDir = getConfigDir(); + const rcPath = join(configDir, ".sentryclirc"); + writeFileSync( + rcPath, + "[auth]\ntoken = test-token\n\n[defaults]\norg = my-org\n" + ); + + const files = await discoverRcFiles(configDir); + expect(files.length).toBeGreaterThanOrEqual(1); + const file = files.find((f) => f.path === rcPath); + expect(file).toBeDefined(); + expect(file?.token).toBe("test-token"); + expect(file?.org).toBe("my-org"); + expect(file?.location).toBe("config-dir"); + expect(file?.contentHash).toHaveLength(64); // SHA-256 hex + }); + + test("finds project-local .sentryclirc via walk-up", async () => { + const configDir = getConfigDir(); + const projectDir = join(configDir, "project", "sub"); + mkdirSync(projectDir, { recursive: true }); + const rcPath = join(configDir, "project", ".sentryclirc"); + writeFileSync(rcPath, "[defaults]\nproject = my-proj\n"); + + const files = await discoverRcFiles(projectDir); + const file = files.find((f) => f.path === rcPath); + expect(file).toBeDefined(); + expect(file?.project).toBe("my-proj"); + expect(file?.location).toBe("project-local"); + }); + + test("returns empty array when no files exist", async () => { + const configDir = getConfigDir(); + const emptyDir = join(configDir, "empty"); + mkdirSync(emptyDir, { recursive: true }); + // Create a .git marker to stop walk-up early + mkdirSync(join(emptyDir, ".git")); + + const files = await discoverRcFiles(emptyDir); + // May find global files — filter to just this dir + const local = files.filter((f) => f.path.startsWith(emptyDir)); + expect(local).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// maskToken +// --------------------------------------------------------------------------- + +describe("maskToken", () => { + test("short token is fully masked", () => { + expect(maskToken("abcdef")).toBe("******"); + }); + + test("12-char token is fully masked", () => { + expect(maskToken("abcdefghijkl")).toBe("************"); + }); + + test("13-char token shows first 4 + last 4", () => { + // 13 chars: 13-8=5 stars → "abcd*****ijklm" + const result = maskToken("abcdefghijklm"); + expect(result.startsWith("abcd")).toBe(true); + expect(result.endsWith("jklm")).toBe(true); + expect(result.length).toBe(13); + }); + + test("long token shows first 4 + last 4", () => { + expect(maskToken("abcdefghijklmnop")).toBe("abcd********mnop"); + }); + + test("very short token is fully masked", () => { + expect(maskToken("ab")).toBe("**"); + }); + + test("masked output never reveals full token", () => { + // Tokens <= 12 chars are fully masked + expect(maskToken("secret")).not.toContain("secret"); + expect(maskToken("123456789ab")).not.toContain("123456789ab"); + // Longer tokens show partial but not full + expect(maskToken("my-secret-token-value")).not.toBe( + "my-secret-token-value" + ); + }); +}); + +// --------------------------------------------------------------------------- +// executeImport — token guarding (C1 fix) +// --------------------------------------------------------------------------- + +describe("executeImport — token guard", () => { + function makePlan(overrides: Partial = {}): ImportPlan { + return { + sources: [ + { + path: "/test/.sentryclirc", + location: "homedir", + contentHash: "abc", + token: "test-token", + }, + ], + effective: { token: "test-token" }, + effectiveSources: { token: "/test/.sentryclirc" }, + newFields: ["token"], + hasExistingAuth: false, + isSaas: true, + trusted: true, + warnings: [], + ...overrides, + }; + } + + test("does not overwrite existing stored auth when token not in newFields", async () => { + // Store existing auth + setAuthToken("existing-oauth-token", 3600, "refresh-tok", { + host: "https://sentry.io", + }); + resetAuthTokenCache(); + + // Import plan has token but NOT in newFields (existing auth detected) + const plan = makePlan({ + newFields: [], // token excluded because auth already exists + hasExistingAuth: true, + }); + const result = await executeImport(plan, { validateToken: false }); + + // Token should NOT have been overwritten + expect(result.stored.token).toBe(false); + resetAuthTokenCache(); + expect(getAuthToken()).toBe("existing-oauth-token"); + }); + + test("stores token when it IS in newFields", async () => { + const plan = makePlan({ newFields: ["token"] }); + const result = await executeImport(plan, { validateToken: false }); + expect(result.stored.token).toBe(true); + resetAuthTokenCache(); + expect(getAuthToken()).toBe("test-token"); + }); + + test("does not store anything when effective has no token", async () => { + const plan = makePlan({ + effective: { org: "my-org" }, + newFields: ["org"], + }); + const result = await executeImport(plan, { validateToken: false }); + expect(result.stored.token).toBe(false); + expect(result.stored.org).toBe(true); + expect(result.imported).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// buildImportPlan — URL normalization and sntrys_ warnings +// --------------------------------------------------------------------------- + +describe("buildImportPlan — URL handling", () => { + function makeFile( + overrides: Partial = {} + ): DiscoveredRcFile { + return { + path: "/test/.sentryclirc", + location: "homedir", + contentHash: "abc123", + ...overrides, + }; + } + + test("normalizes URL to origin", () => { + const file = makeFile({ + token: "test-token", + url: "https://sentry.example.com/path/to/something", + }); + const plan = buildImportPlan([file]); + expect(plan.effective.url).toBe("https://sentry.example.com"); + }); + + test("handles bare hostname URL", () => { + const file = makeFile({ + token: "test-token", + url: "sentry.example.com", + }); + const plan = buildImportPlan([file]); + expect(plan.effective.url).toBe("https://sentry.example.com"); + }); + + test("non-SaaS URL adds url to newFields", () => { + const file = makeFile({ + token: "test-token", + url: "https://sentry.example.com", + }); + const plan = buildImportPlan([file]); + expect(plan.newFields).toContain("url"); + expect(plan.isSaas).toBe(false); + }); + + test("SaaS URL does NOT add url to newFields", () => { + const file = makeFile({ + token: "test-token", + url: "https://sentry.io", + }); + const plan = buildImportPlan([file]); + expect(plan.newFields).not.toContain("url"); + expect(plan.isSaas).toBe(true); + }); + + test("url not in newFields when default already set", () => { + setDefaultUrl("https://existing.example.com"); + const file = makeFile({ + token: "test-token", + url: "https://sentry.example.com", + }); + const plan = buildImportPlan([file]); + expect(plan.newFields).not.toContain("url"); + }); + + test("empty effective fields produce empty newFields", () => { + const file = makeFile({}); // no token, url, org, project + const plan = buildImportPlan([file]); + expect(plan.newFields).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// executeImport — defaults edge cases +// --------------------------------------------------------------------------- + +describe("executeImport — defaults edge cases", () => { + function makePlan(overrides: Partial = {}): ImportPlan { + return { + sources: [ + { + path: "/test/.sentryclirc", + location: "homedir", + contentHash: "abc", + }, + ], + effective: {}, + effectiveSources: {}, + newFields: [], + hasExistingAuth: false, + isSaas: true, + trusted: true, + warnings: [], + ...overrides, + }; + } + + test("does not overwrite existing URL default", async () => { + setDefaultUrl("https://existing.example.com"); + const plan = makePlan({ + effective: { url: "https://new.example.com" }, + isSaas: false, + }); + const result = await executeImport(plan, { validateToken: false }); + expect(result.stored.url).toBe(false); + expect(getDefaultUrl()).toBe("https://existing.example.com"); + }); + + test("does not overwrite existing project default", async () => { + setDefaultProject("existing-proj"); + const plan = makePlan({ + effective: { project: "new-proj" }, + newFields: [], + }); + const result = await executeImport(plan, { validateToken: false }); + expect(result.stored.project).toBe(false); + expect(getDefaultProject()).toBe("existing-proj"); + }); + + test("marks import completed even with defaults only", async () => { + // Store auth so isImportNeededAsync doesn't short-circuit + setAuthToken("existing-token", undefined, undefined, { + host: "https://sentry.io", + }); + resetAuthTokenCache(); + + const plan = makePlan({ + effective: { org: "my-org", project: "my-proj" }, + newFields: ["org", "project"], + }); + const result = await executeImport(plan, { validateToken: false }); + expect(result.imported).toBe(true); + expect(result.stored.org).toBe(true); + expect(result.stored.project).toBe(true); + expect(await isImportNeededAsync()).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Import state tracking — edge cases +// --------------------------------------------------------------------------- + +describe("import state tracking — edge cases", () => { + test("isImportNeededAsync returns true when source file deleted", async () => { + const configDir = getConfigDir(); + const rcPath = join(configDir, ".sentryclirc"); + writeFileSync(rcPath, "[auth]\ntoken = test-token\n"); + + // Store auth so hasStoredAuth() returns true (simulating a real import) + setAuthToken("test-token", undefined, undefined, { + host: "https://sentry.io", + }); + resetAuthTokenCache(); + + const files = await discoverRcFiles(configDir); + const plan = buildImportPlan(files); + markImportCompleted(plan); + clearSentryCliRcCache(); + + // Verify it's initially not needed (auth + hashes match) + expect(await isImportNeededAsync()).toBe(false); + + // Delete the file + const { unlinkSync } = await import("node:fs"); + unlinkSync(rcPath); + + // Now the hash check fails → import needed again + expect(await isImportNeededAsync()).toBe(true); + }); + + test("clearImportState resets everything", async () => { + markImportDeclined(); + expect(await isImportNeededAsync()).toBe(false); + clearImportState(); + expect(await isImportNeededAsync()).toBe(true); + }); + + test("isImportNeededAsync returns true after logout (auth cleared)", async () => { + const configDir = getConfigDir(); + const rcPath = join(configDir, ".sentryclirc"); + writeFileSync(rcPath, "[auth]\ntoken = test-token\n"); + + // Simulate a real import + setAuthToken("test-token", undefined, undefined, { + host: "https://sentry.io", + }); + resetAuthTokenCache(); + + const files = await discoverRcFiles(configDir); + const plan = buildImportPlan(files); + markImportCompleted(plan); + clearSentryCliRcCache(); + + // Initially not needed + expect(await isImportNeededAsync()).toBe(false); + + // Simulate logout — clear auth without clearing import record + const { clearAuth } = await import("../../src/lib/db/auth.js"); + await clearAuth(); + resetAuthTokenCache(); + + // Now import is needed again (auth gone, .sentryclirc still has token) + expect(await isImportNeededAsync()).toBe(true); + }); + + test("import with no contributing sources stores empty sources array", () => { + const plan: ImportPlan = { + sources: [ + { + path: "/test/.sentryclirc", + location: "homedir", + contentHash: "abc", + // No token, url, org, or project + }, + ], + effective: {}, + effectiveSources: {}, + newFields: [], + hasExistingAuth: false, + isSaas: true, + trusted: true, + warnings: [], + }; + // Should not throw + markImportCompleted(plan); + }); +}); + +// --------------------------------------------------------------------------- +// discoverRcFiles — additional scenarios +// --------------------------------------------------------------------------- + +describe("discoverRcFiles — additional", () => { + test("discovers files with all four fields", async () => { + const configDir = getConfigDir(); + const rcPath = join(configDir, ".sentryclirc"); + writeFileSync( + rcPath, + "[auth]\ntoken = my-token\n\n[defaults]\nurl = https://sentry.example.com\norg = my-org\nproject = my-proj\n" + ); + + const files = await discoverRcFiles(configDir); + const file = files.find((f) => f.path === rcPath); + expect(file).toBeDefined(); + expect(file?.token).toBe("my-token"); + expect(file?.url).toBe("https://sentry.example.com"); + expect(file?.org).toBe("my-org"); + expect(file?.project).toBe("my-proj"); + }); + + test("skips files with only comments/empty sections", async () => { + const configDir = getConfigDir(); + const rcPath = join(configDir, ".sentryclirc"); + writeFileSync(rcPath, "# just a comment\n[defaults]\n; nothing here\n"); + + const files = await discoverRcFiles(configDir); + const file = files.find((f) => f.path === rcPath); + // File is found but has no fields + if (file) { + expect(file.token).toBeUndefined(); + expect(file.url).toBeUndefined(); + expect(file.org).toBeUndefined(); + expect(file.project).toBeUndefined(); + } + }); + + test("walk-up merges project-local and global files", async () => { + const configDir = getConfigDir(); + const projectDir = join(configDir, "myproject"); + mkdirSync(projectDir, { recursive: true }); + + // Project-local file has project + writeFileSync( + join(projectDir, ".sentryclirc"), + "[defaults]\nproject = local-proj\n" + ); + // Global file has token + org + writeFileSync( + join(configDir, ".sentryclirc"), + "[auth]\ntoken = global-token\n\n[defaults]\norg = global-org\n" + ); + + const files = await discoverRcFiles(projectDir); + expect(files.length).toBeGreaterThanOrEqual(2); + + // Build a plan to verify merge + const plan = buildImportPlan(files); + expect(plan.effective.project).toBe("local-proj"); + expect(plan.effective.token).toBe("global-token"); + expect(plan.effective.org).toBe("global-org"); + }); +});