Skip to content

Commit 0ff0135

Browse files
committed
feat(project): add project create command
Adds `sentry project create <name> <platform> [--team] [--json]`. Supports org/name syntax (like gh repo create owner/repo), auto-detects org from config/DSN, and auto-selects team when the org has exactly one. Fetches the DSN after creation so users can start sending events immediately. All error paths are actionable — wrong org lists your orgs, wrong team lists available teams, 409 links to the existing project.
1 parent 82d0ad4 commit 0ff0135

File tree

3 files changed

+724
-0
lines changed

3 files changed

+724
-0
lines changed

src/commands/project/create.ts

Lines changed: 358 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,358 @@
1+
/**
2+
* sentry project create
3+
*
4+
* Create a new Sentry project.
5+
* Supports org/name positional syntax (like `gh repo create owner/repo`).
6+
*/
7+
8+
import type { SentryContext } from "../../context.js";
9+
import {
10+
createProject,
11+
getProjectKeys,
12+
listOrganizations,
13+
listTeams,
14+
} from "../../lib/api-client.js";
15+
import { buildCommand } from "../../lib/command.js";
16+
import { ApiError, CliError, ContextError } from "../../lib/errors.js";
17+
import { writeFooter, writeJson } from "../../lib/formatters/index.js";
18+
import { resolveOrg } from "../../lib/resolve-target.js";
19+
import { buildProjectUrl, getSentryBaseUrl } from "../../lib/sentry-urls.js";
20+
import type { SentryProject, SentryTeam } from "../../types/index.js";
21+
22+
type CreateFlags = {
23+
readonly team?: string;
24+
readonly json: boolean;
25+
};
26+
27+
/** Common Sentry platform strings, shown when platform arg is missing */
28+
const PLATFORMS = [
29+
"javascript",
30+
"javascript-react",
31+
"javascript-nextjs",
32+
"javascript-vue",
33+
"javascript-angular",
34+
"javascript-svelte",
35+
"javascript-remix",
36+
"javascript-astro",
37+
"node",
38+
"node-express",
39+
"python",
40+
"python-django",
41+
"python-flask",
42+
"python-fastapi",
43+
"go",
44+
"ruby",
45+
"ruby-rails",
46+
"php",
47+
"php-laravel",
48+
"java",
49+
"android",
50+
"dotnet",
51+
"react-native",
52+
"apple-ios",
53+
"rust",
54+
"elixir",
55+
] as const;
56+
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+
165+
/**
166+
* Create a project with user-friendly error handling.
167+
* Wraps API errors with actionable messages instead of raw HTTP status codes.
168+
*/
169+
async function createProjectWithErrors(
170+
orgSlug: string,
171+
teamSlug: string,
172+
name: string,
173+
platform: string
174+
): Promise<SentryProject> {
175+
try {
176+
return await createProject(orgSlug, teamSlug, { name, platform });
177+
} catch (error) {
178+
if (error instanceof ApiError) {
179+
if (error.status === 409) {
180+
throw new CliError(
181+
`A project named '${name}' already exists in ${orgSlug}.\n\n` +
182+
`View it: sentry project view ${orgSlug}/${name}`
183+
);
184+
}
185+
if (error.status === 404) {
186+
throw new CliError(
187+
`Team '${teamSlug}' not found in ${orgSlug}.\n\n` +
188+
"Check the team slug and try again:\n" +
189+
` sentry project create ${orgSlug}/${name} ${platform} --team <team-slug>`
190+
);
191+
}
192+
throw new CliError(
193+
`Failed to create project '${name}' in ${orgSlug}.\n\n` +
194+
`API error (${error.status}): ${error.detail ?? error.message}`
195+
);
196+
}
197+
throw error;
198+
}
199+
}
200+
201+
/**
202+
* Try to fetch the primary DSN for a newly created project.
203+
* Returns null on any error — DSN display is best-effort.
204+
*/
205+
async function tryGetPrimaryDsn(
206+
orgSlug: string,
207+
projectSlug: string
208+
): Promise<string | null> {
209+
try {
210+
const keys = await getProjectKeys(orgSlug, projectSlug);
211+
const activeKey = keys.find((k) => k.isActive);
212+
return activeKey?.dsn.public ?? keys[0]?.dsn.public ?? null;
213+
} catch {
214+
return null;
215+
}
216+
}
217+
218+
export const createCommand = buildCommand({
219+
docs: {
220+
brief: "Create a new project",
221+
fullDescription:
222+
"Create a new Sentry project in an organization.\n\n" +
223+
"The name supports org/name syntax to specify the organization explicitly.\n" +
224+
"If omitted, the org is auto-detected from config defaults or DSN.\n\n" +
225+
"Projects are created under a team. If the org has one team, it is used\n" +
226+
"automatically. Otherwise, specify --team.\n\n" +
227+
"Examples:\n" +
228+
" sentry project create my-app node\n" +
229+
" sentry project create acme-corp/my-app javascript-nextjs\n" +
230+
" sentry project create my-app python-django --team backend\n" +
231+
" sentry project create my-app go --json",
232+
},
233+
parameters: {
234+
positional: {
235+
kind: "tuple",
236+
parameters: [
237+
{
238+
placeholder: "name",
239+
brief: "Project name (supports org/name syntax)",
240+
parse: String,
241+
optional: true,
242+
},
243+
{
244+
placeholder: "platform",
245+
brief: "Project platform (e.g., node, python, javascript-nextjs)",
246+
parse: String,
247+
optional: true,
248+
},
249+
],
250+
},
251+
flags: {
252+
team: {
253+
kind: "parsed",
254+
parse: String,
255+
brief: "Team to create the project under",
256+
optional: true,
257+
},
258+
json: {
259+
kind: "boolean",
260+
brief: "Output as JSON",
261+
default: false,
262+
},
263+
},
264+
aliases: { t: "team" },
265+
},
266+
async func(
267+
this: SentryContext,
268+
flags: CreateFlags,
269+
nameArg?: string,
270+
platformArg?: string
271+
): Promise<void> {
272+
const { stdout, cwd } = this;
273+
274+
if (!nameArg) {
275+
throw new ContextError(
276+
"Project name",
277+
"sentry project create <name> <platform>",
278+
[
279+
"Use org/name syntax: sentry project create <org>/<name> <platform>",
280+
"Specify team: sentry project create <name> <platform> --team <slug>",
281+
]
282+
);
283+
}
284+
285+
if (!platformArg) {
286+
const list = PLATFORMS.map((p) => ` ${p}`).join("\n");
287+
throw new ContextError(
288+
"Platform",
289+
`sentry project create ${nameArg} <platform>`,
290+
[
291+
`Available platforms:\n\n${list}`,
292+
"Full list: https://docs.sentry.io/platforms/",
293+
]
294+
);
295+
}
296+
297+
// Parse name (may include org/ prefix)
298+
const { org: explicitOrg, name } = parseNameArg(nameArg);
299+
300+
// Resolve organization
301+
const resolved = await resolveOrg({ org: explicitOrg, cwd });
302+
if (!resolved) {
303+
throw new ContextError(
304+
"Organization",
305+
"sentry project create <org>/<name> <platform>",
306+
[
307+
"Include org in name: sentry project create <org>/<name> <platform>",
308+
"Set a default: sentry org view <org>",
309+
"Run from a directory with a Sentry DSN configured",
310+
]
311+
);
312+
}
313+
const orgSlug = resolved.org;
314+
315+
// Resolve team
316+
const teamSlug = await resolveTeam(
317+
orgSlug,
318+
flags.team,
319+
resolved.detectedFrom
320+
);
321+
322+
// Create the project
323+
const project = await createProjectWithErrors(
324+
orgSlug,
325+
teamSlug,
326+
name,
327+
platformArg
328+
);
329+
330+
// Fetch DSN (best-effort, non-blocking for output)
331+
const dsn = await tryGetPrimaryDsn(orgSlug, project.slug);
332+
333+
// JSON output
334+
if (flags.json) {
335+
writeJson(stdout, { ...project, dsn });
336+
return;
337+
}
338+
339+
// Human-readable output
340+
const url = buildProjectUrl(orgSlug, project.slug);
341+
342+
stdout.write(`\nCreated project '${project.name}' in ${orgSlug}\n\n`);
343+
stdout.write(` Project ${project.name}\n`);
344+
stdout.write(` Slug ${project.slug}\n`);
345+
stdout.write(` Org ${orgSlug}\n`);
346+
stdout.write(` Team ${teamSlug}\n`);
347+
stdout.write(` Platform ${project.platform || platformArg}\n`);
348+
if (dsn) {
349+
stdout.write(` DSN ${dsn}\n`);
350+
}
351+
stdout.write(` URL ${url}\n`);
352+
353+
writeFooter(
354+
stdout,
355+
`Tip: Use 'sentry project view ${orgSlug}/${project.slug}' for details`
356+
);
357+
},
358+
});

src/commands/project/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { buildRouteMap } from "@stricli/core";
2+
import { createCommand } from "./create.js";
23
import { listCommand } from "./list.js";
34
import { viewCommand } from "./view.js";
45

56
export const projectRoute = buildRouteMap({
67
routes: {
8+
create: createCommand,
79
list: listCommand,
810
view: viewCommand,
911
},

0 commit comments

Comments
 (0)