From f7eb51b5b8cdaf32fde06c2314f6afd9bfdc588f Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 17 Mar 2026 15:31:51 +0000 Subject: [PATCH] feat: add project delete command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `sentry project delete` subcommand for permanently deleting Sentry projects via the API. Safety measures: - No auto-detect mode — requires explicit org/project target - Type-out confirmation (user types `org/project` like GitHub) - --yes/-y and --force/-f flags to skip confirmation (for CI/agents) - --dry-run/-n to validate without deleting - Non-interactive TTY guard (refuses without --yes/--force) - Role-aware 403 error messages (never suggests reauth) - Verifies project exists before prompting Also adds: - `project:admin` to OAuth SCOPES (required for deletion API) - `orgRole` display in `sentry org view` output - `deleteProject()` in API layer Addresses all review comments from PR #397: 1. Accept -f/--force alongside --yes 2. Type-out org/project confirmation instead of yes/no 3. Note about cached org data (no cache exists yet) 4. Fix misleading 403 token scope messages 5. Show user role in org view 6. Remove sentry auth login suggestions from 403 errors 7. Remove obsolete json: true from output config Co-authored-by: MathurAditya724 --- AGENTS.md | 70 ++-- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 11 + src/commands/project/delete.ts | 276 +++++++++++++++ src/commands/project/index.ts | 2 + src/lib/api-client.ts | 1 + src/lib/api/organizations.ts | 3 + src/lib/api/projects.ts | 25 ++ src/lib/db/regions.ts | 39 ++- src/lib/db/schema.ts | 8 +- src/lib/formatters/human.ts | 50 +++ src/lib/oauth.ts | 1 + test/commands/project/delete.test.ts | 322 ++++++++++++++++++ test/isolated/project-delete-confirm.test.ts | 221 ++++++++++++ 13 files changed, 976 insertions(+), 53 deletions(-) create mode 100644 src/commands/project/delete.ts create mode 100644 test/commands/project/delete.test.ts create mode 100644 test/isolated/project-delete-confirm.test.ts diff --git a/AGENTS.md b/AGENTS.md index 0644a2871..022325e49 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -767,69 +767,39 @@ mock.module("./some-module", () => ({ ### Architecture - -* **api-client.ts split into domain modules under src/lib/api/**: The original monolithic \`src/lib/api-client.ts\` (1,977 lines) was split into 12 focused domain modules under \`src/lib/api/\`: infrastructure.ts (shared helpers, types, raw requests), organizations.ts, projects.ts, teams.ts, repositories.ts, issues.ts, events.ts, traces.ts, logs.ts, seer.ts, trials.ts, users.ts. The original \`api-client.ts\` was converted to a ~100-line barrel re-export file preserving all existing import paths. The \`biome.jsonc\` override for \`noBarrelFile\` already includes \`api-client.ts\`. When adding new API functions, place them in the appropriate domain module under \`src/lib/api/\`, not in the barrel file. + +* **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. - -* **CLI telemetry DSN is public write-only — safe to embed in install script**: The CLI's Sentry DSN (\`SENTRY\_CLI\_DSN\` in \`src/lib/constants.ts\`) is a public write-only ingest key already baked into every binary. Safe to hardcode in install scripts. Opt-out: \`SENTRY\_CLI\_NO\_TELEMETRY=1\`. + +* **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. - -* **cli.sentry.dev is served from gh-pages branch via GitHub Pages**: \`cli.sentry.dev\` is served from gh-pages branch via GitHub Pages. Craft's gh-pages target runs \`git rm -r -f .\` before extracting docs — persist extra files via \`postReleaseCommand\` in \`.craft.yml\`. Install script supports \`--channel nightly\`, downloading from the \`nightly\` release tag directly. version.json is only used by upgrade/version-check flow. + +* **Input validation layer: src/lib/input-validation.ts guards CLI arg parsing**: Four validators in \`src/lib/input-validation.ts\` guard against agent-hallucinated inputs: \`rejectControlChars\` (ASCII < 0x20), \`rejectPreEncoded\` (%XX), \`validateResourceId\` (rejects ?, #, %, whitespace), \`validateEndpoint\` (rejects \`..\` traversal). Applied in \`parseSlashOrgProject\`, bare-slug path in \`parseOrgProjectArg\`, \`parseIssueArg\`, and \`normalizeEndpoint\` (api.ts). NOT applied in \`parseSlashSeparatedArg\` for no-slash plain IDs — those may contain structural separators (newlines for log view batch IDs) that callers split downstream. Validation targets user-facing parse boundaries only; env vars and DB cache values are trusted. - -* **Nightly delta upgrade buildNightlyPatchGraph fetches ALL patch tags — O(N) HTTP calls**: Delta upgrade in \`src/lib/delta-upgrade.ts\` supports stable (GitHub Releases) and nightly (GHCR) channels. \`filterAndSortChainTags\` filters \`patch-\*\` tags by version range using \`Bun.semver.order()\`. GHCR uses \`fetchWithRetry\` (10s timeout + 1 retry; blobs 30s) with optional \`signal?: AbortSignal\` combined via \`AbortSignal.any()\`. \`isExternalAbort(error, signal)\` skips retries for external aborts — critical for background prefetch. Patches cached to \`~/.sentry/patch-cache/\` (file-based, 7-day TTL). \`loadCachedChain\` stitches patches for multi-hop offline upgrades. - - -* **npm bundle requires Node.js >= 22 due to node:sqlite polyfill**: The npm package (dist/bin.cjs) requires Node.js >= 22 because the bun:sqlite polyfill uses \`node:sqlite\`. A runtime version guard in the esbuild banner catches this early. When writing esbuild banner strings in TS template literals, double-escape: \`\\\\\\\n\` in TS → \`\\\n\` in output → newline at runtime. Single \`\\\n\` produces a literal newline inside a JS string, causing SyntaxError. - - -* **Numeric issue ID resolution returns org:undefined despite API success**: Numeric issue ID resolution in \`resolveNumericIssue()\`: (1) try DSN/env/config for org, (2) if found use \`getIssueInOrg(org, id)\` with region routing, (3) else fall back to unscoped \`getIssue(id)\`, (4) extract org from \`issue.permalink\` via \`parseSentryUrl\` as final fallback. \`parseSentryUrl\` handles path-based (\`/organizations/{org}/...\`) and subdomain-style URLs. \`matchSubdomainOrg()\` filters region subdomains by requiring slug length > 2. Self-hosted uses path-based only. - - -* **Seer trial prompt uses middleware layering in bin.ts error handling chain**: The CLI's error recovery middlewares in \`bin.ts\` are layered: \`main() → executeWithAutoAuth() → executeWithSeerTrialPrompt() → runCommand()\`. Seer trial prompts (for \`no\_budget\`/\`not\_enabled\` errors) are caught by the inner wrapper; auth errors bubble up to the outer wrapper. After successful auth login retry, the retry also goes through \`executeWithSeerTrialPrompt\` (not \`runCommand\` directly) so the full middleware chain applies. Trial check API: \`GET /api/0/customers/{org}/\` → \`productTrials\[]\` (prefer \`seerUsers\`, fallback \`seerAutofix\`). Start trial: \`PUT /api/0/customers/{org}/product-trial/\`. The \`/customers/\` endpoint is getsentry SaaS-only; self-hosted 404s gracefully. \`ai\_disabled\` errors are excluded (admin's explicit choice). \`startSeerTrial\` accepts \`category\` from the trial object — don't hardcode it. + +* **Magic @ selectors resolve issues dynamically via sort-based list API queries**: Magic \`@\` selectors (\`@latest\`, \`@most\_frequent\`) in \`parseIssueArg\` are detected early (before \`validateResourceId\`) because \`@\` is not in the forbidden charset. \`SELECTOR\_MAP\` provides case-insensitive matching with common variations (\`@mostfrequent\`, \`@most-frequent\`). Resolution in \`resolveSelector\` (issue/utils.ts) maps selectors to \`IssueSort\` values (\`date\`, \`freq\`), calls \`listIssuesPaginated\` with \`perPage: 1\` and \`query: 'is:unresolved'\`. Supports org-prefixed form: \`sentry/@latest\`. Unrecognized \`@\`-prefixed strings fall through to suffix-only parsing (not an error). The \`ParsedIssueArg\` union includes \`{ type: 'selector'; selector: IssueSelector; org?: string }\`. ### Decision - -* **Raw markdown output for non-interactive terminals, rendered for TTY**: Markdown-first output pipeline: custom renderer in \`src/lib/formatters/markdown.ts\` walks \`marked\` tokens to produce ANSI-styled output. Commands build CommonMark using helpers (\`mdKvTable()\`, \`mdRow()\`, \`colorTag()\`, \`escapeMarkdownCell()\`, \`safeCodeSpan()\`) and pass through \`renderMarkdown()\`. \`isPlainOutput()\` precedence: \`SENTRY\_PLAIN\_OUTPUT\` > \`NO\_COLOR\` > \`FORCE\_COLOR\` > \`!isTTY\`. \`--json\` always outputs JSON. Colors defined in \`COLORS\` object in \`colors.ts\`. Tests run non-TTY so assertions match raw CommonMark; use \`stripAnsi()\` helper for rendered-mode assertions. - - -* **whoami should be separate from auth status command**: The \`sentry auth whoami\` command should be a dedicated command separate from \`sentry auth status\`. They serve different purposes: \`status\` shows everything about auth state (token, expiry, defaults, org verification), while \`whoami\` just shows user identity (name, email, username, ID) by fetching live from \`/auth/\` endpoint. \`sentry whoami\` should be a top-level alias (like \`sentry issues\` → \`sentry issue list\`). \`whoami\` should support \`--json\` for machine consumption and be lightweight — no credential verification, no defaults listing. + +* **All view subcommands should use \ \ positional pattern**: All \`\* view\` subcommands should follow a consistent \`\ \\` positional argument pattern where target is the optional \`org/project\` specifier. During migration, use opportunistic argument swapping with a stderr warning when args are in wrong order. This is an instance of the broader CLI UX auto-correction pattern: safe when input is already invalid, correction is unambiguous, warning goes to stderr. Normalize at command level, keep parsers pure. Model after \`gh\` CLI conventions. ### Gotcha - -* **@sentry/api SDK passes Request object to custom fetch — headers lost on Node.js**: @sentry/api SDK calls \`\_fetch(request)\` with no init object. In \`authenticatedFetch\`, \`init\` is undefined so \`prepareHeaders\` creates empty headers — on Node.js this strips Content-Type (HTTP 415). Fix: fall back to \`input.headers\` when \`init\` is undefined. Use \`unwrapPaginatedResult\` (not \`unwrapResult\`) to access the Response's Link header for pagination. \`per\_page\` is not in SDK types; cast query to pass it at runtime. - - -* **Bun binary build requires SENTRY\_CLIENT\_ID env var**: The build script (\`script/bundle.ts\`) requires \`SENTRY\_CLIENT\_ID\` environment variable and exits with code 1 if missing. When building locally, use \`bun run --env-file=.env.local build\` or set the env var explicitly. The binary build (\`bun run build\`) also needs it. Without it you get: \`Error: SENTRY\_CLIENT\_ID environment variable is required.\` + +* **Dot-notation field filtering is ambiguous for keys containing dots**: The \`filterFields\` function in \`src/lib/formatters/json.ts\` uses dot-notation to address nested fields (e.g., \`metadata.value\`). This means object keys that literally contain dots are ambiguous and cannot be addressed. Property-based tests for this function must generate field name arbitraries that exclude dots — use a restricted charset like \`\[a-zA-Z0-9\_]\` in fast-check arbitraries. Counterexample found by fast-check: \`{"a":{".":false}}\` with path \`"a."\` splits into \`\["a", ""]\` and fails to resolve. - -* **GitHub immutable releases prevent rolling nightly tag pattern**: getsentry/cli has immutable GitHub releases — assets can't be modified and tags can NEVER be reused. Nightly builds publish to GHCR with versioned tags like \`nightly-0.14.0-dev.1772661724\`, not GitHub Releases or npm. \`fetchManifest()\` throws \`UpgradeError("network\_error")\` for both network failures and non-200 — callers must check message for HTTP 404/403. Craft with no \`preReleaseCommand\` silently skips \`bump-version.sh\` if only target is \`github\`. - - -* **Install script: BSD sed and awk JSON parsing breaks OCI digest extraction**: The install script parses OCI manifests with awk (no jq). Key trap: BSD sed \`\n\` is literal, not newline. Fix: single awk pass tracking last-seen \`"digest"\`, printing when \`"org.opencontainers.image.title"\` matches target. The config digest (\`sha256:44136fa...\`) is a 2-byte \`{}\` blob — downloading it instead of the real binary causes \`gunzip: unexpected end of file\`. - - -* **Multiple mockFetch calls replace each other — use unified mocks for multi-endpoint tests**: Bun test mocking gotchas: (1) \`mockFetch()\` replaces \`globalThis.fetch\` — calling it twice replaces the first mock. Use a single unified fetch mock dispatching by URL pattern. (2) \`mock.module()\` pollutes the module registry for ALL subsequent test files. Tests using it must live in \`test/isolated/\` and run via \`test:isolated\`. This also causes \`delta-upgrade.test.ts\` to fail when run alongside \`test/isolated/delta-upgrade.test.ts\` — the isolated test's \`mock.module()\` replaces \`CLI\_VERSION\` for all subsequent files. (3) For \`Bun.spawn\`, use direct property assignment in \`beforeEach\`/\`afterEach\`. - - -* **useTestConfigDir without isolateProjectRoot causes DSN scanning of repo tree**: \`useTestConfigDir()\` creates temp dirs under \`.test-tmp/\` in the repo tree. Without \`{ isolateProjectRoot: true }\`, \`findProjectRoot\` walks up and finds the repo's \`.git\`, causing DSN detection to scan real source code and trigger network calls against test mocks (timeouts). Always pass \`isolateProjectRoot: true\` when tests exercise \`resolveOrg\`, \`detectDsn\`, or \`findProjectRoot\`. + +* **Stricli rejects unknown flags — pre-parsed global flags must be consumed from argv**: Stricli's arg parser is strict: any \`--flag\` not registered on a command throws \`No flag registered for --flag\`. Global flags (parsed before Stricli in bin.ts) MUST be spliced out of argv. \`--log-level\` was correctly consumed but \`--verbose\` was intentionally left in (for the \`api\` command's own \`--verbose\`). This breaks every other command. Also, \`argv.indexOf('--flag')\` doesn't match \`--flag=value\` form — must check both space-separated and equals-sign forms when pre-parsing. A Biome \`noRestrictedImports\` lint rule in \`biome.jsonc\` now blocks \`import { buildCommand } from "@stricli/core"\` at error level — only \`src/lib/command.ts\` is exempted. Other \`@stricli/core\` exports (\`buildRouteMap\`, \`run\`, etc.) are allowed. ### Pattern - -* **Org-scoped SDK calls follow getOrgSdkConfig + unwrapResult pattern**: All org-scoped API calls in src/lib/api-client.ts: (1) call \`getOrgSdkConfig(orgSlug)\` for regional URL + SDK config, (2) spread into SDK function: \`{ ...config, path: { organization\_id\_or\_slug: orgSlug, ... } }\`, (3) pass to \`unwrapResult(result, errorContext)\`. Shared helpers \`resolveAllTargets\`/\`resolveOrgAndProject\` must NOT call \`fetchProjectId\` — commands that need it enrich targets themselves. - - -* **PR workflow: wait for Seer and Cursor BugBot before resolving**: After pushing a PR in the getsentry/cli repo, the CI pipeline includes Seer Code Review and Cursor Bugbot as advisory checks. Both typically take 2-3 minutes but may not trigger on draft PRs — only ready-for-review PRs reliably get bot reviews. The workflow is: push → wait for all CI (including npm build jobs which test the actual bundle) → check for inline review comments from Seer/BugBot → fix if needed → repeat. Use \`gh pr checks \ --watch\` to monitor. Review comments are fetched via \`gh api repos/OWNER/REPO/pulls/NUM/comments\` and \`gh api repos/OWNER/REPO/pulls/NUM/reviews\`. - - -* **Shared pagination infrastructure: buildPaginationContextKey and parseCursorFlag**: List commands with cursor pagination use \`buildPaginationContextKey(type, identifier, flags)\` for composite context keys and \`parseCursorFlag(value)\` accepting \`"last"\` magic value. Critical: \`resolveCursor()\` must be called inside the \`org-all\` override closure, not before \`dispatchOrgScopedList\` — otherwise cursor validation errors fire before the correct mode-specific error. + +* **Property-based tests for input validators use stringMatching for forbidden char coverage**: In \`test/lib/input-validation.property.test.ts\`, forbidden-character arbitraries are built with \`stringMatching\` targeting specific regex patterns (e.g., \`/^\[^\x00-\x1f]\*\[\x00-\x1f]\[^\x00-\x1f]\*$/\` for control chars). This ensures fast-check generates strings that always contain the forbidden character while varying surrounding content. The \`biome-ignore lint/suspicious/noControlCharactersInRegex\` suppression is needed on the control char regex constant in \`input-validation.ts\`. - -* **Telemetry instrumentation pattern: withTracingSpan + captureException for handled errors**: For graceful-fallback operations, use \`withTracingSpan\` from \`src/lib/telemetry.ts\` for child spans and \`captureException\` from \`@sentry/bun\` (named import — Biome forbids namespace imports) with \`level: 'warning'\` for non-fatal errors. \`withTracingSpan\` uses \`onlyIfParent: true\` — no-op without active transaction. User-visible fallbacks use \`log.warn()\` not \`log.debug()\`. Several commands bypass telemetry by importing \`buildCommand\` from \`@stricli/core\` directly instead of \`../../lib/command.js\` (trace/list, trace/view, log/view, api.ts, help.ts). + +* **Shared flag constants in list-command.ts for cross-command consistency**: \`src/lib/list-command.ts\` exports shared Stricli flag definitions (\`FIELDS\_FLAG\`, \`FRESH\_FLAG\`, \`FRESH\_ALIASES\`) reused across all commands. When adding a new global-ish flag to multiple commands, define it once here as a const satisfying Stricli's flag shape, then spread into each command's \`flags\` object. The \`--fields\` flag is \`{ kind: 'parsed', parse: String, brief: '...', optional: true }\`. \`parseFieldsList()\` in \`formatters/json.ts\` handles comma-separated parsing with trim/dedup. \`writeJson()\` accepts an optional \`fields\` array and calls \`filterFields()\` before serialization. - -* **Testing Stricli command func() bodies via spyOn mocking**: To unit-test a Stricli command's \`func()\` body: (1) \`const func = await cmd.loader()\`, (2) \`func.call(mockContext, flags, ...args)\` with mock \`stdout\`, \`stderr\`, \`cwd\`, \`setContext\`. (3) \`spyOn\` namespace imports to mock dependencies (e.g., \`spyOn(apiClient, 'getLogs')\`). The \`loader()\` return type union causes \`.call()\` LSP errors — these are false positives that pass \`tsc --noEmit\`. When API functions are renamed (e.g., \`getLog\` → \`getLogs\`), update both spy target name AND mock return shape (single → array). Slug normalization (\`normalizeSlug\`) replaces underscores with dashes but does NOT lowercase — test assertions must match original casing (e.g., \`'CAM-82X'\` not \`'cam-82x'\`). + +* **SKILL.md generator must filter hidden Stricli flags**: \`script/generate-skill.ts\` introspects Stricli's route tree to auto-generate \`plugins/sentry-cli/skills/sentry-cli/SKILL.md\`. The \`FlagDef\` type must include \`hidden?: boolean\` and \`extractFlags\` must propagate it to \`FlagInfo\`. The filter in \`generateCommandDoc\` must exclude \`f.hidden\` alongside \`help\`/\`helpAll\`. Without this, hidden flags injected by \`buildCommand\` (like \`--log-level\`, \`--verbose\`) appear on every command in the AI agent skill file. Global flags should instead be documented once in \`docs/src/content/docs/commands/index.md\` Global Options section, which the generator pulls into SKILL.md via \`loadCommandsOverview\`. diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 449618f03..e74c0193c 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -180,6 +180,17 @@ Create a new project - `--json - Output as JSON` - `--fields - Comma-separated fields to include in JSON output (dot.notation supported)` +#### `sentry project delete ` + +Delete a project + +**Flags:** +- `-y, --yes - Skip confirmation prompt` +- `-f, --force - Force deletion without confirmation` +- `-n, --dry-run - Validate and show what would be deleted without deleting` +- `--json - Output as JSON` +- `--fields - Comma-separated fields to include in JSON output (dot.notation supported)` + #### `sentry project list ` List projects diff --git a/src/commands/project/delete.ts b/src/commands/project/delete.ts new file mode 100644 index 000000000..d18971abb --- /dev/null +++ b/src/commands/project/delete.ts @@ -0,0 +1,276 @@ +/** + * sentry project delete + * + * Permanently delete a Sentry project. + * + * ## Flow + * + * 1. Parse target arg → extract org/project (e.g., "acme/my-app" or "my-app") + * 2. Verify the project exists via `getProject` (also displays its name) + * 3. Prompt for confirmation by typing `org/project` (unless --yes is passed) + * 4. Call `deleteProject` API + * 5. Display result + * + * Safety measures: + * - No auto-detect mode: requires explicit target to prevent accidental deletion + * - Type-out confirmation: user must type the full `org/project` slug + * - Strict cancellation check (Symbol(clack:cancel) gotcha) + * - Refuses to run in non-interactive mode without --yes flag + */ + +import { isatty } from "node:tty"; +import type { SentryContext } from "../../context.js"; +import { + deleteProject, + getOrganization, + getProject, +} from "../../lib/api-client.js"; +import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; +import { buildCommand } from "../../lib/command.js"; +import { getCachedOrgRole } from "../../lib/db/regions.js"; +import { ApiError, CliError, ContextError } from "../../lib/errors.js"; +import { + formatProjectDeleted, + type ProjectDeleteResult, +} from "../../lib/formatters/human.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; +import { logger } from "../../lib/logger.js"; +import { resolveOrgProjectTarget } from "../../lib/resolve-target.js"; +import { buildProjectUrl } from "../../lib/sentry-urls.js"; + +const log = logger.withTag("project.delete"); + +/** Command name used in error messages and resolution hints */ +const COMMAND_NAME = "project delete"; + +/** + * Prompt for confirmation before deleting a project. + * + * Uses a type-out confirmation where the user must type the full + * `org/project` slug — similar to GitHub's deletion confirmation. + * + * Throws in non-interactive mode without --yes. Returns true if + * the typed input matches, false otherwise. + * + * @param orgSlug - Organization slug for display and matching + * @param project - Project with slug and name for display and matching + * @returns true if confirmed, false if cancelled or mismatched + */ +async function confirmDeletion( + orgSlug: string, + project: { slug: string; name: string } +): Promise { + const expected = `${orgSlug}/${project.slug}`; + + if (!isatty(0)) { + throw new CliError( + `Refusing to delete '${expected}' in non-interactive mode. ` + + "Use --yes or --force to confirm." + ); + } + + const response = await log.prompt( + `Type '${expected}' to permanently delete project '${project.name}':`, + { type: "text", placeholder: expected } + ); + + // consola prompt returns Symbol(clack:cancel) on Ctrl+C — a truthy value. + // Check type to avoid treating cancel as a valid response. + if (typeof response !== "string") { + return false; + } + + return response.trim() === expected; +} + +/** + * Build an actionable 403 error by checking the user's org role. + * + * - member/billing → tell them they need a higher role + * - manager/owner/admin → suggest checking token scope + * - unknown/fetch failure → generic message covering both cases + * + * Never suggests `sentry auth login` — re-authenticating via OAuth won't + * change permissions. The issue is either an insufficient org role or + * a custom auth token missing the `project:admin` scope. + */ +async function buildPermissionError( + orgSlug: string, + projectSlug: string +): Promise { + const label = `'${orgSlug}/${projectSlug}'`; + const rolesWithAccess = "Manager, Owner, or Admin"; + + // Try the org cache first (populated by listOrganizations), then fall back + // to a fresh API call. The cache avoids an extra HTTP round-trip when the + // org listing has already been fetched during this session. + let orgRole = await getCachedOrgRole(orgSlug); + if (!orgRole) { + try { + const org = await getOrganization(orgSlug); + orgRole = (org as Record).orgRole as string | undefined; + } catch { + // Fall through to generic message + } + } + + if (orgRole && ["member", "billing"].includes(orgRole)) { + return new ApiError( + `Permission denied: cannot delete ${label}.\n\n` + + `Your organization role is '${orgRole}'. ` + + `Project deletion requires a ${rolesWithAccess} role.\n` + + " Contact an org admin to change your role or delete the project for you.", + 403 + ); + } + + if (orgRole && ["manager", "owner", "admin"].includes(orgRole)) { + return new ApiError( + `Permission denied: cannot delete ${label}.\n\n` + + `Your org role ('${orgRole}') should have permission. ` + + "If using a custom auth token, ensure it includes the 'project:admin' scope.", + 403 + ); + } + + return new ApiError( + `Permission denied: cannot delete ${label}.\n\n` + + `This requires a ${rolesWithAccess} role, or a token with the 'project:admin' scope.\n` + + ` Check your role: sentry org view ${orgSlug}`, + 403 + ); +} + +/** Build a result object for both dry-run and actual deletion */ +function buildResult( + orgSlug: string, + project: { slug: string; name: string }, + dryRun?: boolean +): ProjectDeleteResult { + return { + orgSlug, + projectSlug: project.slug, + projectName: project.name, + url: buildProjectUrl(orgSlug, project.slug), + dryRun, + }; +} + +type DeleteFlags = { + readonly yes: boolean; + readonly force: boolean; + readonly "dry-run": boolean; + readonly json: boolean; + readonly fields?: string[]; +}; + +export const deleteCommand = buildCommand({ + docs: { + brief: "Delete a project", + fullDescription: + "Permanently delete a Sentry project. This action cannot be undone.\n\n" + + "Requires explicit target — auto-detection is disabled for safety.\n\n" + + "Examples:\n" + + " sentry project delete acme-corp/my-app\n" + + " sentry project delete my-app\n" + + " sentry project delete acme-corp/my-app --yes\n" + + " sentry project delete acme-corp/my-app --force\n" + + " sentry project delete acme-corp/my-app --dry-run", + }, + output: { + human: formatProjectDeleted, + jsonTransform: (result: ProjectDeleteResult) => { + if (result.dryRun) { + return { + dryRun: true, + org: result.orgSlug, + project: result.projectSlug, + name: result.projectName, + url: result.url, + }; + } + return { + deleted: true, + org: result.orgSlug, + project: result.projectSlug, + }; + }, + }, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + placeholder: "org/project", + brief: "/ or (search across orgs)", + parse: String, + }, + ], + }, + flags: { + yes: { + kind: "boolean", + brief: "Skip confirmation prompt", + default: false, + }, + force: { + kind: "boolean", + brief: "Force deletion without confirmation", + default: false, + }, + "dry-run": { + kind: "boolean", + brief: "Validate and show what would be deleted without deleting", + default: false, + }, + }, + aliases: { y: "yes", f: "force", n: "dry-run" }, + }, + async *func(this: SentryContext, flags: DeleteFlags, target: string) { + const { cwd } = this; + + // Block auto-detect for safety — destructive commands require explicit targets + const parsed = parseOrgProjectArg(target); + if (parsed.type === "auto-detect") { + throw new ContextError( + "Project target", + `sentry ${COMMAND_NAME} /`, + [ + "Auto-detection is disabled for delete — specify the target explicitly", + ] + ); + } + + const { org: orgSlug, project: projectSlug } = + await resolveOrgProjectTarget(parsed, cwd, COMMAND_NAME); + + // Verify project exists before prompting — also used to display the project name + const project = await getProject(orgSlug, projectSlug); + + // Dry-run mode: show what would be deleted without deleting it + if (flags["dry-run"]) { + yield new CommandOutput(buildResult(orgSlug, project, true)); + return; + } + + // Confirmation gate + if (!(flags.yes || flags.force)) { + const confirmed = await confirmDeletion(orgSlug, project); + if (!confirmed) { + log.info("Cancelled."); + return; + } + } + + try { + await deleteProject(orgSlug, project.slug); + } catch (error) { + if (error instanceof ApiError && error.status === 403) { + throw await buildPermissionError(orgSlug, project.slug); + } + throw error; + } + + yield new CommandOutput(buildResult(orgSlug, project)); + }, +}); diff --git a/src/commands/project/index.ts b/src/commands/project/index.ts index 0ff178a8a..5186b9fa3 100644 --- a/src/commands/project/index.ts +++ b/src/commands/project/index.ts @@ -1,11 +1,13 @@ import { buildRouteMap } from "@stricli/core"; import { createCommand } from "./create.js"; +import { deleteCommand } from "./delete.js"; import { listCommand } from "./list.js"; import { viewCommand } from "./view.js"; export const projectRoute = buildRouteMap({ routes: { create: createCommand, + delete: deleteCommand, list: listCommand, view: viewCommand, }, diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index ca4d99b4c..0b6bb7fac 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -65,6 +65,7 @@ export { } from "./api/organizations.js"; export { createProject, + deleteProject, findProjectByDsnKey, findProjectsByPattern, findProjectsBySlug, diff --git a/src/lib/api/organizations.ts b/src/lib/api/organizations.ts index 5fa6ecb2d..0f3f50de3 100644 --- a/src/lib/api/organizations.ts +++ b/src/lib/api/organizations.ts @@ -84,6 +84,7 @@ export async function listOrganizations(): Promise { id: org.id, slug: org.slug, name: org.name, + ...(org.orgRole ? { orgRole: org.orgRole } : {}), })); } @@ -119,6 +120,7 @@ export async function listOrganizationsUncached(): Promise< regionUrl: baseUrl, orgId: org.id, orgName: org.name, + orgRole: (org as Record).orgRole as string | undefined, })) ); return orgs; @@ -146,6 +148,7 @@ export async function listOrganizationsUncached(): Promise< regionUrl: r.regionUrl, orgId: r.org.id, orgName: r.org.name, + orgRole: (r.org as Record).orgRole as string | undefined, })); await setOrgRegions(regionEntries); diff --git a/src/lib/api/projects.ts b/src/lib/api/projects.ts index e97715da0..6db4af3bb 100644 --- a/src/lib/api/projects.ts +++ b/src/lib/api/projects.ts @@ -6,6 +6,7 @@ import { createANewProject, + deleteAProject, listAnOrganization_sProjects, listAProject_sClientKeys, retrieveAProject, @@ -152,6 +153,30 @@ export async function createProject( return data as unknown as SentryProject; } +/** + * Delete a project from an organization. + * + * Sends a DELETE request to the Sentry API. Returns 204 No Content on success. + * + * @param orgSlug - The organization slug + * @param projectSlug - The project slug to delete + * @throws {ApiError} 403 if the user lacks permission, 404 if the project doesn't exist + */ +export async function deleteProject( + orgSlug: string, + projectSlug: string +): Promise { + const config = await getOrgSdkConfig(orgSlug); + const result = await deleteAProject({ + ...config, + path: { + organization_id_or_slug: orgSlug, + project_id_or_slug: projectSlug, + }, + }); + unwrapResult(result, "Failed to delete project"); +} + /** Result of searching for projects by slug across all organizations. */ export type ProjectSearchResult = { /** Matching projects with their org context */ diff --git a/src/lib/db/regions.ts b/src/lib/db/regions.ts index 6e28bd323..e63cc2e98 100644 --- a/src/lib/db/regions.ts +++ b/src/lib/db/regions.ts @@ -32,6 +32,7 @@ type OrgRegionRow = { org_slug: string; org_id: string | null; org_name: string | null; + org_role: string | null; region_url: string; updated_at: number; }; @@ -42,6 +43,8 @@ export type OrgRegionEntry = { regionUrl: string; orgId?: string; orgName?: string; + /** The authenticated user's role in this organization (e.g., "member", "admin", "owner"). */ + orgRole?: string; }; /** @@ -137,6 +140,9 @@ export async function setOrgRegions(entries: OrgRegionEntry[]): Promise { if (entry.orgName) { row.org_name = entry.orgName; } + if (entry.orgRole) { + row.org_role = entry.orgRole; + } runUpsert(db, TABLE, row, ["org_slug"]); } })(); @@ -171,6 +177,8 @@ export type CachedOrg = { slug: string; id: string; name: string; + /** The authenticated user's role in this organization, if available. */ + orgRole?: string; }; /** @@ -203,14 +211,41 @@ export async function getCachedOrganizations(): Promise { const cutoff = Date.now() - ORG_CACHE_TTL_MS; const rows = db .query( - `SELECT org_slug, org_id, org_name FROM ${TABLE} WHERE org_id IS NOT NULL AND org_name IS NOT NULL AND updated_at > ?` + `SELECT org_slug, org_id, org_name, org_role FROM ${TABLE} WHERE org_id IS NOT NULL AND org_name IS NOT NULL AND updated_at > ?` ) - .all(cutoff) as Pick[]; + .all(cutoff) as Pick< + OrgRegionRow, + "org_slug" | "org_id" | "org_name" | "org_role" + >[]; return rows.map((row) => ({ slug: row.org_slug, // org_id and org_name are guaranteed non-null by the WHERE clause id: row.org_id as string, name: row.org_name as string, + ...(row.org_role ? { orgRole: row.org_role } : {}), })); } + +/** + * Get the cached org role for a single organization. + * + * Returns the user's role from the org cache without an API call. + * The role is populated when `listOrganizations()` fetches from the API. + * + * @param orgSlug - The organization slug + * @returns The user's role (e.g., "member", "admin", "owner"), or undefined if not cached + */ +export async function getCachedOrgRole( + orgSlug: string +): Promise { + const db = getDatabase(); + const cutoff = Date.now() - ORG_CACHE_TTL_MS; + const row = db + .query( + `SELECT org_role FROM ${TABLE} WHERE org_slug = ? AND org_role IS NOT NULL AND updated_at > ?` + ) + .get(orgSlug, cutoff) as Pick | undefined; + + return row?.org_role ?? undefined; +} diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index d33a63296..2839a229e 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -15,7 +15,7 @@ import type { Database } from "bun:sqlite"; import { stringifyUnknown } from "../errors.js"; import { logger } from "../logger.js"; -export const CURRENT_SCHEMA_VERSION = 9; +export const CURRENT_SCHEMA_VERSION = 10; /** Environment variable to disable auto-repair */ const NO_AUTO_REPAIR_ENV = "SENTRY_CLI_NO_AUTO_REPAIR"; @@ -167,6 +167,7 @@ export const TABLE_SCHEMAS: Record = { org_slug: { type: "TEXT", primaryKey: true }, org_id: { type: "TEXT", addedInVersion: 8 }, org_name: { type: "TEXT", addedInVersion: 9 }, + org_role: { type: "TEXT", addedInVersion: 10 }, region_url: { type: "TEXT", notNull: true }, updated_at: { type: "INTEGER", @@ -727,6 +728,11 @@ export function runMigrations(db: Database): void { addColumnIfMissing(db, "org_regions", "org_name", "TEXT"); } + // Migration 9 -> 10: Add org_role column to org_regions for cached role lookups + if (currentVersion < 10) { + addColumnIfMissing(db, "org_regions", "org_role", "TEXT"); + } + if (currentVersion < CURRENT_SCHEMA_VERSION) { db.query("UPDATE schema_version SET version = ?").run( CURRENT_SCHEMA_VERSION diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index 1df26ce3e..43a554b6a 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -1447,6 +1447,13 @@ export function formatOrgDetails(org: SentryOrganization): string { } kvRows.push(["2FA", org.require2FA ? "Required" : "Not required"]); kvRows.push(["Early Adopter", org.isEarlyAdopter ? "Yes" : "No"]); + // orgRole is returned by the detail API but not yet typed in the SDK + const orgRole = (org as Record).orgRole as + | string + | undefined; + if (orgRole) { + kvRows.push(["Your Role", orgRole]); + } lines.push(mdKvTable(kvRows)); @@ -1888,6 +1895,49 @@ export function formatProjectCreated(result: ProjectCreatedResult): string { return renderMarkdown(lines.join("\n")); } +// Project Deletion Formatting + +/** + * Result of a project deletion (or dry-run). + * + * Contains the minimum context needed for both human and JSON output. + * When `dryRun` is true, no deletion occurred — output uses tentative wording. + */ +export type ProjectDeleteResult = { + /** Organization slug */ + orgSlug: string; + /** Project slug */ + projectSlug: string; + /** Human-readable project name */ + projectName: string; + /** Sentry web URL for the project */ + url: string; + /** When true, nothing was actually deleted — output uses tentative wording */ + dryRun?: boolean; +}; + +/** + * Format a project deletion result as rendered markdown. + * + * @param result - Deletion context + * @returns Rendered terminal string + */ +export function formatProjectDeleted(result: ProjectDeleteResult): string { + const nameEsc = escapeMarkdownInline(result.projectName); + const qualifiedSlug = `${result.orgSlug}/${result.projectSlug}`; + + if (result.dryRun) { + return renderMarkdown( + `Would delete project '${nameEsc}' (${safeCodeSpan(qualifiedSlug)}).\n\n` + + `URL: ${result.url}` + ); + } + + return renderMarkdown( + `Deleted project '${nameEsc}' (${safeCodeSpan(qualifiedSlug)}).` + ); +} + // CLI Fix Formatting /** Structured fix result (imported from the command module) */ diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts index 808d20300..5fb24c12b 100644 --- a/src/lib/oauth.ts +++ b/src/lib/oauth.ts @@ -52,6 +52,7 @@ function getClientId(): string { const SCOPES = [ "project:read", "project:write", + "project:admin", "org:read", "event:read", "event:write", diff --git a/test/commands/project/delete.test.ts b/test/commands/project/delete.test.ts new file mode 100644 index 000000000..e7349aaca --- /dev/null +++ b/test/commands/project/delete.test.ts @@ -0,0 +1,322 @@ +/** + * Project Delete Command Tests + * + * Tests for the project delete command in src/commands/project/delete.ts. + * Uses spyOn to mock api-client and resolve-target to test + * the func() body without real HTTP calls or database access. + * + * Note: Interactive prompt (type-out confirmation) tests live in + * test/isolated/project-delete-confirm.test.ts because they require + * mock.module() to override node:tty and the logger module. + */ + +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import { deleteCommand } from "../../../src/commands/project/delete.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as apiClient from "../../../src/lib/api-client.js"; +import { ApiError, ContextError } from "../../../src/lib/errors.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as resolveTarget from "../../../src/lib/resolve-target.js"; +import type { SentryProject } from "../../../src/types/index.js"; + +const sampleProject: SentryProject = { + id: "999", + slug: "my-app", + name: "My App", + platform: "python", + dateCreated: "2026-02-12T10:00:00Z", +}; + +/** Default flags for confirmed deletion (skips prompt) */ +const defaultFlags = { yes: true, force: false, "dry-run": false }; + +function createMockContext() { + const stdoutWrite = mock(() => true); + const stderrWrite = mock(() => true); + return { + context: { + stdout: { write: stdoutWrite }, + stderr: { write: stderrWrite }, + cwd: "/tmp", + // biome-ignore lint/suspicious/noEmptyBlockStatements: no-op mock + setContext: mock(() => {}), + }, + stdoutWrite, + stderrWrite, + }; +} + +describe("project delete", () => { + let getProjectSpy: ReturnType; + let deleteProjectSpy: ReturnType; + let getOrganizationSpy: ReturnType; + let resolveOrgProjectTargetSpy: ReturnType; + + beforeEach(() => { + getProjectSpy = spyOn(apiClient, "getProject"); + deleteProjectSpy = spyOn(apiClient, "deleteProject"); + getOrganizationSpy = spyOn(apiClient, "getOrganization"); + resolveOrgProjectTargetSpy = spyOn( + resolveTarget, + "resolveOrgProjectTarget" + ); + + // Default mocks + getProjectSpy.mockResolvedValue(sampleProject); + deleteProjectSpy.mockResolvedValue(undefined); + getOrganizationSpy.mockResolvedValue({ + id: "1", + slug: "acme-corp", + name: "Acme Corp", + }); + resolveOrgProjectTargetSpy.mockResolvedValue({ + org: "acme-corp", + project: "my-app", + }); + }); + + afterEach(() => { + getProjectSpy.mockRestore(); + deleteProjectSpy.mockRestore(); + getOrganizationSpy.mockRestore(); + resolveOrgProjectTargetSpy.mockRestore(); + }); + + test("deletes project with explicit org/project and --yes", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await deleteCommand.loader(); + await func.call(context, defaultFlags, "acme-corp/my-app"); + + expect(getProjectSpy).toHaveBeenCalledWith("acme-corp", "my-app"); + expect(deleteProjectSpy).toHaveBeenCalledWith("acme-corp", "my-app"); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("Deleted project"); + expect(output).toContain("My App"); + expect(output).toContain("acme-corp/my-app"); + }); + + test("delegates to resolveOrgProjectTarget for resolution", async () => { + const { context } = createMockContext(); + const func = await deleteCommand.loader(); + await func.call(context, defaultFlags, "acme-corp/my-app"); + + expect(resolveOrgProjectTargetSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: "explicit", + org: "acme-corp", + project: "my-app", + }), + "/tmp", + "project delete" + ); + }); + + test("resolves bare slug via resolveOrgProjectTarget", async () => { + const { context } = createMockContext(); + const func = await deleteCommand.loader(); + await func.call(context, defaultFlags, "my-app"); + + expect(resolveOrgProjectTargetSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: "project-search", + projectSlug: "my-app", + }), + "/tmp", + "project delete" + ); + expect(deleteProjectSpy).toHaveBeenCalledWith("acme-corp", "my-app"); + }); + + test("errors in non-interactive mode without --yes", async () => { + const { context } = createMockContext(); + const func = await deleteCommand.loader(); + + // isatty(0) returns false in test environments (non-TTY) + await expect( + func.call(context, { ...defaultFlags, yes: false }, "acme-corp/my-app") + ).rejects.toThrow("non-interactive mode"); + + expect(deleteProjectSpy).not.toHaveBeenCalled(); + }); + + test("propagates 404 from getProject", async () => { + getProjectSpy.mockRejectedValue( + new ApiError("Not found", 404, "Project not found") + ); + + const { context } = createMockContext(); + const func = await deleteCommand.loader(); + + await expect( + func.call(context, defaultFlags, "acme-corp/my-app") + ).rejects.toThrow(ApiError); + + expect(deleteProjectSpy).not.toHaveBeenCalled(); + }); + + test("403 with member role suggests asking an admin", async () => { + deleteProjectSpy.mockRejectedValue( + new ApiError("Forbidden", 403, "You do not have permission") + ); + getOrganizationSpy.mockResolvedValue({ + id: "1", + slug: "acme-corp", + name: "Acme Corp", + orgRole: "member", + }); + + const { context } = createMockContext(); + const func = await deleteCommand.loader(); + + try { + await func.call(context, defaultFlags, "acme-corp/my-app"); + expect.unreachable("should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + const apiErr = error as ApiError; + expect(apiErr.status).toBe(403); + expect(apiErr.message).toContain("role is 'member'"); + expect(apiErr.message).toContain("Contact an org admin"); + expect(apiErr.message).not.toContain("sentry auth login"); + } + }); + + test("403 with owner role suggests checking token scope", async () => { + deleteProjectSpy.mockRejectedValue( + new ApiError("Forbidden", 403, "You do not have permission") + ); + getOrganizationSpy.mockResolvedValue({ + id: "1", + slug: "acme-corp", + name: "Acme Corp", + orgRole: "owner", + }); + + const { context } = createMockContext(); + const func = await deleteCommand.loader(); + + try { + await func.call(context, defaultFlags, "acme-corp/my-app"); + expect.unreachable("should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + const apiErr = error as ApiError; + expect(apiErr.status).toBe(403); + expect(apiErr.message).toContain("('owner') should have permission"); + expect(apiErr.message).toContain("project:admin"); + expect(apiErr.message).not.toContain("sentry auth login"); + } + }); + + test("403 with role fetch failure shows fallback message", async () => { + deleteProjectSpy.mockRejectedValue( + new ApiError("Forbidden", 403, "You do not have permission") + ); + getOrganizationSpy.mockRejectedValue(new Error("network error")); + + const { context } = createMockContext(); + const func = await deleteCommand.loader(); + + try { + await func.call(context, defaultFlags, "acme-corp/my-app"); + expect.unreachable("should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ApiError); + const apiErr = error as ApiError; + expect(apiErr.status).toBe(403); + expect(apiErr.message).toContain("Manager, Owner, or Admin"); + expect(apiErr.message).toContain("sentry org view"); + expect(apiErr.message).not.toContain("sentry auth login"); + } + }); + + test("verifies project exists before attempting delete", async () => { + const { context } = createMockContext(); + const func = await deleteCommand.loader(); + await func.call(context, defaultFlags, "acme-corp/my-app"); + + // getProject must be called before deleteProject + const getProjectOrder = getProjectSpy.mock.invocationCallOrder[0]; + const deleteProjectOrder = deleteProjectSpy.mock.invocationCallOrder[0]; + expect(getProjectOrder).toBeLessThan(deleteProjectOrder ?? 0); + }); + + // Dry-run tests + + test("dry-run shows what would be deleted without calling deleteProject", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await deleteCommand.loader(); + await func.call( + context, + { ...defaultFlags, "dry-run": true }, + "acme-corp/my-app" + ); + + expect(getProjectSpy).toHaveBeenCalledWith("acme-corp", "my-app"); + expect(deleteProjectSpy).not.toHaveBeenCalled(); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("Would delete project"); + expect(output).toContain("My App"); + expect(output).toContain("acme-corp/my-app"); + }); + + test("dry-run outputs JSON when --json is also set", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await deleteCommand.loader(); + await func.call( + context, + { ...defaultFlags, "dry-run": true, json: true }, + "acme-corp/my-app" + ); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output.trim()); + expect(parsed.dryRun).toBe(true); + expect(parsed.org).toBe("acme-corp"); + expect(parsed.project).toBe("my-app"); + expect(parsed.name).toBe("My App"); + expect(parsed.url).toContain("acme-corp"); + + expect(deleteProjectSpy).not.toHaveBeenCalled(); + }); + + test("JSON output for actual deletion", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await deleteCommand.loader(); + await func.call( + context, + { ...defaultFlags, json: true }, + "acme-corp/my-app" + ); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output.trim()); + expect(parsed).toEqual({ + deleted: true, + org: "acme-corp", + project: "my-app", + }); + }); + + test("rejects auto-detect target", async () => { + const { context } = createMockContext(); + const func = await deleteCommand.loader(); + + // Empty string triggers auto-detect in parseOrgProjectArg + await expect(func.call(context, defaultFlags, "")).rejects.toThrow( + ContextError + ); + + expect(deleteProjectSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/test/isolated/project-delete-confirm.test.ts b/test/isolated/project-delete-confirm.test.ts new file mode 100644 index 000000000..0e6ffd3d9 --- /dev/null +++ b/test/isolated/project-delete-confirm.test.ts @@ -0,0 +1,221 @@ +/** + * Isolated test for project delete interactive confirmation path. + * + * Uses mock.module() to override node:tty so isatty(0) returns true, + * and mocks the logger module to control the prompt response. + * + * Run with: bun test test/isolated/project-delete-confirm.test.ts + */ + +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; + +// Mock isatty to simulate interactive terminal. +// Bun's ESM wrapper for CJS built-ins exposes a `default` re-export plus +// `ReadStream` / `WriteStream` — all must be present or Bun throws +// "Missing 'default' export in module 'node:tty'". +const mockIsatty = mock(() => true); + +class FakeReadStream {} +class FakeWriteStream {} + +const ttyExports = { + isatty: mockIsatty, + ReadStream: FakeReadStream, + WriteStream: FakeWriteStream, +}; +mock.module("node:tty", () => ({ + ...ttyExports, + default: ttyExports, +})); + +// Mock prompt on the logger module — we need to intercept the .prompt() +// call made by the module-scoped `log = logger.withTag("project.delete")`. +const mockPrompt = mock(() => Promise.resolve("acme-corp/my-app")); + +/** Fake scoped logger returned by withTag() */ +const fakeLog = { + prompt: mockPrompt, + // biome-ignore lint/suspicious/noEmptyBlockStatements: no-op mock + info: mock(() => {}), + // biome-ignore lint/suspicious/noEmptyBlockStatements: no-op mock + warn: mock(() => {}), + // biome-ignore lint/suspicious/noEmptyBlockStatements: no-op mock + error: mock(() => {}), + // biome-ignore lint/suspicious/noEmptyBlockStatements: no-op mock + debug: mock(() => {}), + // biome-ignore lint/suspicious/noEmptyBlockStatements: no-op mock + success: mock(() => {}), + withTag: () => fakeLog, +}; + +/** Fake root logger */ +const fakeLogger = { + ...fakeLog, + withTag: () => fakeLog, +}; + +mock.module("../../src/lib/logger.js", () => ({ + logger: fakeLogger, + // biome-ignore lint/suspicious/noEmptyBlockStatements: no-op mock + setLogLevel: mock(() => {}), + // biome-ignore lint/suspicious/noEmptyBlockStatements: no-op mock + attachSentryReporter: mock(() => {}), + // These exports are required by command.ts (in the delete.ts import chain) + LOG_LEVEL_NAMES: ["error", "warn", "log", "info", "debug", "trace"], + LOG_LEVEL_ENV_VAR: "SENTRY_LOG_LEVEL", + parseLogLevel: (name: string) => { + const levels = ["error", "warn", "log", "info", "debug", "trace"]; + const idx = levels.indexOf(name.toLowerCase().trim()); + return idx === -1 ? 3 : idx; + }, + getEnvLogLevel: () => null, +})); + +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as apiClient from "../../src/lib/api-client.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as resolveTarget from "../../src/lib/resolve-target.js"; +import type { SentryProject } from "../../src/types/index.js"; + +const { deleteCommand } = await import("../../src/commands/project/delete.js"); + +const sampleProject: SentryProject = { + id: "999", + slug: "my-app", + name: "My App", + platform: "python", + dateCreated: "2026-02-12T10:00:00Z", +}; + +function createMockContext() { + const stdoutWrite = mock(() => true); + return { + context: { + stdout: { write: stdoutWrite }, + stderr: { write: mock(() => true) }, + cwd: "/tmp", + // biome-ignore lint/suspicious/noEmptyBlockStatements: no-op mock + setContext: mock(() => {}), + }, + stdoutWrite, + }; +} + +describe("project delete — interactive confirmation", () => { + let getProjectSpy: ReturnType; + let deleteProjectSpy: ReturnType; + let resolveOrgProjectTargetSpy: ReturnType; + + beforeEach(() => { + getProjectSpy = spyOn(apiClient, "getProject"); + deleteProjectSpy = spyOn(apiClient, "deleteProject"); + resolveOrgProjectTargetSpy = spyOn( + resolveTarget, + "resolveOrgProjectTarget" + ); + + getProjectSpy.mockResolvedValue(sampleProject); + deleteProjectSpy.mockResolvedValue(undefined); + resolveOrgProjectTargetSpy.mockResolvedValue({ + org: "acme-corp", + project: "my-app", + }); + + mockPrompt.mockClear(); + fakeLog.info.mockClear(); + }); + + afterEach(() => { + getProjectSpy.mockRestore(); + deleteProjectSpy.mockRestore(); + resolveOrgProjectTargetSpy.mockRestore(); + }); + + test("proceeds when user types exact org/project", async () => { + mockPrompt.mockResolvedValue("acme-corp/my-app"); + + const { context, stdoutWrite } = createMockContext(); + const func = await deleteCommand.loader(); + await func.call( + context, + { yes: false, "dry-run": false }, + "acme-corp/my-app" + ); + + expect(deleteProjectSpy).toHaveBeenCalledWith("acme-corp", "my-app"); + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("Deleted project"); + }); + + test("cancels when user types wrong value", async () => { + mockPrompt.mockResolvedValue("wrong-org/wrong-project"); + + const { context } = createMockContext(); + const func = await deleteCommand.loader(); + await func.call( + context, + { yes: false, "dry-run": false }, + "acme-corp/my-app" + ); + + expect(deleteProjectSpy).not.toHaveBeenCalled(); + expect(fakeLog.info).toHaveBeenCalledWith("Cancelled."); + }); + + test("cancels when user presses Ctrl+C (Symbol)", async () => { + // consola returns Symbol(clack:cancel) on Ctrl+C — truthy but not a string. + // Cast needed because the mock is typed as string but consola actually + // returns a Symbol on cancel. + mockPrompt.mockResolvedValue(Symbol("clack:cancel") as unknown as string); + + const { context } = createMockContext(); + const func = await deleteCommand.loader(); + await func.call( + context, + { yes: false, "dry-run": false }, + "acme-corp/my-app" + ); + + expect(deleteProjectSpy).not.toHaveBeenCalled(); + expect(fakeLog.info).toHaveBeenCalledWith("Cancelled."); + }); + + test("cancels when user submits empty string", async () => { + mockPrompt.mockResolvedValue(""); + + const { context } = createMockContext(); + const func = await deleteCommand.loader(); + await func.call( + context, + { yes: false, "dry-run": false }, + "acme-corp/my-app" + ); + + expect(deleteProjectSpy).not.toHaveBeenCalled(); + }); + + test("prompt message includes project name and expected input", async () => { + mockPrompt.mockResolvedValue("acme-corp/my-app"); + + const { context } = createMockContext(); + const func = await deleteCommand.loader(); + await func.call( + context, + { yes: false, "dry-run": false }, + "acme-corp/my-app" + ); + + expect(mockPrompt).toHaveBeenCalledWith( + expect.stringContaining("acme-corp/my-app"), + expect.objectContaining({ type: "text" }) + ); + }); +});