Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 153 additions & 12 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
buildRouteMap,
text_en,
UnexpectedPositionalError,
UnsatisfiedPositionalError,
} from "@stricli/core";
import { apiCommand } from "./commands/api.js";
import { authRoute } from "./commands/auth/index.js";
Expand Down Expand Up @@ -36,6 +37,10 @@ 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 {
AuthError,
Expand All @@ -45,6 +50,7 @@ import {
stringifyUnknown,
} from "./lib/errors.js";
import { error as errorColor, warning } from "./lib/formatters/colors.js";
import { isRouteMap, type RouteMap } from "./lib/introspect.js";

/**
* Plural alias → singular route name mapping.
Expand Down Expand Up @@ -121,6 +127,89 @@ export const routes = buildRouteMap({
},
});

/**
* 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.
*
Expand All @@ -134,23 +223,65 @@ const customText: ApplicationText = {
exc: unknown,
ansiColor: boolean
): string => {
// When a plural alias receives extra positional args (e.g. `sentry projects view cli`),
// Stricli throws UnexpectedPositionalError because the list command only accepts 1 arg.
// Detect this and suggest the singular form.
// 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 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(" ");
const hint = ansiColor
? warning(`\nDid you mean: sentry ${singular} ${rest}\n`)
: `\nDid you mean: sentry ${singular} ${rest}\n`;
return `${text_en.exceptionWhileParsingArguments(exc, ansiColor)}${hint}`;
const pluralHint = detectPluralAliasMisuse(ansiColor);
if (pluralHint) {
return `${text_en.exceptionWhileParsingArguments(exc, ansiColor)}${pluralHint}`;
}

// With defaultCommand: "view", unknown tokens like "events" 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.
Expand All @@ -168,6 +299,16 @@ const customText: ApplicationText = {
throw exc;
}

// Case C: With defaultCommand: "view", unknown tokens like "events" 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. Stricli catches exceptions and doesn't
// re-throw, so we must capture here to get visibility into command failures.
Sentry.captureException(exc);
Expand Down
2 changes: 2 additions & 0 deletions src/commands/dashboard/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export const dashboardRoute = buildRouteMap({
create: createCommand,
widget: widgetRoute,
},
defaultCommand: "view",
aliases: { show: "view" },
docs: {
brief: "Manage Sentry dashboards",
fullDescription:
Expand Down
1 change: 1 addition & 0 deletions src/commands/dashboard/widget/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const widgetRoute = buildRouteMap({
edit: editCommand,
delete: deleteCommand,
},
aliases: { remove: "delete" },
docs: {
brief: "Manage dashboard widgets",
fullDescription:
Expand Down
2 changes: 2 additions & 0 deletions src/commands/event/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export const eventRoute = buildRouteMap({
routes: {
view: viewCommand,
},
defaultCommand: "view",
aliases: { show: "view" },
docs: {
brief: "View Sentry events",
fullDescription:
Expand Down
2 changes: 2 additions & 0 deletions src/commands/issue/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export const issueRoute = buildRouteMap({
plan: planCommand,
view: viewCommand,
},
defaultCommand: "view",
aliases: { show: "view" },
docs: {
brief: "Manage Sentry issues",
fullDescription:
Expand Down
2 changes: 2 additions & 0 deletions src/commands/log/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export const logRoute = buildRouteMap({
list: listCommand,
view: viewCommand,
},
defaultCommand: "view",
aliases: { show: "view" },
docs: {
brief: "View Sentry logs",
fullDescription:
Expand Down
2 changes: 2 additions & 0 deletions src/commands/org/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export const orgRoute = buildRouteMap({
list: listCommand,
view: viewCommand,
},
defaultCommand: "view",
aliases: { show: "view" },
docs: {
brief: "Work with Sentry organizations",
fullDescription:
Expand Down
2 changes: 2 additions & 0 deletions src/commands/project/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export const projectRoute = buildRouteMap({
list: listCommand,
view: viewCommand,
},
defaultCommand: "view",
aliases: { show: "view", remove: "delete" },
docs: {
brief: "Work with Sentry projects",
fullDescription:
Expand Down
2 changes: 2 additions & 0 deletions src/commands/span/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export const spanRoute = buildRouteMap({
list: listCommand,
view: viewCommand,
},
defaultCommand: "view",
aliases: { show: "view" },
docs: {
brief: "List and view spans in projects or traces",
fullDescription:
Expand Down
2 changes: 2 additions & 0 deletions src/commands/trace/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export const traceRoute = buildRouteMap({
view: viewCommand,
logs: logsCommand,
},
defaultCommand: "view",
aliases: { show: "view" },
docs: {
brief: "View distributed traces",
fullDescription:
Expand Down
Loading
Loading