Skip to content

Commit 6294862

Browse files
committed
refactor: extract shared helpers for reuse across create commands
- tryGetPrimaryDsn() → api-client.ts (was duplicated in view + create) - resolveTeam() → resolve-team.ts (reusable for future team-dependent commands) - parseOrgPrefixedArg() → arg-parsing.ts (reusable org/name parsing) - writeKeyValue() for aligned key-value output in create.ts - project/view.ts now uses shared tryGetPrimaryDsn instead of local copy
1 parent 4f7258f commit 6294862

File tree

6 files changed

+251
-201
lines changed

6 files changed

+251
-201
lines changed

src/commands/project/create.ts

Lines changed: 47 additions & 156 deletions
Original file line numberDiff line numberDiff line change
@@ -6,25 +6,25 @@
66
*/
77

88
import 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";
1511
import { buildCommand } from "../../lib/command.js";
1612
import { ApiError, CliError, ContextError } from "../../lib/errors.js";
1713
import { writeFooter, writeJson } from "../../lib/formatters/index.js";
1814
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";
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

2222
type 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 */
2828
const 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 */
16658
function 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,

src/commands/project/view.ts

Lines changed: 6 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
*/
77

88
import type { SentryContext } from "../../context.js";
9-
import { getProject, getProjectKeys } from "../../lib/api-client.js";
9+
import { getProject, tryGetPrimaryDsn } from "../../lib/api-client.js";
1010
import {
1111
ProjectSpecificationType,
1212
parseOrgProjectArg,
@@ -26,7 +26,7 @@ import {
2626
resolveProjectBySlug,
2727
} from "../../lib/resolve-target.js";
2828
import { buildProjectUrl } from "../../lib/sentry-urls.js";
29-
import type { ProjectKey, SentryProject } from "../../types/index.js";
29+
import type { SentryProject } from "../../types/index.js";
3030

3131
type ViewFlags = {
3232
readonly json: boolean;
@@ -76,33 +76,6 @@ async function handleWebView(
7676
);
7777
}
7878

79-
/**
80-
* Try to fetch project keys, returning null on any error.
81-
* Non-blocking - if keys fetch fails, we still display project info.
82-
*/
83-
async function tryGetProjectKeys(
84-
orgSlug: string,
85-
projectSlug: string
86-
): Promise<ProjectKey[] | null> {
87-
try {
88-
return await getProjectKeys(orgSlug, projectSlug);
89-
} catch {
90-
return null;
91-
}
92-
}
93-
94-
/**
95-
* Get the primary DSN from project keys.
96-
* Returns the first active key's public DSN, or null if none found.
97-
*/
98-
function getPrimaryDsn(keys: ProjectKey[] | null): string | null {
99-
if (!keys || keys.length === 0) {
100-
return null;
101-
}
102-
const activeKey = keys.find((k) => k.isActive);
103-
return activeKey?.dsn.public ?? keys[0]?.dsn.public ?? null;
104-
}
105-
10679
/** Result of fetching a single project with its DSN */
10780
type ProjectWithDsn = {
10881
project: SentryProject;
@@ -118,12 +91,12 @@ async function fetchProjectDetails(
11891
target: ResolvedTarget
11992
): Promise<ProjectWithDsn | null> {
12093
try {
121-
// Fetch project and keys in parallel
122-
const [project, keys] = await Promise.all([
94+
// Fetch project and DSN in parallel
95+
const [project, dsn] = await Promise.all([
12396
getProject(target.org, target.project),
124-
tryGetProjectKeys(target.org, target.project),
97+
tryGetPrimaryDsn(target.org, target.project),
12598
]);
126-
return { project, dsn: getPrimaryDsn(keys) };
99+
return { project, dsn };
127100
} catch (error) {
128101
// Rethrow auth errors - user needs to know they're not authenticated
129102
if (error instanceof AuthError) {

src/lib/api-client.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -858,6 +858,30 @@ export function getProjectKeys(
858858
);
859859
}
860860

861+
/**
862+
* Fetch the primary DSN for a project.
863+
* Returns the public DSN of the first active key, or null on any error.
864+
*
865+
* Best-effort: failures are silently swallowed so callers can treat
866+
* DSN display as optional (e.g., after project creation or in views).
867+
*
868+
* @param orgSlug - Organization slug
869+
* @param projectSlug - Project slug
870+
* @returns Public DSN string, or null if unavailable
871+
*/
872+
export async function tryGetPrimaryDsn(
873+
orgSlug: string,
874+
projectSlug: string
875+
): Promise<string | null> {
876+
try {
877+
const keys = await getProjectKeys(orgSlug, projectSlug);
878+
const activeKey = keys.find((k) => k.isActive);
879+
return activeKey?.dsn.public ?? keys[0]?.dsn.public ?? null;
880+
} catch {
881+
return null;
882+
}
883+
}
884+
861885
/**
862886
* List issues for a project.
863887
* Uses region-aware routing for multi-region support.

0 commit comments

Comments
 (0)