Skip to content

Commit 48a9a8f

Browse files
betegonclaude
andauthored
fix(init): resolve numeric org ID from DSN and prompt when Sentry already configured (#532)
## Summary - **Bug fix (sentry init only):** When \`sentry init .\` is run in a project with an existing Sentry DSN, the CLI extracts a numeric org ID (e.g. \`4507492088676352\`) from the DSN. If the org belonged to a different Sentry account, or the org regions cache was empty after a fresh install, this numeric ID was passed directly to \`listTeams()\` → 404 → confusing error: _"Organization '4507492088676352' not found."_ Fix scoped to \`resolveOrgSlug\` in \`local-ops.ts\` (only used by sentry init): when the prefetched org is a raw numeric string, look it up in the org regions cache (\`getOrgByNumericId\`). If found → use the real slug. If not (empty cache or inaccessible org) → fall through to \`listOrganizations()\` so the user selects from their accessible orgs. - **UX improvement:** Before creating a new project, \`sentry init\` now checks if the codebase already has a Sentry DSN that resolves to an accessible project. If so, prompts: _"Found an existing Sentry project (org/project). Use it or create a new one?"_ — avoids silently creating a duplicate when Sentry is already configured. ## Scope: no impact on other commands The numeric ID fallback in \`resolveOrgFromDsn\` is intentionally unchanged. Commands like \`sentry issue list\`, \`sentry trace view\`, and \`sentry event view\` rely on DSN org auto-detection and work correctly with numeric IDs (the Sentry API accepts them for read operations). Only \`sentry init\`'s project-creation path gets the narrower fix. ## How the original bug was triggered Fresh CLI install → \`sentry auth login\` (\`warmOrgCache()\` fires in background, may not complete before process exits) → immediately \`sentry init .\` in a project with a Sentry DSN → empty org regions cache → numeric org ID used directly → 404. ## Test plan - [ ] Replicate bug: clear SQLite cache, \`sentry auth login\`, immediately \`sentry init .\` in a project with DSN → no more numeric-ID error, falls through to org selection - [ ] DSN from accessible org: \`sentry init .\` → prompt "Found existing project (org/slug). Use it?" - [ ] \`--yes\` flag: \`sentry init . --yes\` with accessible DSN → auto-uses existing, no prompt - [ ] Other commands unaffected: \`sentry issue list\` in a project with DSN still auto-detects org from DSN - [ ] Unit tests: \`bun test test/isolated/resolve-target.test.ts\` 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f2eaabb commit 48a9a8f

File tree

3 files changed

+342
-8
lines changed

3 files changed

+342
-8
lines changed

src/lib/init/local-ops.ts

Lines changed: 109 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ import type {
3838
WizardOptions,
3939
} from "./types.js";
4040

41+
/** Matches a bare numeric org ID extracted from a DSN (e.g. "4507492088676352"). */
42+
const NUMERIC_ORG_ID_RE = /^\d+$/;
43+
4144
/** Whitespace characters used for JSON indentation. */
4245
const Indenter = {
4346
SPACE: " ",
@@ -675,7 +678,19 @@ async function resolveOrgSlug(
675678
): Promise<string | LocalOpResult> {
676679
const resolved = await resolveOrgPrefetched(cwd);
677680
if (resolved) {
678-
return resolved.org;
681+
// If the detected org is a raw numeric ID (extracted from a DSN), try to
682+
// resolve it to a real slug. Numeric IDs can fail for write operations like
683+
// project/team creation, and may belong to a different Sentry account.
684+
if (NUMERIC_ORG_ID_RE.test(resolved.org)) {
685+
const { getOrgByNumericId } = await import("../db/regions.js");
686+
const match = await getOrgByNumericId(resolved.org);
687+
if (match) {
688+
return match.slug;
689+
}
690+
// Cache miss — fall through to listOrganizations() for proper selection
691+
} else {
692+
return resolved.org;
693+
}
679694
}
680695

681696
// Fallback: list user's organizations (SQLite-cached after login/first call)
@@ -744,6 +759,85 @@ async function tryGetExistingProject(
744759
}
745760
}
746761

762+
/**
763+
* Detect an existing Sentry project by looking for a DSN in the project.
764+
*
765+
* Returns org and project slugs when the DSN's project can be resolved —
766+
* either from the local cache or via API (when the org is accessible).
767+
* Returns null when no DSN is found or the org belongs to a different account.
768+
*/
769+
async function detectExistingProject(cwd: string): Promise<{
770+
orgSlug: string;
771+
projectSlug: string;
772+
} | null> {
773+
const { detectDsn } = await import("../dsn/index.js");
774+
const dsn = await detectDsn(cwd);
775+
if (!dsn?.publicKey) {
776+
return null;
777+
}
778+
779+
try {
780+
const { resolveDsnByPublicKey } = await import("../resolve-target.js");
781+
const resolved = await resolveDsnByPublicKey(dsn);
782+
if (resolved) {
783+
return { orgSlug: resolved.org, projectSlug: resolved.project };
784+
}
785+
} catch {
786+
// Auth error or network error — org inaccessible, fall through to creation
787+
}
788+
return null;
789+
}
790+
791+
/**
792+
* When no explicit org/project is provided, check for an existing Sentry setup
793+
* and either auto-select it (--yes) or prompt the user interactively.
794+
*
795+
* Returns a LocalOpResult to return early, or null to proceed with creation.
796+
*/
797+
async function promptForExistingProject(
798+
cwd: string,
799+
yes: boolean
800+
): Promise<LocalOpResult | null> {
801+
const existing = await detectExistingProject(cwd);
802+
if (!existing) {
803+
return null;
804+
}
805+
806+
if (yes) {
807+
return tryGetExistingProject(existing.orgSlug, existing.projectSlug);
808+
}
809+
810+
const choice = await select({
811+
message: "Found an existing Sentry project in this codebase.",
812+
options: [
813+
{
814+
value: "existing" as const,
815+
label: `Use existing project (${existing.orgSlug}/${existing.projectSlug})`,
816+
hint: "Sentry is already configured here",
817+
},
818+
{
819+
value: "create" as const,
820+
label: "Create a new Sentry project",
821+
},
822+
],
823+
});
824+
if (isCancel(choice)) {
825+
return { ok: false, error: "Cancelled." };
826+
}
827+
if (choice === "existing") {
828+
const result = await tryGetExistingProject(
829+
existing.orgSlug,
830+
existing.projectSlug
831+
);
832+
if (result) {
833+
return result;
834+
}
835+
// Project deleted or inaccessible — fall through to creation
836+
}
837+
return null;
838+
}
839+
840+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: wizard orchestration requires sequential branching
747841
async function createSentryProject(
748842
payload: CreateSentryProjectPayload,
749843
options: WizardOptions
@@ -774,7 +868,15 @@ async function createSentryProject(
774868
}
775869

776870
try {
777-
// 1. Resolve org — skip interactive resolution if explicitly provided via CLI arg
871+
// 1. When no explicit org/project provided, check if Sentry is already set up
872+
if (!(options.org || options.project)) {
873+
const result = await promptForExistingProject(payload.cwd, options.yes);
874+
if (result) {
875+
return result;
876+
}
877+
}
878+
879+
// 2. Resolve org — skip interactive resolution if explicitly provided via CLI arg
778880
let orgSlug: string;
779881
if (options.org) {
780882
orgSlug = options.org;
@@ -786,7 +888,7 @@ async function createSentryProject(
786888
orgSlug = orgResult;
787889
}
788890

789-
// 2. If both org and project were provided, check if the project already exists.
891+
// 3. If both org and project were provided, check if the project already exists.
790892
// This avoids a 409 Conflict from the create API when re-running init on an
791893
// existing Sentry project (e.g., bare slug resolved via resolveProjectBySlug).
792894
if (options.org && options.project) {
@@ -796,23 +898,23 @@ async function createSentryProject(
796898
}
797899
}
798900

799-
// 3. Resolve or create team
901+
// 4. Resolve or create team
800902
const team = await resolveOrCreateTeam(orgSlug, {
801903
team: options.team,
802904
autoCreateSlug: slug,
803905
usageHint: "sentry init",
804906
});
805907

806-
// 4. Create project
908+
// 5. Create project
807909
const project = await createProject(orgSlug, team.slug, {
808910
name,
809911
platform,
810912
});
811913

812-
// 5. Get DSN (best-effort)
914+
// 6. Get DSN (best-effort)
813915
const dsn = await tryGetPrimaryDsn(orgSlug, project.slug);
814916

815-
// 6. Build URL
917+
// 7. Build URL
816918
const url = buildProjectUrl(orgSlug, project.slug);
817919

818920
return {

src/lib/resolve-target.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ export async function resolveOrgFromDsn(
245245
* @param dsn - Detected DSN (must have publicKey)
246246
* @returns Resolved target or null if resolution failed
247247
*/
248-
async function resolveDsnByPublicKey(
248+
export async function resolveDsnByPublicKey(
249249
dsn: DetectedDsn
250250
): Promise<ResolvedTarget | null> {
251251
const detectedFrom = getDsnSourceDescription(dsn);

0 commit comments

Comments
 (0)