diff --git a/apps/cli-docs/src/fragments/commands/project.md b/apps/cli-docs/src/fragments/commands/project.md index ba0f401f8d..7756c63247 100644 --- a/apps/cli-docs/src/fragments/commands/project.md +++ b/apps/cli-docs/src/fragments/commands/project.md @@ -38,14 +38,18 @@ sentry project view my-org/frontend -w ### Create a project ```bash +# Every project is a name:platform pair; project names cannot contain whitespace # Create a new project -sentry project create my-new-app javascript-nextjs +sentry project create my-new-app:javascript-nextjs + +# Create several projects with their own platforms +sentry project create web:javascript api:python-django worker:node # Create under a specific org and team -sentry project create my-org/my-new-app python --team backend-team +sentry project create my-org/my-new-app:python --team backend-team # Preview without creating -sentry project create my-new-app node --dry-run +sentry project create my-new-app:node --dry-run ``` ### Delete a project diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 6f71a9bcff..81a36beae1 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -234,6 +234,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:javascript api:python-django`. Do not split positional + values on commas; commas may be part of the value. Every project passed to + `project create` requires a `name:platform` pair — there is no space-separated + form, with or without an explicit org. Project names cannot contain whitespace. +- **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/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 01a9da58f9..ab59812e6b 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/packages/cli/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/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/project.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/project.md index 56cf6b3377..f1db582da5 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/project.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/project.md @@ -11,9 +11,9 @@ 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` @@ -22,14 +22,18 @@ Create a new project **Examples:** ```bash +# Every project is a name:platform pair; project names cannot contain whitespace # Create a new project -sentry project create my-new-app javascript-nextjs +sentry project create my-new-app:javascript-nextjs + +# Create several projects with their own platforms +sentry project create web:javascript api:python-django worker:node # Create under a specific org and team -sentry project create my-org/my-new-app python --team backend-team +sentry project create my-org/my-new-app:python --team backend-team # Preview without creating -sentry project create my-new-app node --dry-run +sentry project create my-new-app:node --dry-run ``` ### `sentry project delete ` diff --git a/packages/cli/script/generate-skill-markdown.ts b/packages/cli/script/generate-skill-markdown.ts new file mode 100644 index 0000000000..fd2f22b696 --- /dev/null +++ b/packages/cli/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/packages/cli/script/generate-skill.ts b/packages/cli/script/generate-skill.ts index d290359b80..fc7df43dfe 100644 --- a/packages/cli/script/generate-skill.ts +++ b/packages/cli/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"; import { DOCS_CONTENT, DOCS_PUBLIC } from "./paths.js"; // Bootstrap: ensure the generated skill-content module exists before @@ -296,12 +300,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, @@ -326,9 +324,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, []); @@ -338,23 +335,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[], @@ -366,8 +350,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; @@ -376,7 +359,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/packages/cli/src/commands/project/create.ts b/packages/cli/src/commands/project/create.ts index dee79ae7cb..9c34b7a47d 100644 --- a/packages/cli/src/commands/project/create.ts +++ b/packages/cli/src/commands/project/create.ts @@ -1,19 +1,21 @@ /** * sentry project create * - * Create a new Sentry project. - * Supports org/name positional syntax (like `gh repo create owner/repo`). + * Create one or more Sentry projects. + * Supports `[/]:` pairs for one or more projects. * * ## 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. Parse one or more name:platform pairs and extract any org prefix + * 2. Resolve org → positional prefix > env vars > config defaults > DSN auto-detection + * (all names must share one org) + * 3. For each name: resolve team + create project (fetch DSN, build URL) + * 4. 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. + * Every project is a `name:platform` pair (e.g. `sentry project create + * web:javascript api:python-django`). The platform must always be attached + * with `:` — there is no space-separated form, with or without an explicit + * org. Project names cannot contain whitespace. */ 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"; @@ -57,9 +61,10 @@ import { import { slugify } from "../../lib/utils.js"; const log = logger.withTag("project.create"); +const WHITESPACE_RE = /\s/; /** 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; @@ -131,7 +136,7 @@ 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 to echo in the usage example * @param platform - The invalid platform string, if provided */ function buildPlatformError(nameArg: string, platform?: string): string { @@ -153,9 +158,9 @@ function buildPlatformError(nameArg: string, platform?: string): string { `${heading}\n` + didYouMean + "\nUsage:\n" + - ` sentry project create ${nameArg} \n\n` + + ` sentry project create ${nameArg}:\n\n` + `Common platforms:\n\n${platformTable}\n` + - "Run 'sentry project create ' with any valid Sentry platform identifier." + "Run 'sentry project create :' with any valid Sentry platform identifier." ); } @@ -198,7 +203,7 @@ async function handleCreateProject404(opts: { throw new ResolutionError( `Team '${teamSlug}'`, `not found in ${orgSlug}`, - `sentry project create ${orgSlug}/${name} ${platform} --team `, + `sentry project create ${orgSlug}/${name}:${platform} --team `, [`Available teams: ${teams.map((t) => t.slug).join(", ")}`] ); } @@ -218,7 +223,7 @@ async function handleCreateProject404(opts: { throw new ResolutionError( `Project '${name}' in ${orgSlug}`, "could not be created", - `sentry project create ${orgSlug}/${name} ${platform} --team `, + `sentry project create ${orgSlug}/${name}:${platform} --team `, [ "The organization or team may not exist, or you may lack access", `List teams: sentry team list ${orgSlug}/`, @@ -262,6 +267,24 @@ async function resolveDryRunTeam( } } +/** Inputs shared by both project-creation endpoints. */ +type CreateProjectBaseOpts = { + /** Organization slug that will own the project. */ + orgSlug: string; + /** Project display name. */ + name: string; + /** Validated Sentry platform identifier. */ + platform: string; +}; + +/** Inputs required by the team-scoped project-creation endpoint. */ +type CreateProjectOpts = CreateProjectBaseOpts & { + /** Team slug that will own the project. */ + teamSlug: string; + /** Source used to resolve the organization, when auto-detected. */ + detectedFrom?: string; +}; + /** * Fallback project creation via POST /organizations/{org}/projects/. * @@ -270,11 +293,9 @@ async function resolveDryRunTeam( * server auto-created. Surfaces a clear policy error if the org has disabled * member project creation entirely. */ -async function createProjectWithAutoTeamFallback(opts: { - orgSlug: string; - name: string; - platform: string; -}): Promise< +async function createProjectWithAutoTeamFallback( + opts: CreateProjectBaseOpts +): Promise< CreatedProjectDetails & { teamSlug: string; teamSource: ResolvedConcreteTeam["source"]; @@ -284,31 +305,25 @@ async function createProjectWithAutoTeamFallback(opts: { let result: Awaited>; try { result = await createProjectWithAutoTeam(orgSlug, { name, platform }); - } catch (expError) { - if (expError instanceof ApiError) { - if ( - expError.status === 403 && - expError.detail?.includes(MEMBER_PROJECT_CREATION_DISABLED_DETAIL) - ) { - throw new ApiError( - `Failed to create project '${name}' in ${orgSlug} (HTTP 403).\n\n` + - "Your organization has disabled project creation for members.\n" + - "Ask an org owner or manager to enable it in Organization Settings → Member Roles,\n" + - "or ask them to create the project and add you to it.", - 403, - expError.detail, - expError.endpoint - ); - } - 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}` - ); - } + } catch (error) { + if (!(error instanceof ApiError)) { + throw error; + } + if ( + error.status === 403 && + error.detail?.includes(MEMBER_PROJECT_CREATION_DISABLED_DETAIL) + ) { + throw new ApiError( + `Failed to create project '${name}' in ${orgSlug} (HTTP 403).\n\n` + + "Your organization has disabled project creation for members.\n" + + "Ask an org owner or manager to enable it in Organization Settings → Member Roles,\n" + + "or ask them to create the project and add you to it.", + 403, + error.detail, + error.endpoint + ); } - throw expError; + return handleCreateApiError(error, opts); } return { project: result.project, @@ -319,73 +334,327 @@ async function createProjectWithAutoTeamFallback(opts: { }; } +/** + * 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. + */ +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}` + ); +} + +/** + * Map errors shared by both project-creation endpoints to actionable output. + * Endpoint-specific errors must be handled before calling this function. + */ +function handleCreateApiError( + error: ApiError, + opts: CreateProjectBaseOpts +): never { + 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)); + } + // 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: { +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)) { + throw error; + } + if (error.status === 404) { + return await handleCreateProject404(opts); + } + return handleCreateApiError(error, opts); + } +} + +/** A validated project specification parsed from the command positionals. */ +type ParsedProjectSpec = { + /** Explicit organization slug, when the name used org/name syntax. */ + org?: string; + /** Project display name. */ + name: string; + /** Validated Sentry platform identifier. */ + platform: string; +}; + +/** + * Parse and validate a project name independently from its platform source. + * Project names cannot contain whitespace in either supported syntax. + */ +function parseProjectName( + rawName: string, + platform: string +): ParsedProjectSpec { + if (rawName.trim() === "") { + throw new ValidationError("Project name cannot be empty.", "name"); + } + if (WHITESPACE_RE.test(rawName)) { + throw new ValidationError( + `Project name '${rawName}' cannot contain whitespace.`, + "name" + ); + } + + const parsedName = parseOrgProjectArg(rawName); + switch (parsedName.type) { + case "explicit": + return { + org: parsedName.org, + name: parsedName.project, + platform, + }; + case "project-search": + return { + org: parsedName.org, + name: parsedName.projectSlug, + platform, + }; + case "org-all": + throw new ContextError("Project name", USAGE_HINT, [ + `'${rawName}' looks like an org, not a project name.`, + ]); + case "auto-detect": + throw new ValidationError("Project name cannot be empty.", "name"); + default: + throw new ContextError("Project name", USAGE_HINT, []); + } +} + +/** Validate and normalize a platform associated with a project name. */ +function parseProjectPlatform(rawName: string, rawPlatform: string): string { + const trimmedPlatform = rawPlatform.trim(); + if (trimmedPlatform === "") { + throw new ValidationError(buildPlatformError(rawName), "platform"); + } + const platform = normalizePlatform(trimmedPlatform); + if (!isValidPlatform(platform)) { + throw new ValidationError( + buildPlatformError(rawName, platform), + "platform" + ); + } + return platform; +} + +/** + * Parse one required `:` pair. The final colon is the + * separator so project names may contain earlier colons. There is no + * space-separated fallback — the platform must always be attached with `:`, + * with or without an explicit org prefix on the name. + */ +function parsePairedProjectSpec(rawSpec: string): ParsedProjectSpec { + const separatorIndex = rawSpec.lastIndexOf(":"); + if (separatorIndex === -1) { + throw new ValidationError( + `Project '${rawSpec}' must use : syntax.`, + "project" + ); + } + + const rawName = rawSpec.slice(0, separatorIndex); + const platform = parseProjectPlatform( + rawName, + rawSpec.slice(separatorIndex + 1) + ); + return parseProjectName(rawName, platform); +} + +/** + * Parse one or more `[/]:` pairs, then require explicit + * org prefixes to agree. A lone positional with no colon at all gets a + * friendlier "platform is required" error instead of the generic syntax error. + */ +function parseProjectSpecs(rawSpecs: readonly string[]): { + explicitOrg?: string; + parsed: ParsedProjectSpec[]; +} { + if (rawSpecs.length === 0) { + throw new ContextError("Project specification", USAGE_HINT, []); + } + + if (rawSpecs.length === 1 && !rawSpecs[0]?.includes(":")) { + throw new ValidationError( + buildPlatformError(rawSpecs[0] ?? ""), + "platform" + ); + } + const parsed = rawSpecs.map(parsePairedProjectSpec); + + 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; - teamSlug: string; name: string; platform: string; + flags: CreateFlags; detectedFrom?: string; -}): Promise { - const { orgSlug, teamSlug, name, platform } = opts; + /** + * 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 { - return await createProjectWithDsn(orgSlug, teamSlug, { name, platform }); + 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) { - 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 - ); + // 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; } - 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: ["[/]:..."], 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. Project names\n" + + "cannot contain whitespace.\n\n" + + "Every project is a name:platform pair. Create several projects at once\n" + + "by passing multiple pairs as separate arguments. All projects 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 acme-corp/my-app javascript-nextjs\n" + - " sentry project create my-app python-django --team backend\n" + - " sentry project create my-app go --json", + " sentry project create my-app:node\n" + + " sentry project create acme-corp/my-app:javascript-nextjs\n" + + " sentry project create web:javascript api:python-django worker: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 +664,12 @@ 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:platform", + brief: "One or more project name and platform pairs", + parse: String, + }, }, flags: { team: { @@ -422,60 +682,12 @@ export const createCommand = buildCommand({ }, aliases: { ...DRY_RUN_ALIASES, t: "team" }, }, - 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 ", - ] - ); - } - - if (!platformArg) { - throw new CliError(buildPlatformError(nameArg)); - } - - const platform = normalizePlatform(platformArg); + const { explicitOrg, parsed } = parseProjectSpecs(args); - 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 +696,42 @@ 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). Search the + // whole batch, not just parsed[0]: a name that slugifies to "" (e.g. + // punctuation-only or non-ASCII) must not disable auto-create for every + // other project in the batch — createOneProject's `??` fallback treats + // "" as a set value, not a missing one. + const teamAutoCreateSlug = parsed + .map((p) => slugify(p.name)) + .find((slug) => slug !== ""); + + // 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, platform } 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/packages/cli/src/lib/command.ts b/packages/cli/src/lib/command.ts index f1a2e75806..956fb53ab2 100644 --- a/packages/cli/src/lib/command.ts +++ b/packages/cli/src/lib/command.ts @@ -94,11 +94,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. @@ -108,8 +109,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 @@ -829,6 +831,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/packages/cli/src/lib/complete.ts b/packages/cli/src/lib/complete.ts index f334b30fed..4b78c14c6f 100644 --- a/packages/cli/src/lib/complete.ts +++ b/packages/cli/src/lib/complete.ts @@ -20,6 +20,9 @@ import { getProjectAliases } from "./db/project-aliases.js"; import { getCachedProjectsForOrg } from "./db/project-cache.js"; import { getCachedOrganizations } from "./db/regions.js"; import { fuzzyMatch } from "./fuzzy.js"; +import { COMMON_PLATFORMS, VALID_PLATFORMS } from "./platforms.js"; + +const WHITESPACE_RE = /\s/; /** * Completion result with optional description for rich shell display. @@ -102,7 +105,6 @@ export const ORG_PROJECT_COMMANDS = new Set([ "project list", "project view", "project delete", - "project create", "replay list", "replay view", "trace list", @@ -164,6 +166,10 @@ export function getCompletions( ? `${precedingWords[0]} ${precedingWords[1]}` : ""; + if (cmdPath === "project create") { + return completeProjectCreateSpec(partial); + } + if (ORG_PROJECT_COMMANDS.has(cmdPath)) { return completeOrgSlashProject(partial); } @@ -176,6 +182,52 @@ export function getCompletions( return []; } +/** + * Complete the required `name:platform` positional used by `project create`. + * + * Before the final colon, only organization prefixes are suggested because a + * create command targets a new project rather than an existing cached project. + * After the colon, the project-name portion is preserved and valid platform + * identifiers are completed. + * + * @param partial - The partial project specification being completed + * @returns Organization-prefix or complete name/platform suggestions + */ +export function completeProjectCreateSpec(partial: string): Completion[] { + if (WHITESPACE_RE.test(partial)) { + return []; + } + + const colonIdx = partial.lastIndexOf(":"); + if (colonIdx !== -1) { + const namePart = partial.slice(0, colonIdx); + const slashIdx = namePart.indexOf("/"); + if (slashIdx === 0 || namePart.indexOf("/", slashIdx + 1) !== -1) { + return []; + } + const projectName = + slashIdx === -1 ? namePart : namePart.slice(slashIdx + 1); + if (projectName === "") { + return []; + } + + const specPrefix = partial.slice(0, colonIdx + 1); + const platformPartial = partial.slice(colonIdx + 1); + const candidates = + platformPartial === "" ? COMMON_PLATFORMS : VALID_PLATFORMS; + return fuzzyMatch(platformPartial, candidates).map((platform) => ({ + value: `${specPrefix}${platform}`, + description: "Platform", + })); + } + + if (partial.includes("/")) { + return []; + } + + return completeOrgSlugs(partial, "/"); +} + /** * Complete organization slugs with fuzzy matching. * diff --git a/packages/cli/src/lib/completions.ts b/packages/cli/src/lib/completions.ts index d3746e2198..8d5f648e3a 100644 --- a/packages/cli/src/lib/completions.ts +++ b/packages/cli/src/lib/completions.ts @@ -429,11 +429,13 @@ ${caseBranches} # In the args state, $line[1] and $line[2] hold the parsed command # and subcommand. Pass them to __complete for context detection. local -a completions + local escaped_value while IFS=$'\\t' read -r value desc; do + escaped_value="\${value//:/\\\\:}" if [[ -n "$desc" ]]; then - completions+=("\${value}:\${desc}") + completions+=("\${escaped_value}:\${desc}") else - completions+=("\${value}") + completions+=("\${escaped_value}") fi done < <( if (( \${#words} == 0 )); then diff --git a/packages/cli/src/lib/formatters/human.ts b/packages/cli/src/lib/formatters/human.ts index aa72fd588f..32f4f08721 100644 --- a/packages/cli/src/lib/formatters/human.ts +++ b/packages/cli/src/lib/formatters/human.ts @@ -2036,6 +2036,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/packages/cli/src/lib/help.ts b/packages/cli/src/lib/help.ts index a961b936fa..53a429466f 100644 --- a/packages/cli/src/lib/help.ts +++ b/packages/cli/src/lib/help.ts @@ -116,11 +116,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/packages/cli/src/lib/introspect.ts b/packages/cli/src/lib/introspect.ts index 3e0d182486..7d5e9e561c 100644 --- a/packages/cli/src/lib/introspect.ts +++ b/packages/cli/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/packages/cli/src/lib/resolve-team.ts b/packages/cli/src/lib/resolve-team.ts index 59289a01ad..1a15b94da5 100644 --- a/packages/cli/src/lib/resolve-team.ts +++ b/packages/cli/src/lib/resolve-team.ts @@ -59,7 +59,7 @@ export type ResolveTeamOptions = { team?: string; /** Source of the auto-detected org, shown in error messages */ detectedFrom?: string; - /** Usage hint shown in error messages (e.g., "sentry project create / ") */ + /** Usage hint shown in errors (e.g., "sentry project create /:") */ usageHint: string; /** * Slug to use when auto-creating a team in an empty org. diff --git a/packages/cli/test/commands/project/create.test.ts b/packages/cli/test/commands/project/create.test.ts index b77268d9e8..55609bfad2 100644 --- a/packages/cli/test/commands/project/create.test.ts +++ b/packages/cli/test/commands/project/create.test.ts @@ -2,12 +2,16 @@ * Project Create Command Tests * * Tests for the project create command in src/commands/project/create.ts. - * Uses spyOn to mock api-client and resolve-target to test - * the func() body without real HTTP calls or database access. + * Covers the public Stricli parser contract and uses mocked API boundaries for + * command behavior without real HTTP calls. */ +import { run } from "@stricli/core"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { app } from "../../../src/app.js"; import { createCommand } from "../../../src/commands/project/create.js"; +import type { SentryContext } from "../../../src/context.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 @@ -29,10 +33,14 @@ import { ApiError, CliError, ContextError, + EXIT, ResolutionError, + ValidationError, } 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,17 +68,35 @@ 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-"); -function createMockContext() { +function createMockContext(): { + context: SentryContext; + stdoutWrite: ReturnType; +} { const stdoutWrite = vi.fn(() => true); return { context: { + process, + env: process.env, stdout: { write: stdoutWrite }, stderr: { write: vi.fn(() => true) }, + stdin: process.stdin, cwd: "/tmp", + homeDir: "/tmp", + configDir: "/tmp", }, stdoutWrite, }; @@ -136,7 +162,7 @@ describe("project create", () => { test("creates project with auto-detected org and single team", async () => { const { context, stdoutWrite } = createMockContext(); const func = await createCommand.loader(); - await func.call(context, { json: false }, "my-app", "node"); + await func.call(context, { json: false }, "my-app:node"); expect(createProjectWithDsnSpy).toHaveBeenCalledWith( "acme-corp", @@ -157,7 +183,7 @@ describe("project create", () => { test("parses org/name positional syntax", async () => { const { context } = createMockContext(); const func = await createCommand.loader(); - await func.call(context, { json: false }, "my-org/my-app", "python"); + await func.call(context, { json: false }, "my-org/my-app:python"); // resolveOrg should receive the explicit org expect(resolveOrgSpy).toHaveBeenCalledWith({ @@ -166,10 +192,10 @@ describe("project create", () => { }); }); - test("passes platform positional to createProject", async () => { + test("passes the paired platform to createProject", async () => { const { context } = createMockContext(); const func = await createCommand.loader(); - await func.call(context, { json: false }, "my-app", "python-flask"); + await func.call(context, { json: false }, "my-app:python-flask"); expect(createProjectWithDsnSpy).toHaveBeenCalledWith( "acme-corp", @@ -181,12 +207,78 @@ describe("project create", () => { ); }); + test.each([ + ["without an org", ["my-app", "python-flask"]], + ["with an explicit org", ["my-org/my-app", "python-flask"]], + ])("rejects the space-separated form %s — platform must use :", async (_label, args) => { + const { context } = createMockContext(); + const func = await createCommand.loader(); + + const err = await func + .call(context, { json: false }, ...args) + .catch((error: Error) => error); + + expect(err).toBeInstanceOf(ValidationError); + expect(err.message).toContain("must use : syntax"); + expect(createProjectWithDsnSpy).not.toHaveBeenCalled(); + }); + + test("rejects the space-separated form through Stricli", async () => { + const { context, stdoutWrite } = createMockContext(); + const stderrWrite = vi + .spyOn(process.stderr, "write") + .mockImplementation(() => true); + + try { + await run( + app, + [ + "project", + "create", + "my-org/my-app", + "node", + "--team", + "backend", + "--dry-run", + ], + context + ); + + expect(createProjectWithDsnSpy).not.toHaveBeenCalled(); + const output = + stdoutWrite.mock.calls.join("") + + stderrWrite.mock.calls.map(([data]) => String(data)).join(""); + expect(output).toContain("must use : syntax"); + } finally { + stderrWrite.mockRestore(); + } + }); + + test("splits historical colon-containing names on the final colon instead", async () => { + const { context } = createMockContext(); + const func = await createCommand.loader(); + await func.call(context, { json: false }, "my-org/api:europe:node"); + + expect(resolveOrgSpy).toHaveBeenCalledWith({ + org: "my-org", + cwd: "/tmp", + }); + expect(createProjectWithDsnSpy).toHaveBeenCalledWith( + "acme-corp", + "engineering", + { + name: "api:europe", + platform: "node", + } + ); + }); + test("passes --team to skip team auto-detection", async () => { listTeamsSpy.mockResolvedValue([sampleTeam, sampleTeam2]); const { context } = createMockContext(); const func = await createCommand.loader(); - await func.call(context, { team: "mobile", json: false }, "my-app", "go"); + await func.call(context, { team: "mobile", json: false }, "my-app:go"); // listTeams should NOT be called when --team is explicit expect(listTeamsSpy).not.toHaveBeenCalled(); @@ -206,7 +298,7 @@ describe("project create", () => { const { context } = createMockContext(); const func = await createCommand.loader(); - await func.call(context, { json: false }, "my-app", "node"); + await func.call(context, { json: false }, "my-app:node"); // Should auto-select the one team the user is a member of expect(createProjectWithDsnSpy).toHaveBeenCalledWith( @@ -226,7 +318,7 @@ describe("project create", () => { const func = await createCommand.loader(); const err = await func - .call(context, { json: false }, "my-app", "node") + .call(context, { json: false }, "my-app:node") .catch((e: Error) => e); expect(err).toBeInstanceOf(ContextError); expect(err.message).toContain("You belong to 2 teams"); @@ -249,7 +341,7 @@ describe("project create", () => { const func = await createCommand.loader(); const err = await func - .call(context, { json: false }, "my-app", "node") + .call(context, { json: false }, "my-app:node") .catch((e: Error) => e); expect(err).toBeInstanceOf(ContextError); expect(err.message).toContain("engineering"); @@ -267,7 +359,7 @@ describe("project create", () => { const func = await createCommand.loader(); const err = await func - .call(context, { json: false }, "my-app", "node") + .call(context, { json: false }, "my-app:node") .catch((e: Error) => e); expect(err).toBeInstanceOf(ContextError); expect(err.message).toContain("Multiple teams found"); @@ -285,7 +377,7 @@ describe("project create", () => { const { context, stdoutWrite } = createMockContext(); const func = await createCommand.loader(); - await func.call(context, { json: false }, "my-app", "node"); + await func.call(context, { json: false }, "my-app:node"); expect(createTeamSpy).toHaveBeenCalledWith("acme-corp", "my-app"); expect(createProjectWithDsnSpy).toHaveBeenCalledWith( @@ -309,7 +401,7 @@ describe("project create", () => { const func = await createCommand.loader(); await expect( - func.call(context, { json: false }, "my-app", "node") + func.call(context, { json: false }, "my-app:node") ).rejects.toThrow(ContextError); }); @@ -326,7 +418,7 @@ describe("project create", () => { const func = await createCommand.loader(); const err = await func - .call(context, { json: false }, "my-app", "node") + .call(context, { json: false }, "my-app:node") .catch((e: Error) => e); expect(err).toBeInstanceOf(CliError); expect(err.message).toContain("already exists"); @@ -343,7 +435,7 @@ describe("project create", () => { // Use --team with a slug that doesn't match any team in the org const err = await func - .call(context, { team: "nonexistent", json: false }, "my-app", "node") + .call(context, { team: "nonexistent", json: false }, "my-app:node") .catch((e: Error) => e); expect(err).toBeInstanceOf(CliError); expect(err.message).toContain("Team 'nonexistent' not found"); @@ -367,7 +459,7 @@ describe("project create", () => { const func = await createCommand.loader(); const err = await func - .call(context, { json: false }, "my-app", "node") + .call(context, { json: false }, "my-app:node") .catch((e: Error) => e); expect(err).toBeInstanceOf(CliError); expect(err.message).toContain("exists but the request was rejected"); @@ -389,7 +481,7 @@ describe("project create", () => { const func = await createCommand.loader(); const err = await func - .call(context, { json: false, team: "backend" }, "my-app", "node") + .call(context, { json: false, team: "backend" }, "my-app:node") .catch((e: Error) => e); expect(err).toBeInstanceOf(CliError); expect(err.message).toContain("Organization 'acme-corp' not found"); @@ -410,7 +502,7 @@ describe("project create", () => { const func = await createCommand.loader(); const err = await func - .call(context, { json: false, team: "backend" }, "my-app", "node") + .call(context, { json: false, team: "backend" }, "my-app:node") .catch((e: Error) => e); expect(err).toBeInstanceOf(CliError); expect(err.message).toContain("could not be created"); @@ -422,9 +514,10 @@ describe("project create", () => { const func = await createCommand.loader(); const err = await func - .call(context, { json: false }, "my-app", "javascript-node") + .call(context, { json: false }, "my-app:javascript-node") .catch((e: Error) => e); - expect(err).toBeInstanceOf(CliError); + expect(err).toBeInstanceOf(ValidationError); + expect((err as ValidationError).exitCode).toBe(EXIT.VALIDATION); expect(err.message).toContain("Invalid platform 'javascript-node'"); expect(err.message).toContain("Did you mean?"); expect(err.message).toContain("node"); @@ -448,7 +541,7 @@ describe("project create", () => { // Use a valid platform so client-side check passes, but API rejects const err = await func - .call(context, { json: false }, "my-app", "node") + .call(context, { json: false }, "my-app:node") .catch((e: Error) => e); expect(err).toBeInstanceOf(CliError); expect(err.message).toContain("Invalid platform 'node'"); @@ -466,7 +559,7 @@ describe("project create", () => { const func = await createCommand.loader(); const err = (await func - .call(context, { json: false }, "my-app", "node") + .call(context, { json: false }, "my-app:node") .catch((e: Error) => e)) as ApiError; // Stays ApiError (not a plain CliError wrapper) so 5xx errors are // captured for error reporting. @@ -492,7 +585,7 @@ describe("project create", () => { const { context } = createMockContext(); const func = await createCommand.loader(); - await func.call(context, { json: false }, "my-app", "node"); + await func.call(context, { json: false }, "my-app:node"); expect(createProjectWithAutoTeamSpy).toHaveBeenCalledWith("acme-corp", { name: "my-app", @@ -500,6 +593,70 @@ describe("project create", () => { }); }); + test("preserves unrelated API 400 errors for project 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.message).toContain("Failed to create project 'my-cool-app'"); + expect(err.detail).toContain("no more than 50 characters"); + expect(err.message).not.toContain("separate argument"); + }); + + test("handles API platform errors on the org-scoped fallback", async () => { + createProjectWithDsnSpy.mockRejectedValue( + new ApiError("Forbidden", 403, "You do not have permission") + ); + createProjectWithAutoTeamSpy.mockRejectedValue( + new ApiError( + "API request failed: 400 Bad Request", + 400, + '{"platform":["Invalid platform"]}' + ) + ); + const { context } = createMockContext(); + const func = await createCommand.loader(); + const err = await func + .call(context, { json: false }, "my-app:node") + .catch((error: Error) => error); + + expect(err).toBeInstanceOf(CliError); + expect(err.message).toContain("Invalid platform 'node'"); + expect(err.message).toContain("Common platforms:"); + }); + 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"). @@ -512,7 +669,7 @@ describe("project create", () => { const func = await createCommand.loader(); const err = (await func - .call(context, { json: false }, "my-app", "node") + .call(context, { json: false }, "my-app:node") .catch((e: Error) => e)) as ApiError; expect(err).toBeInstanceOf(ApiError); expect(err.status).toBe(403); @@ -522,7 +679,7 @@ describe("project create", () => { test("outputs JSON when --json flag is set", async () => { const { context, stdoutWrite } = createMockContext(); const func = await createCommand.loader(); - await func.call(context, { json: true }, "my-app", "node"); + await func.call(context, { json: true }, "my-app:node"); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); const parsed = JSON.parse(output); @@ -541,7 +698,7 @@ describe("project create", () => { const { context, stdoutWrite } = createMockContext(); const func = await createCommand.loader(); - await func.call(context, { json: false }, "my-app", "node"); + await func.call(context, { json: false }, "my-app:node"); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); // Should still show project info without DSN @@ -555,14 +712,14 @@ describe("project create", () => { // Missing name after slash await expect( - func.call(context, { json: false }, "acme-corp/", "node") + func.call(context, { json: false }, "acme-corp/:node") ).rejects.toThrow(ContextError); }); test("shows platform in human output", async () => { const { context, stdoutWrite } = createMockContext(); const func = await createCommand.loader(); - await func.call(context, { json: false }, "my-app", "python-django"); + await func.call(context, { json: false }, "my-app:python-django"); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); expect(output).toContain("python"); @@ -571,7 +728,7 @@ describe("project create", () => { test("shows project URL in human output", async () => { const { context, stdoutWrite } = createMockContext(); const func = await createCommand.loader(); - await func.call(context, { json: false }, "my-app", "node"); + await func.call(context, { json: false }, "my-app:node"); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); expect(output).toContain( @@ -589,7 +746,7 @@ describe("project create", () => { const { context, stdoutWrite } = createMockContext(); const func = await createCommand.loader(); - await func.call(context, { json: false }, "my-app", "node"); + await func.call(context, { json: false }, "my-app:node"); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); // Plain mode renders code spans as plain text without padding @@ -600,7 +757,7 @@ describe("project create", () => { test("does not show slug note when slug matches name", async () => { const { context, stdoutWrite } = createMockContext(); const func = await createCommand.loader(); - await func.call(context, { json: false }, "my-app", "node"); + await func.call(context, { json: false }, "my-app:node"); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); expect(output).not.toContain("was assigned"); @@ -614,22 +771,56 @@ describe("project create", () => { .call(context, { json: false }) .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("Project specification is required"); + expect(err.message).toContain( + "sentry project create [/]:..." + ); + }); + + test.each([ + "", + " ", + ])("rejects an empty project name argument (%j)", async (name) => { + const { context } = createMockContext(); + const func = await createCommand.loader(); + + const err = await func + .call(context, { json: false }, `${name}:node`) + .catch((error: Error) => error); + expect(err).toBeInstanceOf(ValidationError); + expect(err.message).toContain("Project name cannot be empty"); }); - test("shows helpful error when platform is missing", async () => { + test.each([ + "my-app", + "my-app:", + ])("shows a helpful error when platform is missing from %s", async (projectArg) => { const { context } = createMockContext(); const func = await createCommand.loader(); const err = await func - .call(context, { json: false }, "my-app") + .call(context, { json: false }, projectArg) .catch((e: Error) => e); - expect(err).toBeInstanceOf(CliError); + expect(err).toBeInstanceOf(ValidationError); + expect((err as ValidationError).exitCode).toBe(EXIT.VALIDATION); expect(err.message).toContain("Platform is required"); expect(err.message).toContain("Common platforms:"); expect(err.message).toContain("javascript-nextjs"); expect(err.message).toContain("python"); + // There is no space-separated form at all, so it must never be suggested. + expect(err.message).not.toContain(" "); + }); + + test("rejects trailing-platform batches that were never supported", async () => { + const { context } = createMockContext(); + const func = await createCommand.loader(); + + const err = await func + .call(context, { json: false }, "web", "api", "node") + .catch((error: Error) => error); + expect(err).toBeInstanceOf(ValidationError); + expect(err.message).toContain("must use : syntax"); + expect(createProjectWithDsnSpy).not.toHaveBeenCalled(); }); test("wraps listTeams API failure with org list", async () => { @@ -641,7 +832,7 @@ describe("project create", () => { const func = await createCommand.loader(); const err = await func - .call(context, { json: false }, "my-app", "node") + .call(context, { json: false }, "my-app:node") .catch((e: Error) => e); expect(err).toBeInstanceOf(ResolutionError); expect(err.message).toContain("acme-corp"); @@ -664,7 +855,7 @@ describe("project create", () => { const func = await createCommand.loader(); const err = await func - .call(context, { json: false }, "my-app", "node") + .call(context, { json: false }, "my-app:node") .catch((e: Error) => e); expect(err).toBeInstanceOf(ResolutionError); expect(err.message).toContain("auto-detected from test/mocks/routes.ts"); @@ -685,7 +876,7 @@ describe("project create", () => { const func = await createCommand.loader(); const err = (await func - .call(context, { json: false }, "my-app", "node") + .call(context, { json: false }, "my-app:node") .catch((e: Error) => e)) as ApiError; expect(err).toBeInstanceOf(ApiError); expect(err.status).toBe(403); @@ -695,7 +886,7 @@ describe("project create", () => { test("auto-corrects dot-separated platform to hyphen-separated", async () => { const { context } = createMockContext(); const func = await createCommand.loader(); - await func.call(context, { json: false }, "my-app", "javascript.nextjs"); + await func.call(context, { json: false }, "my-app:javascript.nextjs"); // Should send corrected platform to API expect(createProjectWithDsnSpy).toHaveBeenCalledWith( @@ -711,7 +902,7 @@ describe("project create", () => { test("does not correct platform without dots", async () => { const { context } = createMockContext(); const func = await createCommand.loader(); - await func.call(context, { json: false }, "my-app", "javascript-nextjs"); + await func.call(context, { json: false }, "my-app:javascript-nextjs"); // Should send platform as-is to API (no correction needed) expect(createProjectWithDsnSpy).toHaveBeenCalledWith( @@ -730,24 +921,169 @@ describe("project create", () => { // python.django.rest → python-django-rest (not a valid platform) const err = await func - .call(context, { json: false }, "my-app", "python.django.rest") + .call(context, { json: false }, "my-app:python.django.rest") .catch((e: Error) => e); expect(err).toBeInstanceOf(CliError); expect(err.message).toContain("Invalid platform 'python-django-rest'"); }); - // --dry-run tests - - test("dry-run shows what would be created without API call", async () => { + test("creates multiple projects with independent platforms", async () => { const { context, stdoutWrite } = createMockContext(); const func = await createCommand.loader(); await func.call( context, - { json: false, "dry-run": true }, - "my-app", - "node" + { json: false }, + "web:javascript", + "api:python-django", + "worker:node" ); + expect(createProjectWithDsnSpy).toHaveBeenCalledTimes(3); + for (const [name, platform] of [ + ["web", "javascript"], + ["api", "python-django"], + ["worker", "node"], + ] as const) { + expect(createProjectWithDsnSpy).toHaveBeenCalledWith( + "acme-corp", + "engineering", + { name, platform } + ); + } + const output = stdoutWrite.mock.calls.map((call) => call[0]).join(""); + expect(output.match(/Created project/g)).toHaveLength(3); + }); + + test("creates one hundred project pairs in one command", async () => { + const specs = Array.from( + { length: 100 }, + (_, index) => `project-${index}:node` + ); + const { context } = createMockContext(); + const func = await createCommand.loader(); + + await func.call(context, { json: false }, ...specs); + + expect(createProjectWithDsnSpy).toHaveBeenCalledTimes(100); + expect(createProjectWithDsnSpy).toHaveBeenLastCalledWith( + "acme-corp", + "engineering", + { name: "project-99", platform: "node" } + ); + }); + + 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:node", "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:node", "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:node", "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 identifies its project pair", async () => { + const { context } = createMockContext(); + const func = await createCommand.loader(); + const err = await func + .call(context, { json: false }, "proj1:node", "proj2:not-a-platform") + .catch((e: Error) => e); + expect(err).toBeInstanceOf(ValidationError); + expect(err.message).toContain("Invalid platform 'not-a-platform'"); + expect(err.message).toContain("sentry project create proj2:"); + }); + + test.each([ + ["paired", ["My Cool App:node"]], + ])("rejects whitespace in a %s project name", async (_syntax, args) => { + const { context } = createMockContext(); + const func = await createCommand.loader(); + const err = await func + .call(context, { json: false }, ...args) + .catch((error: Error) => error); + + expect(err).toBeInstanceOf(ValidationError); + expect(err.message).toContain( + "Project name 'My Cool App' cannot contain whitespace" + ); + expect(createProjectWithDsnSpy).not.toHaveBeenCalled(); + }); + + 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("splits a project specification on its final colon", async () => { + const { context } = createMockContext(); + const func = await createCommand.loader(); + await func.call(context, { json: false }, "api:europe:node"); + + expect(createProjectWithDsnSpy).toHaveBeenCalledWith( + "acme-corp", + "engineering", + { name: "api:europe", platform: "node" } + ); + }); + + // --dry-run tests + + test("dry-run shows what would be created without API call", async () => { + const { context, stdoutWrite } = createMockContext(); + const func = await createCommand.loader(); + await func.call(context, { json: false, "dry-run": true }, "my-app:node"); + // Should NOT call createProject expect(createProjectWithDsnSpy).not.toHaveBeenCalled(); // Should NOT fetch DSN @@ -761,6 +1097,30 @@ 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:javascript", + "api:python-django", + "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(); @@ -769,8 +1129,7 @@ describe("project create", () => { .call( context, { json: false, "dry-run": true }, - "my-app", - "invalid-platform" + "my-app:invalid-platform" ) .catch((e: Error) => e); expect(err).toBeInstanceOf(CliError); @@ -783,8 +1142,7 @@ describe("project create", () => { await func.call( context, { json: false, "dry-run": true }, - "my-org/my-app", - "python" + "my-org/my-app:python" ); expect(resolveOrgSpy).toHaveBeenCalledWith({ @@ -796,7 +1154,7 @@ describe("project create", () => { test("dry-run outputs JSON when --json is set", async () => { const { context, stdoutWrite } = createMockContext(); const func = await createCommand.loader(); - await func.call(context, { json: true, "dry-run": true }, "my-app", "node"); + await func.call(context, { json: true, "dry-run": true }, "my-app:node"); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); const parsed = JSON.parse(output); @@ -813,16 +1171,29 @@ describe("project create", () => { expect(createProjectWithDsnSpy).not.toHaveBeenCalled(); }); - test("dry-run shows team source for auto-selected teams", async () => { + 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: false, "dry-run": true }, - "my-app", - "node" + { json: true, "dry-run": true }, + "web:node", + "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(); + await func.call(context, { json: false, "dry-run": true }, "my-app:node"); + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); // Single team = auto-selected → note about team usage expect(output).toContain("Would use team"); @@ -833,12 +1204,7 @@ describe("project create", () => { const { context, stdoutWrite } = createMockContext(); const func = await createCommand.loader(); - await func.call( - context, - { json: false, "dry-run": true }, - "my-app", - "node" - ); + await func.call(context, { json: false, "dry-run": true }, "my-app:node"); // Should NOT call createTeam expect(createTeamSpy).not.toHaveBeenCalled(); @@ -850,4 +1216,29 @@ describe("project create", () => { expect(output).toContain("my-app"); expect(output).toContain("Would create team"); }); + + test("falls back to a later project's slug when the first name slugifies to empty", async () => { + listTeamsSpy.mockResolvedValue([]); + + const { context, stdoutWrite } = createMockContext(); + const func = await createCommand.loader(); + // "!!!" has no characters slugify() keeps, so it alone can't name the + // auto-created team — the batch should still share "my-app" instead of + // rejecting every project with "No teams found". + await func.call( + context, + { json: false, "dry-run": true }, + "!!!:node", + "my-app:python" + ); + + expect(createTeamSpy).not.toHaveBeenCalled(); + expect(createProjectWithDsnSpy).not.toHaveBeenCalled(); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).not.toContain("No teams found"); + expect(output).toContain("Would create team"); + // Both projects share the same auto-create slug, derived from "my-app". + expect(output.match(/Would create team 'my-app'/g)).toHaveLength(2); + }); }); diff --git a/packages/cli/test/lib/command.test.ts b/packages/cli/test/lib/command.test.ts index 3069c1051e..981a5fb569 100644 --- a/packages/cli/test/lib/command.test.ts +++ b/packages/cli/test/lib/command.test.ts @@ -104,6 +104,29 @@ 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: [":..."], + }, + parameters: { + positional: { + kind: "array", + parameter: { brief: "Name and kind pairs", 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/packages/cli/test/lib/complete.test.ts b/packages/cli/test/lib/complete.test.ts index 2e01417605..545ad38617 100644 --- a/packages/cli/test/lib/complete.test.ts +++ b/packages/cli/test/lib/complete.test.ts @@ -11,6 +11,7 @@ import { completeAliases, completeOrgSlashProject, completeOrgSlugs, + completeProjectCreateSpec, completeProjectSlugs, getCompletions, } from "../../src/lib/complete.js"; @@ -97,6 +98,86 @@ describe("getCompletions: context detection", () => { const result = getCompletions(["team", "list"], ""); expect(result.some((c) => c.value === "acme")).toBe(true); }); + + test("returns only valid project create prefixes and pairs", async () => { + await seedOrgs([{ slug: "acme", name: "Acme Inc" }]); + await seedProjects([ + { + orgId: "1", + projectId: "10", + orgSlug: "acme", + projectSlug: "existing", + projectName: "Existing Project", + }, + ]); + setProjectAliases( + { app: { orgSlug: "acme", projectSlug: "existing" } }, + "fingerprint" + ); + + expect(getCompletions(["project", "create"], "")).toEqual([ + { value: "acme/", description: "Acme Inc" }, + ]); + expect(getCompletions(["project", "create"], "acme/new")).toEqual([]); + expect(getCompletions(["project", "create"], "web:java")).toContainEqual({ + value: "web:javascript", + description: "Platform", + }); + // Platform must always be attached with ":" — a bare positional never + // gets legacy space-separated platform completions. + expect(getCompletions(["project", "create", "web"], "java")).toEqual([]); + }); + + test("completes a new batch pair's platform regardless of preceding pairs", () => { + // Completion only ever looks at the current partial — a self-contained + // colon pair completes the same way no matter what preceded it. + expect( + getCompletions(["project", "create", "api:node"], "worker:java") + ).toContainEqual({ + value: "worker:javascript", + description: "Platform", + }); + expect(getCompletions(["project", "create", "--team"], "back")).toEqual([]); + }); +}); + +describe("completeProjectCreateSpec", () => { + test("suggests common platforms while preserving the project name", () => { + const result = completeProjectCreateSpec("my-project:"); + + expect(result).toContainEqual({ + value: "my-project:javascript", + description: "Platform", + }); + expect(result.every((completion) => completion.value.includes(":"))).toBe( + true + ); + }); + + test("matches every valid platform after a partial", () => { + const result = completeProjectCreateSpec("acme/app:nintendo"); + + expect(result).toEqual([ + { value: "acme/app:nintendo-switch", description: "Platform" }, + ]); + }); + + test("does not complete existing project names before the colon", () => { + expect(completeProjectCreateSpec("acme/new-project")).toEqual([]); + }); + + test("does not complete project specifications containing whitespace", () => { + expect(completeProjectCreateSpec("My App:java")).toEqual([]); + }); + + test.each([ + ":java", + "acme/:java", + "/app:java", + "acme/app/child:java", + ])("does not complete a malformed project target in %s", (partial) => { + expect(completeProjectCreateSpec(partial)).toEqual([]); + }); }); describe("completeOrgSlugs", () => { diff --git a/packages/cli/test/lib/completions.test.ts b/packages/cli/test/lib/completions.test.ts index 83564ac60b..4aaeabef2b 100644 --- a/packages/cli/test/lib/completions.test.ts +++ b/packages/cli/test/lib/completions.test.ts @@ -50,6 +50,11 @@ describe("completions", () => { expect(script).toContain("__complete"); }); + test("zsh script escapes colons in dynamic completion values", () => { + const script = getCompletionScript("zsh"); + expect(script).toContain(`escaped_value="\${value//:/\\\\:}"`); + }); + test("fish script includes __complete callback", () => { const script = getCompletionScript("fish"); expect(script).toContain("__complete"); diff --git a/packages/cli/test/lib/help-positional.test.ts b/packages/cli/test/lib/help-positional.test.ts index 55e00d519a..cb141ed3b4 100644 --- a/packages/cli/test/lib/help-positional.test.ts +++ b/packages/cli/test/lib/help-positional.test.ts @@ -12,8 +12,8 @@ * and verify help output is shown when resolution fails. */ -import { run } from "@stricli/core"; -import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { generateHelpTextForAllCommands, run } from "@stricli/core"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { app } from "../../src/app.js"; import type { SentryContext } from "../../src/context.js"; import { mockFetch, useTestConfigDir } from "../helpers.js"; @@ -178,4 +178,55 @@ 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 the required paired syntax", () => { + const help = generateHelpTextForAllCommands(app).find( + ([route]) => route === "sentry project create" + )?.[1]; + + expect(help).toContain( + "sentry project create [/]:..." + ); + expect(help).not.toContain("--platform"); + expect(help).not.toContain(""); + expect(help).toContain("cannot contain whitespace"); + }); +}); + +describe("project create parser contract", () => { + test.each([ + "--platform", + "-p", + ])("rejects the removed %s flag", async (flag) => { + const captured = { stdout: "", stderr: "" }; + const mockContext = buildMockContext(captured); + const stderrWrite = vi + .spyOn(process.stderr, "write") + .mockImplementation(() => true); + + try { + await run( + app, + ["project", "create", "my-app:node", flag, "javascript"], + mockContext + ); + + const errorOutput = stderrWrite.mock.calls + .map(([data]) => String(data)) + .join(""); + expect(errorOutput).toContain(flag); + expect(errorOutput).toMatch(/No (flag|alias) registered/); + } finally { + stderrWrite.mockRestore(); + } + }); }); diff --git a/packages/cli/test/lib/introspect.test.ts b/packages/cli/test/lib/introspect.test.ts index da62712a8c..5636f0e857 100644 --- a/packages/cli/test/lib/introspect.test.ts +++ b/packages/cli/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:kind" }, + }, + }, + }); + + const info = buildCommandInfo(cmd, "sentry project create"); + expect(info.positional).toBe(":..."); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/cli/test/script/generate-skill-markdown.test.ts b/packages/cli/test/script/generate-skill-markdown.test.ts new file mode 100644 index 0000000000..6bce18c8b3 --- /dev/null +++ b/packages/cli/test/script/generate-skill-markdown.test.ts @@ -0,0 +1,67 @@ +/** 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:javascript api:python-django", + ].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).not.toContain('sentry project create "My New App":'); + // The platform must always be attached with ":" — no space-separated form. + expect(reference).not.toContain( + "sentry project create my-new-app javascript-nextjs" + ); + expect(reference).not.toContain( + "sentry project create my-org/my-new-app javascript-nextjs" + ); + expect(reference).toContain( + "sentry project create web:javascript api:python-django worker:node" + ); + expect(reference).toContain( + "sentry project create my-new-app:javascript-nextjs" + ); + }); +});