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" }) + ); + }); +});