diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index 1f29d9778..431b88ad7 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -38,6 +38,9 @@ import type { WizardOptions, } from "./types.js"; +/** Matches a bare numeric org ID extracted from a DSN (e.g. "4507492088676352"). */ +const NUMERIC_ORG_ID_RE = /^\d+$/; + /** Whitespace characters used for JSON indentation. */ const Indenter = { SPACE: " ", @@ -675,7 +678,19 @@ async function resolveOrgSlug( ): Promise { const resolved = await resolveOrgPrefetched(cwd); if (resolved) { - return resolved.org; + // If the detected org is a raw numeric ID (extracted from a DSN), try to + // resolve it to a real slug. Numeric IDs can fail for write operations like + // project/team creation, and may belong to a different Sentry account. + if (NUMERIC_ORG_ID_RE.test(resolved.org)) { + const { getOrgByNumericId } = await import("../db/regions.js"); + const match = await getOrgByNumericId(resolved.org); + if (match) { + return match.slug; + } + // Cache miss — fall through to listOrganizations() for proper selection + } else { + return resolved.org; + } } // Fallback: list user's organizations (SQLite-cached after login/first call) @@ -744,6 +759,85 @@ async function tryGetExistingProject( } } +/** + * Detect an existing Sentry project by looking for a DSN in the project. + * + * Returns org and project slugs when the DSN's project can be resolved — + * either from the local cache or via API (when the org is accessible). + * Returns null when no DSN is found or the org belongs to a different account. + */ +async function detectExistingProject(cwd: string): Promise<{ + orgSlug: string; + projectSlug: string; +} | null> { + const { detectDsn } = await import("../dsn/index.js"); + const dsn = await detectDsn(cwd); + if (!dsn?.publicKey) { + return null; + } + + try { + const { resolveDsnByPublicKey } = await import("../resolve-target.js"); + const resolved = await resolveDsnByPublicKey(dsn); + if (resolved) { + return { orgSlug: resolved.org, projectSlug: resolved.project }; + } + } catch { + // Auth error or network error — org inaccessible, fall through to creation + } + return null; +} + +/** + * When no explicit org/project is provided, check for an existing Sentry setup + * and either auto-select it (--yes) or prompt the user interactively. + * + * Returns a LocalOpResult to return early, or null to proceed with creation. + */ +async function promptForExistingProject( + cwd: string, + yes: boolean +): Promise { + const existing = await detectExistingProject(cwd); + if (!existing) { + return null; + } + + if (yes) { + return tryGetExistingProject(existing.orgSlug, existing.projectSlug); + } + + const choice = await select({ + message: "Found an existing Sentry project in this codebase.", + options: [ + { + value: "existing" as const, + label: `Use existing project (${existing.orgSlug}/${existing.projectSlug})`, + hint: "Sentry is already configured here", + }, + { + value: "create" as const, + label: "Create a new Sentry project", + }, + ], + }); + if (isCancel(choice)) { + return { ok: false, error: "Cancelled." }; + } + if (choice === "existing") { + const result = await tryGetExistingProject( + existing.orgSlug, + existing.projectSlug + ); + if (result) { + return result; + } + // Project deleted or inaccessible — fall through to creation + } + return null; +} + +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: wizard orchestration requires sequential branching async function createSentryProject( payload: CreateSentryProjectPayload, options: WizardOptions @@ -774,7 +868,15 @@ async function createSentryProject( } try { - // 1. Resolve org — skip interactive resolution if explicitly provided via CLI arg + // 1. When no explicit org/project provided, check if Sentry is already set up + if (!(options.org || options.project)) { + const result = await promptForExistingProject(payload.cwd, options.yes); + if (result) { + return result; + } + } + + // 2. Resolve org — skip interactive resolution if explicitly provided via CLI arg let orgSlug: string; if (options.org) { orgSlug = options.org; @@ -786,7 +888,7 @@ async function createSentryProject( orgSlug = orgResult; } - // 2. If both org and project were provided, check if the project already exists. + // 3. If both org and project were provided, check if the project already exists. // This avoids a 409 Conflict from the create API when re-running init on an // existing Sentry project (e.g., bare slug resolved via resolveProjectBySlug). if (options.org && options.project) { @@ -796,23 +898,23 @@ async function createSentryProject( } } - // 3. Resolve or create team + // 4. Resolve or create team const team = await resolveOrCreateTeam(orgSlug, { team: options.team, autoCreateSlug: slug, usageHint: "sentry init", }); - // 4. Create project + // 5. Create project const project = await createProject(orgSlug, team.slug, { name, platform, }); - // 5. Get DSN (best-effort) + // 6. Get DSN (best-effort) const dsn = await tryGetPrimaryDsn(orgSlug, project.slug); - // 6. Build URL + // 7. Build URL const url = buildProjectUrl(orgSlug, project.slug); return { diff --git a/src/lib/resolve-target.ts b/src/lib/resolve-target.ts index 14d76b4bc..9d42228c0 100644 --- a/src/lib/resolve-target.ts +++ b/src/lib/resolve-target.ts @@ -245,7 +245,7 @@ export async function resolveOrgFromDsn( * @param dsn - Detected DSN (must have publicKey) * @returns Resolved target or null if resolution failed */ -async function resolveDsnByPublicKey( +export async function resolveDsnByPublicKey( dsn: DetectedDsn ): Promise { const detectedFrom = getDsnSourceDescription(dsn); diff --git a/test/lib/init/local-ops.create-sentry-project.test.ts b/test/lib/init/local-ops.create-sentry-project.test.ts index 1de1c5577..505d25d9a 100644 --- a/test/lib/init/local-ops.create-sentry-project.test.ts +++ b/test/lib/init/local-ops.create-sentry-project.test.ts @@ -10,6 +10,12 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import * as clack from "@clack/prompts"; // biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference import * as apiClient from "../../../src/lib/api-client.js"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as projectCache from "../../../src/lib/db/project-cache.js"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as dbRegions from "../../../src/lib/db/regions.js"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as dsnIndex from "../../../src/lib/dsn/index.js"; import { handleLocalOp } from "../../../src/lib/init/local-ops.js"; import type { CreateSentryProjectPayload, @@ -64,6 +70,12 @@ describe("create-sentry-project", () => { let buildProjectUrlSpy: ReturnType; let selectSpy: ReturnType; let isCancelSpy: ReturnType; + let getOrgByNumericIdSpy: ReturnType; + let detectDsnSpy: ReturnType; + let getCachedProjectByDsnKeySpy: ReturnType; + let setCachedProjectByDsnKeySpy: ReturnType; + let findProjectByDsnKeySpy: ReturnType; + let getProjectSpy: ReturnType; beforeEach(() => { resolveOrgSpy = spyOn(resolveTarget, "resolveOrg"); @@ -76,6 +88,25 @@ describe("create-sentry-project", () => { isCancelSpy = spyOn(clack, "isCancel").mockImplementation( (v: unknown) => v === Symbol.for("cancel") ); + // New spies — default to no-op so existing tests are unaffected + getOrgByNumericIdSpy = spyOn( + dbRegions, + "getOrgByNumericId" + ).mockResolvedValue(undefined); + detectDsnSpy = spyOn(dsnIndex, "detectDsn").mockResolvedValue(null); + getCachedProjectByDsnKeySpy = spyOn( + projectCache, + "getCachedProjectByDsnKey" + ).mockResolvedValue(undefined); + setCachedProjectByDsnKeySpy = spyOn( + projectCache, + "setCachedProjectByDsnKey" + ).mockResolvedValue(undefined); + findProjectByDsnKeySpy = spyOn( + apiClient, + "findProjectByDsnKey" + ).mockResolvedValue(null); + getProjectSpy = spyOn(apiClient, "getProject"); }); afterEach(() => { @@ -87,6 +118,12 @@ describe("create-sentry-project", () => { buildProjectUrlSpy.mockRestore(); selectSpy.mockRestore(); isCancelSpy.mockRestore(); + getOrgByNumericIdSpy.mockRestore(); + detectDsnSpy.mockRestore(); + getCachedProjectByDsnKeySpy.mockRestore(); + setCachedProjectByDsnKeySpy.mockRestore(); + findProjectByDsnKeySpy.mockRestore(); + getProjectSpy.mockRestore(); }); function mockDownstreamSuccess(orgSlug: string) { @@ -241,4 +278,199 @@ describe("create-sentry-project", () => { const data = result.data as { dsn: string }; expect(data.dsn).toBe(""); }); + + describe("resolveOrgSlug — numeric org ID from DSN", () => { + test("numeric ID + cache hit → resolved to slug for project creation", async () => { + resolveOrgSpy.mockResolvedValue({ org: "4507492088676352" }); + getOrgByNumericIdSpy.mockResolvedValue({ + slug: "acme-corp", + regionUrl: "https://us.sentry.io", + }); + mockDownstreamSuccess("acme-corp"); + + const result = await handleLocalOp(makePayload(), makeOptions()); + + expect(result.ok).toBe(true); + const data = result.data as { orgSlug: string }; + expect(data.orgSlug).toBe("acme-corp"); + expect(getOrgByNumericIdSpy).toHaveBeenCalledWith("4507492088676352"); + }); + + test("numeric ID + cache miss → falls through to single org in listOrganizations", async () => { + resolveOrgSpy.mockResolvedValue({ org: "4507492088676352" }); + getOrgByNumericIdSpy.mockResolvedValue(undefined); + listOrgsSpy.mockResolvedValue([ + { id: "1", slug: "solo-org", name: "Solo Org" }, + ]); + mockDownstreamSuccess("solo-org"); + + const result = await handleLocalOp(makePayload(), makeOptions()); + + expect(result.ok).toBe(true); + const data = result.data as { orgSlug: string }; + expect(data.orgSlug).toBe("solo-org"); + }); + + test("numeric ID + cache miss + multiple orgs + --yes → error with org list", async () => { + resolveOrgSpy.mockResolvedValue({ org: "4507492088676352" }); + getOrgByNumericIdSpy.mockResolvedValue(undefined); + listOrgsSpy.mockResolvedValue([ + { id: "1", slug: "org-a", name: "Org A" }, + { id: "2", slug: "org-b", name: "Org B" }, + ]); + + const result = await handleLocalOp( + makePayload(), + makeOptions({ yes: true }) + ); + + expect(result.ok).toBe(false); + expect(result.error).toContain("Multiple organizations found"); + expect(createProjectSpy).not.toHaveBeenCalled(); + }); + }); + + describe("detectExistingProject — existing DSN prompt", () => { + function mockExistingProject(orgSlug: string, projectSlug: string) { + detectDsnSpy.mockResolvedValue({ + publicKey: "test-key-abc", + protocol: "https", + host: "o123.ingest.sentry.io", + projectId: "42", + raw: "https://test-key-abc@o123.ingest.sentry.io/42", + source: "env_file" as const, + }); + getCachedProjectByDsnKeySpy.mockResolvedValue({ + orgSlug, + orgName: orgSlug, + projectSlug, + projectName: projectSlug, + projectId: "42", + cachedAt: Date.now(), + }); + getProjectSpy.mockResolvedValue({ ...sampleProject, slug: projectSlug }); + tryGetPrimaryDsnSpy.mockResolvedValue( + "https://abc@o1.ingest.sentry.io/42" + ); + buildProjectUrlSpy.mockReturnValue( + `https://sentry.io/settings/${orgSlug}/projects/${projectSlug}/` + ); + } + + test("no DSN found → no prompt, proceeds with normal creation", async () => { + detectDsnSpy.mockResolvedValue(null); + resolveOrgSpy.mockResolvedValue({ org: "acme-corp" }); + mockDownstreamSuccess("acme-corp"); + + const result = await handleLocalOp(makePayload(), makeOptions()); + + expect(result.ok).toBe(true); + expect(selectSpy).not.toHaveBeenCalled(); + expect(createProjectSpy).toHaveBeenCalledTimes(1); + }); + + test("DSN found + --yes flag → auto-uses existing project without prompt", async () => { + mockExistingProject("acme-corp", "my-app"); + + const result = await handleLocalOp( + makePayload(), + makeOptions({ yes: true }) + ); + + expect(result.ok).toBe(true); + const data = result.data as { orgSlug: string; projectSlug: string }; + expect(data.orgSlug).toBe("acme-corp"); + expect(data.projectSlug).toBe("my-app"); + expect(selectSpy).not.toHaveBeenCalled(); + expect(createProjectSpy).not.toHaveBeenCalled(); + }); + + test("DSN found + pick 'existing' → returns existing project details", async () => { + mockExistingProject("acme-corp", "my-app"); + selectSpy.mockResolvedValue("existing"); + + const result = await handleLocalOp(makePayload(), makeOptions()); + + expect(result.ok).toBe(true); + const data = result.data as { orgSlug: string; projectSlug: string }; + expect(data.orgSlug).toBe("acme-corp"); + expect(data.projectSlug).toBe("my-app"); + expect(createProjectSpy).not.toHaveBeenCalled(); + }); + + test("DSN found + pick 'create' → proceeds with normal project creation", async () => { + mockExistingProject("acme-corp", "my-app"); + selectSpy.mockResolvedValue("create"); + resolveOrgSpy.mockResolvedValue({ org: "acme-corp" }); + mockDownstreamSuccess("acme-corp"); + + const result = await handleLocalOp(makePayload(), makeOptions()); + + expect(result.ok).toBe(true); + expect(createProjectSpy).toHaveBeenCalledTimes(1); + }); + + test("DSN found + cancel select → ok:false with cancelled error", async () => { + mockExistingProject("acme-corp", "my-app"); + selectSpy.mockResolvedValue(Symbol.for("cancel")); + + const result = await handleLocalOp(makePayload(), makeOptions()); + + expect(result.ok).toBe(false); + expect(result.error).toContain("Cancelled"); + expect(createProjectSpy).not.toHaveBeenCalled(); + }); + + test("DSN found + API lookup (cache miss) → caches project and prompts user", async () => { + detectDsnSpy.mockResolvedValue({ + publicKey: "test-key-abc", + protocol: "https", + host: "o123.ingest.sentry.io", + projectId: "42", + raw: "https://test-key-abc@o123.ingest.sentry.io/42", + source: "env_file" as const, + }); + getCachedProjectByDsnKeySpy.mockResolvedValue(undefined); // cache miss + findProjectByDsnKeySpy.mockResolvedValue({ + ...sampleProject, + organization: { id: "1", slug: "acme-corp", name: "Acme Corp" }, + }); + setCachedProjectByDsnKeySpy.mockResolvedValue(undefined); + selectSpy.mockResolvedValue("existing"); + getProjectSpy.mockResolvedValue(sampleProject); + tryGetPrimaryDsnSpy.mockResolvedValue( + "https://abc@o1.ingest.sentry.io/42" + ); + buildProjectUrlSpy.mockReturnValue( + "https://sentry.io/settings/acme-corp/projects/my-app/" + ); + + const result = await handleLocalOp(makePayload(), makeOptions()); + + expect(result.ok).toBe(true); + expect(setCachedProjectByDsnKeySpy).toHaveBeenCalledTimes(1); + expect(createProjectSpy).not.toHaveBeenCalled(); + }); + + test("DSN found + API throws (inaccessible org) → no prompt, normal creation", async () => { + detectDsnSpy.mockResolvedValue({ + publicKey: "test-key-abc", + protocol: "https", + host: "o999.ingest.sentry.io", + projectId: "99", + raw: "https://test-key-abc@o999.ingest.sentry.io/99", + source: "env_file" as const, + }); + getCachedProjectByDsnKeySpy.mockResolvedValue(undefined); + findProjectByDsnKeySpy.mockRejectedValue(new Error("403 Forbidden")); + resolveOrgSpy.mockResolvedValue({ org: "acme-corp" }); + mockDownstreamSuccess("acme-corp"); + + const result = await handleLocalOp(makePayload(), makeOptions()); + + expect(result.ok).toBe(true); + expect(selectSpy).not.toHaveBeenCalled(); + expect(createProjectSpy).toHaveBeenCalledTimes(1); + }); + }); });