diff --git a/src/commands/init.ts b/src/commands/init.ts index 5ef3a179d..a69b2b266 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -22,6 +22,7 @@ import type { SentryContext } from "../context.js"; import { looksLikePath, parseOrgProjectArg } from "../lib/arg-parsing.js"; import { buildCommand } from "../lib/command.js"; import { ContextError } from "../lib/errors.js"; +import { warmOrgDetection } from "../lib/init/prefetch.js"; import { runWizard } from "../lib/init/wizard-runner.js"; import { validateResourceId } from "../lib/input-validation.js"; import { logger } from "../lib/logger.js"; @@ -229,7 +230,15 @@ export const initCommand = buildCommand< const { org: explicitOrg, project: explicitProject } = await resolveTarget(targetArg); - // 5. Run the wizard + // 5. Start background org detection when org is not yet known. + // The prefetch runs concurrently with the preamble, the wizard startup, + // and all early suspend/resume rounds — by the time the wizard needs the + // org (inside createSentryProject), the result is already cached. + if (!explicitOrg) { + warmOrgDetection(targetDir); + } + + // 6. Run the wizard await runWizard({ directory: targetDir, yes: flags.yes, diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index a68fe815d..1f29d9778 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -16,7 +16,6 @@ import { tryGetPrimaryDsn, } from "../api-client.js"; import { ApiError } from "../errors.js"; -import { resolveOrg } from "../resolve-target.js"; import { resolveOrCreateTeam } from "../resolve-team.js"; import { buildProjectUrl } from "../sentry-urls.js"; import { slugify } from "../utils.js"; @@ -25,6 +24,7 @@ import { MAX_FILE_BYTES, MAX_OUTPUT_BYTES, } from "./constants.js"; +import { resolveOrgPrefetched } from "./prefetch.js"; import type { ApplyPatchsetPayload, CreateSentryProjectPayload, @@ -658,18 +658,27 @@ function applyPatchset( /** * Resolve the org slug from local config, env vars, or by listing the user's * organizations from the API as a fallback. - * Returns the slug on success, or a LocalOpResult error to return early. + * + * DSN scanning uses the prefetch-aware helper from `./prefetch.ts` — if + * {@link warmOrgDetection} was called earlier (by `init.ts`), the result is + * already cached and returns near-instantly. + * + * `listOrganizations()` uses SQLite caching for near-instant warm lookups + * (populated after `sentry login` or the first API call), so it does not + * need background prefetching. + * + * @returns The org slug on success, or a {@link LocalOpResult} error to return early. */ async function resolveOrgSlug( cwd: string, yes: boolean ): Promise { - const resolved = await resolveOrg({ cwd }); + const resolved = await resolveOrgPrefetched(cwd); if (resolved) { return resolved.org; } - // Fallback: list user's organizations from API + // Fallback: list user's organizations (SQLite-cached after login/first call) const orgs = await listOrganizations(); if (orgs.length === 0) { return { diff --git a/src/lib/init/prefetch.ts b/src/lib/init/prefetch.ts new file mode 100644 index 000000000..c2ca8f75c --- /dev/null +++ b/src/lib/init/prefetch.ts @@ -0,0 +1,64 @@ +/** + * Background Org Detection Prefetch + * + * Provides a warm/consume pattern for org resolution during `sentry init`. + * Call {@link warmOrgDetection} early (before the preamble) to start DSN + * scanning in the background. Later, call {@link resolveOrgPrefetched} — + * it returns the cached result instantly if the background work has + * finished, or falls back to a live call if it hasn't been warmed. + * + * `listOrganizations()` does NOT need prefetching because it has its own + * SQLite cache layer (PR #446). After `sentry login`, the org cache is + * pre-populated (PR #490), so subsequent calls return from cache instantly + * without any HTTP requests. Only `resolveOrg()` (DSN scanning) benefits + * from background prefetching since it performs filesystem I/O. + * + * This keeps the hot path (inside the wizard's `createSentryProject`) + * free of explicit promise-threading — callers just swap in the + * prefetch-aware functions. + */ + +import type { ResolvedOrg } from "../resolve-target.js"; +import { resolveOrg } from "../resolve-target.js"; + +type OrgResult = ResolvedOrg | null; + +let orgPromise: Promise | undefined; +let warmedCwd: string | undefined; + +/** + * Kick off background DSN scanning + env var / config checks. + * + * Safe to call multiple times — subsequent calls are no-ops. + * Errors are silently swallowed so the foreground path can retry. + */ +export function warmOrgDetection(cwd: string): void { + if (!orgPromise) { + warmedCwd = cwd; + orgPromise = resolveOrg({ cwd }).catch(() => null); + } +} + +/** + * Resolve the org, using the prefetched result if available. + * + * Returns the cached background result only when `cwd` matches the + * directory that was passed to {@link warmOrgDetection}. If `cwd` + * differs (or warming was never called), falls back to a live + * `resolveOrg()` call so callers always get results for the correct + * working directory. + */ +export function resolveOrgPrefetched(cwd: string): Promise { + if (orgPromise && cwd === warmedCwd) { + return orgPromise; + } + return resolveOrg({ cwd }).catch(() => null); +} + +/** + * Reset prefetch state. Used by tests to prevent cross-test leakage. + */ +export function resetPrefetch(): void { + orgPromise = undefined; + warmedCwd = undefined; +} diff --git a/test/commands/init.test.ts b/test/commands/init.test.ts index 6b16fbb2b..06205e9b9 100644 --- a/test/commands/init.test.ts +++ b/test/commands/init.test.ts @@ -11,6 +11,9 @@ import path from "node:path"; import { initCommand } from "../../src/commands/init.js"; import { ContextError } from "../../src/lib/errors.js"; // biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as prefetchNs from "../../src/lib/init/prefetch.js"; +import { resetPrefetch } from "../../src/lib/init/prefetch.js"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference import * as wizardRunner from "../../src/lib/init/wizard-runner.js"; // biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference import * as resolveTarget from "../../src/lib/resolve-target.js"; @@ -19,6 +22,7 @@ import * as resolveTarget from "../../src/lib/resolve-target.js"; let capturedArgs: Record | undefined; let runWizardSpy: ReturnType; let resolveProjectSpy: ReturnType; +let warmSpy: ReturnType; const func = (await initCommand.loader()) as unknown as ( this: { @@ -45,6 +49,7 @@ const DEFAULT_FLAGS = { yes: true, "dry-run": false } as const; beforeEach(() => { capturedArgs = undefined; + resetPrefetch(); runWizardSpy = spyOn(wizardRunner, "runWizard").mockImplementation( (args: Record) => { capturedArgs = args; @@ -59,11 +64,19 @@ beforeEach(() => { org: "resolved-org", project: slug, })); + // Spy on warmOrgDetection to verify it's called/skipped appropriately. + // The mock prevents real DSN scans and API calls from the background. + warmSpy = spyOn(prefetchNs, "warmOrgDetection").mockImplementation( + // biome-ignore lint/suspicious/noEmptyBlockStatements: intentional no-op mock + () => {} + ); }); afterEach(() => { runWizardSpy.mockRestore(); resolveProjectSpy.mockRestore(); + warmSpy.mockRestore(); + resetPrefetch(); }); describe("init command func", () => { @@ -324,4 +337,48 @@ describe("init command func", () => { expect(capturedArgs?.team).toBe("backend"); }); }); + + // ── Background org detection ────────────────────────────────────────── + + describe("background org detection", () => { + test("warms prefetch when org is not explicit", async () => { + const ctx = makeContext(); + await func.call(ctx, DEFAULT_FLAGS); + expect(warmSpy).toHaveBeenCalledTimes(1); + expect(warmSpy).toHaveBeenCalledWith("/projects/app"); + }); + + test("skips prefetch when org is explicit", async () => { + const ctx = makeContext(); + await func.call(ctx, DEFAULT_FLAGS, "acme/my-app"); + expect(warmSpy).not.toHaveBeenCalled(); + }); + + test("skips prefetch when org-only is explicit", async () => { + const ctx = makeContext(); + await func.call(ctx, DEFAULT_FLAGS, "acme/"); + expect(warmSpy).not.toHaveBeenCalled(); + }); + + test("skips prefetch for bare slug (project-search resolves org)", async () => { + const ctx = makeContext(); + await func.call(ctx, DEFAULT_FLAGS, "my-app"); + // resolveProjectBySlug returns { org: "resolved-org" } → org is known + expect(warmSpy).not.toHaveBeenCalled(); + }); + + test("warms prefetch for path-only arg", async () => { + const ctx = makeContext(); + await func.call(ctx, DEFAULT_FLAGS, "./subdir"); + expect(warmSpy).toHaveBeenCalledTimes(1); + }); + + test("warms prefetch with resolved directory path", async () => { + const ctx = makeContext("/projects/app"); + await func.call(ctx, DEFAULT_FLAGS, "./subdir"); + expect(warmSpy).toHaveBeenCalledWith( + path.resolve("/projects/app", "./subdir") + ); + }); + }); });