diff --git a/AGENTS.md b/AGENTS.md index 798b0250b..cdff7a8e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -236,6 +236,23 @@ const parsed = parseOrgProjectArg(targetArg); Reference: `span/list.ts`, `trace/view.ts`, `event/view.ts` +### Multiple Values in Arguments + +Split by argument type — do not mix the conventions: + +- **Required positionals → space-separated variadic.** Declare the arg as + variadic and accept repeated tokens: `issue merge A B C`, + `project create web api node`. Do not split positional values on commas; + commas may be part of the value. Quoting preserves a multi-word positional + as one value: `project create "Web API" node` creates one project, while + `project create web api node` creates two. +- **Optional flags → comma-separated (sometimes also repeatable).** Split the + flag value on `,`: `--features errors,tracing`, set-commits `--path a,b`, + `auth login --scope a,b`. Use `value.split(",")` (repeatable array flags: + `flags.x.flatMap((v) => v.split(","))`). + +Reference: `project/create.ts`, `release/set-commits.ts` (`--path`), `auth/login.ts` (`--scope`) + ### Markdown Rendering All non-trivial human output must use the markdown rendering pipeline: diff --git a/docs/src/fragments/commands/project.md b/docs/src/fragments/commands/project.md index ba0f401f8..6732a66db 100644 --- a/docs/src/fragments/commands/project.md +++ b/docs/src/fragments/commands/project.md @@ -41,6 +41,12 @@ sentry project view my-org/frontend -w # Create a new project sentry project create my-new-app javascript-nextjs +# Create a project with a multi-word display name +sentry project create "My New App" javascript-nextjs + +# Create several projects +sentry project create web api worker node + # Create under a specific org and team sentry project create my-org/my-new-app python --team backend-team diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 620acb853..b27d77900 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -318,7 +318,7 @@ Work with Sentry organizations Work with Sentry projects -- `sentry project create ` — Create a new project +- `sentry project create [/] ` — Create one or more projects - `sentry project delete ` — Delete a project - `sentry project list ` — List projects - `sentry project view ` — View details of a project diff --git a/plugins/sentry-cli/skills/sentry-cli/references/project.md b/plugins/sentry-cli/skills/sentry-cli/references/project.md index b68a6a34c..8f139e0c2 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/project.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/project.md @@ -11,12 +11,13 @@ requires: Work with Sentry projects -### `sentry project create ` +### `sentry project create [/] ` -Create a new project +Create one or more projects **Flags:** - `-t, --team - Team to create the project under` +- `-p, --platform - Project platform (e.g., node, python, javascript-nextjs)` - `-n, --dry-run - Show what would happen without making changes` **Examples:** @@ -25,6 +26,12 @@ Create a new project # Create a new project sentry project create my-new-app javascript-nextjs +# Create a project with a multi-word display name +sentry project create "My New App" javascript-nextjs + +# Create several projects +sentry project create web api worker node + # Create under a specific org and team sentry project create my-org/my-new-app python --team backend-team diff --git a/script/generate-skill-markdown.ts b/script/generate-skill-markdown.ts new file mode 100644 index 000000000..fd2f22b69 --- /dev/null +++ b/script/generate-skill-markdown.ts @@ -0,0 +1,27 @@ +/** + * Markdown parsing helpers shared by the skill generator and its tests. + */ + +/** Matches a generated command heading and stops before positional usage. */ +const COMMAND_HEADING_RE = + /^`sentry\s+([^<[`\s]+(?:\s+[^<[`\s]+)*)(?:\s*(?:<|\[)[^`]*)?`$/; + +/** Extract the literal command path from a generated command heading. */ +export function extractCommandPathFromHeading( + heading: string +): string | undefined { + const match = COMMAND_HEADING_RE.exec(heading); + return match?.[1] ? `sentry ${match[1]}` : undefined; +} + +/** Find the command whose literal path appears in a loose example block. */ +export function matchExampleToCommand( + code: string, + commandPaths: readonly string[], + groupFallback: string +): string | undefined { + return ( + commandPaths.find((path) => code.includes(path)) ?? + (code.includes(groupFallback) ? groupFallback : undefined) + ); +} diff --git a/script/generate-skill.ts b/script/generate-skill.ts index 8f30189b3..2c630c1ab 100644 --- a/script/generate-skill.ts +++ b/script/generate-skill.ts @@ -23,6 +23,10 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { access, readFile, writeFile } from "node:fs/promises"; import type { Token } from "marked"; import { marked } from "marked"; +import { + extractCommandPathFromHeading, + matchExampleToCommand, +} from "./generate-skill-markdown.js"; // Bootstrap: ensure the generated skill-content module exists before // importing the route tree (app.ts → agent-skills.ts → skill-content.ts). @@ -295,12 +299,6 @@ sentry auth status \`\`\``; } -/** - * Regex to extract the command path from a heading like `` `sentry issue list ` ``. - * Captures the words between `sentry` and the first `<` or closing backtick. - */ -const CMD_HEADING_RE = /^`sentry\s+(.*?)\s*(?:<[^>]*>.*)?`$/; - /** Append a code block to a map entry, creating the array if needed */ function appendExample( map: Map, @@ -325,9 +323,8 @@ function collectCommandPaths( if (token.type !== "heading" || token.depth !== 3) { continue; } - const m = CMD_HEADING_RE.exec(token.text); - if (m) { - const cmdPath = `sentry ${m[1]}`; + const cmdPath = extractCommandPathFromHeading(token.text); + if (cmdPath) { paths.push(cmdPath); if (!examples.has(cmdPath)) { examples.set(cmdPath, []); @@ -337,23 +334,10 @@ function collectCommandPaths( return paths; } -/** Find the best command path match for a loose code block by content */ -function matchCodeToCommand( - code: string, - commandPaths: string[], - groupFallback: string -): string | undefined { - return ( - commandPaths.find((p) => code.includes(p)) ?? - (code.includes(groupFallback) ? groupFallback : undefined) - ); -} - /** * Walk tokens sequentially and associate each bash code block with * the appropriate command path — either by heading context or content matching. */ -// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: sequential token walk with type narrowing function associateCodeBlocks( tokens: Token[], commandPaths: string[], @@ -365,8 +349,7 @@ function associateCodeBlocks( for (const token of tokens) { if (token.type === "heading" && token.depth === 3) { - const m = CMD_HEADING_RE.exec(token.text); - currentCmd = m ? `sentry ${m[1]}` : null; + currentCmd = extractCommandPathFromHeading(token.text) ?? null; } if (token.type !== "code" || token.lang !== "bash") { continue; @@ -375,7 +358,7 @@ function associateCodeBlocks( if (currentCmd && examples.has(currentCmd)) { appendExample(examples, currentCmd, code); } else { - const target = matchCodeToCommand(code, commandPaths, groupFallback); + const target = matchExampleToCommand(code, commandPaths, groupFallback); if (target) { appendExample(examples, target, code); } diff --git a/src/commands/project/create.ts b/src/commands/project/create.ts index dee79ae7c..52e0b4a2a 100644 --- a/src/commands/project/create.ts +++ b/src/commands/project/create.ts @@ -1,19 +1,21 @@ /** * sentry project create * - * Create a new Sentry project. + * Create one or more Sentry projects. * Supports org/name positional syntax (like `gh repo create owner/repo`). * * ## Flow * - * 1. Parse name arg → extract org prefix if present (e.g., "acme/my-app") - * 2. Resolve org → CLI flag > env vars > config defaults > DSN auto-detection - * 3. Resolve team → `--team` flag > auto-select single team > auto-create if empty - * 4. Call `createProjectWithDsn` (creates project, fetches DSN, builds URL) - * 5. Display results + * 1. Resolve platform (--platform flag, or the trailing positional) and names + * 2. Parse each name → extract org prefix if present (e.g., "acme/my-app") + * 3. Resolve org → CLI flag > env vars > config defaults > DSN auto-detection + * (all names must share one org) + * 4. For each name: resolve team + create project (fetch DSN, build URL) + * 5. Display results (one block per project) * - * When the team is auto-selected or auto-created, the output includes a note - * so the user knows which team was used and how to change it. + * Multiple projects are created by passing several names as separate arguments + * (e.g. `sentry project create web api worker node`). Quoted multi-word display + * names remain a single project (e.g. `sentry project create "Web API" node`). */ import type { SentryContext } from "../../context.js"; @@ -31,11 +33,13 @@ import { CliError, ContextError, ResolutionError, + ValidationError, withAuthGuard, } from "../../lib/errors.js"; import { - formatProjectCreated, + formatProjectCreateOutput, type ProjectCreatedResult, + type ProjectCreateOutput, } from "../../lib/formatters/human.js"; import { isPlainOutput } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -59,10 +63,11 @@ import { slugify } from "../../lib/utils.js"; const log = logger.withTag("project.create"); /** Full usage hint shown in errors and help text. */ -const USAGE_HINT = "sentry project create / "; +const USAGE_HINT = "sentry project create [/] "; type CreateFlags = { readonly team?: string; + readonly platform?: string; readonly "dry-run": boolean; readonly json: boolean; readonly fields?: string[]; @@ -131,7 +136,8 @@ function isPlatformError(error: ApiError): boolean { /** * Build a user-friendly error message for missing or invalid platform. * - * @param nameArg - The name arg (used in the usage example) + * @param nameArg - The project name(s) to echo in the usage example (may be + * several space-separated names for the variadic form) * @param platform - The invalid platform string, if provided */ function buildPlatformError(nameArg: string, platform?: string): string { @@ -301,11 +307,7 @@ async function createProjectWithAutoTeamFallback(opts: { ); } if (expError.status === 409) { - const slug = slugify(name); - throw new CliError( - `A project named '${name}' already exists in ${orgSlug}.\n\n` + - `View it: sentry project view ${orgSlug}/${slug}` - ); + throw projectExistsError(orgSlug, name); } } throw expError; @@ -320,72 +322,298 @@ async function createProjectWithAutoTeamFallback(opts: { } /** - * Create a project (with DSN + URL) with user-friendly error handling. - * Wraps API errors with actionable messages instead of raw HTTP status codes. + * A project with this name already exists in the org (HTTP 409). Shared by the + * team-scoped and org-scoped fallback create paths so the "already exists" + * message and the `project view` hint stay in one place. */ -async function createProjectWithErrors(opts: { +function projectExistsError(orgSlug: string, name: string): CliError { + const slug = slugify(name); + return new CliError( + `A project named '${name}' already exists in ${orgSlug}.\n\n` + + `View it: sentry project view ${orgSlug}/${slug}` + ); +} + +type CreateProjectOpts = { orgSlug: string; teamSlug: string; name: string; platform: string; detectedFrom?: string; -}): Promise { +}; + +/** + * Map an ApiError from project creation to an actionable error. Always throws + * (the 404 branch delegates to {@link handleCreateProject404}, also `never`). + */ +async function handleCreateApiError( + error: ApiError, + opts: CreateProjectOpts +): Promise { + const { orgSlug, name, platform } = opts; + if (error.status === 409) { + throw projectExistsError(orgSlug, name); + } + if (error.status === 400 && isPlatformError(error)) { + throw new CliError(buildPlatformError(`${orgSlug}/${name}`, platform)); + } + if (error.status === 404) { + return await handleCreateProject404(opts); + } + // Re-throw as ApiError (not CliError) so the 401–499 user-error silencing in + // error-reporting.ts applies — e.g. a 403 "feature disabled for members" is a + // permission issue, not a CLI bug. 5xx and network errors still get captured. + // The message is kept short — ApiError.format() appends detail/endpoint. + throw new ApiError( + `Failed to create project '${name}' in ${orgSlug} (HTTP ${error.status}).`, + error.status, + error.detail, + error.endpoint + ); +} + +/** + * Create a project (with DSN + URL) with user-friendly error handling. + * Wraps API errors with actionable messages instead of raw HTTP status codes. + */ +async function createProjectWithErrors( + opts: CreateProjectOpts +): Promise { const { orgSlug, teamSlug, name, platform } = opts; try { return await createProjectWithDsn(orgSlug, teamSlug, { name, platform }); } catch (error) { if (error instanceof ApiError) { - if (error.status === 409) { - const slug = slugify(name); - throw new CliError( - `A project named '${name}' already exists in ${orgSlug}.\n\n` + - `View it: sentry project view ${orgSlug}/${slug}` - ); - } - if (error.status === 400 && isPlatformError(error)) { - throw new CliError(buildPlatformError(`${orgSlug}/${name}`, platform)); - } - if (error.status === 404) { - // handleCreateProject404 always throws — cast needed because - // createProjectWithDsn's return type differs from SentryProject - return await (handleCreateProject404(opts) as never); - } - // Re-throw as ApiError (not CliError) so the 401–499 user-error - // silencing in error-reporting.ts applies — e.g. 403 "Your organization - // has disabled this feature for members" is a permission issue, not a - // CLI bug. 5xx and network errors still get captured. - // - // The message is kept short — ApiError.format() appends `detail` and - // `endpoint` on separate lines, so embedding them in the message would - // duplicate the output. - throw new ApiError( - `Failed to create project '${name}' in ${orgSlug} (HTTP ${error.status}).`, - error.status, - error.detail, - error.endpoint - ); + return await handleCreateApiError(error, opts); } throw error; } } +/** + * Resolve the platform and the list of project names from the raw positionals. + * + * Platform sources, in order: + * 1. `--platform`/`-p` flag → all positionals are names. + * 2. Trailing positional, when it is a valid platform → the rest are names. + * Keeps the classic `sentry project create ` shape working + * while allowing `sentry project create `. + */ +function resolvePlatformAndNames( + flags: CreateFlags, + args: readonly string[] +): { platform: string; names: string[] } { + if (args.length === 0) { + throw new ContextError("Project name", USAGE_HINT, [ + "Create several at once: sentry project create web api worker ", + ]); + } + + if (flags.platform) { + const platform = normalizePlatform(flags.platform); + if (!isValidPlatform(platform)) { + throw new CliError(buildPlatformError(args.join(" "), platform)); + } + return { platform, names: [...args] }; + } + + if (args.length < 2) { + throw new CliError(buildPlatformError(args.join(" "))); + } + + const platform = normalizePlatform(args.at(-1) as string); + if (!isValidPlatform(platform)) { + throw new CliError( + buildPlatformError(args.slice(0, -1).join(" "), platform) + ); + } + return { platform, names: args.slice(0, -1) }; +} + +/** A single project's name plus any org prefix parsed from the positional. */ +type ParsedName = { org?: string; name: string }; + +/** + * Parse each raw name into an org (optional) + project name, and require that + * any explicit org prefixes agree (mirrors `issue merge`'s single-org rule). + */ +function parseNames(rawNames: readonly string[]): { + explicitOrg?: string; + parsed: ParsedName[]; +} { + if (rawNames.length === 0) { + throw new ContextError("Project name", USAGE_HINT, []); + } + + const parsed: ParsedName[] = rawNames.map((raw) => { + const p = parseOrgProjectArg(raw); + switch (p.type) { + case "explicit": + return { org: p.org, name: p.project }; + case "project-search": + return { org: p.org, name: p.projectSlug }; + case "org-all": + throw new ContextError("Project name", USAGE_HINT, [ + `'${raw}' looks like an org, not a project name.`, + ]); + default: + throw new ContextError("Project name", USAGE_HINT, []); + } + }); + + const orgs = new Set( + parsed.map((p) => p.org).filter((o): o is string => Boolean(o)) + ); + if (orgs.size > 1) { + throw new ValidationError( + `Cannot create projects across multiple organizations (${[...orgs].join(", ")}).\n\n` + + "All names must belong to the same org.", + "organization" + ); + } + const [explicitOrg] = orgs; + return { explicitOrg, parsed }; +} + +/** + * Preserve the existing object shape for a single create while giving every + * batch—complete or partial—a stable array shape. + */ +function buildProjectCreateOutput( + results: ProjectCreatedResult[], + requestedCount: number +): ProjectCreateOutput { + const [singleResult] = results; + return requestedCount === 1 && singleResult ? singleResult : results; +} + +/** + * Create a single project end-to-end (team resolve → create → fallback), + * returning the display result. Handles --dry-run internally. + */ +async function createOneProject(opts: { + orgSlug: string; + name: string; + platform: string; + flags: CreateFlags; + detectedFrom?: string; + /** + * Slug to use when auto-creating a team in an org with no teams. Shared + * across a multi-project batch so every project lands in (or previews) the + * one team the first project creates — rather than each resolving its own. + */ + teamAutoCreateSlug?: string; +}): Promise { + const { orgSlug, name, platform, flags, detectedFrom } = opts; + const expectedSlug = slugify(name); + const autoCreateSlug = opts.teamAutoCreateSlug ?? expectedSlug; + + if (flags["dry-run"]) { + const team = await resolveDryRunTeam(orgSlug, { + team: flags.team, + detectedFrom, + autoCreateSlug, + }); + return { + project: { id: "", slug: expectedSlug, name, platform }, + orgSlug, + teamSlug: team.slug, + teamSource: team.source, + requestedPlatform: platform, + dsn: null, + url: "", + slugDiverged: false, + expectedSlug, + dryRun: true, + }; + } + + let teamSlug: string; + let teamSource: ResolvedConcreteTeam["source"]; + let projectDetails: CreatedProjectDetails; + + try { + const team: ResolvedConcreteTeam = await resolveOrCreateTeam(orgSlug, { + team: flags.team, + detectedFrom, + usageHint: USAGE_HINT, + autoCreateSlug, + }); + teamSlug = team.slug; + teamSource = team.source; + projectDetails = await createProjectWithErrors({ + orgSlug, + teamSlug, + name, + platform, + detectedFrom, + }); + } catch (error) { + // 403 means the user lacks permission to create or access teams, or to + // create projects on the resolved team. Fall back to the org-scoped endpoint + // which requires only project:read and auto-creates a personal team. + // Skip the fallback when --team was explicit: the 403 is meaningful there. + if (!(error instanceof ApiError && error.status === 403) || flags.team) { + throw error; + } + // Policy 403: org has disabled member project creation. The org-scoped + // endpoint enforces the same flag — re-throw to avoid a wasted round-trip. + if (error.detail?.includes(MEMBER_PROJECT_CREATION_DISABLED_DETAIL)) { + throw error; + } + log.debug("403 on team-based flow — falling back to org-scoped endpoint"); + const fallback = await createProjectWithAutoTeamFallback({ + orgSlug, + name, + platform, + }); + teamSlug = fallback.teamSlug; + teamSource = fallback.teamSource; + projectDetails = fallback; + } + + const { project, dsn, url } = projectDetails; + return { + project, + orgSlug, + teamSlug, + teamSource, + requestedPlatform: platform, + dsn, + url, + slugDiverged: project.slug !== expectedSlug, + expectedSlug, + }; +} + export const createCommand = buildCommand({ docs: { - brief: "Create a new project", + brief: "Create one or more projects", + customUsage: [ + "[/] ", + "[/] --platform ", + ], fullDescription: - "Create a new Sentry project in an organization.\n\n" + - "The name supports org/name syntax to specify the organization explicitly.\n" + - "If omitted, the org is auto-detected from config defaults.\n\n" + + "Create Sentry projects in an organization.\n\n" + + "Names support org/name syntax to specify the organization explicitly.\n" + + "If omitted, the org is auto-detected from config defaults. Quoted project\n" + + "display names may contain whitespace.\n\n" + + "Create several projects at once by passing multiple names as separate\n" + + "arguments — the platform is the trailing argument (or --platform).\n" + + "All names share one org.\n\n" + "Projects are created under a team. If the org has one team, it is used\n" + "automatically. If no teams exist, one is created. Otherwise, specify --team.\n\n" + "Examples:\n" + " sentry project create my-app node\n" + + ' sentry project create "My App" node\n' + " sentry project create acme-corp/my-app javascript-nextjs\n" + + " sentry project create web api worker node\n" + + " sentry project create web api worker --platform node\n" + " sentry project create my-app python-django --team backend\n" + " sentry project create my-app go --json", }, output: { - human: formatProjectCreated, + human: formatProjectCreateOutput, jsonExclude: [ "slugDiverged", "expectedSlug", @@ -395,21 +623,13 @@ export const createCommand = buildCommand({ }, parameters: { positional: { - kind: "tuple", - parameters: [ - { - placeholder: "name", - brief: "Project name (supports org/name syntax)", - parse: String, - optional: true, - }, - { - placeholder: "platform", - brief: "Project platform (e.g., node, python, javascript-nextjs)", - parse: String, - optional: true, - }, - ], + kind: "array", + parameter: { + placeholder: "name-or-platform", + brief: + "Project name(s), followed by the required platform unless --platform is set", + parse: String, + }, }, flags: { team: { @@ -418,64 +638,23 @@ export const createCommand = buildCommand({ brief: "Team to create the project under", optional: true, }, + platform: { + kind: "parsed", + parse: String, + brief: "Project platform (e.g., node, python, javascript-nextjs)", + optional: true, + }, "dry-run": DRY_RUN_FLAG, }, - aliases: { ...DRY_RUN_ALIASES, t: "team" }, + aliases: { ...DRY_RUN_ALIASES, t: "team", p: "platform" }, }, - async *func( - this: SentryContext, - flags: CreateFlags, - nameArg?: string, - platformArg?: string - ) { + async *func(this: SentryContext, flags: CreateFlags, ...args: string[]) { const { cwd } = this; - if (!nameArg) { - throw new ContextError( - "Project name", - "sentry project create ", - [ - `Use org/name syntax: ${USAGE_HINT}`, - "Specify team: sentry project create --team ", - ] - ); - } + const { platform, names: rawNames } = resolvePlatformAndNames(flags, args); + const { explicitOrg, parsed } = parseNames(rawNames); - if (!platformArg) { - throw new CliError(buildPlatformError(nameArg)); - } - - const platform = normalizePlatform(platformArg); - - if (!isValidPlatform(platform)) { - throw new CliError(buildPlatformError(nameArg, platform)); - } - - const parsed = parseOrgProjectArg(nameArg); - - let explicitOrg: string | undefined; - let name: string; - - switch (parsed.type) { - case "explicit": - explicitOrg = parsed.org; - name = parsed.project; - break; - case "project-search": - name = parsed.projectSlug; - break; - case "org-all": - throw new ContextError("Project name", USAGE_HINT, []); - case "auto-detect": - // Shouldn't happen — nameArg is a required positional - throw new ContextError("Project name", USAGE_HINT, []); - default: { - const _exhaustive: never = parsed; - throw new ContextError("Project name", String(_exhaustive), []); - } - } - - // Resolve organization + // Resolve organization once — all projects are created in the same org. const resolved = await resolveOrg({ org: explicitOrg, cwd }); if (!resolved) { throw new ContextError("Organization", USAGE_HINT, [ @@ -484,91 +663,36 @@ export const createCommand = buildCommand({ } const orgSlug = resolved.org; - const expectedSlug = slugify(name); - - // Dry-run mode: resolve team (or preview auto-create) without hitting create APIs - if (flags["dry-run"]) { - const team = await resolveDryRunTeam(orgSlug, { - team: flags.team, - detectedFrom: resolved.detectedFrom, - autoCreateSlug: expectedSlug, - }); - const result: ProjectCreatedResult = { - project: { id: "", slug: expectedSlug, name, platform }, - orgSlug, - teamSlug: team.slug, - teamSource: team.source, - requestedPlatform: platform, - dsn: null, - url: "", - slugDiverged: false, - expectedSlug, - dryRun: true, - }; - return yield new CommandOutput(result); - } - - // If either step 403s (member can't create/see teams, or lacks project:write on - // the team), fall back to POST /organizations/{org}/projects/ which mirrors - // what the Sentry onboarding UI uses: auto-creates a personal team for the - // caller and only requires project:read scope. - let teamSlug: string; - let teamSource: ResolvedConcreteTeam["source"]; - let projectDetails: CreatedProjectDetails; + // If the org has no teams, the first project auto-creates one and the rest + // reuse it. Pin that team slug up front so a real run and a --dry-run + // preview agree (dry-run never actually creates the team). + const teamAutoCreateSlug = slugify(parsed[0]?.name ?? ""); + // Create sequentially to respect rate limits. Results are emitted as one + // value so --json stays parseable, including partial success before an error. + const results: ProjectCreatedResult[] = []; try { - const team: ResolvedConcreteTeam = await resolveOrCreateTeam(orgSlug, { - team: flags.team, - detectedFrom: resolved.detectedFrom, - usageHint: USAGE_HINT, - autoCreateSlug: expectedSlug, - }); - teamSlug = team.slug; - teamSource = team.source; - projectDetails = await createProjectWithErrors({ - orgSlug, - teamSlug, - name, - platform, - detectedFrom: resolved.detectedFrom, - }); - } catch (error) { - // 403 means the user lacks permission to create or access teams, or to - // create projects on the resolved team. Fall back to the org-scoped endpoint - // which requires only project:read and auto-creates a personal team. - // Skip the fallback when --team was explicit: the 403 is meaningful there. - if (!(error instanceof ApiError && error.status === 403) || flags.team) { - throw error; + for (const { name } of parsed) { + results.push( + await createOneProject({ + orgSlug, + name, + platform, + flags, + detectedFrom: resolved.detectedFrom, + teamAutoCreateSlug, + }) + ); } - // Policy 403: org has disabled member project creation. The org-scoped - // endpoint enforces the same flag — re-throw to avoid a wasted round-trip. - if (error.detail?.includes(MEMBER_PROJECT_CREATION_DISABLED_DETAIL)) { - throw error; + } catch (error) { + if (results.length > 0) { + yield new CommandOutput( + buildProjectCreateOutput(results, parsed.length) + ); } - log.debug("403 on team-based flow — falling back to org-scoped endpoint"); - const fallback = await createProjectWithAutoTeamFallback({ - orgSlug, - name, - platform, - }); - teamSlug = fallback.teamSlug; - teamSource = fallback.teamSource; - projectDetails = fallback; + throw error; } - const { project, dsn, url } = projectDetails; - const result: ProjectCreatedResult = { - project, - orgSlug, - teamSlug, - teamSource, - requestedPlatform: platform, - dsn, - url, - slugDiverged: project.slug !== expectedSlug, - expectedSlug, - }; - - return yield new CommandOutput(result); + yield new CommandOutput(buildProjectCreateOutput(results, parsed.length)); }, }); diff --git a/src/lib/command.ts b/src/lib/command.ts index f9554304c..4fd2269ad 100644 --- a/src/lib/command.ts +++ b/src/lib/command.ts @@ -93,11 +93,12 @@ type BaseArgs = readonly unknown[]; type StricliBuilderArgs = import("@stricli/core").CommandBuilderArguments; -/** Command documentation */ -type CommandDocumentation = { - readonly brief: string; - readonly fullDescription?: string; -}; +/** + * Native Stricli documentation. When `customUsage` is present, its first line + * must be the canonical signature suffix used by introspection and generated + * docs; later lines may document equivalent forms. + */ +type CommandDocumentation = StricliBuilderArgs["docs"]; /** * Command function type for Sentry CLI commands. @@ -107,8 +108,9 @@ type CommandDocumentation = { * * - **Non-streaming**: yield a single `CommandOutput`, optionally * return `{ hint }` for a post-output footer. - * - **Streaming**: yield multiple values; each is rendered immediately - * (JSONL in `--json` mode, human text otherwise). + * - **Streaming**: each yield is rendered immediately. Commands that support + * `--json` must yield one aggregate value when callers need one parseable + * JSON document; individual JSON chunks are pretty-printed, not JSONL. * - **Void**: return without yielding for early exits (e.g. `--web`). * * The return value (`CommandReturn`) is captured by the wrapper and @@ -824,6 +826,14 @@ export function buildCommand< func: wrappedFunc, } as unknown as StricliBuilderArgs); + // Stricli uses customUsage for native help but does not expose it on the + // built command. Preserve its primary line for introspection consumers. + const primaryUsage = enrichedDocs.customUsage?.[0]; + if (primaryUsage) { + (cmd as unknown as Record).__primaryUsage = + typeof primaryUsage === "string" ? primaryUsage : primaryUsage.input; + } + // Attach the JSON schema to the built command as a non-standard property. // introspect.ts reads this to populate CommandInfo.jsonFields for help // output and SKILL.md generation. diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index d725da501..a175f5e70 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -2018,6 +2018,15 @@ export function formatProjectCreated(result: ProjectCreatedResult): string { return renderMarkdown(lines.join("\n")); } +/** Output contract for one project creation or a multi-project batch. */ +export type ProjectCreateOutput = ProjectCreatedResult | ProjectCreatedResult[]; + +/** Format one project creation or every result in a batch. */ +export function formatProjectCreateOutput(output: ProjectCreateOutput): string { + const results = Array.isArray(output) ? output : [output]; + return results.map(formatProjectCreated).join("\n"); +} + // Project Deletion Formatting /** diff --git a/src/lib/help.ts b/src/lib/help.ts index 7c52cfc04..3df259f09 100644 --- a/src/lib/help.ts +++ b/src/lib/help.ts @@ -110,11 +110,11 @@ function generateCommands(): HelpCommand[] { }; } - // Direct command - extract placeholder from positional parameters + // Direct command - use any public syntax override before raw parameters if (isCommand(entry.target)) { - const placeholder = getPositionalString( - entry.target.parameters.positional - ); + const placeholder = + entry.target.__primaryUsage ?? + getPositionalString(entry.target.parameters.positional); const usageSuffix = placeholder ? ` ${placeholder}` : ""; return { usage: `sentry ${routeName}${usageSuffix}`, diff --git a/src/lib/introspect.ts b/src/lib/introspect.ts index 3e0d18248..7d5e9e561 100644 --- a/src/lib/introspect.ts +++ b/src/lib/introspect.ts @@ -54,6 +54,11 @@ export type Command = { * reads it to populate {@link CommandInfo.jsonFields}. */ __jsonSchema?: import("zod").ZodType; + /** + * Primary Stricli custom usage line, retained by `buildCommand` for + * introspection because Stricli does not expose it on the built command. + */ + __primaryUsage?: string; }; /** Positional parameter definitions — either fixed-length tuple or variadic array */ @@ -286,7 +291,8 @@ export function buildCommandInfo( brief: cmd.brief, fullDescription: cmd.fullDescription, flags: extractFlags(cmd.parameters.flags), - positional: getPositionalString(cmd.parameters.positional), + positional: + cmd.__primaryUsage ?? getPositionalString(cmd.parameters.positional), positionals: extractPositionals(cmd.parameters.positional), aliases: cmd.parameters.aliases ?? {}, examples, diff --git a/test/commands/project/create.test.ts b/test/commands/project/create.test.ts index b77268d9e..d269fef67 100644 --- a/test/commands/project/create.test.ts +++ b/test/commands/project/create.test.ts @@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { createCommand } from "../../../src/commands/project/create.js"; +import type { CreatedProjectDetails } from "../../../src/lib/api-client.js"; // Auto-mock at the definition site so internal calls (e.g. createProjectWithDsn // calling createProject within projects.js) are intercepted. All exports become @@ -31,8 +32,10 @@ import { ContextError, ResolutionError, } from "../../../src/lib/errors.js"; +import type { ProjectCreatedResult } from "../../../src/lib/formatters/human.js"; // biome-ignore lint/performance/noNamespaceImport: needed for vi.spyOn mocking import * as resolveTarget from "../../../src/lib/resolve-target.js"; +import { slugify } from "../../../src/lib/utils.js"; import type { SentryProject, SentryTeam } from "../../../src/types/index.js"; import { useTestConfigDir } from "../../helpers.js"; @@ -60,6 +63,16 @@ const sampleProject: SentryProject = { dateCreated: "2026-02-12T10:00:00Z", }; +/** Build an API result whose project identity matches the requested name. */ +function createdProjectDetails(name: string): CreatedProjectDetails { + const slug = slugify(name); + return { + project: { ...sampleProject, name, slug, platform: "node" }, + dsn: `https://${slug}@o123.ingest.us.sentry.io/999`, + url: `https://sentry.io/organizations/acme-corp/projects/${slug}/`, + }; +} + // Isolated DB for region cache — prevents "unexpected fetch" warnings // from resolveOrgRegion when buildOrgNotFoundError calls resolveEffectiveOrg useTestConfigDir("test-project-create-"); @@ -500,6 +513,47 @@ describe("project create", () => { }); }); + test("preserves unrelated API 400 errors for multi-word display names", async () => { + createProjectWithDsnSpy.mockRejectedValue( + new ApiError( + "Bad Request", + 400, + '{"name":["Ensure this field has no more than 50 characters."]}' + ) + ); + const { context } = createMockContext(); + const func = await createCommand.loader(); + const err = (await func + .call(context, { json: false }, "My Cool App", "node") + .catch((e: Error) => e)) as ApiError; + expect(err).toBeInstanceOf(ApiError); + expect(err.status).toBe(400); + expect(err.detail).toContain("no more than 50 characters"); + expect(err.message).not.toContain("separate argument"); + }); + + test("preserves unrelated API 400 errors on the org-scoped fallback", async () => { + createProjectWithDsnSpy.mockRejectedValue( + new ApiError("Forbidden", 403, "You do not have permission") + ); + createProjectWithAutoTeamSpy.mockRejectedValue( + new ApiError( + "Bad Request", + 400, + '{"name":["Ensure this field has no more than 50 characters."]}' + ) + ); + const { context } = createMockContext(); + const func = await createCommand.loader(); + const err = (await func + .call(context, { json: false }, "My Cool App", "node") + .catch((e: Error) => e)) as ApiError; + expect(err).toBeInstanceOf(ApiError); + expect(err.status).toBe(400); + expect(err.detail).toContain("no more than 50 characters"); + expect(err.message).not.toContain("separate argument"); + }); + test("surfaces policy error when org has disabled member project creation", async () => { // Both paths 403: team-based creation fails, and the fallback returns // the org-level policy error ("disabled this feature"). @@ -615,7 +669,9 @@ describe("project create", () => { .catch((e: Error) => e); expect(err).toBeInstanceOf(ContextError); expect(err.message).toContain("Project name is required"); - expect(err.message).toContain("sentry project create "); + expect(err.message).toContain( + "sentry project create [/] " + ); }); test("shows helpful error when platform is missing", async () => { @@ -736,6 +792,127 @@ describe("project create", () => { expect(err.message).toContain("Invalid platform 'python-django-rest'"); }); + test("creates multiple projects from separate name args", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await createCommand.loader(); + await func.call(context, { json: false }, "web", "api", "worker", "node"); + + expect(createProjectWithDsnSpy).toHaveBeenCalledTimes(3); + for (const name of ["web", "api", "worker"]) { + expect(createProjectWithDsnSpy).toHaveBeenCalledWith( + "acme-corp", + "engineering", + { name, platform: "node" } + ); + } + const output = stdoutWrite.mock.calls.map((call) => call[0]).join(""); + expect(output.match(/Created project/g)).toHaveLength(3); + }); + + test("outputs a single JSON array for created projects", async () => { + createProjectWithDsnSpy + .mockResolvedValueOnce(createdProjectDetails("web")) + .mockResolvedValueOnce(createdProjectDetails("api")); + const { context, stdoutWrite } = createMockContext(); + const func = await createCommand.loader(); + await func.call(context, { json: true }, "web", "api", "node"); + + const output = stdoutWrite.mock.calls.map((call) => call[0]).join(""); + const parsed = JSON.parse(output) as Array< + Record & { project: { name: string } } + >; + expect(parsed.map((result) => result.project.name)).toEqual(["web", "api"]); + for (const result of parsed) { + expect(result).not.toHaveProperty("slugDiverged"); + expect(result).not.toHaveProperty("expectedSlug"); + expect(result).not.toHaveProperty("teamSource"); + expect(result).not.toHaveProperty("requestedPlatform"); + } + }); + + test("shows completed projects when a later creation fails", async () => { + createProjectWithDsnSpy + .mockResolvedValueOnce(createdProjectDetails("web")) + .mockRejectedValueOnce(new ApiError("Server Error", 500)); + const { context, stdoutWrite } = createMockContext(); + const func = await createCommand.loader(); + const error = await func + .call(context, { json: false }, "web", "api", "node") + .catch((caught: Error) => caught); + + expect(error).toBeInstanceOf(ApiError); + const output = stdoutWrite.mock.calls.map((call) => call[0]).join(""); + expect(output).toContain("Created project 'web'"); + expect(output).not.toContain("Created project 'api'"); + }); + + test("keeps partial batch JSON parseable when a later creation fails", async () => { + createProjectWithDsnSpy + .mockResolvedValueOnce(createdProjectDetails("web")) + .mockRejectedValueOnce(new ApiError("Server Error", 500)); + const { context, stdoutWrite } = createMockContext(); + const func = await createCommand.loader(); + const error = await func + .call(context, { json: true }, "web", "api", "node") + .catch((caught: Error) => caught); + + expect(error).toBeInstanceOf(ApiError); + const output = stdoutWrite.mock.calls.map((call) => call[0]).join(""); + const parsed = JSON.parse(output) as ProjectCreatedResult[]; + expect(parsed.map((result) => result.project.name)).toEqual(["web"]); + }); + + test("invalid platform with multiple names shows all names in usage hint", async () => { + const { context } = createMockContext(); + const func = await createCommand.loader(); + const err = await func + .call(context, { json: false }, "proj1", "proj2", "not-a-platform") + .catch((e: Error) => e); + expect(err).toBeInstanceOf(CliError); + expect(err.message).toContain("Invalid platform 'not-a-platform'"); + expect(err.message).toContain( + "sentry project create proj1 proj2 " + ); + }); + + test("preserves commas inside a project name", async () => { + const { context } = createMockContext(); + const func = await createCommand.loader(); + await func.call(context, { json: false }, "payments,eu", "node"); + + expect(createProjectWithDsnSpy).toHaveBeenCalledTimes(1); + expect(createProjectWithDsnSpy).toHaveBeenCalledWith( + "acme-corp", + "engineering", + { name: "payments,eu", platform: "node" } + ); + }); + + test("takes platform from --platform flag with multiple names", async () => { + const { context } = createMockContext(); + const func = await createCommand.loader(); + await func.call(context, { json: false, platform: "node" }, "web", "api"); + + expect(createProjectWithDsnSpy).toHaveBeenCalledTimes(2); + expect(createProjectWithDsnSpy).toHaveBeenCalledWith( + "acme-corp", + "engineering", + { name: "api", platform: "node" } + ); + }); + + test("preserves whitespace in a quoted project display name", async () => { + const { context } = createMockContext(); + const func = await createCommand.loader(); + await func.call(context, { json: false }, "My Cool App", "node"); + + expect(createProjectWithDsnSpy).toHaveBeenCalledWith( + "acme-corp", + "engineering", + { name: "My Cool App", platform: "node" } + ); + }); + // --dry-run tests test("dry-run shows what would be created without API call", async () => { @@ -761,6 +938,31 @@ describe("project create", () => { expect(output).toContain("node"); }); + test("dry-run: multi-project empty org previews one shared team", async () => { + listTeamsSpy.mockResolvedValue([]); + const { context, stdoutWrite } = createMockContext(); + const func = await createCommand.loader(); + await func.call( + context, + { json: false, "dry-run": true }, + "web", + "api", + "worker", + "node" + ); + + // Dry-run never creates teams or projects. + expect(createTeamSpy).not.toHaveBeenCalled(); + expect(createProjectWithDsnSpy).not.toHaveBeenCalled(); + + // Every project previews the SAME team (first project's slug), matching a + // real run that creates one team and reuses it — not one team per project. + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("Would create team 'web'"); + expect(output).not.toContain("Would create team 'api'"); + expect(output).not.toContain("Would create team 'worker'"); + }); + test("dry-run still validates platform", async () => { const { context } = createMockContext(); const func = await createCommand.loader(); @@ -813,6 +1015,25 @@ describe("project create", () => { expect(createProjectWithDsnSpy).not.toHaveBeenCalled(); }); + test("dry-run outputs one JSON array for multiple projects", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await createCommand.loader(); + await func.call( + context, + { json: true, "dry-run": true }, + "web", + "api", + "node" + ); + + const output = stdoutWrite.mock.calls.map((call) => call[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed).toHaveLength(2); + expect( + parsed.map((result: ProjectCreatedResult) => result.project.name) + ).toEqual(["web", "api"]); + }); + test("dry-run shows team source for auto-selected teams", async () => { const { context, stdoutWrite } = createMockContext(); const func = await createCommand.loader(); diff --git a/test/lib/command.test.ts b/test/lib/command.test.ts index 3069c1051..72807e075 100644 --- a/test/lib/command.test.ts +++ b/test/lib/command.test.ts @@ -104,6 +104,32 @@ describe("buildCommand", () => { expect(command).toBeDefined(); }); + test("retains the primary custom usage line for introspection", () => { + const command = buildCommand, string[]>({ + auth: false, + docs: { + brief: "Create things", + customUsage: [ + " ", + " --platform ", + ], + }, + parameters: { + positional: { + kind: "array", + parameter: { brief: "Names and platform", parse: String }, + }, + }, + async *func() { + yield null; + }, + }); + + expect( + (command as unknown as { __primaryUsage?: string }).__primaryUsage + ).toBe(" "); + }); + test("re-exports numberParser from Stricli", () => { expect(numberParser).toBeDefined(); expect(typeof numberParser).toBe("function"); diff --git a/test/lib/help-positional.test.ts b/test/lib/help-positional.test.ts index 55e00d519..63be21a3c 100644 --- a/test/lib/help-positional.test.ts +++ b/test/lib/help-positional.test.ts @@ -12,7 +12,7 @@ * and verify help output is shown when resolution fails. */ -import { run } from "@stricli/core"; +import { generateHelpTextForAllCommands, run } from "@stricli/core"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { app } from "../../src/app.js"; import type { SentryContext } from "../../src/context.js"; @@ -178,4 +178,27 @@ describe("help command unchanged", () => { // Should NOT have the recovery tip — this is the normal help path expect(stderr).not.toContain("Tip"); }); + + test("sentry help project create shows the public positional syntax", async () => { + const { stdout, stderr } = await runCommand(["help", "project", "create"]); + + expect(stdout).toContain( + "sentry project create [/] " + ); + expect(stderr).not.toContain("Tip"); + }); + + test("project create --help shows both supported platform forms", () => { + const help = generateHelpTextForAllCommands(app).find( + ([route]) => route === "sentry project create" + )?.[1]; + + expect(help).toContain( + "sentry project create [/] " + ); + expect(help).toContain( + "sentry project create [/] --platform " + ); + expect(help).not.toContain("..."); + }); }); diff --git a/test/lib/introspect.test.ts b/test/lib/introspect.test.ts index da62712a8..e9e633f94 100644 --- a/test/lib/introspect.test.ts +++ b/test/lib/introspect.test.ts @@ -233,6 +233,21 @@ describe("buildCommandInfo", () => { const info = buildCommandInfo(cmd, "sentry do"); expect(info.positional).toBe(""); }); + + test("prefers a public positional syntax override", () => { + const cmd = makeCommand({ + __primaryUsage: " ", + parameters: { + positional: { + kind: "array", + parameter: { placeholder: "name-or-platform" }, + }, + }, + }); + + const info = buildCommandInfo(cmd, "sentry project create"); + expect(info.positional).toBe(" "); + }); }); // --------------------------------------------------------------------------- diff --git a/test/script/generate-skill-markdown.test.ts b/test/script/generate-skill-markdown.test.ts new file mode 100644 index 000000000..83a2914e8 --- /dev/null +++ b/test/script/generate-skill-markdown.test.ts @@ -0,0 +1,55 @@ +/** Tests for generated command-heading and example association parsing. */ + +import { readFile } from "node:fs/promises"; +import { describe, expect, test } from "vitest"; +import { + extractCommandPathFromHeading, + matchExampleToCommand, +} from "../../script/generate-skill-markdown.js"; + +describe("extractCommandPathFromHeading", () => { + test.each([ + ["`sentry issue view `", "sentry issue view"], + [ + "`sentry project create [/] `", + "sentry project create", + ], + ["`sentry auth status`", "sentry auth status"], + ])("extracts the command path from %s", (heading, expected) => { + expect(extractCommandPathFromHeading(heading)).toBe(expected); + }); + + test("ignores descriptive headings", () => { + expect(extractCommandPathFromHeading("Create a project")).toBeUndefined(); + }); +}); + +describe("matchExampleToCommand", () => { + test("associates a project create block with its command", () => { + const code = [ + "# Create projects", + "sentry project create web api node", + ].join("\n"); + + expect( + matchExampleToCommand( + code, + ["sentry project create", "sentry project delete"], + "sentry project" + ) + ).toBe("sentry project create"); + }); + + test("the generated project reference retains create examples", async () => { + const reference = await readFile( + "plugins/sentry-cli/skills/sentry-cli/references/project.md", + "utf8" + ); + + expect(reference).toContain( + "### `sentry project create [/] `" + ); + expect(reference).toContain('sentry project create "My New App"'); + expect(reference).toContain("sentry project create web api worker node"); + }); +});