From 0beee02d8f0005fb7c7541e2340e61af192fd1d0 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Mon, 16 Mar 2026 16:27:40 +0530 Subject: [PATCH 01/10] feat(init): support org/project positional to pin org and project name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the directory positional with an org/project target using parseOrgProjectArg. Directory is now a --directory/-d flag. Supported forms: sentry init — auto-detect everything sentry init acme — explicit org sentry init acme/my-app — explicit org + project name sentry init --directory ./dir — specify project directory When org is provided explicitly, createSentryProject skips interactive org resolution. When project name is provided, it overrides the wizard-detected name. --- src/commands/init.ts | 69 ++++++++++++++++++++-- src/lib/init/local-ops.ts | 21 ++++--- src/lib/init/types.ts | 4 ++ test/commands/init.test.ts | 115 +++++++++++++++++++++++++++++++++---- 4 files changed, 185 insertions(+), 24 deletions(-) diff --git a/src/commands/init.ts b/src/commands/init.ts index 10fdd7fe..cc217f32 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -4,11 +4,19 @@ * Initialize Sentry in a project using the remote wizard workflow. * Communicates with the Mastra API via suspend/resume to perform * local filesystem operations and interactive prompts. + * + * Supports org/project positional syntax to pin org and/or project name: + * sentry init — auto-detect everything + * sentry init acme — explicit org, wizard picks project name + * sentry init acme/my-app — explicit org + project name override + * sentry init --directory ./dir — specify project directory */ import path from "node:path"; import type { SentryContext } from "../context.js"; +import { parseOrgProjectArg } from "../lib/arg-parsing.js"; import { buildCommand } from "../lib/command.js"; +import { ContextError } from "../lib/errors.js"; import { runWizard } from "../lib/init/wizard-runner.js"; const FEATURE_DELIMITER = /[,+ ]+/; @@ -18,6 +26,7 @@ type InitFlags = { readonly "dry-run": boolean; readonly features?: string[]; readonly team?: string; + readonly directory?: string; }; export const initCommand = buildCommand({ @@ -25,15 +34,23 @@ export const initCommand = buildCommand({ brief: "Initialize Sentry in your project", fullDescription: "Runs the Sentry setup wizard to detect your project's framework, " + - "install the SDK, and configure Sentry.", + "install the SDK, and configure Sentry.\n\n" + + "The target supports org/project syntax to specify context explicitly.\n" + + "If omitted, the org is auto-detected from config defaults.\n\n" + + "Examples:\n" + + " sentry init\n" + + " sentry init acme\n" + + " sentry init acme/my-app\n" + + " sentry init acme/my-app --directory ./my-project\n" + + " sentry init --directory ./my-project", }, parameters: { positional: { kind: "tuple", parameters: [ { - placeholder: "directory", - brief: "Project directory (default: current directory)", + placeholder: "target", + brief: "/, , or omit for auto-detect", parse: String, optional: true, }, @@ -63,25 +80,67 @@ export const initCommand = buildCommand({ brief: "Team slug to create the project under", optional: true, }, + directory: { + kind: "parsed", + parse: String, + brief: "Project directory (default: current directory)", + optional: true, + }, }, aliases: { y: "yes", t: "team", + d: "directory", }, }, - async *func(this: SentryContext, flags: InitFlags, directory?: string) { - const targetDir = directory ? path.resolve(this.cwd, directory) : this.cwd; + async *func(this: SentryContext, flags: InitFlags, targetArg?: string) { + const targetDir = flags.directory + ? path.resolve(this.cwd, flags.directory) + : this.cwd; + const featuresList = flags.features ?.flatMap((f) => f.split(FEATURE_DELIMITER)) .map((f) => f.trim()) .filter(Boolean); + // Parse the target arg to extract org and/or project + const parsed = parseOrgProjectArg(targetArg); + + let explicitOrg: string | undefined; + let explicitProject: string | undefined; + + switch (parsed.type) { + case "explicit": + explicitOrg = parsed.org; + explicitProject = parsed.project; + break; + case "org-all": + // "acme/" or bare "acme" — org only, no project name override + explicitOrg = parsed.org; + break; + case "project-search": + // Bare string without "/" — could be an org slug or a project name. + // Treat it as an org slug since `sentry init ` is the primary use case. + // Users who want to override the project name should use org/project syntax. + explicitOrg = parsed.projectSlug; + break; + case "auto-detect": + // No target provided — auto-detect everything + break; + default: { + const _exhaustive: never = parsed; + throw new ContextError("Target", String(_exhaustive)); + } + } + await runWizard({ directory: targetDir, yes: flags.yes, dryRun: flags["dry-run"], features: featuresList, team: flags.team, + org: explicitOrg, + project: explicitProject, }); }, }); diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index 5944b62b..d7c5a6bd 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -706,7 +706,9 @@ async function createSentryProject( payload: CreateSentryProjectPayload, options: WizardOptions ): Promise { - const { name, platform } = payload.params; + // Use CLI-provided project name if available, otherwise use wizard-detected name + const name = options.project ?? payload.params.name; + const { platform } = payload.params; const slug = slugify(name); if (!slug) { return { @@ -720,7 +722,7 @@ async function createSentryProject( return { ok: true, data: { - orgSlug: "(dry-run)", + orgSlug: options.org ?? "(dry-run)", projectSlug: slug, projectId: "(dry-run)", dsn: "(dry-run)", @@ -730,12 +732,17 @@ async function createSentryProject( } try { - // 1. Resolve org - const orgResult = await resolveOrgSlug(payload.cwd, options.yes); - if (typeof orgResult !== "string") { - return orgResult; + // 1. Resolve org — skip interactive resolution if explicitly provided via CLI arg + let orgSlug: string; + if (options.org) { + orgSlug = options.org; + } else { + const orgResult = await resolveOrgSlug(payload.cwd, options.yes); + if (typeof orgResult !== "string") { + return orgResult; + } + orgSlug = orgResult; } - const orgSlug = orgResult; // 2. Resolve or create team const team = await resolveOrCreateTeam(orgSlug, { diff --git a/src/lib/init/types.ts b/src/lib/init/types.ts index a7e425e9..3cb0fe2f 100644 --- a/src/lib/init/types.ts +++ b/src/lib/init/types.ts @@ -11,6 +11,10 @@ export type WizardOptions = { features?: string[]; /** Explicit team slug to create the project under. Skips team resolution. */ team?: string; + /** Explicit org slug from CLI arg (e.g., "acme" from "acme/my-app"). Skips interactive org selection. */ + org?: string; + /** Explicit project name from CLI arg (e.g., "my-app" from "acme/my-app"). Overrides wizard-detected name. */ + project?: string; }; // Local-op suspend payloads diff --git a/test/commands/init.test.ts b/test/commands/init.test.ts index 374cd2b9..67834ffb 100644 --- a/test/commands/init.test.ts +++ b/test/commands/init.test.ts @@ -15,7 +15,7 @@ import * as wizardRunner from "../../src/lib/init/wizard-runner.js"; let capturedArgs: Record | undefined; let runWizardSpy: ReturnType; -const func = (await initCommand.loader()) as ( +const func = (await initCommand.loader()) as unknown as ( this: { cwd: string; stdout: { write: () => boolean }; @@ -23,7 +23,7 @@ const func = (await initCommand.loader()) as ( stdin: typeof process.stdin; }, flags: Record, - directory?: string + target?: string ) => Promise; function makeContext(cwd = "/projects/app") { @@ -129,7 +129,7 @@ describe("init command func", () => { }); describe("directory resolution", () => { - test("defaults to cwd when no directory provided", async () => { + test("defaults to cwd when no --directory flag provided", async () => { const ctx = makeContext("/projects/app"); await func.call(ctx, { yes: true, @@ -139,16 +139,13 @@ describe("init command func", () => { expect(capturedArgs?.directory).toBe("/projects/app"); }); - test("resolves relative directory against cwd", async () => { + test("resolves relative --directory flag against cwd", async () => { const ctx = makeContext("/projects/app"); - await func.call( - ctx, - { - yes: true, - "dry-run": false, - }, - "sub/dir" - ); + await func.call(ctx, { + yes: true, + "dry-run": false, + directory: "sub/dir", + }); expect(capturedArgs?.directory).toBe( path.resolve("/projects/app", "sub/dir") @@ -168,4 +165,98 @@ describe("init command func", () => { expect(capturedArgs?.dryRun).toBe(true); }); }); + + describe("org/project parsing", () => { + test("passes undefined org/project when no target provided", async () => { + const ctx = makeContext(); + await func.call(ctx, { + yes: true, + "dry-run": false, + }); + + expect(capturedArgs?.org).toBeUndefined(); + expect(capturedArgs?.project).toBeUndefined(); + }); + + test("parses org/project from explicit target", async () => { + const ctx = makeContext(); + await func.call( + ctx, + { + yes: true, + "dry-run": false, + }, + "acme/my-app" + ); + + expect(capturedArgs?.org).toBe("acme"); + expect(capturedArgs?.project).toBe("my-app"); + }); + + test("parses bare string as org (no project override)", async () => { + const ctx = makeContext(); + await func.call( + ctx, + { + yes: true, + "dry-run": false, + }, + "acme" + ); + + expect(capturedArgs?.org).toBe("acme"); + expect(capturedArgs?.project).toBeUndefined(); + }); + + test("parses org/ as org-only (no project override)", async () => { + const ctx = makeContext(); + await func.call( + ctx, + { + yes: true, + "dry-run": false, + }, + "acme/" + ); + + expect(capturedArgs?.org).toBe("acme"); + expect(capturedArgs?.project).toBeUndefined(); + }); + + test("combines target with --directory flag", async () => { + const ctx = makeContext("/projects/app"); + await func.call( + ctx, + { + yes: true, + "dry-run": false, + directory: "sub/dir", + }, + "acme/my-app" + ); + + expect(capturedArgs?.org).toBe("acme"); + expect(capturedArgs?.project).toBe("my-app"); + expect(capturedArgs?.directory).toBe( + path.resolve("/projects/app", "sub/dir") + ); + }); + + test("forwards team flag alongside org/project", async () => { + const ctx = makeContext(); + await func.call( + ctx, + { + yes: true, + "dry-run": false, + team: "backend", + }, + "acme/my-app" + ); + + expect(capturedArgs?.org).toBe("acme"); + expect(capturedArgs?.project).toBe("my-app"); + expect(capturedArgs?.team).toBe("backend"); + }); + }); }); From 8cf50cec2f99d84d3a9bdd937f44a271505494af Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 16 Mar 2026 10:59:04 +0000 Subject: [PATCH 02/10] chore: regenerate SKILL.md --- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 986ba5fb..db4bf177 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -700,7 +700,7 @@ Start a product trial Initialize Sentry in your project -#### `sentry init ` +#### `sentry init ` Initialize Sentry in your project @@ -709,6 +709,7 @@ Initialize Sentry in your project - `--dry-run - Preview changes without applying them` - `--features ... - Features to enable: errors,tracing,logs,replay,metrics` - `-t, --team - Team slug to create the project under` +- `-d, --directory - Project directory (default: current directory)` ### Issues From 16edb3728e00b64475f39dabbb9f8b5e74ab3a51 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Mon, 16 Mar 2026 16:37:57 +0530 Subject: [PATCH 03/10] fix(init): reject ambiguous bare target and validate org/project slugs Bare strings like 'sentry init acme' are ambiguous (could be org or project slug). Now throws a ContextError with a disambiguation hint telling users to use 'acme/' for org or 'acme/' for both. Also validates explicit org and project slugs via validateResourceId to catch malformed input (control chars, whitespace, etc.) early before API calls. --- src/commands/init.ts | 26 +++++++++++------ test/commands/init.test.ts | 58 ++++++++++++++++++++++++++++++-------- 2 files changed, 63 insertions(+), 21 deletions(-) diff --git a/src/commands/init.ts b/src/commands/init.ts index cc217f32..78136540 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -7,7 +7,7 @@ * * Supports org/project positional syntax to pin org and/or project name: * sentry init — auto-detect everything - * sentry init acme — explicit org, wizard picks project name + * sentry init acme/ — explicit org, wizard picks project name * sentry init acme/my-app — explicit org + project name override * sentry init --directory ./dir — specify project directory */ @@ -18,6 +18,7 @@ import { parseOrgProjectArg } from "../lib/arg-parsing.js"; import { buildCommand } from "../lib/command.js"; import { ContextError } from "../lib/errors.js"; import { runWizard } from "../lib/init/wizard-runner.js"; +import { validateResourceId } from "../lib/input-validation.js"; const FEATURE_DELIMITER = /[,+ ]+/; @@ -39,7 +40,7 @@ export const initCommand = buildCommand({ "If omitted, the org is auto-detected from config defaults.\n\n" + "Examples:\n" + " sentry init\n" + - " sentry init acme\n" + + " sentry init acme/\n" + " sentry init acme/my-app\n" + " sentry init acme/my-app --directory ./my-project\n" + " sentry init --directory ./my-project", @@ -50,7 +51,7 @@ export const initCommand = buildCommand({ parameters: [ { placeholder: "target", - brief: "/, , or omit for auto-detect", + brief: "/, /, or omit for auto-detect", parse: String, optional: true, }, @@ -115,15 +116,14 @@ export const initCommand = buildCommand({ explicitProject = parsed.project; break; case "org-all": - // "acme/" or bare "acme" — org only, no project name override explicitOrg = parsed.org; break; case "project-search": - // Bare string without "/" — could be an org slug or a project name. - // Treat it as an org slug since `sentry init ` is the primary use case. - // Users who want to override the project name should use org/project syntax. - explicitOrg = parsed.projectSlug; - break; + // Bare string without "/" is ambiguous — could be an org or project slug. + // Require the trailing slash to disambiguate (consistent with other commands). + throw new ContextError("Target", `sentry init ${parsed.projectSlug}/`, [ + `'${parsed.projectSlug}' is ambiguous. Use '${parsed.projectSlug}/' for org or '${parsed.projectSlug}/' for org + project.`, + ]); case "auto-detect": // No target provided — auto-detect everything break; @@ -133,6 +133,14 @@ export const initCommand = buildCommand({ } } + // Validate explicit org slug format before passing to API calls + if (explicitOrg) { + validateResourceId(explicitOrg, "organization slug"); + } + if (explicitProject) { + validateResourceId(explicitProject, "project name"); + } + await runWizard({ directory: targetDir, yes: flags.yes, diff --git a/test/commands/init.test.ts b/test/commands/init.test.ts index 67834ffb..753fdf95 100644 --- a/test/commands/init.test.ts +++ b/test/commands/init.test.ts @@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; 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 wizardRunner from "../../src/lib/init/wizard-runner.js"; @@ -193,19 +194,18 @@ describe("init command func", () => { expect(capturedArgs?.project).toBe("my-app"); }); - test("parses bare string as org (no project override)", async () => { + test("throws on bare string (ambiguous — could be org or project)", async () => { const ctx = makeContext(); - await func.call( - ctx, - { - yes: true, - "dry-run": false, - }, - "acme" - ); - - expect(capturedArgs?.org).toBe("acme"); - expect(capturedArgs?.project).toBeUndefined(); + expect( + func.call( + ctx, + { + yes: true, + "dry-run": false, + }, + "acme" + ) + ).rejects.toThrow(ContextError); }); test("parses org/ as org-only (no project override)", async () => { @@ -258,5 +258,39 @@ describe("init command func", () => { expect(capturedArgs?.project).toBe("my-app"); expect(capturedArgs?.team).toBe("backend"); }); + + test("rejects org slug with invalid characters", async () => { + const ctx = makeContext(); + expect( + func.call( + ctx, + { + yes: true, + "dry-run": false, + }, + "acme corp/" + ) + ).rejects.toThrow(); + }); + + test("error message for bare string includes disambiguation hint", async () => { + const ctx = makeContext(); + try { + await func.call( + ctx, + { + yes: true, + "dry-run": false, + }, + "myorg" + ); + expect.unreachable("should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ContextError); + const msg = (error as ContextError).message; + expect(msg).toContain("myorg/"); + expect(msg).toContain("myorg/"); + } + }); }); }); From 0acb1f61470b9b14d5fefc89061b6a1df96d2500 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Mon, 16 Mar 2026 18:19:03 +0530 Subject: [PATCH 04/10] feat(init): display org (and project) during wizard execution Show which organization is being used during the init wizard: - Explicit org from CLI arg: shown at startup before the spinner - Auto-detected/selected org: shown after resolution in createSentryProject Uses @clack/prompts log.info for consistent styling with other wizard messages. --- src/lib/init/local-ops.ts | 4 +++- src/lib/init/wizard-runner.ts | 11 +++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index d7c5a6bd..815aa24f 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -8,7 +8,7 @@ import { spawn } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; -import { isCancel, select } from "@clack/prompts"; +import { isCancel, log, select } from "@clack/prompts"; import { createProject, listOrganizations, @@ -742,6 +742,8 @@ async function createSentryProject( return orgResult; } orgSlug = orgResult; + // Show which org was resolved (explicit org is shown at startup by wizard-runner) + log.info(`Organization: ${orgSlug}`); } // 2. Resolve or create team diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index 3fff4871..8d8ed195 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -122,6 +122,15 @@ function errorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } +/** Log the explicitly provided org (and project) target before the wizard starts. */ +function logExplicitTarget(options: WizardOptions): void { + if (!options.org) { + return; + } + const projectHint = options.project ? ` (project: ${options.project})` : ""; + log.info(`Organization: ${options.org}${projectHint}`); +} + function assertWorkflowResult(raw: unknown): WorkflowRunResult { if (!raw || typeof raw !== "object") { throw new Error("Invalid workflow response: expected object"); @@ -255,6 +264,8 @@ export async function runWizard(options: WizardOptions): Promise { `\nFor manual setup: ${terminalLink(SENTRY_DOCS_URL)}` ); + logExplicitTarget(options); + const tracingOptions = { traceId: randomBytes(16).toString("hex"), tags: ["sentry-cli", "init-wizard"], From 12151fdaddbbfa8a0e0ae0313dc0626bfcc1e238 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Mon, 16 Mar 2026 23:03:23 +0530 Subject: [PATCH 05/10] feat(init): two positionals with smart path/target disambiguation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redesign init command to accept two optional positionals: sentry init [target] [directory] Path-like args (starting with . / ~) are treated as the directory; everything else is treated as the org/project target. When args are in the wrong order (path first, target second), they are auto-swapped with a warning — following the established swap pattern from view commands. Bare slugs (e.g., 'sentry init my-app') are resolved via resolveProjectBySlug to search across all accessible orgs, setting both org and project from the match. Add looksLikePath() to arg-parsing.ts for syntactic path detection (no filesystem I/O). Remove the --directory flag in favor of the second positional. --- src/commands/init.ts | 196 ++++++++++++++++------- src/lib/arg-parsing.ts | 38 +++++ test/commands/init.test.ts | 315 ++++++++++++++++++++----------------- 3 files changed, 354 insertions(+), 195 deletions(-) diff --git a/src/commands/init.ts b/src/commands/init.ts index 78136540..2bdbeced 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -5,45 +5,149 @@ * Communicates with the Mastra API via suspend/resume to perform * local filesystem operations and interactive prompts. * - * Supports org/project positional syntax to pin org and/or project name: - * sentry init — auto-detect everything - * sentry init acme/ — explicit org, wizard picks project name - * sentry init acme/my-app — explicit org + project name override - * sentry init --directory ./dir — specify project directory + * Supports two optional positionals with smart disambiguation: + * sentry init — auto-detect everything, dir = cwd + * sentry init . — dir = cwd, auto-detect org + * sentry init ./subdir — dir = subdir, auto-detect org + * sentry init acme/ — explicit org, dir = cwd + * sentry init acme/my-app — explicit org + project, dir = cwd + * sentry init my-app — search for project across orgs + * sentry init acme/ ./subdir — explicit org, dir = subdir + * sentry init acme/my-app ./subdir — explicit org + project, dir = subdir + * sentry init ./subdir acme/ — swapped, auto-correct with warning */ import path from "node:path"; import type { SentryContext } from "../context.js"; -import { parseOrgProjectArg } from "../lib/arg-parsing.js"; +import { looksLikePath, parseOrgProjectArg } from "../lib/arg-parsing.js"; import { buildCommand } from "../lib/command.js"; import { ContextError } from "../lib/errors.js"; import { runWizard } from "../lib/init/wizard-runner.js"; import { validateResourceId } from "../lib/input-validation.js"; +import { logger } from "../lib/logger.js"; +import { resolveProjectBySlug } from "../lib/resolve-target.js"; + +const log = logger.withTag("init"); const FEATURE_DELIMITER = /[,+ ]+/; +const USAGE_HINT = "sentry init / [directory]"; + type InitFlags = { readonly yes: boolean; readonly "dry-run": boolean; readonly features?: string[]; readonly team?: string; - readonly directory?: string; }; -export const initCommand = buildCommand({ +/** + * Classify and separate two optional positional args into a target and a directory. + * + * Uses {@link looksLikePath} to distinguish filesystem paths from org/project targets. + * Detects swapped arguments and emits a warning when auto-correcting. + * + * @returns Resolved target string (or undefined) and directory string (or undefined) + */ +function classifyArgs( + first?: string, + second?: string +): { target: string | undefined; directory: string | undefined } { + // No args — auto-detect everything + if (!first) { + return { target: undefined, directory: undefined }; + } + + const firstIsPath = looksLikePath(first); + + // Single arg + if (!second) { + return firstIsPath + ? { target: undefined, directory: first } + : { target: first, directory: undefined }; + } + + const secondIsPath = looksLikePath(second); + + // Two paths → error + if (firstIsPath && secondIsPath) { + throw new ContextError("Arguments", USAGE_HINT, [ + "Two directory paths provided. Only one directory is allowed.", + ]); + } + + // Two targets → error + if (!(firstIsPath || secondIsPath)) { + throw new ContextError("Arguments", USAGE_HINT, [ + "Two targets provided. Use / for the target and a path (e.g., ./dir) for the directory.", + ]); + } + + // (TARGET, PATH) — correct order + if (!firstIsPath && secondIsPath) { + return { target: first, directory: second }; + } + + // (PATH, TARGET) — swapped, auto-correct with warning + log.warn(`Arguments appear reversed. Interpreting as: ${second} ${first}`); + return { target: second, directory: first }; +} + +/** + * Resolve the parsed org/project target into explicit org and project values. + * + * For `project-search` (bare slug), calls {@link resolveProjectBySlug} to search + * across all accessible orgs and determine both org and project from the match. + */ +async function resolveTarget(targetArg: string | undefined): Promise<{ + org: string | undefined; + project: string | undefined; +}> { + const parsed = parseOrgProjectArg(targetArg); + + switch (parsed.type) { + case "explicit": + return { org: parsed.org, project: parsed.project }; + case "org-all": + return { org: parsed.org, project: undefined }; + case "project-search": { + // Bare slug — search for a project with this name across all orgs. + // resolveProjectBySlug handles not-found, ambiguity, and org-name-collision errors. + const resolved = await resolveProjectBySlug( + parsed.projectSlug, + USAGE_HINT, + `sentry init ${parsed.projectSlug}/ (if '${parsed.projectSlug}' is an org)` + ); + return { org: resolved.org, project: resolved.project }; + } + case "auto-detect": + return { org: undefined, project: undefined }; + default: { + const _exhaustive: never = parsed; + throw new ContextError("Target", String(_exhaustive)); + } + } +} + +export const initCommand = buildCommand< + InitFlags, + [string?, string?], + SentryContext +>({ docs: { brief: "Initialize Sentry in your project", fullDescription: "Runs the Sentry setup wizard to detect your project's framework, " + "install the SDK, and configure Sentry.\n\n" + - "The target supports org/project syntax to specify context explicitly.\n" + - "If omitted, the org is auto-detected from config defaults.\n\n" + + "Supports org/project syntax and a directory positional. Path-like\n" + + "arguments (starting with . / ~) are treated as the directory;\n" + + "everything else is treated as the target.\n\n" + "Examples:\n" + " sentry init\n" + " sentry init acme/\n" + " sentry init acme/my-app\n" + - " sentry init acme/my-app --directory ./my-project\n" + - " sentry init --directory ./my-project", + " sentry init my-app\n" + + " sentry init acme/my-app ./my-project\n" + + " sentry init ./my-project", }, parameters: { positional: { @@ -51,7 +155,13 @@ export const initCommand = buildCommand({ parameters: [ { placeholder: "target", - brief: "/, /, or omit for auto-detect", + brief: "/, /, , or a directory path", + parse: String, + optional: true, + }, + { + placeholder: "directory", + brief: "Project directory (default: current directory)", parse: String, optional: true, }, @@ -81,59 +191,38 @@ export const initCommand = buildCommand({ brief: "Team slug to create the project under", optional: true, }, - directory: { - kind: "parsed", - parse: String, - brief: "Project directory (default: current directory)", - optional: true, - }, }, aliases: { y: "yes", t: "team", - d: "directory", }, }, - async *func(this: SentryContext, flags: InitFlags, targetArg?: string) { - const targetDir = flags.directory - ? path.resolve(this.cwd, flags.directory) - : this.cwd; + async *func( + this: SentryContext, + flags: InitFlags, + first?: string, + second?: string + ) { + // 1. Classify positionals into target vs directory + const { target: targetArg, directory: dirArg } = classifyArgs( + first, + second + ); + + // 2. Resolve directory + const targetDir = dirArg ? path.resolve(this.cwd, dirArg) : this.cwd; + // 3. Parse features const featuresList = flags.features ?.flatMap((f) => f.split(FEATURE_DELIMITER)) .map((f) => f.trim()) .filter(Boolean); - // Parse the target arg to extract org and/or project - const parsed = parseOrgProjectArg(targetArg); - - let explicitOrg: string | undefined; - let explicitProject: string | undefined; - - switch (parsed.type) { - case "explicit": - explicitOrg = parsed.org; - explicitProject = parsed.project; - break; - case "org-all": - explicitOrg = parsed.org; - break; - case "project-search": - // Bare string without "/" is ambiguous — could be an org or project slug. - // Require the trailing slash to disambiguate (consistent with other commands). - throw new ContextError("Target", `sentry init ${parsed.projectSlug}/`, [ - `'${parsed.projectSlug}' is ambiguous. Use '${parsed.projectSlug}/' for org or '${parsed.projectSlug}/' for org + project.`, - ]); - case "auto-detect": - // No target provided — auto-detect everything - break; - default: { - const _exhaustive: never = parsed; - throw new ContextError("Target", String(_exhaustive)); - } - } + // 4. Resolve target → org + project + const { org: explicitOrg, project: explicitProject } = + await resolveTarget(targetArg); - // Validate explicit org slug format before passing to API calls + // 5. Validate explicit slugs before passing to API calls if (explicitOrg) { validateResourceId(explicitOrg, "organization slug"); } @@ -141,6 +230,7 @@ export const initCommand = buildCommand({ validateResourceId(explicitProject, "project name"); } + // 6. Run the wizard await runWizard({ directory: targetDir, yes: flags.yes, diff --git a/src/lib/arg-parsing.ts b/src/lib/arg-parsing.ts index 9bfb3d46..a63d4a85 100644 --- a/src/lib/arg-parsing.ts +++ b/src/lib/arg-parsing.ts @@ -79,6 +79,44 @@ export function looksLikeIssueShortId(str: string): boolean { } // --------------------------------------------------------------------------- +// Path detection +// --------------------------------------------------------------------------- + +/** + * Check if a string looks like a filesystem path rather than a slug/identifier. + * + * Uses purely syntactic checks — no filesystem I/O. Detects: + * - `.` (current directory) + * - `./foo`, `../foo` (relative paths) + * - `/foo` (absolute paths) + * - `~/foo` (home directory paths) + * + * Bare names like `my-org` or `my-project` never match, which is what makes + * this useful for disambiguating positional arguments that could be either + * a filesystem path or an org/project target. + * + * @param arg - CLI argument string to check + * @returns true if the string looks like a filesystem path + * + * @example + * looksLikePath(".") // true + * looksLikePath("./subdir") // true + * looksLikePath("../parent") // true + * looksLikePath("/absolute") // true + * looksLikePath("~/home") // true + * looksLikePath("my-project") // false + * looksLikePath("acme/app") // false + */ +export function looksLikePath(arg: string): boolean { + return ( + arg === "." || + arg.startsWith("./") || + arg.startsWith("../") || + arg.startsWith("/") || + arg.startsWith("~") + ); +} + // Argument swap detection for view commands // --------------------------------------------------------------------------- diff --git a/test/commands/init.test.ts b/test/commands/init.test.ts index 753fdf95..87722d52 100644 --- a/test/commands/init.test.ts +++ b/test/commands/init.test.ts @@ -1,8 +1,9 @@ /** * Tests for the `sentry init` command entry point. * - * Uses spyOn on the wizard-runner namespace to capture runWizard calls - * without mock.module (which leaks across test files). + * Uses spyOn on the wizard-runner and resolve-target namespaces to + * capture runWizard calls and mock resolveProjectBySlug without + * mock.module (which leaks across test files). */ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; @@ -11,10 +12,13 @@ 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 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"; -// ── Spy on runWizard to capture call args ───────────────────────────────── +// ── Spy setup ───────────────────────────────────────────────────────────── let capturedArgs: Record | undefined; let runWizardSpy: ReturnType; +let resolveProjectSpy: ReturnType; const func = (await initCommand.loader()) as unknown as ( this: { @@ -24,7 +28,8 @@ const func = (await initCommand.loader()) as unknown as ( stdin: typeof process.stdin; }, flags: Record, - target?: string + first?: string, + second?: string ) => Promise; function makeContext(cwd = "/projects/app") { @@ -36,6 +41,8 @@ function makeContext(cwd = "/projects/app") { }; } +const DEFAULT_FLAGS = { yes: true, "dry-run": false } as const; + beforeEach(() => { capturedArgs = undefined; runWizardSpy = spyOn(wizardRunner, "runWizard").mockImplementation( @@ -44,253 +51,277 @@ beforeEach(() => { return Promise.resolve(); } ); + // Default: mock resolveProjectBySlug to return a match + resolveProjectSpy = spyOn( + resolveTarget, + "resolveProjectBySlug" + ).mockImplementation(async (slug: string) => ({ + org: "resolved-org", + project: slug, + })); }); afterEach(() => { runWizardSpy.mockRestore(); + resolveProjectSpy.mockRestore(); }); describe("init command func", () => { + // ── Features parsing ────────────────────────────────────────────────── + describe("features parsing", () => { test("splits comma-separated features", async () => { const ctx = makeContext(); await func.call(ctx, { - yes: true, - "dry-run": false, + ...DEFAULT_FLAGS, features: ["errors,tracing,logs"], }); - expect(capturedArgs?.features).toEqual(["errors", "tracing", "logs"]); }); test("splits plus-separated features", async () => { const ctx = makeContext(); await func.call(ctx, { - yes: true, - "dry-run": false, + ...DEFAULT_FLAGS, features: ["errors+tracing+logs"], }); - expect(capturedArgs?.features).toEqual(["errors", "tracing", "logs"]); }); test("splits space-separated features", async () => { const ctx = makeContext(); await func.call(ctx, { - yes: true, - "dry-run": false, + ...DEFAULT_FLAGS, features: ["errors tracing logs"], }); - expect(capturedArgs?.features).toEqual(["errors", "tracing", "logs"]); }); test("merges multiple --features flags", async () => { const ctx = makeContext(); await func.call(ctx, { - yes: true, - "dry-run": false, + ...DEFAULT_FLAGS, features: ["errors,tracing", "logs"], }); - expect(capturedArgs?.features).toEqual(["errors", "tracing", "logs"]); }); test("trims whitespace from features", async () => { const ctx = makeContext(); await func.call(ctx, { - yes: true, - "dry-run": false, + ...DEFAULT_FLAGS, features: [" errors , tracing "], }); - expect(capturedArgs?.features).toEqual(["errors", "tracing"]); }); test("filters empty segments", async () => { const ctx = makeContext(); await func.call(ctx, { - yes: true, - "dry-run": false, + ...DEFAULT_FLAGS, features: ["errors,,tracing,"], }); - expect(capturedArgs?.features).toEqual(["errors", "tracing"]); }); test("passes undefined when features not provided", async () => { const ctx = makeContext(); - await func.call(ctx, { - yes: true, - "dry-run": false, - }); - + await func.call(ctx, DEFAULT_FLAGS); expect(capturedArgs?.features).toBeUndefined(); }); }); - describe("directory resolution", () => { - test("defaults to cwd when no --directory flag provided", async () => { - const ctx = makeContext("/projects/app"); - await func.call(ctx, { - yes: true, - "dry-run": false, - }); + // ── No arguments ────────────────────────────────────────────────────── + describe("no arguments", () => { + test("defaults to cwd with auto-detect", async () => { + const ctx = makeContext("/projects/app"); + await func.call(ctx, DEFAULT_FLAGS); expect(capturedArgs?.directory).toBe("/projects/app"); + expect(capturedArgs?.org).toBeUndefined(); + expect(capturedArgs?.project).toBeUndefined(); }); + }); + + // ── Single path argument ────────────────────────────────────────────── - test("resolves relative --directory flag against cwd", async () => { + describe("single path argument", () => { + test(". resolves to cwd", async () => { const ctx = makeContext("/projects/app"); - await func.call(ctx, { - yes: true, - "dry-run": false, - directory: "sub/dir", - }); + await func.call(ctx, DEFAULT_FLAGS, "."); + expect(capturedArgs?.directory).toBe(path.resolve("/projects/app", ".")); + expect(capturedArgs?.org).toBeUndefined(); + expect(capturedArgs?.project).toBeUndefined(); + }); + test("./subdir resolves relative to cwd", async () => { + const ctx = makeContext("/projects/app"); + await func.call(ctx, DEFAULT_FLAGS, "./subdir"); expect(capturedArgs?.directory).toBe( - path.resolve("/projects/app", "sub/dir") + path.resolve("/projects/app", "./subdir") ); + expect(capturedArgs?.org).toBeUndefined(); }); - }); - describe("flag forwarding", () => { - test("forwards yes and dry-run flags", async () => { - const ctx = makeContext(); - await func.call(ctx, { - yes: true, - "dry-run": true, - }); + test("../other resolves relative to cwd", async () => { + const ctx = makeContext("/projects/app"); + await func.call(ctx, DEFAULT_FLAGS, "../other"); + expect(capturedArgs?.directory).toBe( + path.resolve("/projects/app", "../other") + ); + expect(capturedArgs?.org).toBeUndefined(); + }); - expect(capturedArgs?.yes).toBe(true); - expect(capturedArgs?.dryRun).toBe(true); + test("/absolute/path used as-is", async () => { + const ctx = makeContext("/projects/app"); + await func.call(ctx, DEFAULT_FLAGS, "/absolute/path"); + expect(capturedArgs?.directory).toBe("/absolute/path"); + expect(capturedArgs?.org).toBeUndefined(); + }); + + test("~/home resolves relative to cwd", async () => { + const ctx = makeContext("/projects/app"); + await func.call(ctx, DEFAULT_FLAGS, "~/projects/other"); + expect(capturedArgs?.directory).toBe( + path.resolve("/projects/app", "~/projects/other") + ); + expect(capturedArgs?.org).toBeUndefined(); }); }); - describe("org/project parsing", () => { - test("passes undefined org/project when no target provided", async () => { - const ctx = makeContext(); - await func.call(ctx, { - yes: true, - "dry-run": false, - }); + // ── Single target argument ──────────────────────────────────────────── - expect(capturedArgs?.org).toBeUndefined(); + describe("single target argument", () => { + test("org/ sets explicit org, dir = cwd", async () => { + const ctx = makeContext("/projects/app"); + await func.call(ctx, DEFAULT_FLAGS, "acme/"); + expect(capturedArgs?.org).toBe("acme"); expect(capturedArgs?.project).toBeUndefined(); + expect(capturedArgs?.directory).toBe("/projects/app"); }); - test("parses org/project from explicit target", async () => { - const ctx = makeContext(); - await func.call( - ctx, - { - yes: true, - "dry-run": false, - }, - "acme/my-app" - ); - + test("org/project sets both, dir = cwd", async () => { + const ctx = makeContext("/projects/app"); + await func.call(ctx, DEFAULT_FLAGS, "acme/my-app"); expect(capturedArgs?.org).toBe("acme"); expect(capturedArgs?.project).toBe("my-app"); + expect(capturedArgs?.directory).toBe("/projects/app"); }); - test("throws on bare string (ambiguous — could be org or project)", async () => { - const ctx = makeContext(); - expect( - func.call( - ctx, - { - yes: true, - "dry-run": false, - }, - "acme" - ) - ).rejects.toThrow(ContextError); + test("bare slug resolves project via resolveProjectBySlug", async () => { + const ctx = makeContext("/projects/app"); + await func.call(ctx, DEFAULT_FLAGS, "my-app"); + expect(resolveProjectSpy).toHaveBeenCalledWith( + "my-app", + expect.any(String), + expect.any(String) + ); + expect(capturedArgs?.org).toBe("resolved-org"); + expect(capturedArgs?.project).toBe("my-app"); + expect(capturedArgs?.directory).toBe("/projects/app"); }); + }); - test("parses org/ as org-only (no project override)", async () => { - const ctx = makeContext(); - await func.call( - ctx, - { - yes: true, - "dry-run": false, - }, - "acme/" - ); + // ── Two arguments: target + directory ───────────────────────────────── + describe("two arguments (target + directory)", () => { + test("org/ + path", async () => { + const ctx = makeContext("/projects/app"); + await func.call(ctx, DEFAULT_FLAGS, "acme/", "./subdir"); expect(capturedArgs?.org).toBe("acme"); expect(capturedArgs?.project).toBeUndefined(); + expect(capturedArgs?.directory).toBe( + path.resolve("/projects/app", "./subdir") + ); }); - test("combines target with --directory flag", async () => { + test("org/project + path", async () => { const ctx = makeContext("/projects/app"); - await func.call( - ctx, - { - yes: true, - "dry-run": false, - directory: "sub/dir", - }, - "acme/my-app" + await func.call(ctx, DEFAULT_FLAGS, "acme/my-app", "./subdir"); + expect(capturedArgs?.org).toBe("acme"); + expect(capturedArgs?.project).toBe("my-app"); + expect(capturedArgs?.directory).toBe( + path.resolve("/projects/app", "./subdir") + ); + }); + + test("bare slug + path", async () => { + const ctx = makeContext("/projects/app"); + await func.call(ctx, DEFAULT_FLAGS, "my-app", "./subdir"); + expect(resolveProjectSpy).toHaveBeenCalled(); + expect(capturedArgs?.org).toBe("resolved-org"); + expect(capturedArgs?.project).toBe("my-app"); + expect(capturedArgs?.directory).toBe( + path.resolve("/projects/app", "./subdir") ); + }); + }); + + // ── Swapped arguments ───────────────────────────────────────────────── + describe("swapped arguments (path first, target second)", () => { + test(". org/ swaps with warning", async () => { + const ctx = makeContext("/projects/app"); + await func.call(ctx, DEFAULT_FLAGS, ".", "acme/"); + expect(capturedArgs?.org).toBe("acme"); + expect(capturedArgs?.project).toBeUndefined(); + expect(capturedArgs?.directory).toBe(path.resolve("/projects/app", ".")); + }); + + test("./subdir org/project swaps with warning", async () => { + const ctx = makeContext("/projects/app"); + await func.call(ctx, DEFAULT_FLAGS, "./subdir", "acme/my-app"); expect(capturedArgs?.org).toBe("acme"); expect(capturedArgs?.project).toBe("my-app"); expect(capturedArgs?.directory).toBe( - path.resolve("/projects/app", "sub/dir") + path.resolve("/projects/app", "./subdir") ); }); + }); - test("forwards team flag alongside org/project", async () => { + // ── Error cases ─────────────────────────────────────────────────────── + + describe("error cases", () => { + test("two paths throws ContextError", async () => { const ctx = makeContext(); - await func.call( - ctx, - { - yes: true, - "dry-run": false, - team: "backend", - }, - "acme/my-app" + expect(func.call(ctx, DEFAULT_FLAGS, "./dir1", "./dir2")).rejects.toThrow( + ContextError ); + }); - expect(capturedArgs?.org).toBe("acme"); - expect(capturedArgs?.project).toBe("my-app"); - expect(capturedArgs?.team).toBe("backend"); + test("two targets throws ContextError", async () => { + const ctx = makeContext(); + expect(func.call(ctx, DEFAULT_FLAGS, "acme/", "other/")).rejects.toThrow( + ContextError + ); }); - test("rejects org slug with invalid characters", async () => { + test("invalid org slug (whitespace) throws", async () => { const ctx = makeContext(); - expect( - func.call( - ctx, - { - yes: true, - "dry-run": false, - }, - "acme corp/" - ) - ).rejects.toThrow(); + expect(func.call(ctx, DEFAULT_FLAGS, "acme corp/")).rejects.toThrow(); }); + }); + + // ── Flag forwarding ─────────────────────────────────────────────────── - test("error message for bare string includes disambiguation hint", async () => { + describe("flag forwarding", () => { + test("forwards yes and dry-run flags", async () => { + const ctx = makeContext(); + await func.call(ctx, { yes: true, "dry-run": true }); + expect(capturedArgs?.yes).toBe(true); + expect(capturedArgs?.dryRun).toBe(true); + }); + + test("forwards team flag alongside org/project", async () => { const ctx = makeContext(); - try { - await func.call( - ctx, - { - yes: true, - "dry-run": false, - }, - "myorg" - ); - expect.unreachable("should have thrown"); - } catch (error) { - expect(error).toBeInstanceOf(ContextError); - const msg = (error as ContextError).message; - expect(msg).toContain("myorg/"); - expect(msg).toContain("myorg/"); - } + await func.call( + ctx, + { ...DEFAULT_FLAGS, team: "backend" }, + "acme/my-app" + ); + expect(capturedArgs?.org).toBe("acme"); + expect(capturedArgs?.project).toBe("my-app"); + expect(capturedArgs?.team).toBe("backend"); }); }); }); From 6337d42b6217974a8ae1ab6b4d2f24e64d288d4a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 16 Mar 2026 17:34:02 +0000 Subject: [PATCH 06/10] chore: regenerate SKILL.md --- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index db4bf177..5c7a1d89 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -700,7 +700,7 @@ Start a product trial Initialize Sentry in your project -#### `sentry init ` +#### `sentry init ` Initialize Sentry in your project @@ -709,7 +709,6 @@ Initialize Sentry in your project - `--dry-run - Preview changes without applying them` - `--features ... - Features to enable: errors,tracing,logs,replay,metrics` - `-t, --team - Team slug to create the project under` -- `-d, --directory - Project directory (default: current directory)` ### Issues From b1fbb5873d6eff8d96c687dabf3376478eaebf5f Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Mon, 16 Mar 2026 23:13:03 +0530 Subject: [PATCH 07/10] fix(init): validate only user-provided slugs, tighten looksLikePath MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move validateResourceId into resolveTarget so it only runs on user-provided values (explicit and org-all cases), not on API-resolved slugs from resolveProjectBySlug. Tighten looksLikePath to match ~ only as '~' or '~/' — not '~foo', which could be a valid slug. Rename misleading test accordingly. --- src/commands/init.ts | 16 +++++++--------- src/lib/arg-parsing.ts | 12 ++++++++++-- test/commands/init.test.ts | 2 +- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/commands/init.ts b/src/commands/init.ts index 2bdbeced..233c572c 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -106,8 +106,12 @@ async function resolveTarget(targetArg: string | undefined): Promise<{ switch (parsed.type) { case "explicit": + // Validate user-provided slugs before they reach API calls + validateResourceId(parsed.org, "organization slug"); + validateResourceId(parsed.project, "project name"); return { org: parsed.org, project: parsed.project }; case "org-all": + validateResourceId(parsed.org, "organization slug"); return { org: parsed.org, project: undefined }; case "project-search": { // Bare slug — search for a project with this name across all orgs. @@ -219,18 +223,12 @@ export const initCommand = buildCommand< .filter(Boolean); // 4. Resolve target → org + project + // Validation of user-provided slugs happens inside resolveTarget. + // API-resolved values (from resolveProjectBySlug) are already valid. const { org: explicitOrg, project: explicitProject } = await resolveTarget(targetArg); - // 5. Validate explicit slugs before passing to API calls - if (explicitOrg) { - validateResourceId(explicitOrg, "organization slug"); - } - if (explicitProject) { - validateResourceId(explicitProject, "project name"); - } - - // 6. Run the wizard + // 5. Run the wizard await runWizard({ directory: targetDir, yes: flags.yes, diff --git a/src/lib/arg-parsing.ts b/src/lib/arg-parsing.ts index a63d4a85..a78b3f5a 100644 --- a/src/lib/arg-parsing.ts +++ b/src/lib/arg-parsing.ts @@ -89,12 +89,17 @@ export function looksLikeIssueShortId(str: string): boolean { * - `.` (current directory) * - `./foo`, `../foo` (relative paths) * - `/foo` (absolute paths) - * - `~/foo` (home directory paths) + * - `~` or `~/foo` (home directory paths) * * Bare names like `my-org` or `my-project` never match, which is what makes * this useful for disambiguating positional arguments that could be either * a filesystem path or an org/project target. * + * Note: `~` is only matched as `~` alone or `~/...`, not `~foo`. This avoids + * false positives on slugs that happen to start with tilde (valid in Sentry slugs). + * Shell expansion of `~/foo` happens before the CLI sees the argument, so a literal + * `~/foo` reaching this function means the shell didn't expand it (e.g., it was quoted). + * * @param arg - CLI argument string to check * @returns true if the string looks like a filesystem path * @@ -104,16 +109,19 @@ export function looksLikeIssueShortId(str: string): boolean { * looksLikePath("../parent") // true * looksLikePath("/absolute") // true * looksLikePath("~/home") // true + * looksLikePath("~") // true + * looksLikePath("~foo") // false (could be a slug) * looksLikePath("my-project") // false * looksLikePath("acme/app") // false */ export function looksLikePath(arg: string): boolean { return ( arg === "." || + arg === "~" || arg.startsWith("./") || arg.startsWith("../") || arg.startsWith("/") || - arg.startsWith("~") + arg.startsWith("~/") ); } diff --git a/test/commands/init.test.ts b/test/commands/init.test.ts index 87722d52..6b16fbb2 100644 --- a/test/commands/init.test.ts +++ b/test/commands/init.test.ts @@ -179,7 +179,7 @@ describe("init command func", () => { expect(capturedArgs?.org).toBeUndefined(); }); - test("~/home resolves relative to cwd", async () => { + test("~/path treated as literal path (no shell expansion)", async () => { const ctx = makeContext("/projects/app"); await func.call(ctx, DEFAULT_FLAGS, "~/projects/other"); expect(capturedArgs?.directory).toBe( From 93b90fd541a64d3abe8dec767249cfc44a35694b Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Mon, 16 Mar 2026 23:32:17 +0530 Subject: [PATCH 08/10] refactor(init): remove org logging from wizard runner and local-ops Drop logExplicitTarget() from wizard-runner.ts and the log.info() call after org resolution in local-ops.ts. This keeps the PR focused on the arg parsing changes. --- src/lib/init/local-ops.ts | 4 +--- src/lib/init/wizard-runner.ts | 11 ----------- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index 815aa24f..d7c5a6bd 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -8,7 +8,7 @@ import { spawn } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; -import { isCancel, log, select } from "@clack/prompts"; +import { isCancel, select } from "@clack/prompts"; import { createProject, listOrganizations, @@ -742,8 +742,6 @@ async function createSentryProject( return orgResult; } orgSlug = orgResult; - // Show which org was resolved (explicit org is shown at startup by wizard-runner) - log.info(`Organization: ${orgSlug}`); } // 2. Resolve or create team diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index 8d8ed195..3fff4871 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -122,15 +122,6 @@ function errorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } -/** Log the explicitly provided org (and project) target before the wizard starts. */ -function logExplicitTarget(options: WizardOptions): void { - if (!options.org) { - return; - } - const projectHint = options.project ? ` (project: ${options.project})` : ""; - log.info(`Organization: ${options.org}${projectHint}`); -} - function assertWorkflowResult(raw: unknown): WorkflowRunResult { if (!raw || typeof raw !== "object") { throw new Error("Invalid workflow response: expected object"); @@ -264,8 +255,6 @@ export async function runWizard(options: WizardOptions): Promise { `\nFor manual setup: ${terminalLink(SENTRY_DOCS_URL)}` ); - logExplicitTarget(options); - const tracingOptions = { traceId: randomBytes(16).toString("hex"), tags: ["sentry-cli", "init-wizard"], From cc6606764c46d1b8d4597b5df3a381278cb91afa Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Mon, 16 Mar 2026 23:49:57 +0530 Subject: [PATCH 09/10] fix(init): skip project creation when project already exists When both org and project are explicitly provided (e.g., from resolveProjectBySlug resolving a bare slug), check if the project already exists via getProject before attempting to create it. This avoids a 409 Conflict from the create API when re-running init on an existing Sentry project. Also extract resolveOrgForInit and formatLocalOpError helpers to keep createSentryProject under the cognitive complexity limit. --- src/lib/init/local-ops.ts | 103 ++++++++++++++++++++++++++++++-------- 1 file changed, 81 insertions(+), 22 deletions(-) diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index d7c5a6bd..b489d8dc 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -11,6 +11,7 @@ import path from "node:path"; import { isCancel, select } from "@clack/prompts"; import { createProject, + getProject, listOrganizations, tryGetPrimaryDsn, } from "../api-client.js"; @@ -702,6 +703,56 @@ async function resolveOrgSlug( return selected; } +/** + * Try to fetch an existing project by org + slug. Returns a successful + * LocalOpResult if the project exists, or null if it doesn't (404). + * Other errors are left to propagate. + */ +async function tryGetExistingProject( + orgSlug: string, + projectSlug: string +): Promise { + try { + const project = await getProject(orgSlug, projectSlug); + const dsn = await tryGetPrimaryDsn(orgSlug, project.slug); + const url = buildProjectUrl(orgSlug, project.slug); + return { + ok: true, + data: { + orgSlug, + projectSlug: project.slug, + projectId: project.id, + dsn: dsn ?? "", + url, + }, + }; + } catch (error) { + // 404 means project doesn't exist — fall through to creation + if (error instanceof ApiError && error.status === 404) { + return null; + } + throw error; + } +} + +/** + * Resolve the org slug from CLI args, env, config, or interactive prompt. + * Returns the org slug string, or a LocalOpResult if resolution fails or is cancelled. + */ +async function resolveOrgForInit( + cwd: string, + options: WizardOptions +): Promise { + if (options.org) { + return options.org; + } + const orgResult = await resolveOrgSlug(cwd, options.yes); + if (typeof orgResult !== "string") { + return orgResult; + } + return orgResult; +} + async function createSentryProject( payload: CreateSentryProjectPayload, options: WizardOptions @@ -732,35 +783,40 @@ async function createSentryProject( } try { - // 1. Resolve org — skip interactive resolution if explicitly provided via CLI arg - let orgSlug: string; - if (options.org) { - orgSlug = options.org; - } else { - const orgResult = await resolveOrgSlug(payload.cwd, options.yes); - if (typeof orgResult !== "string") { - return orgResult; + // 1. Resolve org + const orgResult = await resolveOrgForInit(payload.cwd, options); + if (typeof orgResult !== "string") { + return orgResult; + } + const orgSlug = orgResult; + + // 2. 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) { + const existing = await tryGetExistingProject(orgSlug, slug); + if (existing) { + return existing; } - orgSlug = orgResult; } - // 2. Resolve or create team + // 3. Resolve or create team const team = await resolveOrCreateTeam(orgSlug, { team: options.team, autoCreateSlug: slug, usageHint: "sentry init", }); - // 3. Create project + // 4. Create project const project = await createProject(orgSlug, team.slug, { name, platform, }); - // 4. Get DSN (best-effort) + // 5. Get DSN (best-effort) const dsn = await tryGetPrimaryDsn(orgSlug, project.slug); - // 5. Build URL + // 6. Build URL const url = buildProjectUrl(orgSlug, project.slug); return { @@ -774,14 +830,17 @@ async function createSentryProject( }, }; } catch (error) { - let message: string; - if (error instanceof ApiError) { - message = error.format(); - } else if (error instanceof Error) { - message = error.message; - } else { - message = String(error); - } - return { ok: false, error: message }; + return { ok: false, error: formatLocalOpError(error) }; + } +} + +/** Format an error from a local-op into a user-facing message string. */ +function formatLocalOpError(error: unknown): string { + if (error instanceof ApiError) { + return error.format(); + } + if (error instanceof Error) { + return error.message; } + return String(error); } From f3eb08d886d5095fa8220b9f47ad4b27f12f0dc5 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Mon, 16 Mar 2026 23:52:36 +0530 Subject: [PATCH 10/10] refactor(init): remove unnecessary resolveOrgForInit wrapper Inline the org resolution back into createSentryProject. The formatLocalOpError extraction alone keeps the function under the cognitive complexity limit. --- src/lib/init/local-ops.ts | 33 ++++++++++----------------------- 1 file changed, 10 insertions(+), 23 deletions(-) diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index b489d8dc..a68fe815 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -735,24 +735,6 @@ async function tryGetExistingProject( } } -/** - * Resolve the org slug from CLI args, env, config, or interactive prompt. - * Returns the org slug string, or a LocalOpResult if resolution fails or is cancelled. - */ -async function resolveOrgForInit( - cwd: string, - options: WizardOptions -): Promise { - if (options.org) { - return options.org; - } - const orgResult = await resolveOrgSlug(cwd, options.yes); - if (typeof orgResult !== "string") { - return orgResult; - } - return orgResult; -} - async function createSentryProject( payload: CreateSentryProjectPayload, options: WizardOptions @@ -783,12 +765,17 @@ async function createSentryProject( } try { - // 1. Resolve org - const orgResult = await resolveOrgForInit(payload.cwd, options); - if (typeof orgResult !== "string") { - return orgResult; + // 1. Resolve org — skip interactive resolution if explicitly provided via CLI arg + let orgSlug: string; + if (options.org) { + orgSlug = options.org; + } else { + const orgResult = await resolveOrgSlug(payload.cwd, options.yes); + if (typeof orgResult !== "string") { + return orgResult; + } + orgSlug = orgResult; } - const orgSlug = orgResult; // 2. 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