-
-
Notifications
You must be signed in to change notification settings - Fork 8
Expand file tree
/
Copy pathapp.ts
More file actions
369 lines (351 loc) · 12.8 KB
/
app.ts
File metadata and controls
369 lines (351 loc) · 12.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
import {
type ApplicationText,
buildApplication,
text_en,
UnexpectedPositionalError,
UnsatisfiedPositionalError,
} from "@stricli/core";
import { alertRoute } from "./commands/alert/index.js";
import { apiCommand } from "./commands/api.js";
import { authRoute } from "./commands/auth/index.js";
import { whoamiCommand } from "./commands/auth/whoami.js";
import { cliRoute } from "./commands/cli/index.js";
import { dashboardRoute } from "./commands/dashboard/index.js";
import { listCommand as dashboardListCommand } from "./commands/dashboard/list.js";
import { eventRoute } from "./commands/event/index.js";
import { listCommand as eventListCommand } from "./commands/event/list.js";
import { exploreCommand } from "./commands/explore.js";
import { helpCommand } from "./commands/help.js";
import { initCommand } from "./commands/init.js";
import { issueRoute } from "./commands/issue/index.js";
import { listCommand as issueListCommand } from "./commands/issue/list.js";
import { localRoute } from "./commands/local/index.js";
import { logRoute } from "./commands/log/index.js";
import { listCommand as logListCommand } from "./commands/log/list.js";
import { orgRoute } from "./commands/org/index.js";
import { listCommand as orgListCommand } from "./commands/org/list.js";
import { projectRoute } from "./commands/project/index.js";
import { listCommand as projectListCommand } from "./commands/project/list.js";
import { releaseRoute } from "./commands/release/index.js";
import { listCommand as releaseListCommand } from "./commands/release/list.js";
import { replayRoute } from "./commands/replay/index.js";
import { listCommand as replayListCommand } from "./commands/replay/list.js";
import { repoRoute } from "./commands/repo/index.js";
import { listCommand as repoListCommand } from "./commands/repo/list.js";
import { schemaCommand } from "./commands/schema.js";
import { sourcemapRoute } from "./commands/sourcemap/index.js";
import { spanRoute } from "./commands/span/index.js";
import { listCommand as spanListCommand } from "./commands/span/list.js";
import { teamRoute } from "./commands/team/index.js";
import { listCommand as teamListCommand } from "./commands/team/list.js";
import { traceRoute } from "./commands/trace/index.js";
import { listCommand as traceListCommand } from "./commands/trace/list.js";
import { trialRoute } from "./commands/trial/index.js";
import { listCommand as trialListCommand } from "./commands/trial/list.js";
import {
getCommandSuggestion,
getSynonymSuggestionFromArgv,
} from "./lib/command-suggestions.js";
import { CLI_VERSION } from "./lib/constants.js";
import { reportCliError } from "./lib/error-reporting.js";
import {
AuthError,
CliError,
getExitCode,
OutputError,
stringifyUnknown,
WizardError,
} from "./lib/errors.js";
import { error as errorColor, warning } from "./lib/formatters/colors.js";
import { isRouteMap, type RouteMap } from "./lib/introspect.js";
import { buildRouteMap } from "./lib/route-map.js";
/**
* Plural alias → singular route name mapping.
* Used to suggest the correct command when users type e.g. `sentry projects view cli`.
*/
const PLURAL_TO_SINGULAR: Record<string, string> = {
dashboards: "dashboard",
events: "event",
issues: "issue",
orgs: "org",
projects: "project",
releases: "release",
repos: "repo",
teams: "team",
logs: "log",
replays: "replay",
spans: "span",
traces: "trace",
trials: "trial",
};
/** Top-level route map containing all CLI commands */
export const routes = buildRouteMap({
routes: {
help: helpCommand,
alert: alertRoute,
auth: authRoute,
cli: cliRoute,
dashboard: dashboardRoute,
org: orgRoute,
project: projectRoute,
replay: replayRoute,
release: releaseRoute,
repo: repoRoute,
team: teamRoute,
issue: issueRoute,
event: eventRoute,
events: eventListCommand,
explore: exploreCommand,
log: logRoute,
sourcemap: sourcemapRoute,
sourcemaps: sourcemapRoute,
span: spanRoute,
trace: traceRoute,
trial: trialRoute,
init: initCommand,
local: localRoute,
api: apiCommand,
schema: schemaCommand,
dashboards: dashboardListCommand,
issues: issueListCommand,
orgs: orgListCommand,
projects: projectListCommand,
replays: replayListCommand,
releases: releaseListCommand,
repos: repoListCommand,
teams: teamListCommand,
logs: logListCommand,
spans: spanListCommand,
traces: traceListCommand,
trials: trialListCommand,
whoami: whoamiCommand,
},
defaultCommand: "help",
docs: {
brief: "A gh-like CLI for Sentry",
fullDescription:
"sentry is a command-line interface for interacting with Sentry. " +
"It provides commands for authentication, viewing issues, and making API calls.",
hideRoute: {
dashboards: true,
events: true,
issues: true,
orgs: true,
projects: true,
replays: true,
releases: true,
repos: true,
teams: true,
logs: true,
spans: true,
traces: true,
trials: true,
sourcemaps: true,
whoami: true,
},
},
});
/**
* Route group names that have `defaultCommand` set.
*
* Derived from the route map at module load time — no manual list to maintain.
* Used to detect the no-args case (`sentry issue` with no subcommand)
* so we can show a usage hint instead of a confusing parse error.
*/
const routesWithDefaultCommand: ReadonlySet<string> = new Set(
routes
.getAllEntries()
.filter(
(e) =>
isRouteMap(e.target as unknown) &&
(e.target as unknown as RouteMap).getDefaultCommand?.()
)
.map((e) => e.name.original)
);
/**
* Detect when the user typed a bare route group with no subcommand (e.g., `sentry issue`).
*
* With `defaultCommand: "view"` on route groups, Stricli routes to the view
* command which then fails with UnsatisfiedPositionalError because no issue ID
* was provided. Returns a usage hint string, or undefined if this isn't the
* bare-route-group case.
*/
function detectBareRouteGroup(ansiColor: boolean): string | undefined {
const args = process.argv.slice(2);
const nonFlags = args.filter((t) => !t.startsWith("-"));
if (
nonFlags.length <= 1 &&
nonFlags[0] &&
routesWithDefaultCommand.has(nonFlags[0])
) {
const route = nonFlags[0];
const msg = `Usage: sentry ${route} <command> [args]\nRun "sentry ${route} --help" to see available commands`;
return ansiColor ? warning(msg) : msg;
}
return;
}
/**
* Detect when a plural alias received extra positional args and suggest the
* singular form. E.g., `sentry projects view cli` → `sentry project view cli`.
*/
function detectPluralAliasMisuse(ansiColor: boolean): string | undefined {
const args = process.argv.slice(2);
const firstArg = args[0];
if (firstArg && firstArg in PLURAL_TO_SINGULAR) {
const singular = PLURAL_TO_SINGULAR[firstArg];
const rest = args.slice(1).join(" ");
return ansiColor
? warning(`\nDid you mean: sentry ${singular} ${rest}\n`)
: `\nDid you mean: sentry ${singular} ${rest}\n`;
}
return;
}
/**
* Format a CliError with a synonym suggestion when the user typed a known
* synonym that was consumed as a positional arg by `defaultCommand: "view"`.
*
* Returns the formatted error string if a synonym match is found,
* undefined otherwise. Skips Sentry capture for these known user mistakes.
*/
function formatSynonymError(
exc: unknown,
ansiColor: boolean
): string | undefined {
if (!(exc instanceof CliError)) {
return;
}
const synonymHint = getSynonymSuggestionFromArgv();
if (!synonymHint) {
return;
}
const prefix = ansiColor ? errorColor("Error:") : "Error:";
const tip = ansiColor
? warning(`Tip: ${synonymHint}`)
: `Tip: ${synonymHint}`;
return `${prefix} ${exc.format()}\n${tip}`;
}
/**
* Custom error formatting for CLI errors.
*
* - AuthError (not_authenticated): Re-thrown to allow auto-login flow in bin.ts
* - Other CliError subclasses: Show clean user-friendly message without stack trace
* - Other errors: Show stack trace for debugging unexpected issues
*/
const customText: ApplicationText = {
...text_en,
exceptionWhileParsingArguments: (
exc: unknown,
ansiColor: boolean
): string => {
// Case A: bare route group with no subcommand (e.g., `sentry issue`)
if (exc instanceof UnsatisfiedPositionalError) {
const bareHint = detectBareRouteGroup(ansiColor);
if (bareHint) {
return bareHint;
}
}
// Case B + plural alias: extra args that Stricli can't consume
if (exc instanceof UnexpectedPositionalError) {
const pluralHint = detectPluralAliasMisuse(ansiColor);
if (pluralHint) {
return `${text_en.exceptionWhileParsingArguments(exc, ansiColor)}${pluralHint}`;
}
// With defaultCommand: "view", unknown tokens like "metrics" fill the
// positional slot, then extra args (e.g., CLI-AB) trigger this error.
// Check if the first non-route token is a known synonym.
const synonymHint = getSynonymSuggestionFromArgv();
if (synonymHint) {
const tip = ansiColor
? warning(`\nTip: ${synonymHint}`)
: `\nTip: ${synonymHint}`;
return `${text_en.exceptionWhileParsingArguments(exc, ansiColor)}${tip}`;
}
}
return text_en.exceptionWhileParsingArguments(exc, ansiColor);
},
noCommandRegisteredForInput: ({ input, corrections, ansiColor }): string => {
// Default error message from Stricli (e.g., "No command registered for `info`")
const base = text_en.noCommandRegisteredForInput({
input,
corrections,
ansiColor,
});
// Check for known synonym suggestions on routes without defaultCommand
// (e.g., `sentry cli info` → suggest `sentry auth status`).
// Routes WITH defaultCommand won't reach here — their unknown tokens
// are consumed as positional args and handled by Cases A/B/C above.
const args = process.argv.slice(2);
const nonFlags = args.filter((t) => !t.startsWith("-"));
const routeContext = nonFlags[0] ?? "";
const suggestion = getCommandSuggestion(routeContext, input);
if (suggestion) {
const hint = suggestion.explanation
? `${suggestion.explanation}: ${suggestion.command}`
: suggestion.command;
// Stricli wraps our return value in bold-red ANSI codes.
// Reset before applying warning() color so the tip is yellow, not red.
const formatted = ansiColor
? `\n\x1B[39m\x1B[22m${warning(`Tip: ${hint}`)}`
: `\nTip: ${hint}`;
return `${base}${formatted}`;
}
return base;
},
exceptionWhileRunningCommand: (exc: unknown, ansiColor: boolean): string => {
// OutputError: data was already rendered to stdout — just re-throw
// so the exit code propagates without Stricli printing an error message.
if (exc instanceof OutputError) {
throw exc;
}
// Re-throw AuthError for auto-login flow in bin.ts
// Don't capture to Sentry - it's an expected state (user not logged in or token expired), not an error
// Note: skipAutoAuth is checked in bin.ts, not here — all auth errors must escape Sentry capture
if (
exc instanceof AuthError &&
(exc.reason === "not_authenticated" || exc.reason === "expired")
) {
throw exc;
}
// Case C: With defaultCommand: "view", unknown tokens like "metrics" are
// silently consumed as the positional arg. The view command fails at the
// domain level (e.g., ResolutionError). Check argv for a known synonym
// and show the suggestion — skip Sentry capture since these are known
// user mistakes, not real errors.
const synonymResult = formatSynonymError(exc, ansiColor);
if (synonymResult) {
return synonymResult;
}
// Report command errors to Sentry with stable fingerprinting. Stricli
// catches exceptions and doesn't re-throw, so we must capture here to
// get visibility into command failures. Silencing rules (OutputError,
// expected AuthError, 401–499 ApiError) and fingerprint normalization
// are enforced inside reportCliError. 400 Bad Request = CLI bug.
reportCliError(exc);
if (exc instanceof CliError) {
// WizardError with rendered=true: clack already displayed the error.
// Return empty string to avoid double output, exit code flows through.
if (exc instanceof WizardError && exc.rendered) {
return "";
}
const prefix = ansiColor ? errorColor("Error:") : "Error:";
return `${prefix} ${exc.format()}`;
}
if (exc instanceof Error) {
return `Unexpected error: ${exc.stack ?? exc.message}`;
}
return `Unexpected error: ${stringifyUnknown(exc)}`;
},
};
export const app = buildApplication(routes, {
name: "sentry",
versionInfo: {
currentVersion: CLI_VERSION,
},
scanner: {
caseStyle: "allow-kebab-for-camel",
},
determineExitCode: getExitCode,
localization: {
loadText: () => customText,
},
});