66 */
77
88import type { SentryContext } from "../../context.js" ;
9- import {
10- createProject ,
11- getProjectKeys ,
12- listOrganizations ,
13- listTeams ,
14- } from "../../lib/api-client.js" ;
9+ import { createProject , tryGetPrimaryDsn } from "../../lib/api-client.js" ;
10+ import { parseOrgPrefixedArg } from "../../lib/arg-parsing.js" ;
1511import { buildCommand } from "../../lib/command.js" ;
1612import { ApiError , CliError , ContextError } from "../../lib/errors.js" ;
1713import { writeFooter , writeJson } from "../../lib/formatters/index.js" ;
1814import { resolveOrg } from "../../lib/resolve-target.js" ;
19- import { buildProjectUrl , getSentryBaseUrl } from "../../lib/sentry-urls.js" ;
20- import type { SentryProject , SentryTeam } from "../../types/index.js" ;
15+ import { resolveTeam } from "../../lib/resolve-team.js" ;
16+ import { buildProjectUrl } from "../../lib/sentry-urls.js" ;
17+ import type { SentryProject } from "../../types/index.js" ;
18+
19+ /** Usage hint template — base command without positionals */
20+ const USAGE_HINT = "sentry project create <org>/<name> <platform>" ;
2121
2222type CreateFlags = {
2323 readonly team ?: string ;
2424 readonly json : boolean ;
2525} ;
2626
27- /** Common Sentry platform strings, shown when platform arg is missing */
27+ /** Common Sentry platform strings, shown when platform arg is missing or invalid */
2828const PLATFORMS = [
2929 "javascript" ,
3030 "javascript-react" ,
@@ -54,114 +54,6 @@ const PLATFORMS = [
5454 "elixir" ,
5555] as const ;
5656
57- /**
58- * Parse the name positional argument.
59- * Supports `org/name` syntax for explicit org, or bare `name` for auto-detect.
60- *
61- * @returns Parsed org (if explicit) and project name
62- */
63- function parseNameArg ( arg : string ) : { org ?: string ; name : string } {
64- if ( arg . includes ( "/" ) ) {
65- const slashIndex = arg . indexOf ( "/" ) ;
66- const org = arg . slice ( 0 , slashIndex ) ;
67- const name = arg . slice ( slashIndex + 1 ) ;
68-
69- if ( ! ( org && name ) ) {
70- throw new ContextError (
71- "Project name" ,
72- "sentry project create <org>/<name> <platform>\n\n" +
73- 'Both org and name are required when using "/" syntax.'
74- ) ;
75- }
76-
77- return { org, name } ;
78- }
79-
80- return { name : arg } ;
81- }
82-
83- /**
84- * Resolve which team to create the project under.
85- *
86- * Priority:
87- * 1. Explicit --team flag
88- * 2. Auto-detect: if org has exactly one team, use it
89- * 3. Error with list of available teams
90- *
91- * @param orgSlug - Organization to list teams from
92- * @param teamFlag - Explicit team slug from --team flag
93- * @param detectedFrom - Source of auto-detected org (shown in error messages)
94- * @returns Team slug to use
95- */
96- async function resolveTeam (
97- orgSlug : string ,
98- teamFlag ?: string ,
99- detectedFrom ?: string
100- ) : Promise < string > {
101- if ( teamFlag ) {
102- return teamFlag ;
103- }
104-
105- let teams : SentryTeam [ ] ;
106- try {
107- teams = await listTeams ( orgSlug ) ;
108- } catch ( error ) {
109- if ( error instanceof ApiError ) {
110- // Try to list the user's actual orgs to help them fix the command
111- let orgHint =
112- "Specify org explicitly: sentry project create <org>/<name> <platform>" ;
113- try {
114- const orgs = await listOrganizations ( ) ;
115- if ( orgs . length > 0 ) {
116- const orgList = orgs . map ( ( o ) => ` ${ o . slug } ` ) . join ( "\n" ) ;
117- orgHint = `Your organizations:\n\n${ orgList } ` ;
118- }
119- } catch {
120- // Best-effort — if this also fails, use the generic hint
121- }
122-
123- const alternatives = [
124- `Could not list teams for org '${ orgSlug } ' (${ error . status } )` ,
125- ] ;
126- if ( detectedFrom ) {
127- alternatives . push (
128- `Org '${ orgSlug } ' was auto-detected from ${ detectedFrom } `
129- ) ;
130- }
131- alternatives . push ( orgHint ) ;
132- throw new ContextError (
133- "Organization" ,
134- "sentry project create <org>/<name> <platform> --team <team-slug>" ,
135- alternatives
136- ) ;
137- }
138- throw error ;
139- }
140-
141- if ( teams . length === 0 ) {
142- const teamsUrl = `${ getSentryBaseUrl ( ) } /settings/${ orgSlug } /teams/` ;
143- throw new ContextError (
144- "Team" ,
145- `sentry project create ${ orgSlug } /<name> <platform> --team <team-slug>` ,
146- [ `No teams found in org '${ orgSlug } '` , `Create a team at ${ teamsUrl } ` ]
147- ) ;
148- }
149-
150- if ( teams . length === 1 ) {
151- return ( teams [ 0 ] as SentryTeam ) . slug ;
152- }
153-
154- // Multiple teams — user must specify
155- const teamList = teams . map ( ( t ) => ` ${ t . slug } ` ) . join ( "\n" ) ;
156- throw new ContextError (
157- "Team" ,
158- `sentry project create <name> <platform> --team ${ ( teams [ 0 ] as SentryTeam ) . slug } ` ,
159- [
160- `Multiple teams found in ${ orgSlug } . Specify one with --team:\n\n${ teamList } ` ,
161- ]
162- ) ;
163- }
164-
16557/** Check whether an API error is about an invalid platform value */
16658function isPlatformError ( error : ApiError ) : boolean {
16759 const detail = error . detail ?? error . message ;
@@ -229,19 +121,16 @@ async function createProjectWithErrors(
229121}
230122
231123/**
232- * Try to fetch the primary DSN for a newly created project .
233- * Returns null on any error — DSN display is best-effort .
124+ * Write key-value pairs with aligned columns .
125+ * Used for human-readable output after resource creation .
234126 */
235- async function tryGetPrimaryDsn (
236- orgSlug : string ,
237- projectSlug : string
238- ) : Promise < string | null > {
239- try {
240- const keys = await getProjectKeys ( orgSlug , projectSlug ) ;
241- const activeKey = keys . find ( ( k ) => k . isActive ) ;
242- return activeKey ?. dsn . public ?? keys [ 0 ] ?. dsn . public ?? null ;
243- } catch {
244- return null ;
127+ function writeKeyValue (
128+ stdout : { write : ( s : string ) => void } ,
129+ pairs : [ label : string , value : string ] [ ]
130+ ) : void {
131+ const maxLabel = Math . max ( ...pairs . map ( ( [ l ] ) => l . length ) ) ;
132+ for ( const [ label , value ] of pairs ) {
133+ stdout . write ( ` ${ label . padEnd ( maxLabel + 2 ) } ${ value } \n` ) ;
245134 }
246135}
247136
@@ -306,7 +195,7 @@ export const createCommand = buildCommand({
306195 "Project name" ,
307196 "sentry project create <name> <platform>" ,
308197 [
309- " Use org/name syntax: sentry project create <org>/<name> <platform>" ,
198+ ` Use org/name syntax: ${ USAGE_HINT } ` ,
310199 "Specify team: sentry project create <name> <platform> --team <slug>" ,
311200 ]
312201 ) ;
@@ -316,30 +205,29 @@ export const createCommand = buildCommand({
316205 throw new CliError ( buildPlatformError ( nameArg ) ) ;
317206 }
318207
319- // Parse name (may include org/ prefix)
320- const { org : explicitOrg , name } = parseNameArg ( nameArg ) ;
208+ const { org : explicitOrg , name } = parseOrgPrefixedArg (
209+ nameArg ,
210+ "Project name" ,
211+ USAGE_HINT
212+ ) ;
321213
322214 // Resolve organization
323215 const resolved = await resolveOrg ( { org : explicitOrg , cwd } ) ;
324216 if ( ! resolved ) {
325- throw new ContextError (
326- "Organization" ,
327- "sentry project create <org>/<name> <platform>" ,
328- [
329- "Include org in name: sentry project create <org>/<name> <platform>" ,
330- "Set a default: sentry org view <org>" ,
331- "Run from a directory with a Sentry DSN configured" ,
332- ]
333- ) ;
217+ throw new ContextError ( "Organization" , USAGE_HINT , [
218+ `Include org in name: ${ USAGE_HINT } ` ,
219+ "Set a default: sentry org view <org>" ,
220+ "Run from a directory with a Sentry DSN configured" ,
221+ ] ) ;
334222 }
335223 const orgSlug = resolved . org ;
336224
337225 // Resolve team
338- const teamSlug = await resolveTeam (
339- orgSlug ,
340- flags . team ,
341- resolved . detectedFrom
342- ) ;
226+ const teamSlug = await resolveTeam ( orgSlug , {
227+ team : flags . team ,
228+ detectedFrom : resolved . detectedFrom ,
229+ usageHint : USAGE_HINT ,
230+ } ) ;
343231
344232 // Create the project
345233 const project = await createProjectWithErrors (
@@ -349,7 +237,7 @@ export const createCommand = buildCommand({
349237 platformArg
350238 ) ;
351239
352- // Fetch DSN (best-effort, non-blocking for output )
240+ // Fetch DSN (best-effort)
353241 const dsn = await tryGetPrimaryDsn ( orgSlug , project . slug ) ;
354242
355243 // JSON output
@@ -360,17 +248,20 @@ export const createCommand = buildCommand({
360248
361249 // Human-readable output
362250 const url = buildProjectUrl ( orgSlug , project . slug ) ;
363-
364- stdout . write ( `\nCreated project ' ${ project . name } ' in ${ orgSlug } \n\n` ) ;
365- stdout . write ( ` Project ${ project . name } \n` ) ;
366- stdout . write ( ` Slug ${ project . slug } \n` ) ;
367- stdout . write ( ` Org ${ orgSlug } \n` ) ;
368- stdout . write ( ` Team ${ teamSlug } \n` ) ;
369- stdout . write ( ` Platform ${ project . platform || platformArg } \n` ) ;
251+ const fields : [ string , string ] [ ] = [
252+ [ "Project" , project . name ] ,
253+ [ "Slug" , project . slug ] ,
254+ [ "Org" , orgSlug ] ,
255+ [ "Team" , teamSlug ] ,
256+ [ "Platform" , project . platform || platformArg ] ,
257+ ] ;
370258 if ( dsn ) {
371- stdout . write ( ` DSN ${ dsn } \n` ) ;
259+ fields . push ( [ " DSN" , dsn ] ) ;
372260 }
373- stdout . write ( ` URL ${ url } \n` ) ;
261+ fields . push ( [ "URL" , url ] ) ;
262+
263+ stdout . write ( `\nCreated project '${ project . name } ' in ${ orgSlug } \n\n` ) ;
264+ writeKeyValue ( stdout , fields ) ;
374265
375266 writeFooter (
376267 stdout ,
0 commit comments