From 3762ecc57131e00132e0ff7102cf55ad98b0f227 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:34:03 +0000 Subject: [PATCH 1/8] feat(cli): Declare a completion kind for every flag Every visible value-taking flag now says how shell completion handles its value, and test/lib/completion-coverage.test.ts fails, naming the command and flag, for any flag without a kind. There is no allow-list. - --fields completes the output schema's field paths, nested ones in dot notation, one comma-separated item at a time, leaving out the fields already listed (new choice-list kind, choiceListParser). - Shared parsers get kinds through PARSER_TARGETS: numberParser and sanitizeQuery complete nothing; parseCursorFlag, parseLogSort, parseSpanDepth, parseInstallationMethod, and parseReleaseChannel complete the values they accept. parseCursorFlag moves to arg-parsing.ts so completion-target.ts can import it without a cycle. - A command's own validating parser keeps its checks when wrapped: choiceParser(values, parse) completes values (sort, status, format, method, datasets), textParser(parse) completes nothing (ports, limits, thresholds). - Every parse: String flag uses a named parser: paths complete files or directories, --platform completes platforms, org list --account completes accounts, --scope/--features/--type complete their lists, --timezone completes tz names, and the rest are parseText. --- packages/cli/AGENTS.md | 46 +++--- .../src/commands/agent-conversation/list.ts | 5 +- .../cli/src/commands/alert/issues/create.ts | 18 ++- .../cli/src/commands/alert/issues/edit.ts | 21 +-- .../cli/src/commands/alert/issues/list.ts | 9 +- .../cli/src/commands/alert/metrics/create.ts | 20 +-- .../cli/src/commands/alert/metrics/edit.ts | 23 +-- .../cli/src/commands/alert/metrics/list.ts | 13 +- .../cli/src/commands/alert/mutation-utils.ts | 16 ++- packages/cli/src/commands/api.ts | 18 ++- packages/cli/src/commands/attribute/list.ts | 14 +- packages/cli/src/commands/auth/default.ts | 12 +- packages/cli/src/commands/auth/login.ts | 12 +- packages/cli/src/commands/auth/logout.ts | 7 +- packages/cli/src/commands/auth/refresh.ts | 4 +- packages/cli/src/commands/auth/switch.ts | 7 +- packages/cli/src/commands/bash-hook.ts | 13 +- packages/cli/src/commands/build/download.ts | 4 +- packages/cli/src/commands/build/upload.ts | 30 ++-- packages/cli/src/commands/cli/import.ts | 3 +- .../cli/src/commands/code-mappings/upload.ts | 6 +- .../src/commands/dart-symbol-map/upload.ts | 4 +- .../cli/src/commands/dashboard/restore.ts | 5 +- packages/cli/src/commands/dashboard/view.ts | 3 +- .../cli/src/commands/dashboard/widget/add.ts | 28 ++-- .../src/commands/dashboard/widget/delete.ts | 3 +- .../cli/src/commands/dashboard/widget/edit.ts | 19 +-- .../src/commands/debug-files/bundle-jvm.ts | 8 +- .../commands/debug-files/bundle-sources.ts | 2 +- packages/cli/src/commands/debug-files/find.ts | 10 +- .../cli/src/commands/debug-files/upload.ts | 15 +- packages/cli/src/commands/docs/list.ts | 4 +- packages/cli/src/commands/dsn/list.ts | 4 +- packages/cli/src/commands/event/send.ts | 30 ++-- .../cli/src/commands/event/shared-events.ts | 3 +- packages/cli/src/commands/explore.ts | 24 ++-- packages/cli/src/commands/feedback/list.ts | 3 +- packages/cli/src/commands/init.ts | 12 +- packages/cli/src/commands/issue/archive.ts | 3 +- packages/cli/src/commands/issue/link-utils.ts | 6 +- packages/cli/src/commands/issue/link.ts | 3 +- packages/cli/src/commands/issue/list.ts | 7 +- packages/cli/src/commands/issue/merge.ts | 3 +- packages/cli/src/commands/issue/resolve.ts | 6 +- packages/cli/src/commands/issue/tags.ts | 2 +- packages/cli/src/commands/local/management.ts | 8 +- .../src/commands/local/retention-buffer.ts | 5 +- packages/cli/src/commands/local/run.ts | 24 ++-- packages/cli/src/commands/local/server.ts | 15 +- packages/cli/src/commands/log/list.ts | 6 +- packages/cli/src/commands/monitor/run.ts | 22 +-- packages/cli/src/commands/org/list.ts | 7 +- packages/cli/src/commands/platform/list.ts | 3 +- packages/cli/src/commands/proguard/upload.ts | 4 +- packages/cli/src/commands/project/create.ts | 4 +- packages/cli/src/commands/project/list.ts | 3 +- .../cli/src/commands/react-native/gradle.ts | 13 +- .../cli/src/commands/react-native/xcode.ts | 16 ++- packages/cli/src/commands/release/create.ts | 7 +- packages/cli/src/commands/release/deploy.ts | 8 +- packages/cli/src/commands/release/finalize.ts | 5 +- packages/cli/src/commands/release/list.ts | 12 +- .../cli/src/commands/release/set-commits.ts | 7 +- packages/cli/src/commands/replay/download.ts | 3 +- packages/cli/src/commands/replay/list.ts | 11 +- packages/cli/src/commands/schema.ts | 2 +- packages/cli/src/commands/send-envelope.ts | 5 +- packages/cli/src/commands/snapshots/diff.ts | 6 +- .../cli/src/commands/snapshots/download.ts | 9 +- packages/cli/src/commands/snapshots/upload.ts | 31 ++-- packages/cli/src/commands/sourcemap/inject.ts | 12 +- .../cli/src/commands/sourcemap/resolve.ts | 12 +- packages/cli/src/commands/sourcemap/upload.ts | 20 +-- packages/cli/src/commands/span/list.ts | 10 +- packages/cli/src/commands/status/show.ts | 3 +- packages/cli/src/commands/trace/list.ts | 5 +- packages/cli/src/commands/trace/logs.ts | 4 +- packages/cli/src/commands/wasm-split.ts | 14 +- packages/cli/src/lib/arg-parsing.ts | 55 ++++++- packages/cli/src/lib/binary.ts | 6 +- packages/cli/src/lib/command.ts | 17 ++- packages/cli/src/lib/complete.ts | 52 +++++-- packages/cli/src/lib/completion-target.ts | 136 +++++++++++++++--- packages/cli/src/lib/db/pagination.ts | 2 +- packages/cli/src/lib/db/release-channel.ts | 13 +- packages/cli/src/lib/formatters/output.ts | 40 ++++++ packages/cli/src/lib/introspect.ts | 19 +-- packages/cli/src/lib/list-command.ts | 47 +----- packages/cli/src/types/dashboard.ts | 5 +- packages/cli/test/lib/arg-parsing.test.ts | 23 +++ packages/cli/test/lib/complete.test.ts | 51 ++++++- .../cli/test/lib/completion-coverage.test.ts | 70 +++++---- .../cli/test/lib/formatters/output.test.ts | 25 ++++ packages/cli/test/lib/introspect.test.ts | 50 +++++++ packages/cli/test/lib/list-command.test.ts | 26 +--- 95 files changed, 991 insertions(+), 470 deletions(-) diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index e6cc7bb6c..76668fd53 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -382,35 +382,49 @@ grammar knows no kind of ID: the command's `ID_KINDS` entry tells it what an ID looks like, so the syntax cannot drift between commands. It accepts any input; errors come from `parseIdArguments`, never Peggy's own syntax errors. -**Shell completion:** Every visible positional declares a completion kind, -a key of `COMPLETERS` in `src/lib/complete.ts`; `test/lib/completion-coverage.test.ts` -walks the route tree and fails, naming the command and placeholder, for any -positional without one. Pick the parser by what the argument takes: +**Shell completion:** Every visible positional and every visible +value-taking flag declares a completion kind, a key of `COMPLETERS` in +`src/lib/complete.ts`; `test/lib/completion-coverage.test.ts` walks the +route tree and fails, naming the command and the placeholder or flag, for +any without one. There is no allow-list: a flag with nothing to suggest +says so with `parseText` or `textParser`. Pick the parser by what the +argument takes: | Argument | Parser | Completes | |----------|--------|-----------| | Org, project, issue, dashboard target | the bound parsers above (`boundPositional(kind, …)`, `boundArguments({ kind, … })`, `listTargetPositional`, `issuePositional`, `releaseParameter`) | `ORG_TARGET` org slugs; `ORG_PROJECT_TARGET` `org/`, `org/project`, aliases; `ISSUE_TARGET` plus cached issue short IDs; `DASHBOARD_TARGET` cached dashboards | -| File or directory path | `parseFilePath` | entries of the partial's directory, directories with a trailing `/` | +| File or directory path (`--output`, `--ignore-file`) | `parseFilePath` | entries of the partial's directory, directories with a trailing `/` | | Directory | `parseDirectoryPath` | directories only | | Target or directory (`init`) | `boundArguments({ kind: ORG_PROJECT_OR_DIRECTORY_TARGET, … })` | `org/project` targets, or directories once the word looks like a path (`./`, `/`, `~/`), so a suggestion is never read as a target | -| Fixed values | `choiceParser(values)` (take the values from the module that defines them) | those values; a variadic positional completes only its first word (`cli defaults `). The parser returns its input, so the command still validates it | -| Stored account | `parseAccountSelector` | stored accounts (`auth switch`, `auth logout`) | +| Fixed values | `choiceParser(values)`, or `choiceParser(values, parse)` to keep a command's own validating parser (`choiceParser(VALID_SORT_VALUES, parseSort)`). Take the values from the module that defines them | those values; a variadic positional completes only its first word (`cli defaults `). Without `parse` the parser returns its input, so the command still validates it | +| Comma-separated fixed values (`--scope`, `--features`) | `choiceListParser(values)` (or `(values, parse)`) | the next item of the list, leaving out the items already in it (`org:read,org:w` → `org:read,org:write`) | +| Platform | `parsePlatform` | platform identifiers, common ones first | +| Stored account | `parseAccountSelector` | stored accounts (`auth switch`, `auth logout`, `org list --account`) | | CLI command path | `parseCommandPath` | route names, one segment per word (`help`) | | URL | `parseUrl` (the command validates it) | nothing | | Free text, IDs with no cache, names of new things | `parseText` | nothing | +| Numbers and other values a command parses itself (`parsePort`, `parseLimit`) | `textParser(parse)` | nothing | Import the pre-tagged parsers; never tag `String` or write -`withCompletionTarget` in a command (the lint rules above reject it there). -Org, project, and issue targets stay bound parsers. `enum` flags complete -their `values` like `choiceParser`. +`withCompletionTarget` in a command (the lint rules above reject it there), +and never leave `parse: String` on a flag. Org, project, and issue targets +stay bound parsers. `enum` flags complete their `values` like +`choiceParser`. `buildCommand` tags the injected `--fields` once: with an +output schema, it completes the schema's field paths, nested ones in dot +notation (`--fields id,ti` → `id,title`, `project.s` → `project.slug`); +without one, nothing. Shared value parsers are not tagged where they live; `PARSER_TARGETS` in -`completion-target.ts` lists them instead, so every flag that parses with -`sanitizeIssueQuery` completes issue search terms (`is:unr` → -`is:unresolved`), every flag that parses with `parsePeriod` completes -relative periods, and `parseLoginEmail` completes stored accounts. Fixed -values (search keys, period units) come from the modules that define them -through `COMPLETION_VALUES`. +`completion-target.ts` lists them instead, with a kind or the values they +accept, so every flag that parses with `sanitizeIssueQuery` completes issue +search terms (`is:unr` → `is:unresolved`), every flag that parses with +`parsePeriod` completes relative periods, `parseCursorFlag` completes +`next`/`prev`/`first`, `parseLogSort` its directions, `numberParser` +nothing, and `parseLoginEmail` completes stored accounts. Fixed values +(search keys, period units) come from the modules that define them +through `COMPLETION_VALUES`. A new shared parser goes in `PARSER_TARGETS`; +its module must not import `completion-target.ts`, or the two would load +in a cycle. Issue and dashboard suggestions come from the `completion_cache` table. The authenticated fetch in `src/lib/sentry-client.ts` offers every API response to `src/lib/api/completion-seed.ts`, which stores entities from the `@sentry/api` diff --git a/packages/cli/src/commands/agent-conversation/list.ts b/packages/cli/src/commands/agent-conversation/list.ts index 1297e1d6e..168c8f118 100644 --- a/packages/cli/src/commands/agent-conversation/list.ts +++ b/packages/cli/src/commands/agent-conversation/list.ts @@ -7,6 +7,7 @@ import type { SentryContext } from "../../context.js"; import { getProject, listConversations } from "../../lib/api-client.js"; import { validateLimit } from "../../lib/arg-parsing.js"; +import { parseText, textParser } from "../../lib/completion-target.js"; import { advancePaginationState, buildPaginationContextKey, @@ -208,13 +209,13 @@ export const listCommand = buildListCommand("agent-conversation", { flags: { limit: { kind: "parsed", - parse: parseLimit, + parse: textParser(parseLimit), brief: `Number of conversations (${LIST_MIN_LIMIT}-${LIST_MAX_LIMIT})`, default: String(LIST_DEFAULT_LIMIT), }, query: { kind: "parsed", - parse: String, + parse: parseText, brief: "Any matching span selects its conversation; totals include all spans in selected projects and time range", optional: true, diff --git a/packages/cli/src/commands/alert/issues/create.ts b/packages/cli/src/commands/alert/issues/create.ts index 878abbdb7..757c79c64 100644 --- a/packages/cli/src/commands/alert/issues/create.ts +++ b/packages/cli/src/commands/alert/issues/create.ts @@ -11,6 +11,7 @@ import { } from "../../../lib/api-client.js"; import type { ParsedOrgProject } from "../../../lib/arg-parsing.js"; import { buildCommand, numberParser } from "../../../lib/command.js"; +import { choiceParser, parseText } from "../../../lib/completion-target.js"; import { ContextError, stringifyUnknown, @@ -27,6 +28,7 @@ import { orgProjectTarget, } from "../../../lib/target-positional.js"; import { + ISSUE_MATCH_MODES, matchToLogicType, parseJsonObjectList, parseMatchMode, @@ -150,19 +152,19 @@ export const createCommand = buildCommand({ flags: { name: { kind: "parsed", - parse: String, + parse: parseText, brief: "Rule name", }, condition: { kind: "parsed", - parse: String, + parse: parseText, variadic: true, optional: true, brief: "Condition object JSON (repeatable, or pass one JSON array)", }, action: { kind: "parsed", - parse: String, + parse: parseText, variadic: true, optional: true, brief: "Action object JSON (repeatable, or pass one JSON array)", @@ -175,26 +177,28 @@ export const createCommand = buildCommand({ }, environment: { kind: "parsed", - parse: String, + parse: parseText, optional: true, brief: "Environment filter", }, filter: { kind: "parsed", - parse: String, + parse: parseText, variadic: true, optional: true, brief: "Filter object JSON (repeatable, or pass one JSON array)", }, "filter-match": { kind: "parsed", - parse: (value: string) => parseMatchMode(value, "filter-match"), + parse: choiceParser(ISSUE_MATCH_MODES, (value) => + parseMatchMode(value, "filter-match"), + ), optional: true, brief: "Filter match mode: all or any", }, owner: { kind: "parsed", - parse: String, + parse: parseText, optional: true, brief: "Owner (team:user style value accepted by Sentry API)", }, diff --git a/packages/cli/src/commands/alert/issues/edit.ts b/packages/cli/src/commands/alert/issues/edit.ts index 0456820cf..b5f097526 100644 --- a/packages/cli/src/commands/alert/issues/edit.ts +++ b/packages/cli/src/commands/alert/issues/edit.ts @@ -11,6 +11,7 @@ import { updateIssueAlertRule, } from "../../../lib/api-client.js"; import { buildCommand, numberParser } from "../../../lib/command.js"; +import { choiceParser, parseText } from "../../../lib/completion-target.js"; import { ContextError, stringifyUnknown, @@ -19,10 +20,12 @@ import { import { CommandOutput } from "../../../lib/formatters/output.js"; import { resolveProjectBoundTargets } from "../../../lib/resolve-target.js"; import { + ISSUE_MATCH_MODES, matchToLogicType, parseJsonObjectList, parseMatchMode, parseStatusFlag, + RULE_STATUSES, triggerLogicType, validateIssueRuleArrays, } from "../mutation-utils.js"; @@ -210,26 +213,26 @@ export const editCommand = buildCommand({ flags: { name: { kind: "parsed", - parse: String, + parse: parseText, optional: true, brief: "New rule name", }, status: { kind: "parsed", - parse: parseStatusFlag, + parse: choiceParser(RULE_STATUSES, parseStatusFlag), optional: true, brief: "Rule status: active or disabled", }, condition: { kind: "parsed", - parse: String, + parse: parseText, variadic: true, optional: true, brief: "Condition object JSON (repeatable, or pass one JSON array)", }, action: { kind: "parsed", - parse: String, + parse: parseText, variadic: true, optional: true, brief: "Action object JSON (repeatable, or pass one JSON array)", @@ -242,26 +245,28 @@ export const editCommand = buildCommand({ }, environment: { kind: "parsed", - parse: String, + parse: parseText, optional: true, brief: "Environment value (pass empty string to clear)", }, filter: { kind: "parsed", - parse: String, + parse: parseText, variadic: true, optional: true, brief: "Filter object JSON (repeatable, or pass one JSON array)", }, "filter-match": { kind: "parsed", - parse: (value: string) => parseMatchMode(value, "filter-match"), + parse: choiceParser(ISSUE_MATCH_MODES, (value) => + parseMatchMode(value, "filter-match"), + ), optional: true, brief: "Filter match mode: all or any", }, owner: { kind: "parsed", - parse: String, + parse: parseText, optional: true, brief: "Owner value (pass empty string to clear)", }, diff --git a/packages/cli/src/commands/alert/issues/list.ts b/packages/cli/src/commands/alert/issues/list.ts index f9473ed16..bf0df7adf 100644 --- a/packages/cli/src/commands/alert/issues/list.ts +++ b/packages/cli/src/commands/alert/issues/list.ts @@ -15,8 +15,12 @@ import { buildProjectAliasMap } from "../../../lib/alias.js"; import type { IssueAlertRule } from "../../../lib/api/alerts.js"; import { paginate } from "../../../lib/api/infrastructure.js"; import { listIssueAlertsPaginated } from "../../../lib/api-client.js"; -import type { ParsedOrgProject } from "../../../lib/arg-parsing.js"; +import { + type ParsedOrgProject, + parseCursorFlag, +} from "../../../lib/arg-parsing.js"; import { openInBrowser } from "../../../lib/browser.js"; +import { parseText } from "../../../lib/completion-target.js"; import { advancePaginationState, buildMultiTargetContextKey, @@ -48,7 +52,6 @@ import { LIST_MAX_LIMIT, LIST_TARGET_POSITIONAL, paginationHint, - parseCursorFlag, targetPatternExplanation, } from "../../../lib/list-command.js"; import { logger } from "../../../lib/logger.js"; @@ -540,7 +543,7 @@ export const listCommand = buildListCommand("alert issues", { limit: buildListLimitFlag("issue alert rules"), query: { kind: "parsed", - parse: String, + parse: parseText, brief: "Filter rules by name", optional: true, }, diff --git a/packages/cli/src/commands/alert/metrics/create.ts b/packages/cli/src/commands/alert/metrics/create.ts index ef6d1e390..d504eec63 100644 --- a/packages/cli/src/commands/alert/metrics/create.ts +++ b/packages/cli/src/commands/alert/metrics/create.ts @@ -8,6 +8,7 @@ import type { SentryContext } from "../../../context.js"; import { createMetricAlertRule } from "../../../lib/api-client.js"; import type { ParsedOrgProject } from "../../../lib/arg-parsing.js"; import { buildCommand, numberParser } from "../../../lib/command.js"; +import { choiceParser, parseText } from "../../../lib/completion-target.js"; import { stringifyUnknown, ValidationError } from "../../../lib/errors.js"; import { warning } from "../../../lib/formatters/colors.js"; import { CommandOutput } from "../../../lib/formatters/output.js"; @@ -19,6 +20,7 @@ import { } from "../../../lib/target-positional.js"; import { METRIC_ALERT_DATASET_BRIEF, + METRIC_ALERT_DATASET_NAMES, parseMetricTriggers, } from "../../../types/metric-alert.js"; import { @@ -124,22 +126,22 @@ export const createCommand = buildCommand({ flags: { name: { kind: "parsed", - parse: String, + parse: parseText, brief: "Rule name", }, query: { kind: "parsed", - parse: String, + parse: parseText, brief: "Metric query filter string", }, aggregate: { kind: "parsed", - parse: String, + parse: parseText, brief: "Aggregate expression (for example count(), p95(span.duration))", }, dataset: { kind: "parsed", - parse: String, + parse: choiceParser(METRIC_ALERT_DATASET_NAMES), brief: METRIC_ALERT_DATASET_BRIEF, }, "time-window": { @@ -149,33 +151,33 @@ export const createCommand = buildCommand({ }, trigger: { kind: "parsed", - parse: String, + parse: parseText, variadic: true, optional: true, brief: "Trigger object JSON (repeatable, or pass one JSON array)", }, project: { kind: "parsed", - parse: String, + parse: parseText, variadic: true, optional: true, brief: "Project slug filter (repeatable or comma-separated)", }, environment: { kind: "parsed", - parse: String, + parse: parseText, optional: true, brief: "Environment filter", }, owner: { kind: "parsed", - parse: String, + parse: parseText, optional: true, brief: "Owner value accepted by Sentry API", }, workflow: { kind: "parsed", - parse: String, + parse: parseText, variadic: true, optional: true, brief: "Alert (workflow) id to notify (repeatable or comma-separated)", diff --git a/packages/cli/src/commands/alert/metrics/edit.ts b/packages/cli/src/commands/alert/metrics/edit.ts index f29e345e9..e59811ad7 100644 --- a/packages/cli/src/commands/alert/metrics/edit.ts +++ b/packages/cli/src/commands/alert/metrics/edit.ts @@ -10,12 +10,14 @@ import { putMetricAlertRule, } from "../../../lib/api-client.js"; import { buildCommand, numberParser } from "../../../lib/command.js"; +import { choiceParser, parseText } from "../../../lib/completion-target.js"; import { stringifyUnknown, ValidationError } from "../../../lib/errors.js"; import { warning } from "../../../lib/formatters/colors.js"; import { CommandOutput } from "../../../lib/formatters/output.js"; import { resolveOrgOnlyTarget } from "../../../lib/resolve-target.js"; import { METRIC_ALERT_DATASET_BRIEF, + METRIC_ALERT_DATASET_NAMES, parseMetricTriggers, } from "../../../types/metric-alert.js"; import { @@ -25,6 +27,7 @@ import { parseStatusFlag, parseWorkflowIds, resolveMetricDataset, + RULE_STATUSES, statusToMetricValue, validateMetricDataset, validateMetricTimeWindow, @@ -214,31 +217,31 @@ export const editCommand = buildCommand({ flags: { name: { kind: "parsed", - parse: String, + parse: parseText, optional: true, brief: "New rule name", }, status: { kind: "parsed", - parse: parseStatusFlag, + parse: choiceParser(RULE_STATUSES, parseStatusFlag), optional: true, brief: "active or disabled", }, query: { kind: "parsed", - parse: String, + parse: parseText, optional: true, brief: "Metric query filter", }, aggregate: { kind: "parsed", - parse: String, + parse: parseText, optional: true, brief: "Aggregate expression", }, dataset: { kind: "parsed", - parse: String, + parse: choiceParser(METRIC_ALERT_DATASET_NAMES), optional: true, brief: METRIC_ALERT_DATASET_BRIEF, }, @@ -250,33 +253,33 @@ export const editCommand = buildCommand({ }, trigger: { kind: "parsed", - parse: String, + parse: parseText, variadic: true, optional: true, brief: "Trigger object JSON (repeatable, or pass one JSON array)", }, project: { kind: "parsed", - parse: String, + parse: parseText, variadic: true, optional: true, brief: "Project slug filter (repeatable or comma-separated)", }, environment: { kind: "parsed", - parse: String, + parse: parseText, optional: true, brief: "Environment value (pass empty string to clear)", }, owner: { kind: "parsed", - parse: String, + parse: parseText, optional: true, brief: "Owner value (pass empty string to clear)", }, workflow: { kind: "parsed", - parse: String, + parse: parseText, variadic: true, optional: true, brief: diff --git a/packages/cli/src/commands/alert/metrics/list.ts b/packages/cli/src/commands/alert/metrics/list.ts index c0aa78df9..44f127e5a 100644 --- a/packages/cli/src/commands/alert/metrics/list.ts +++ b/packages/cli/src/commands/alert/metrics/list.ts @@ -14,9 +14,15 @@ import type { SentryContext } from "../../../context.js"; import type { MetricAlertRule } from "../../../lib/api/alerts.js"; import { paginate } from "../../../lib/api/infrastructure.js"; import { listMetricAlertsPaginated } from "../../../lib/api-client.js"; -import type { ParsedOrgProject } from "../../../lib/arg-parsing.js"; +import { + type ParsedOrgProject, + parseCursorFlag, +} from "../../../lib/arg-parsing.js"; import { openInBrowser } from "../../../lib/browser.js"; -import { ORG_PROJECT_TARGET } from "../../../lib/completion-target.js"; +import { + ORG_PROJECT_TARGET, + parseText, +} from "../../../lib/completion-target.js"; import { advancePaginationState, buildMultiOrgContextKey, @@ -42,7 +48,6 @@ import { LIST_BASE_ALIASES, LIST_MAX_LIMIT, paginationHint, - parseCursorFlag, targetPatternExplanation, } from "../../../lib/list-command.js"; import { logger } from "../../../lib/logger.js"; @@ -535,7 +540,7 @@ export const listCommand = buildListCommand("alert metrics", { limit: buildListLimitFlag("metric alert rules"), query: { kind: "parsed", - parse: String, + parse: parseText, brief: "Filter rules by name", optional: true, }, diff --git a/packages/cli/src/commands/alert/mutation-utils.ts b/packages/cli/src/commands/alert/mutation-utils.ts index 97275e4c9..8ef281da4 100644 --- a/packages/cli/src/commands/alert/mutation-utils.ts +++ b/packages/cli/src/commands/alert/mutation-utils.ts @@ -9,7 +9,8 @@ import { metricAlertSource, } from "../../types/metric-alert.js"; -const ISSUE_MATCH_MODES = new Set(["all", "any"]); +/** Match modes of an issue alert's filter group. */ +export const ISSUE_MATCH_MODES = ["all", "any"] as const; /** Deprecated dataset names that now route to `spans` with `is_transaction:true`. */ const TRANSACTION_DATASET_NAMES = new Set(["transaction", "transactions"]); @@ -33,8 +34,8 @@ export function parseMatchMode( return; } const normalized = value.trim().toLowerCase(); - if (ISSUE_MATCH_MODES.has(normalized)) { - return normalized as "all" | "any"; + if ((ISSUE_MATCH_MODES as readonly string[]).includes(normalized)) { + return normalized as (typeof ISSUE_MATCH_MODES)[number]; } throw new ValidationError( `${field} must be 'all' or 'any' (got ${JSON.stringify(value)}).`, @@ -72,16 +73,19 @@ export function triggerLogicType(): "any-short" { return "any-short"; } +/** Statuses `--status` sets on an alert rule. */ +export const RULE_STATUSES = ["active", "disabled"] as const; + /** Parse and validate an "active" | "disabled" status flag. Returns `undefined` when absent. */ export function parseStatusFlag( value: string | undefined, -): "active" | "disabled" | undefined { +): (typeof RULE_STATUSES)[number] | undefined { if (value === undefined || value === "") { return; } const normalized = value.trim().toLowerCase(); - if (normalized === "active" || normalized === "disabled") { - return normalized; + if ((RULE_STATUSES as readonly string[]).includes(normalized)) { + return normalized as (typeof RULE_STATUSES)[number]; } throw new ValidationError( `Status must be 'active' or 'disabled' (got ${JSON.stringify(value)}).`, diff --git a/packages/cli/src/commands/api.ts b/packages/cli/src/commands/api.ts index 4954983ea..62738e2a4 100644 --- a/packages/cli/src/commands/api.ts +++ b/packages/cli/src/commands/api.ts @@ -20,7 +20,11 @@ import { rawApiRequest, } from "../lib/api-client.js"; import { buildCommand } from "../lib/command.js"; -import { parseText } from "../lib/completion-target.js"; +import { + choiceParser, + parseFilePath, + parseText, +} from "../lib/completion-target.js"; import { applyCustomHeaders } from "../lib/custom-headers.js"; import { getIdentityFingerprint } from "../lib/db/auth.js"; import { @@ -1701,7 +1705,7 @@ export const apiCommand = buildCommand({ flags: { method: { kind: "parsed", - parse: parseMethod, + parse: choiceParser(VALID_METHODS, parseMethod), brief: "The HTTP method for the request", default: "GET" as const, placeholder: "method", @@ -1709,35 +1713,35 @@ export const apiCommand = buildCommand({ cursor: LIST_CURSOR_FLAG, data: { kind: "parsed", - parse: String, + parse: parseText, brief: "Inline JSON body for the request (like curl -d)", optional: true, placeholder: "json", }, field: { kind: "parsed", - parse: String, + parse: parseText, brief: "Add a typed parameter (key=value, key[sub]=value, key[]=value)", variadic: true, optional: true, }, "raw-field": { kind: "parsed", - parse: String, + parse: parseText, brief: "Add a string parameter without JSON parsing", variadic: true, optional: true, }, header: { kind: "parsed", - parse: String, + parse: parseText, brief: "Add a HTTP request header in key:value format", variadic: true, optional: true, }, input: { kind: "parsed", - parse: String, + parse: parseFilePath, brief: 'The file to use as body for the HTTP request (use "-" to read from standard input)', optional: true, diff --git a/packages/cli/src/commands/attribute/list.ts b/packages/cli/src/commands/attribute/list.ts index 7b4c8fafb..f1df2164a 100644 --- a/packages/cli/src/commands/attribute/list.ts +++ b/packages/cli/src/commands/attribute/list.ts @@ -21,7 +21,12 @@ import { parseAttributeTypes, } from "../../lib/attribute-search.js"; import { validateLimit } from "../../lib/arg-parsing.js"; -import { parseText } from "../../lib/completion-target.js"; +import { + choiceListParser, + choiceParser, + parseText, + textParser, +} from "../../lib/completion-target.js"; import { buildDatasetParser, datasetDisplayName } from "../../lib/datasets.js"; import { advancePaginationState, @@ -292,13 +297,13 @@ export const listCommand = buildListCommand("attribute", { flags: { dataset: { kind: "parsed", - parse: DATASETS.parse, + parse: choiceParser(DATASETS.names, DATASETS.parse), brief: `Dataset to list attributes for (${DATASETS.names.join(", ")})`, default: DEFAULT_DATASET, }, type: { kind: "parsed", - parse: String, + parse: choiceListParser(ATTRIBUTE_TYPES), brief: `Only show attributes of these types: ${ATTRIBUTE_TYPES.join(", ")}. Repeat the flag for each type or pass a comma-separated list (e.g. --type string,number)`, variadic: true, optional: true, @@ -306,8 +311,9 @@ export const listCommand = buildListCommand("attribute", { period: LIST_PERIOD_FLAG, limit: { ...buildListLimitFlag("attributes"), - parse: (value: string) => + parse: textParser((value: string) => validateLimit(value, LIST_MIN_LIMIT, LIST_MAX_LIMIT), + ), }, }, aliases: { diff --git a/packages/cli/src/commands/auth/default.ts b/packages/cli/src/commands/auth/default.ts index 498271988..c76ffcd46 100644 --- a/packages/cli/src/commands/auth/default.ts +++ b/packages/cli/src/commands/auth/default.ts @@ -12,6 +12,11 @@ import type { Command } from "@stricli/core"; import type { SentryContext } from "../../context.js"; import { buildCommand, numberParser } from "../../lib/command.js"; +import { + choiceListParser, + parseText, + textParser, +} from "../../lib/completion-target.js"; import { isAuthenticated } from "../../lib/db/auth.js"; import { formatAuthStatus } from "../../lib/formatters/human.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -19,6 +24,7 @@ import type { LoginResult } from "../../lib/interactive-login.js"; import { FRESH_ALIASES, FRESH_FLAG } from "../../lib/list-command.js"; import { formatLoginResult, loginCommand, parseLoginUrl } from "./login.js"; import { type AuthStatusData, statusCommand } from "./status.js"; +import { SENTRY_SCOPES } from "../../lib/api-scope.js"; type AuthDefaultFlags = { readonly token?: string; @@ -120,7 +126,7 @@ export const authDefaultCommand = buildCommand({ flags: { token: { kind: "parsed", - parse: String, + parse: parseText, brief: "Authenticate using an API token instead of OAuth", optional: true, }, @@ -137,7 +143,7 @@ export const authDefaultCommand = buildCommand({ }, url: { kind: "parsed", - parse: parseLoginUrl, + parse: textParser(parseLoginUrl), brief: "Sentry instance URL to authenticate against (e.g. https://sentry.example.com). " + "Required for self-hosted; defaults to SaaS (https://sentry.io).", @@ -152,7 +158,7 @@ export const authDefaultCommand = buildCommand({ }, scope: { kind: "parsed", - parse: String, + parse: choiceListParser(SENTRY_SCOPES), brief: "Request specific OAuth scopes (repeatable, comma-separated). " + "E.g. --scope project:read --scope org:read. Overrides the default scope set.", diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index b62913008..854740d00 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -9,6 +9,11 @@ import { } from "../../lib/api-client.js"; import { normalizeAuthToken } from "../../lib/auth-header.js"; import { buildCommand, numberParser } from "../../lib/command.js"; +import { + choiceListParser, + parseText, + textParser, +} from "../../lib/completion-target.js"; import { normalizeUrl } from "../../lib/constants.js"; import { getAccountIdToReplace, @@ -62,6 +67,7 @@ import { type SentryCliRcConfig, } from "../../lib/sentryclirc.js"; import { registerLoginTrustAnchor } from "../../lib/token-host.js"; +import { SENTRY_SCOPES } from "../../lib/api-scope.js"; const log = logger.withTag("auth.login"); @@ -540,7 +546,7 @@ export const loginCommand = buildCommand({ flags: { token: { kind: "parsed", - parse: String, + parse: parseText, brief: "Authenticate using an API token instead of OAuth", optional: true, }, @@ -559,7 +565,7 @@ export const loginCommand = buildCommand({ }, url: { kind: "parsed", - parse: parseLoginUrl, + parse: textParser(parseLoginUrl), brief: "Sentry instance URL to authenticate against (e.g. https://sentry.example.com). " + "Required for self-hosted; defaults to SaaS (https://sentry.io).", @@ -574,7 +580,7 @@ export const loginCommand = buildCommand({ }, scope: { kind: "parsed", - parse: String, + parse: choiceListParser(SENTRY_SCOPES), brief: "Request specific OAuth scopes (repeatable, comma-separated). " + "E.g. --scope project:read --scope org:read. Overrides the default scope set.", diff --git a/packages/cli/src/commands/auth/logout.ts b/packages/cli/src/commands/auth/logout.ts index c16bce23a..a88af7dd6 100644 --- a/packages/cli/src/commands/auth/logout.ts +++ b/packages/cli/src/commands/auth/logout.ts @@ -10,7 +10,10 @@ import { resolveAccountSelector, } from "../../lib/account-selection.js"; import { buildCommand } from "../../lib/command.js"; -import { parseAccountSelector } from "../../lib/completion-target.js"; +import { + parseAccountSelector, + textParser, +} from "../../lib/completion-target.js"; import { type AccountSummary, getAccountRow, @@ -124,7 +127,7 @@ export const logoutCommand = buildCommand({ flags: { url: { kind: "parsed", - parse: parseLoginUrl, + parse: textParser(parseLoginUrl), brief: "Only match accounts on this server (disambiguates the same email on several servers)", optional: true, diff --git a/packages/cli/src/commands/auth/refresh.ts b/packages/cli/src/commands/auth/refresh.ts index 8eae3422c..01c2dbc99 100644 --- a/packages/cli/src/commands/auth/refresh.ts +++ b/packages/cli/src/commands/auth/refresh.ts @@ -12,6 +12,7 @@ import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; +import { choiceListParser } from "../../lib/completion-target.js"; import { ENV_SOURCE_PREFIX, getActiveEnvVarName, @@ -24,6 +25,7 @@ import { formatDuration } from "../../lib/formatters/human.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { runInteractiveLogin } from "../../lib/interactive-login.js"; import { resolveOAuthScopeString } from "../../lib/oauth.js"; +import { SENTRY_SCOPES } from "../../lib/api-scope.js"; type RefreshFlags = { readonly json: boolean; @@ -140,7 +142,7 @@ session (similar to \`gh auth refresh -s \`). }, scope: { kind: "parsed", - parse: String, + parse: choiceListParser(SENTRY_SCOPES), brief: "Re-authenticate with specific OAuth scopes (repeatable, comma-separated). " + "E.g. --scope project:read --scope org:read", diff --git a/packages/cli/src/commands/auth/switch.ts b/packages/cli/src/commands/auth/switch.ts index 281f4b4da..365e3e238 100644 --- a/packages/cli/src/commands/auth/switch.ts +++ b/packages/cli/src/commands/auth/switch.ts @@ -11,7 +11,10 @@ import { resolveAccountSelector, } from "../../lib/account-selection.js"; import { buildCommand } from "../../lib/command.js"; -import { parseAccountSelector } from "../../lib/completion-target.js"; +import { + parseAccountSelector, + textParser, +} from "../../lib/completion-target.js"; import { type AccountSummary, getAccountRow, @@ -105,7 +108,7 @@ export const switchCommand = buildCommand({ flags: { url: { kind: "parsed", - parse: parseLoginUrl, + parse: textParser(parseLoginUrl), brief: "Only consider accounts on this server (disambiguates the same email on several servers)", optional: true, diff --git a/packages/cli/src/commands/bash-hook.ts b/packages/cli/src/commands/bash-hook.ts index 59cab3058..b67485f5f 100644 --- a/packages/cli/src/commands/bash-hook.ts +++ b/packages/cli/src/commands/bash-hook.ts @@ -19,6 +19,7 @@ import { createEventEnvelope, makeDsn, serializeEnvelope } from "@sentry/core"; import type { SentryContext } from "../context.js"; import { buildBashErrorEvent } from "../lib/bash-hook/traceback.js"; import { buildCommand } from "../lib/command.js"; +import { parseFilePath, parseText } from "../lib/completion-target.js"; import { parseKeyValue } from "../lib/envelope/event-builder.js"; import { requireDsn, sendEnvelopeRequest } from "../lib/envelope/transport.js"; import { ValidationError } from "../lib/errors.js"; @@ -274,26 +275,26 @@ export const bashHookCommand = buildCommand({ }, cli: { kind: "parsed", - parse: String, + parse: parseFilePath, brief: "Override the sentry-cli command path in the generated script", optional: true, }, tag: { kind: "parsed", - parse: String, + parse: parseText, brief: "Add a tag as KEY:VALUE to the event (repeatable)", variadic: true, optional: true, }, release: { kind: "parsed", - parse: String, + parse: parseText, brief: "Set the release version for the event", optional: true, }, dsn: { kind: "parsed", - parse: String, + parse: parseText, brief: "DSN to send events to (overrides SENTRY_DSN env var)", optional: true, }, @@ -307,14 +308,14 @@ export const bashHookCommand = buildCommand({ }, traceback: { kind: "parsed", - parse: String, + parse: parseFilePath, brief: "Internal: path to the traceback file", optional: true, hidden: true as const, }, log: { kind: "parsed", - parse: String, + parse: parseFilePath, brief: "Internal: path to the log file", optional: true, hidden: true as const, diff --git a/packages/cli/src/commands/build/download.ts b/packages/cli/src/commands/build/download.ts index 7cc3f4004..35ce12980 100644 --- a/packages/cli/src/commands/build/download.ts +++ b/packages/cli/src/commands/build/download.ts @@ -17,7 +17,7 @@ import { toBinaryDownloadUrl, } from "../../lib/api/preprod-artifacts.js"; import { buildCommand } from "../../lib/command.js"; -import { parseText } from "../../lib/completion-target.js"; +import { parseFilePath, parseText } from "../../lib/completion-target.js"; import { ContextError, ResolutionError } from "../../lib/errors.js"; import { mdKvTable, renderMarkdown } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -89,7 +89,7 @@ export const downloadCommand = buildCommand({ flags: { output: { kind: "parsed", - parse: String, + parse: parseFilePath, brief: "Output path (default: preprod_artifact_. in the current directory)", optional: true, diff --git a/packages/cli/src/commands/build/upload.ts b/packages/cli/src/commands/build/upload.ts index 4af5df120..e938d6cb6 100644 --- a/packages/cli/src/commands/build/upload.ts +++ b/packages/cli/src/commands/build/upload.ts @@ -32,7 +32,11 @@ import { } from "../../lib/build/vcs.js"; import { isCi } from "../../lib/ci.js"; import { buildCommand } from "../../lib/command.js"; -import { parseFilePath } from "../../lib/completion-target.js"; +import { + parseFilePath, + parseText, + textParser, +} from "../../lib/completion-target.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { colorTag, @@ -233,20 +237,20 @@ export const uploadCommand = buildCommand({ flags: { "build-configuration": { kind: "parsed", - parse: String, + parse: parseText, brief: "Build configuration for the upload (defaults to the current version)", optional: true, }, "release-notes": { kind: "parsed", - parse: String, + parse: parseText, brief: "Release notes for the build", optional: true, }, "install-group": { kind: "parsed", - parse: String, + parse: parseText, brief: "Install group(s) for this build (repeatable); builds sharing a group show updates for each other", optional: true, @@ -254,7 +258,7 @@ export const uploadCommand = buildCommand({ }, dsym: { kind: "parsed", - parse: String, + parse: parseFilePath, brief: "dSYM bundle, directory of bundles, or ZIP for a single IPA (repeatable)", optional: true, @@ -262,52 +266,52 @@ export const uploadCommand = buildCommand({ }, "head-sha": { kind: "parsed", - parse: String, + parse: parseText, brief: "VCS commit SHA (defaults to the current commit)", optional: true, }, "base-sha": { kind: "parsed", - parse: String, + parse: parseText, brief: "VCS base commit SHA (defaults to the merge-base with the base ref)", optional: true, }, "vcs-provider": { kind: "parsed", - parse: String, + parse: parseText, brief: "VCS provider (defaults to the current remote's provider)", optional: true, }, "head-repo-name": { kind: "parsed", - parse: String, + parse: parseText, brief: "Head repository name, e.g. owner/repo (defaults to the current)", optional: true, }, "base-repo-name": { kind: "parsed", - parse: String, + parse: parseText, brief: "Base repository name, e.g. owner/repo (for forks)", optional: true, }, "head-ref": { kind: "parsed", - parse: String, + parse: parseText, brief: "Head branch/reference (defaults to the current branch)", optional: true, }, "base-ref": { kind: "parsed", - parse: String, + parse: parseText, brief: "Base branch/reference (defaults to the merge-base tracking ref)", optional: true, }, "pr-number": { kind: "parsed", - parse: parsePrNumber, + parse: textParser(parsePrNumber), brief: "Pull request number (auto-detected in pull_request GitHub Actions runs)", optional: true, diff --git a/packages/cli/src/commands/cli/import.ts b/packages/cli/src/commands/cli/import.ts index 2d22537f3..b8af7bdb5 100644 --- a/packages/cli/src/commands/cli/import.ts +++ b/packages/cli/src/commands/cli/import.ts @@ -14,6 +14,7 @@ import { isatty } from "node:tty"; import { isSaaSTrustOrigin } from "@sentry/toolkit-core/sentry-origin"; import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; +import { textParser } from "../../lib/completion-target.js"; import { getDefaultUrl } from "../../lib/db/defaults.js"; import { HostScopeError, ValidationError } from "../../lib/errors.js"; import { success, warning } from "../../lib/formatters/colors.js"; @@ -305,7 +306,7 @@ export const importCommand = buildCommand({ "dry-run": DRY_RUN_FLAG, url: { kind: "parsed", - parse: parseImportUrl, + parse: textParser(parseImportUrl), brief: "Explicitly trust this URL (bypasses same-file trust check)", optional: true, }, diff --git a/packages/cli/src/commands/code-mappings/upload.ts b/packages/cli/src/commands/code-mappings/upload.ts index bc0aba3fc..a0c51cebf 100644 --- a/packages/cli/src/commands/code-mappings/upload.ts +++ b/packages/cli/src/commands/code-mappings/upload.ts @@ -23,7 +23,7 @@ import { uploadCodeMappings, } from "../../lib/api/code-mappings.js"; import { buildCommand } from "../../lib/command.js"; -import { parseFilePath } from "../../lib/completion-target.js"; +import { parseFilePath, parseText } from "../../lib/completion-target.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { mdKvTable, renderMarkdown } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -213,14 +213,14 @@ export const uploadCommand = buildCommand({ flags: { repo: { kind: "parsed", - parse: String, + parse: parseText, brief: "Repository name (e.g., owner/repo). Auto-detected from git remote if omitted.", optional: true, }, "default-branch": { kind: "parsed", - parse: String, + parse: parseText, brief: "Default branch name. Auto-detected from git remote HEAD if omitted.", optional: true, diff --git a/packages/cli/src/commands/dart-symbol-map/upload.ts b/packages/cli/src/commands/dart-symbol-map/upload.ts index 0d0b16681..652a5540e 100644 --- a/packages/cli/src/commands/dart-symbol-map/upload.ts +++ b/packages/cli/src/commands/dart-symbol-map/upload.ts @@ -15,7 +15,7 @@ import { basename } from "node:path"; import type { SentryContext } from "../../context.js"; import { uploadDartSymbolMap } from "../../lib/api/dart-symbols.js"; import { buildCommand } from "../../lib/command.js"; -import { parseFilePath } from "../../lib/completion-target.js"; +import { parseFilePath, parseText } from "../../lib/completion-target.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { mdKvTable, renderMarkdown } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -204,7 +204,7 @@ export const uploadCommand = buildCommand({ flags: { "debug-id": { kind: "parsed", - parse: String, + parse: parseText, brief: "Debug ID (UUID) from the companion native debug file", }, "no-upload": { diff --git a/packages/cli/src/commands/dashboard/restore.ts b/packages/cli/src/commands/dashboard/restore.ts index c07badee1..15e86bb41 100644 --- a/packages/cli/src/commands/dashboard/restore.ts +++ b/packages/cli/src/commands/dashboard/restore.ts @@ -7,6 +7,7 @@ import type { SentryContext } from "../../context.js"; import { restoreDashboardRevision } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; +import { textParser } from "../../lib/completion-target.js"; import { ValidationError } from "../../lib/errors.js"; import { colorTag, escapeMarkdownCell } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -83,7 +84,7 @@ export const restoreCommand = buildCommand({ flags: { revision: { kind: "parsed", - parse: (value: string) => { + parse: textParser((value: string) => { const revision = value.trim(); if (!revision) { throw new ValidationError( @@ -92,7 +93,7 @@ export const restoreCommand = buildCommand({ ); } return revision; - }, + }), brief: "Revision ID to restore", }, }, diff --git a/packages/cli/src/commands/dashboard/view.ts b/packages/cli/src/commands/dashboard/view.ts index d6f37a886..1bf325a1c 100644 --- a/packages/cli/src/commands/dashboard/view.ts +++ b/packages/cli/src/commands/dashboard/view.ts @@ -9,6 +9,7 @@ import type { SentryContext } from "../../context.js"; import { getDashboard, queryAllWidgets } from "../../lib/api-client.js"; import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; +import { textParser } from "../../lib/completion-target.js"; import type { DashboardViewData } from "../../lib/formatters/dashboard.js"; import { createDashboardViewRenderer } from "../../lib/formatters/dashboard.js"; import { ClearScreen, CommandOutput } from "../../lib/formatters/output.js"; @@ -225,7 +226,7 @@ export const viewCommand = buildCommand({ fresh: FRESH_FLAG, refresh: { kind: "parsed", - parse: parseRefresh, + parse: textParser(parseRefresh), brief: "Auto-refresh interval in seconds (default: 60, min: 10)", optional: true, inferEmpty: true, diff --git a/packages/cli/src/commands/dashboard/widget/add.ts b/packages/cli/src/commands/dashboard/widget/add.ts index ab1c960f3..ec2c5aa11 100644 --- a/packages/cli/src/commands/dashboard/widget/add.ts +++ b/packages/cli/src/commands/dashboard/widget/add.ts @@ -7,7 +7,11 @@ import type { SentryContext } from "../../../context.js"; import { getDashboard, updateDashboard } from "../../../lib/api-client.js"; import { buildCommand, numberParser } from "../../../lib/command.js"; -import { DASHBOARD_TARGET } from "../../../lib/completion-target.js"; +import { + choiceParser, + DASHBOARD_TARGET, + parseText, +} from "../../../lib/completion-target.js"; import { ValidationError } from "../../../lib/errors.js"; import { formatWidgetAdded } from "../../../lib/formatters/human.js"; import { CommandOutput } from "../../../lib/formatters/output.js"; @@ -20,9 +24,12 @@ import { assignDefaultLayout, type DashboardDetail, type DashboardWidget, + DISPLAY_TYPES, FALLBACK_LAYOUT, prepareDashboardForUpdate, validateWidgetLayout, + WIDGET_LAYOUT_MODES, + WIDGET_TYPES, type WidgetLayoutFlags, type WidgetLayoutMode, } from "../../../types/dashboard.js"; @@ -168,40 +175,40 @@ export const addCommand = buildCommand({ flags: { display: { kind: "parsed", - parse: String, + parse: choiceParser(DISPLAY_TYPES), brief: "Display type (big_number, line, area, bar, table, stacked_area, top_n, text, categorical_bar, details, wheel, rage_and_dead_clicks, server_tree, agents_traces_table)", }, dataset: { kind: "parsed", - parse: String, + parse: choiceParser(WIDGET_TYPES), brief: "Widget dataset (default: spans). Accepts canonical names and API synonyms: spans, error-events/errors, tracemetrics/metrics, logs, issue", optional: true, }, query: { kind: "parsed", - parse: String, + parse: parseText, brief: "Aggregate expression (e.g. count, p95:span.duration)", variadic: true, optional: true, }, where: { kind: "parsed", - parse: String, + parse: parseText, brief: "Search conditions filter (e.g. is:unresolved)", optional: true, }, "group-by": { kind: "parsed", - parse: String, + parse: parseText, brief: "Group-by column (repeatable)", variadic: true, optional: true, }, sort: { kind: "parsed", - parse: String, + parse: parseText, brief: "Order by (prefix - for desc, e.g. -count)", optional: true, }, @@ -237,7 +244,7 @@ export const addCommand = buildCommand({ }, layout: { kind: "parsed", - parse: String, + parse: choiceParser(WIDGET_LAYOUT_MODES), brief: "Layout mode: sequential (append in order) or dense (fill gaps)", default: "sequential", }, @@ -286,11 +293,10 @@ export const addCommand = buildCommand({ // Validate layout mode before any network calls if ( flags.layout && - flags.layout !== "sequential" && - flags.layout !== "dense" + !(WIDGET_LAYOUT_MODES as readonly string[]).includes(flags.layout) ) { throw new ValidationError( - `Invalid --layout mode "${flags.layout}". Valid values: sequential, dense`, + `Invalid --layout mode "${flags.layout}". Valid values: ${WIDGET_LAYOUT_MODES.join(", ")}`, "layout", ); } diff --git a/packages/cli/src/commands/dashboard/widget/delete.ts b/packages/cli/src/commands/dashboard/widget/delete.ts index fe686bfd6..3c5fe4963 100644 --- a/packages/cli/src/commands/dashboard/widget/delete.ts +++ b/packages/cli/src/commands/dashboard/widget/delete.ts @@ -13,6 +13,7 @@ import type { SentryContext } from "../../../context.js"; import { getDashboard, updateDashboard } from "../../../lib/api-client.js"; import { numberParser } from "../../../lib/command.js"; +import { parseText } from "../../../lib/completion-target.js"; import { ValidationError } from "../../../lib/errors.js"; import { formatWidgetDeleted } from "../../../lib/formatters/human.js"; import { CommandOutput } from "../../../lib/formatters/output.js"; @@ -100,7 +101,7 @@ export const deleteCommand = buildDeleteCommand( }, title: { kind: "parsed", - parse: String, + parse: parseText, brief: "Widget title to match", optional: true, }, diff --git a/packages/cli/src/commands/dashboard/widget/edit.ts b/packages/cli/src/commands/dashboard/widget/edit.ts index ddea9d817..de08ab83f 100644 --- a/packages/cli/src/commands/dashboard/widget/edit.ts +++ b/packages/cli/src/commands/dashboard/widget/edit.ts @@ -7,6 +7,7 @@ import type { SentryContext } from "../../../context.js"; import { getDashboard, updateDashboard } from "../../../lib/api-client.js"; import { buildCommand, numberParser } from "../../../lib/command.js"; +import { choiceParser, parseText } from "../../../lib/completion-target.js"; import { ValidationError } from "../../../lib/errors.js"; import { formatWidgetEdited } from "../../../lib/formatters/human.js"; import { CommandOutput } from "../../../lib/formatters/output.js"; @@ -15,6 +16,7 @@ import { type DashboardDetail, type DashboardWidget, type DashboardWidgetQuery, + DISPLAY_TYPES, FALLBACK_LAYOUT, parseAggregate, parseSortExpression, @@ -23,6 +25,7 @@ import { prepareWidgetQueries, validateAggregateNames, validateWidgetLayout, + WIDGET_TYPES, type WidgetLayoutFlags, } from "../../../types/dashboard.js"; import { type DashboardArguments, dashboardArguments } from "../arguments.js"; @@ -236,53 +239,53 @@ export const editCommand = buildCommand({ }, title: { kind: "parsed", - parse: String, + parse: parseText, brief: "Widget title to match", optional: true, }, "new-title": { kind: "parsed", - parse: String, + parse: parseText, brief: "New widget title", optional: true, }, display: { kind: "parsed", - parse: String, + parse: choiceParser(DISPLAY_TYPES), brief: "Display type (big_number, line, area, bar, table, stacked_area, top_n, text, categorical_bar, details, wheel, rage_and_dead_clicks, server_tree, agents_traces_table)", optional: true, }, dataset: { kind: "parsed", - parse: String, + parse: choiceParser(WIDGET_TYPES), brief: "Widget dataset (default: spans). Accepts canonical names and API synonyms: spans, error-events/errors, tracemetrics/metrics, logs, issue", optional: true, }, query: { kind: "parsed", - parse: String, + parse: parseText, brief: "Aggregate expression (e.g. count, p95:span.duration)", variadic: true, optional: true, }, where: { kind: "parsed", - parse: String, + parse: parseText, brief: "Search conditions filter (e.g. is:unresolved)", optional: true, }, "group-by": { kind: "parsed", - parse: String, + parse: parseText, brief: "Group-by column (repeatable)", variadic: true, optional: true, }, sort: { kind: "parsed", - parse: String, + parse: parseText, brief: "Order by (prefix - for desc, e.g. -count)", optional: true, }, diff --git a/packages/cli/src/commands/debug-files/bundle-jvm.ts b/packages/cli/src/commands/debug-files/bundle-jvm.ts index e7272d748..227afd46a 100644 --- a/packages/cli/src/commands/debug-files/bundle-jvm.ts +++ b/packages/cli/src/commands/debug-files/bundle-jvm.ts @@ -12,7 +12,7 @@ import { mkdir, stat } from "node:fs/promises"; import { join, resolve } from "node:path"; import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; -import { parseDirectoryPath } from "../../lib/completion-target.js"; +import { parseDirectoryPath, parseText } from "../../lib/completion-target.js"; import { ValidationError } from "../../lib/errors.js"; import { mdKvTable, renderMarkdown } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -92,17 +92,17 @@ export const bundleJvmCommand = buildCommand({ flags: { output: { kind: "parsed", - parse: String, + parse: parseDirectoryPath, brief: "Output directory for the bundle ZIP", }, "debug-id": { kind: "parsed", - parse: String, + parse: parseText, brief: "Debug ID (UUID) to stamp on the bundle", }, exclude: { kind: "parsed", - parse: String, + parse: parseText, brief: "Additional directory names to exclude (repeatable)", optional: true, variadic: true, diff --git a/packages/cli/src/commands/debug-files/bundle-sources.ts b/packages/cli/src/commands/debug-files/bundle-sources.ts index e3203e81f..5fa067ea6 100644 --- a/packages/cli/src/commands/debug-files/bundle-sources.ts +++ b/packages/cli/src/commands/debug-files/bundle-sources.ts @@ -111,7 +111,7 @@ export const bundleSourcesCommand = buildCommand({ flags: { output: { kind: "parsed", - parse: String, + parse: parseFilePath, brief: "Output path for the source bundle ZIP (default: .src.zip)", optional: true, diff --git a/packages/cli/src/commands/debug-files/find.ts b/packages/cli/src/commands/debug-files/find.ts index fb1a817c2..035e44942 100644 --- a/packages/cli/src/commands/debug-files/find.ts +++ b/packages/cli/src/commands/debug-files/find.ts @@ -19,7 +19,11 @@ import { type FindResult, findDebugFiles, } from "../../lib/dif/find.js"; -import { parseText } from "../../lib/completion-target.js"; +import { + choiceParser, + parseDirectoryPath, + parseText, +} from "../../lib/completion-target.js"; import { ValidationError } from "../../lib/errors.js"; import { colorTag, renderMarkdown } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -151,7 +155,7 @@ export const findCommand = buildCommand({ flags: { type: { kind: "parsed", - parse: String, + parse: choiceParser(FIND_DIF_TYPES), variadic: true, brief: "Only consider debug files of the given type (repeatable). Default: all", @@ -169,7 +173,7 @@ export const findCommand = buildCommand({ }, path: { kind: "parsed", - parse: String, + parse: parseDirectoryPath, variadic: true, brief: "Add a directory to search recursively (repeatable)", optional: true, diff --git a/packages/cli/src/commands/debug-files/upload.ts b/packages/cli/src/commands/debug-files/upload.ts index 7bcad5eff..42cc83038 100644 --- a/packages/cli/src/commands/debug-files/upload.ts +++ b/packages/cli/src/commands/debug-files/upload.ts @@ -36,7 +36,7 @@ import { type DebugFileUploadResult, uploadDebugFiles, } from "../../lib/api/debug-files.js"; -import { buildCommand } from "../../lib/command.js"; +import { buildCommand, numberParser } from "../../lib/command.js"; import { createIl2cppLineMapping, createSourceBundle, @@ -47,8 +47,13 @@ import { type PreparedDif, prepareDifs, scanPaths, + VALID_DIF_TYPES, } from "../../lib/dif/scan.js"; -import { parseFilePath } from "../../lib/completion-target.js"; +import { + choiceParser, + parseFilePath, + parseText, +} from "../../lib/completion-target.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { colorTag, @@ -613,7 +618,7 @@ export const uploadCommand = buildCommand({ flags: { type: { kind: "parsed", - parse: String, + parse: choiceParser(VALID_DIF_TYPES), brief: "Only upload files of this type (repeatable): dsym, elf, pe, pdb, " + "portablepdb, wasm, breakpad, sourcebundle, jvm", @@ -622,7 +627,7 @@ export const uploadCommand = buildCommand({ }, id: { kind: "parsed", - parse: String, + parse: parseText, brief: "Only upload the object with this debug id (repeatable)", optional: true, variadic: true, @@ -690,7 +695,7 @@ export const uploadCommand = buildCommand({ }, "wait-for": { kind: "parsed", - parse: Number, + parse: numberParser, brief: "Wait up to this many seconds for server-side processing", optional: true, }, diff --git a/packages/cli/src/commands/docs/list.ts b/packages/cli/src/commands/docs/list.ts index 072846f1e..757a5324a 100644 --- a/packages/cli/src/commands/docs/list.ts +++ b/packages/cli/src/commands/docs/list.ts @@ -1,7 +1,7 @@ import type { SentryContext } from "../../context.js"; import { validateLimit } from "../../lib/arg-parsing.js"; import { buildCommand } from "../../lib/command.js"; -import { parseText } from "../../lib/completion-target.js"; +import { parseText, textParser } from "../../lib/completion-target.js"; import { type DocsListResponse, listDocs } from "../../lib/docs-service.js"; import { ValidationError } from "../../lib/errors.js"; import { muted } from "../../lib/formatters/colors.js"; @@ -58,7 +58,7 @@ export const listCommand = buildCommand({ flags: { limit: { kind: "parsed", - parse: parseLimit, + parse: textParser(parseLimit), brief: "Maximum matches to return (1-20)", default: "8", }, diff --git a/packages/cli/src/commands/dsn/list.ts b/packages/cli/src/commands/dsn/list.ts index 883953382..dc04182f7 100644 --- a/packages/cli/src/commands/dsn/list.ts +++ b/packages/cli/src/commands/dsn/list.ts @@ -11,6 +11,7 @@ import { listProjectDsns, } from "../../lib/api/projects.js"; import { validateLimit } from "../../lib/arg-parsing.js"; +import { textParser } from "../../lib/completion-target.js"; import { advancePaginationState, buildMultiTargetContextKey, @@ -344,8 +345,9 @@ export const listCommand = buildListCommand("dsn", { flags: { limit: { ...buildListLimitFlag("DSNs"), - parse: (value: string) => + parse: textParser((value: string) => validateLimit(value, LIST_MIN_LIMIT, LIST_MAX_LIMIT), + ), }, }, aliases: { n: "limit" }, diff --git a/packages/cli/src/commands/event/send.ts b/packages/cli/src/commands/event/send.ts index 12d990225..316934013 100644 --- a/packages/cli/src/commands/event/send.ts +++ b/packages/cli/src/commands/event/send.ts @@ -24,7 +24,11 @@ import { readFileBytes, sendEnvelopeRequest, } from "../../lib/envelope/transport.js"; -import { parseFilePath } from "../../lib/completion-target.js"; +import { + parseFilePath, + parsePlatform, + parseText, +} from "../../lib/completion-target.js"; import { ConfigError, ValidationError } from "../../lib/errors.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { logger } from "../../lib/logger.js"; @@ -212,14 +216,14 @@ built entirely from the file contents. flags: { message: { kind: "parsed", - parse: String, + parse: parseText, brief: "Event message (repeat for multi-line)", variadic: true, optional: true, }, "message-arg": { kind: "parsed", - parse: String, + parse: parseText, brief: "Arguments for message template (repeat for multiple)", variadic: true, optional: true, @@ -233,45 +237,45 @@ built entirely from the file contents. }, release: { kind: "parsed", - parse: String, + parse: parseText, brief: "Release version", optional: true, }, dist: { kind: "parsed", - parse: String, + parse: parseText, brief: "Distribution identifier", optional: true, }, env: { kind: "parsed", - parse: String, + parse: parseText, brief: "Environment name (e.g. production, staging)", optional: true, }, platform: { kind: "parsed", - parse: String, + parse: parsePlatform, brief: "Platform identifier (default: other)", optional: true, }, tag: { kind: "parsed", - parse: String, + parse: parseText, brief: "Tag as KEY:VALUE (repeat for multiple)", variadic: true, optional: true, }, extra: { kind: "parsed", - parse: String, + parse: parseText, brief: "Extra data as KEY:VALUE (repeat for multiple)", variadic: true, optional: true, }, user: { kind: "parsed", - parse: String, + parse: parseText, brief: "User info as KEY:VALUE — id, email, username, ip_address, or custom", variadic: true, @@ -279,14 +283,14 @@ built entirely from the file contents. }, fingerprint: { kind: "parsed", - parse: String, + parse: parseText, brief: "Custom fingerprint part (repeat for multiple)", variadic: true, optional: true, }, timestamp: { kind: "parsed", - parse: String, + parse: parseText, brief: "Event timestamp (Unix epoch, ISO 8601, or RFC 2822)", optional: true, }, @@ -298,7 +302,7 @@ built entirely from the file contents. }, logfile: { kind: "parsed", - parse: String, + parse: parseFilePath, brief: "Path to a log file — last 100 lines are attached as breadcrumbs", optional: true, diff --git a/packages/cli/src/commands/event/shared-events.ts b/packages/cli/src/commands/event/shared-events.ts index 2e4a8a8e1..e698c93ae 100644 --- a/packages/cli/src/commands/event/shared-events.ts +++ b/packages/cli/src/commands/event/shared-events.ts @@ -7,6 +7,7 @@ */ import { validateLimit } from "../../lib/arg-parsing.js"; +import { textParser } from "../../lib/completion-target.js"; import { filterFields } from "../../lib/formatters/json.js"; import { colorTag, escapeMarkdownCell } from "../../lib/formatters/markdown.js"; import { type Column, formatTable } from "../../lib/formatters/table.js"; @@ -208,7 +209,7 @@ export function parseLimit(value: string): number { export const EVENTS_FLAGS = { limit: { kind: "parsed", - parse: parseLimit, + parse: textParser(parseLimit), brief: `Number of events (${LIST_MIN_LIMIT}-${LIST_MAX_LIMIT})`, default: String(LIST_DEFAULT_LIMIT), }, diff --git a/packages/cli/src/commands/explore.ts b/packages/cli/src/commands/explore.ts index 3b8a40e1a..57399da9a 100644 --- a/packages/cli/src/commands/explore.ts +++ b/packages/cli/src/commands/explore.ts @@ -21,6 +21,11 @@ import { queryMetricsMeta, } from "../lib/api-client.js"; import { validateLimit } from "../lib/arg-parsing.js"; +import { + choiceParser, + parseText, + textParser, +} from "../lib/completion-target.js"; import { advancePaginationState, buildPaginationContextKey, @@ -767,7 +772,7 @@ export const exploreCommand = buildListCommand("explore", { flags: { field: { kind: "parsed", - parse: String, + parse: parseText, brief: 'API field or aggregate (repeatable). E.g., title, "count()", "p50(span.duration)"', variadic: true, @@ -775,55 +780,56 @@ export const exploreCommand = buildListCommand("explore", { }, metric: { kind: "parsed", - parse: String, + parse: parseText, brief: "Metric name for --dataset metrics. Auto-resolves type/unit via API.", optional: true, }, agg: { kind: "parsed", - parse: String, + parse: parseText, brief: "Aggregation for --metric (sum, avg, count, p50, p95, etc.)", default: "sum", }, dataset: { kind: "parsed", - parse: DATASETS.parse, + parse: choiceParser(DATASETS.names, DATASETS.parse), brief: `Dataset to query (${DATASETS.names.join(", ")}; transaction(s) routes to spans)`, default: DEFAULT_DATASET, }, query: { kind: "parsed", - parse: String, + parse: parseText, brief: "Search query (Sentry search syntax)", optional: true, }, sort: { kind: "parsed", - parse: String, + parse: parseText, brief: 'Sort field (prefix with - for desc, e.g., "-count()")', optional: true, }, environment: { kind: "parsed", - parse: String, + parse: parseText, brief: "Environment filter (repeatable, comma-separated)", variadic: true, optional: true, }, limit: { kind: "parsed", - parse: parseLimit, + parse: textParser(parseLimit), placeholder: "number", brief: `Number of table rows (1-${LIST_MAX_LIMIT}, default: ${DEFAULT_LIMIT})`, optional: true, }, "time-series": { kind: "parsed", - parse: (value: string) => + parse: textParser((value: string) => value === "" || value === "auto" ? "auto" : parseTimeSeriesInterval(value), + ), placeholder: "auto|interval", brief: "Query time series (default: auto; optional bucket duration, e.g., 15m)", diff --git a/packages/cli/src/commands/feedback/list.ts b/packages/cli/src/commands/feedback/list.ts index 5c53351d6..6ef037995 100644 --- a/packages/cli/src/commands/feedback/list.ts +++ b/packages/cli/src/commands/feedback/list.ts @@ -11,6 +11,7 @@ import { listFeedback, } from "../../lib/api-client.js"; import { validateLimit } from "../../lib/arg-parsing.js"; +import { textParser } from "../../lib/completion-target.js"; import { advancePaginationState, buildPaginationContextKey, @@ -206,7 +207,7 @@ export const listCommand = buildListCommand("feedback", { }, limit: { kind: "parsed", - parse: parseLimit, + parse: textParser(parseLimit), brief: `Number of feedback items (${LIST_MIN_LIMIT}-${LIST_MAX_LIMIT})`, default: String(LIST_DEFAULT_LIMIT), }, diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index d01e450eb..be68c60fc 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -26,7 +26,11 @@ import { parseOrgProjectInput, } from "../lib/arg-parsing.js"; import { buildCommand } from "../lib/command.js"; -import { ORG_PROJECT_OR_DIRECTORY_TARGET } from "../lib/completion-target.js"; +import { + choiceListParser, + ORG_PROJECT_OR_DIRECTORY_TARGET, + parseText, +} from "../lib/completion-target.js"; import { refreshToken } from "../lib/db/auth.js"; import { ContextError, ValidationError } from "../lib/errors.js"; import { warmOrgDetection } from "../lib/init/org-prefetch.js"; @@ -416,7 +420,7 @@ export const initCommand = buildCommand< "dry-run": DRY_RUN_FLAG, features: { kind: "parsed", - parse: String, + parse: choiceListParser(SUPPORTED_FEATURE_NAMES), brief: "Features to enable: errors,tracing,logs,replay,profiling,crons,agent-tracing,mcp-observability", variadic: true, @@ -424,13 +428,13 @@ export const initCommand = buildCommand< }, team: { kind: "parsed", - parse: String, + parse: parseText, brief: "Team slug to create the project under", optional: true, }, app: { kind: "parsed", - parse: String, + parse: parseText, brief: "App to initialize in a monorepo (required with --yes when multiple apps are detected)", optional: true, diff --git a/packages/cli/src/commands/issue/archive.ts b/packages/cli/src/commands/issue/archive.ts index a85af4e45..d9f5c7f20 100644 --- a/packages/cli/src/commands/issue/archive.ts +++ b/packages/cli/src/commands/issue/archive.ts @@ -11,6 +11,7 @@ import { updateIssueStatus, } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; +import { parseText } from "../../lib/completion-target.js"; import { ValidationError } from "../../lib/errors.js"; import { formatIssueDetails, muted } from "../../lib/formatters/index.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -398,7 +399,7 @@ export const archiveCommand = buildCommand({ flags: { until: { kind: "parsed", - parse: String, + parse: parseText, brief: "Condition for unarchival: forever, auto, 30m, 10x, 10u, 10x/5m, etc.", optional: true, diff --git a/packages/cli/src/commands/issue/link-utils.ts b/packages/cli/src/commands/issue/link-utils.ts index 5f096e2ea..46bc30e0c 100644 --- a/packages/cli/src/commands/issue/link-utils.ts +++ b/packages/cli/src/commands/issue/link-utils.ts @@ -1,6 +1,6 @@ /** Shared arguments for external issue association commands. */ -import { parseUrl } from "../../lib/completion-target.js"; +import { parseText, parseUrl } from "../../lib/completion-target.js"; import { ValidationError } from "../../lib/errors.js"; import { issueParameter } from "./positional.js"; @@ -23,13 +23,13 @@ export function externalIssuePositionals(command: string) { export const EXTERNAL_ISSUE_FLAGS = { integration: { kind: "parsed", - parse: String, + parse: parseText, brief: "Native integration ID, when multiple installations match", optional: true, }, app: { kind: "parsed", - parse: String, + parse: parseText, brief: "Sentry App slug (automatically detected for Linear URLs)", optional: true, }, diff --git a/packages/cli/src/commands/issue/link.ts b/packages/cli/src/commands/issue/link.ts index 6fb77cefd..90086ab74 100644 --- a/packages/cli/src/commands/issue/link.ts +++ b/packages/cli/src/commands/issue/link.ts @@ -2,6 +2,7 @@ import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; +import { parseText } from "../../lib/completion-target.js"; import { formatIssueLinkResult } from "../../lib/formatters/issue-links.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { linkExternalIssue } from "../../lib/issue-links.js"; @@ -71,7 +72,7 @@ export const linkCommand = buildCommand({ "dry-run": DRY_RUN_FLAG, field: { kind: "parsed", - parse: String, + parse: parseText, brief: "Additional Sentry App link form field (name=value, repeatable)", variadic: true, optional: true, diff --git a/packages/cli/src/commands/issue/list.ts b/packages/cli/src/commands/issue/list.ts index f75be5500..7994a96ae 100644 --- a/packages/cli/src/commands/issue/list.ts +++ b/packages/cli/src/commands/issue/list.ts @@ -15,12 +15,15 @@ import { } from "../../lib/api-client.js"; import { extractRequiredScopes } from "../../lib/api-scope.js"; import { + CURSOR_KEYWORDS, formatProjectTarget, looksLikeIssueShortId, type ParsedOrgProject, + parseCursorFlag, rejectIssueCommandTokenListTarget, } from "../../lib/arg-parsing.js"; +import { choiceParser } from "../../lib/completion-target.js"; import { getActiveEnvVarName, isEnvTokenActive } from "../../lib/db/auth.js"; import { advancePaginationState, @@ -58,12 +61,10 @@ import { appendQueryHint, buildListCommand, buildListLimitFlag, - CURSOR_KEYWORDS, LIST_BASE_ALIASES, LIST_MAX_LIMIT, listTargetPositional, paginationHint, - parseCursorFlag, targetPatternExplanation, } from "../../lib/list-command.js"; import { logger } from "../../lib/logger.js"; @@ -1723,7 +1724,7 @@ export const listCommand = buildListCommand("issue", { limit: buildListLimitFlag("issues"), sort: { kind: "parsed", - parse: parseSort, + parse: choiceParser(VALID_SORT_VALUES, parseSort), brief: "Sort by: recommended, date, new, freq, user (default: recommended on sentry.io, else date)", optional: true, diff --git a/packages/cli/src/commands/issue/merge.ts b/packages/cli/src/commands/issue/merge.ts index 28f89b140..b00d0fde1 100644 --- a/packages/cli/src/commands/issue/merge.ts +++ b/packages/cli/src/commands/issue/merge.ts @@ -23,6 +23,7 @@ import type { SentryContext } from "../../context.js"; import { type MergeIssuesResult, mergeIssues } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; +import { parseText } from "../../lib/completion-target.js"; import { ApiError, CliError, @@ -302,7 +303,7 @@ export const mergeCommand = buildCommand({ flags: { into: { kind: "parsed", - parse: String, + parse: parseText, brief: "Prefer this issue as the canonical parent (included in the merge if not already listed)", optional: true, diff --git a/packages/cli/src/commands/issue/resolve.ts b/packages/cli/src/commands/issue/resolve.ts index 23f7e4949..6f63efa34 100644 --- a/packages/cli/src/commands/issue/resolve.ts +++ b/packages/cli/src/commands/issue/resolve.ts @@ -25,6 +25,7 @@ import { updateIssueStatus, } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; +import { choiceParser } from "../../lib/completion-target.js"; import { ContextError } from "../../lib/errors.js"; import { formatIssueDetails, muted } from "../../lib/formatters/index.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -149,7 +150,10 @@ export const resolveCommand = buildCommand({ flags: { in: { kind: "parsed", - parse: String, + parse: choiceParser([ + RESOLVE_NEXT_RELEASE_SENTINEL, + RESOLVE_COMMIT_SENTINEL, + ]), brief: `Resolve in a release, next release, or commit ('' | '${RESOLVE_NEXT_RELEASE_SENTINEL}' | '${RESOLVE_COMMIT_SENTINEL}' | '${RESOLVE_COMMIT_EXPLICIT_PREFIX}@')`, optional: true, }, diff --git a/packages/cli/src/commands/issue/tags.ts b/packages/cli/src/commands/issue/tags.ts index d72a88f5c..dc70faee1 100644 --- a/packages/cli/src/commands/issue/tags.ts +++ b/packages/cli/src/commands/issue/tags.ts @@ -104,7 +104,7 @@ export const tagsCommand = buildCommand({ flags: { environment: { kind: "parsed", - parse: String, + parse: parseText, brief: "Filter to one environment (default: all environments)", optional: true, }, diff --git a/packages/cli/src/commands/local/management.ts b/packages/cli/src/commands/local/management.ts index 5ba2c9bd7..4a9ebb598 100644 --- a/packages/cli/src/commands/local/management.ts +++ b/packages/cli/src/commands/local/management.ts @@ -3,7 +3,7 @@ import { basename } from "node:path"; import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; -import { parseText } from "../../lib/completion-target.js"; +import { parseText, textParser } from "../../lib/completion-target.js"; import { CliError, ValidationError } from "../../lib/errors.js"; import { formatLocalControlResult } from "../../lib/formatters/local.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -55,13 +55,13 @@ const CONTROL_COMMAND_DEFAULTS = { const DAEMON_CONNECTION_FLAGS = { host: { kind: "parsed", - parse: String, + parse: parseText, brief: "Loopback host", default: "localhost", }, port: { kind: "parsed", - parse: parsePort, + parse: textParser(parsePort), brief: "Control-plane port", default: "8969", }, @@ -215,7 +215,7 @@ const sessionCreateCommand = buildCommand({ flags: { clientId: { kind: "parsed", - parse: String, + parse: parseText, brief: "Idempotency key for retried agent session creation", optional: true, }, diff --git a/packages/cli/src/commands/local/retention-buffer.ts b/packages/cli/src/commands/local/retention-buffer.ts index 9f4f13fe9..80696015c 100644 --- a/packages/cli/src/commands/local/retention-buffer.ts +++ b/packages/cli/src/commands/local/retention-buffer.ts @@ -1,5 +1,6 @@ /** Resize through the Spotlight SDK's public API while keeping a stable buffer facade. */ import { createSpotlightBuffer } from "@spotlightjs/spotlight/sdk"; +import { textParser } from "../../lib/completion-target.js"; import { ValidationError } from "../../lib/errors.js"; import { DEFAULT_MAX_ENVELOPES, @@ -42,7 +43,7 @@ export function validateMaxEnvelopes(value: unknown): asserts value is number { const limitFlag = { kind: "parsed", brief: "Maximum envelopes retained by this receiver or session", - parse(value: string): number { + parse: textParser((value: string): number => { const parsed = /^\d+$/.test(value) ? Number(value) : NaN; if (!isValidMaxEnvelopes(parsed)) throw new ValidationError( @@ -50,7 +51,7 @@ const limitFlag = { "max-envelopes", ); return parsed; - }, + }), } as const; export const MAX_ENVELOPES_FLAG = { ...limitFlag, diff --git a/packages/cli/src/commands/local/run.ts b/packages/cli/src/commands/local/run.ts index b7d6cf9ce..31c38a812 100644 --- a/packages/cli/src/commands/local/run.ts +++ b/packages/cli/src/commands/local/run.ts @@ -22,16 +22,22 @@ import type { Server } from "node:http"; import { basename, resolve } from "node:path"; import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; -import { parseText } from "../../lib/completion-target.js"; +import { + choiceParser, + parseText, + textParser, +} from "../../lib/completion-target.js"; import { detectDevCommand } from "../../lib/dev-script.js"; import { CliError, EXIT, ValidationError } from "../../lib/errors.js"; import { bold } from "../../lib/formatters/colors.js"; import { - printLocalViewerHint, + FILTER_VALUES, type FilterValue, - type FormatValue, + FORMAT_VALUES, formatEnvelopeLines, formatEnvelopeLinesJson, + type FormatValue, + printLocalViewerHint, } from "../../lib/formatters/local.js"; import { logger, printJsonLine, printLine } from "../../lib/logger.js"; import { DEFAULT_MAX_ENVELOPES } from "./retention-limits.js"; @@ -448,19 +454,19 @@ export const runCommand = buildCommand({ flags: { port: { kind: "parsed", - parse: parsePort, + parse: textParser(parsePort), brief: "Port for the local server", default: String(DEFAULT_PORT), }, host: { kind: "parsed", - parse: String, + parse: parseText, brief: "Hostname for the local server", default: "localhost", }, filter: { kind: "parsed", - parse: parseFilter, + parse: choiceParser(FILTER_VALUES, parseFilter), brief: "Only show items of this type (repeatable: error, transaction, log, ai)", variadic: true, @@ -473,14 +479,14 @@ export const runCommand = buildCommand({ }, timeout: { kind: "parsed", - parse: parseTimeout, + parse: textParser(parseTimeout), brief: "Kill the child after N seconds (0 = no timeout; defaults to 30 s in --verify mode)", default: "0", }, format: { kind: "parsed", - parse: parseFormat, + parse: choiceParser(FORMAT_VALUES, parseFormat), brief: "Output format: human (default) or json (NDJSON on stdout)", default: "human", }, @@ -497,7 +503,7 @@ export const runCommand = buildCommand({ "max-envelopes": OPTIONAL_MAX_ENVELOPES_FLAG, session: { kind: "parsed", - parse: String, + parse: parseText, brief: "Create a daemon-owned telemetry session with this label", optional: true, }, diff --git a/packages/cli/src/commands/local/server.ts b/packages/cli/src/commands/local/server.ts index 88663ca79..dd919e49d 100644 --- a/packages/cli/src/commands/local/server.ts +++ b/packages/cli/src/commands/local/server.ts @@ -1,3 +1,8 @@ +import { + choiceParser, + parseText, + textParser, +} from "../../lib/completion-target.js"; import { isHostedUiOrigin, LOCAL_SESSION_EVENT } from "./viewer-protocol.js"; export { isHostedUiOrigin } from "./viewer-protocol.js"; import { isLoopbackHost } from "./loopback.js"; @@ -1051,13 +1056,13 @@ export const serverCommand = buildCommand({ flags: { port: { kind: "parsed", - parse: parsePort, + parse: textParser(parsePort), brief: "Port to listen on", default: String(DEFAULT_PORT), }, host: { kind: "parsed", - parse: String, + parse: parseText, brief: "Hostname to bind to", default: "localhost", }, @@ -1068,7 +1073,7 @@ export const serverCommand = buildCommand({ }, filter: { kind: "parsed", - parse: parseFilter, + parse: choiceParser(FILTER_VALUES, parseFilter), brief: "Only show items of this type (repeatable: error, transaction, log, ai)", variadic: true, @@ -1076,7 +1081,7 @@ export const serverCommand = buildCommand({ }, format: { kind: "parsed", - parse: parseFormat, + parse: choiceParser(FORMAT_VALUES, parseFormat), brief: "Output format: human (default) or json (NDJSON on stdout)", default: "human", }, @@ -1094,7 +1099,7 @@ export const serverCommand = buildCommand({ "max-envelopes": OPTIONAL_MAX_ENVELOPES_FLAG, session: { kind: "parsed", - parse: String, + parse: parseText, brief: "Create a daemon-owned telemetry session with this label", optional: true, }, diff --git a/packages/cli/src/commands/log/list.ts b/packages/cli/src/commands/log/list.ts index 5a23fe479..20f100df1 100644 --- a/packages/cli/src/commands/log/list.ts +++ b/packages/cli/src/commands/log/list.ts @@ -21,7 +21,7 @@ import { parseOrgProjectInput, validateLimit, } from "../../lib/arg-parsing.js"; -import { ORG_PROJECT_TARGET } from "../../lib/completion-target.js"; +import { ORG_PROJECT_TARGET, textParser } from "../../lib/completion-target.js"; import { AuthError, stringifyUnknown, @@ -832,7 +832,7 @@ export const listCommand = buildListCommand( flags: { limit: { kind: "parsed", - parse: parseLimit, + parse: textParser(parseLimit), brief: `Number of log entries (${LIST_MIN_LIMIT}-${LIST_MAX_LIMIT})`, default: "100", // Logs are high-volume; 25 is too stingy for debugging }, @@ -845,7 +845,7 @@ export const listCommand = buildListCommand( }, follow: { kind: "parsed", - parse: parseFollow, + parse: textParser(parseFollow), brief: "Stream logs (optionally specify poll interval in seconds)", optional: true, inferEmpty: true, diff --git a/packages/cli/src/commands/monitor/run.ts b/packages/cli/src/commands/monitor/run.ts index 4598b949e..077fcaa81 100644 --- a/packages/cli/src/commands/monitor/run.ts +++ b/packages/cli/src/commands/monitor/run.ts @@ -34,7 +34,11 @@ import { resolveIngestDsn, sendEnvelopeRequest, } from "../../lib/envelope/transport.js"; -import { parseText } from "../../lib/completion-target.js"; +import { + choiceParser, + parseText, + textParser, +} from "../../lib/completion-target.js"; import { CliError, ConfigError, ValidationError } from "../../lib/errors.js"; import { logger } from "../../lib/logger.js"; @@ -205,54 +209,54 @@ The wrapped command receives the \`SENTRY_MONITOR_SLUG\` environment variable.`, flags: { dsn: { kind: "parsed", - parse: String, + parse: parseText, brief: "DSN to send check-ins to (overrides SENTRY_DSN env var)", optional: true, }, environment: { kind: "parsed", - parse: String, + parse: parseText, brief: "Environment of the monitor", default: "production", }, schedule: { kind: "parsed", - parse: String, + parse: parseText, brief: "Upsert the monitor with this crontab schedule (e.g. '0 * * * *')", optional: true, }, "check-in-margin": { kind: "parsed", - parse: parsePositiveInt, + parse: textParser(parsePositiveInt), brief: "Minutes after the expected check-in before it is missed (requires --schedule)", optional: true, }, "max-runtime": { kind: "parsed", - parse: parsePositiveInt, + parse: textParser(parsePositiveInt), brief: "Minutes a check-in may run before timing out (requires --schedule)", optional: true, }, timezone: { kind: "parsed", - parse: String, + parse: choiceParser(Intl.supportedValuesOf("timeZone")), brief: "Timezone of the schedule, tz database string (requires --schedule)", optional: true, }, "failure-issue-threshold": { kind: "parsed", - parse: parsePositiveInt, + parse: textParser(parsePositiveInt), brief: "Consecutive failures before an issue is created (requires --schedule)", optional: true, }, "recovery-threshold": { kind: "parsed", - parse: parsePositiveInt, + parse: textParser(parsePositiveInt), brief: "Consecutive successes before an issue is resolved (requires --schedule)", optional: true, diff --git a/packages/cli/src/commands/org/list.ts b/packages/cli/src/commands/org/list.ts index 3892dd7fe..aa7f230cc 100644 --- a/packages/cli/src/commands/org/list.ts +++ b/packages/cli/src/commands/org/list.ts @@ -9,7 +9,10 @@ import type { SentryContext } from "../../context.js"; import { resolveAccountSelector } from "../../lib/account-selection.js"; import { listOrganizationsUncached } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; -import { parseText } from "../../lib/completion-target.js"; +import { + parseAccountSelector, + parseText, +} from "../../lib/completion-target.js"; import { DEFAULT_SENTRY_HOST } from "../../lib/constants.js"; import { type AccountSummary, @@ -446,7 +449,7 @@ export const listCommand = buildCommand({ fresh: FRESH_FLAG, account: { kind: "parsed", - parse: String, + parse: parseAccountSelector, brief: "Only list organizations of this account (email, username, or account id; : for one server; env:SENTRY_AUTH_TOKEN)", optional: true, diff --git a/packages/cli/src/commands/platform/list.ts b/packages/cli/src/commands/platform/list.ts index 0ba9beac5..a7edc68aa 100644 --- a/packages/cli/src/commands/platform/list.ts +++ b/packages/cli/src/commands/platform/list.ts @@ -14,6 +14,7 @@ import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; +import { parseText } from "../../lib/completion-target.js"; import { OutputError } from "../../lib/errors.js"; import { muted } from "../../lib/formatters/colors.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -58,7 +59,7 @@ export const listCommand = buildCommand({ flags: { search: { kind: "parsed", - parse: String, + parse: parseText, brief: "Filter platforms by substring", optional: true, }, diff --git a/packages/cli/src/commands/proguard/upload.ts b/packages/cli/src/commands/proguard/upload.ts index c667c69a4..e67e0e7fc 100644 --- a/packages/cli/src/commands/proguard/upload.ts +++ b/packages/cli/src/commands/proguard/upload.ts @@ -13,7 +13,7 @@ import type { SentryContext } from "../../context.js"; import type { ProguardMapping } from "../../lib/api/proguard.js"; import { uploadProguardMappings } from "../../lib/api/proguard.js"; import { buildCommand } from "../../lib/command.js"; -import { parseFilePath } from "../../lib/completion-target.js"; +import { parseFilePath, parseText } from "../../lib/completion-target.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { mdKvTable, renderMarkdown } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -198,7 +198,7 @@ export const uploadCommand = buildCommand({ flags: { uuid: { kind: "parsed", - parse: String, + parse: parseText, brief: "Force a specific UUID instead of computing from file content " + "(only valid with a single file)", diff --git a/packages/cli/src/commands/project/create.ts b/packages/cli/src/commands/project/create.ts index 3bd128f1a..70c9477f5 100644 --- a/packages/cli/src/commands/project/create.ts +++ b/packages/cli/src/commands/project/create.ts @@ -28,7 +28,7 @@ import { } from "../../lib/api-client.js"; import { parseOrgProjectInput } from "../../lib/arg-parsing.js"; import { buildCommand } from "../../lib/command.js"; -import { PROJECT_SPEC_TARGET } from "../../lib/completion-target.js"; +import { parseText, PROJECT_SPEC_TARGET } from "../../lib/completion-target.js"; import { ApiError, CliError, @@ -729,7 +729,7 @@ export const createCommand = buildCommand({ flags: { team: { kind: "parsed", - parse: String, + parse: parseText, brief: "Team to create the project under", optional: true, }, diff --git a/packages/cli/src/commands/project/list.ts b/packages/cli/src/commands/project/list.ts index 9393cbdeb..085a685d8 100644 --- a/packages/cli/src/commands/project/list.ts +++ b/packages/cli/src/commands/project/list.ts @@ -25,6 +25,7 @@ import { formatProjectTarget, type ParsedOrgProject, } from "../../lib/arg-parsing.js"; +import { parsePlatform } from "../../lib/completion-target.js"; import { getDefaultOrganization } from "../../lib/db/defaults.js"; import { advancePaginationState, @@ -927,7 +928,7 @@ export const listCommand = buildListCommand("project", { limit: buildListLimitFlag("projects"), platform: { kind: "parsed", - parse: String, + parse: parsePlatform, brief: "Filter by platform (e.g., javascript, python)", optional: true, }, diff --git a/packages/cli/src/commands/react-native/gradle.ts b/packages/cli/src/commands/react-native/gradle.ts index 26aa731c9..38eee9b45 100644 --- a/packages/cli/src/commands/react-native/gradle.ts +++ b/packages/cli/src/commands/react-native/gradle.ts @@ -17,7 +17,8 @@ import { resolveUploadWait, uploadSourcemaps, } from "../../lib/api/sourcemaps.js"; -import { buildCommand } from "../../lib/command.js"; +import { buildCommand, numberParser } from "../../lib/command.js"; +import { parseFilePath, parseText } from "../../lib/completion-target.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { mdKvTable, renderMarkdown } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -151,23 +152,23 @@ export const gradleCommand = buildCommand({ flags: { sourcemap: { kind: "parsed", - parse: String, + parse: parseFilePath, brief: "Path to the sourcemap to upload", }, bundle: { kind: "parsed", - parse: String, + parse: parseFilePath, brief: "Path to the bundle to upload", }, release: { kind: "parsed", - parse: String, + parse: parseText, brief: "Release version to publish to", optional: true, }, dist: { kind: "parsed", - parse: String, + parse: parseText, variadic: true, brief: "Distribution(s) to publish (repeatable; requires --release)", optional: true, @@ -179,7 +180,7 @@ export const gradleCommand = buildCommand({ }, "wait-for": { kind: "parsed", - parse: Number, + parse: numberParser, brief: "Wait for processing, but at most this many seconds", optional: true, }, diff --git a/packages/cli/src/commands/react-native/xcode.ts b/packages/cli/src/commands/react-native/xcode.ts index 4ddb5424f..b5af2287e 100644 --- a/packages/cli/src/commands/react-native/xcode.ts +++ b/packages/cli/src/commands/react-native/xcode.ts @@ -26,8 +26,12 @@ import { resolveUploadWait, uploadSourcemaps, } from "../../lib/api/sourcemaps.js"; -import { buildCommand } from "../../lib/command.js"; -import { parseText } from "../../lib/completion-target.js"; +import { buildCommand, numberParser } from "../../lib/command.js"; +import { + parseFilePath, + parseText, + parseUrl, +} from "../../lib/completion-target.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { mdKvTable, renderMarkdown } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -459,19 +463,19 @@ export const xcodeCommand = buildCommand({ }, "fetch-from": { kind: "parsed", - parse: String, + parse: parseUrl, brief: `Packager URL to fetch from (default: ${DEFAULT_PACKAGER_URL})`, optional: true, }, "build-script": { kind: "parsed", - parse: String, + parse: parseFilePath, brief: "Path to the react-native-xcode.sh build script", optional: true, }, dist: { kind: "parsed", - parse: String, + parse: parseText, variadic: true, brief: "Distribution(s) to publish (repeatable)", optional: true, @@ -483,7 +487,7 @@ export const xcodeCommand = buildCommand({ }, "wait-for": { kind: "parsed", - parse: Number, + parse: numberParser, brief: "Wait for processing, but at most this many seconds", optional: true, }, diff --git a/packages/cli/src/commands/release/create.ts b/packages/cli/src/commands/release/create.ts index 126ba4a7c..5cb588d99 100644 --- a/packages/cli/src/commands/release/create.ts +++ b/packages/cli/src/commands/release/create.ts @@ -7,6 +7,7 @@ import type { SentryContext } from "../../context.js"; import { createRelease } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; +import { parseText, parseUrl } from "../../lib/completion-target.js"; import { colorTag, escapeMarkdownInline, @@ -120,7 +121,7 @@ export const createCommand = buildCommand({ flags: { project: { kind: "parsed", - parse: String, + parse: parseText, brief: "Associate with project(s), comma-separated", optional: true, }, @@ -131,13 +132,13 @@ export const createCommand = buildCommand({ }, ref: { kind: "parsed", - parse: String, + parse: parseText, brief: "Git ref (branch or tag name)", optional: true, }, url: { kind: "parsed", - parse: String, + parse: parseUrl, brief: "URL to the release source", optional: true, }, diff --git a/packages/cli/src/commands/release/deploy.ts b/packages/cli/src/commands/release/deploy.ts index cf9dec607..bdd528609 100644 --- a/packages/cli/src/commands/release/deploy.ts +++ b/packages/cli/src/commands/release/deploy.ts @@ -8,7 +8,7 @@ import type { SentryContext } from "../../context.js"; import { createReleaseDeploy } from "../../lib/api-client.js"; import { buildCommand, numberParser } from "../../lib/command.js"; -import { parseText } from "../../lib/completion-target.js"; +import { parseText, parseUrl } from "../../lib/completion-target.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { mdKvTable, @@ -126,19 +126,19 @@ export const deployCommand = buildCommand({ flags: { url: { kind: "parsed", - parse: String, + parse: parseUrl, brief: "URL for the deploy", optional: true, }, started: { kind: "parsed", - parse: String, + parse: parseText, brief: "Deploy start time (ISO 8601)", optional: true, }, finished: { kind: "parsed", - parse: String, + parse: parseText, brief: "Deploy finish time (ISO 8601)", optional: true, }, diff --git a/packages/cli/src/commands/release/finalize.ts b/packages/cli/src/commands/release/finalize.ts index 111880e73..92f3cae8c 100644 --- a/packages/cli/src/commands/release/finalize.ts +++ b/packages/cli/src/commands/release/finalize.ts @@ -7,6 +7,7 @@ import type { SentryContext } from "../../context.js"; import { updateRelease } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; +import { parseText, parseUrl } from "../../lib/completion-target.js"; import { colorTag, escapeMarkdownInline, @@ -79,13 +80,13 @@ export const finalizeCommand = buildCommand({ flags: { released: { kind: "parsed", - parse: String, + parse: parseText, brief: "Custom release timestamp (ISO 8601). Defaults to now.", optional: true, }, url: { kind: "parsed", - parse: String, + parse: parseUrl, brief: "URL for the release", optional: true, }, diff --git a/packages/cli/src/commands/release/list.ts b/packages/cli/src/commands/release/list.ts index 200a9d946..f65e52125 100644 --- a/packages/cli/src/commands/release/list.ts +++ b/packages/cli/src/commands/release/list.ts @@ -14,6 +14,7 @@ import { type ReleaseSortValue, } from "../../lib/api-client.js"; import type { ParsedOrgProject } from "../../lib/arg-parsing.js"; +import { choiceParser, parseText } from "../../lib/completion-target.js"; import { colorTag, escapeMarkdownInline, @@ -77,6 +78,9 @@ const SORT_ALIASES: Record = { const DEFAULT_SORT: ReleaseSortValue = "date"; +/** Release statuses `--status` filters by. */ +const RELEASE_STATUSES = ["open", "archived"]; + function parseSortFlag(value: string): ReleaseSortValue { if (VALID_SORT_VALUES.includes(value as ReleaseSortValue)) { return value as ReleaseSortValue; @@ -682,27 +686,27 @@ export const listCommand = buildListCommand("release", { limit: buildListLimitFlag("releases"), sort: { kind: "parsed" as const, - parse: parseSortFlag, + parse: choiceParser(VALID_SORT_VALUES, parseSortFlag), brief: "Sort: date, sessions, users, crash_free_sessions (cfs), crash_free_users (cfu)", default: DEFAULT_SORT, }, environment: { kind: "parsed" as const, - parse: String, + parse: parseText, brief: "Filter by environment (repeatable, comma-separated)", variadic: true as const, optional: true as const, }, period: { kind: "parsed" as const, - parse: String, + parse: parseText, brief: "Health stats period (e.g., 24h, 7d, 14d, 90d)", default: "90d", }, status: { kind: "parsed" as const, - parse: String, + parse: choiceParser(RELEASE_STATUSES), brief: "Filter by status: open (default) or archived", default: "open", }, diff --git a/packages/cli/src/commands/release/set-commits.ts b/packages/cli/src/commands/release/set-commits.ts index fab4935e3..f2ff7a9de 100644 --- a/packages/cli/src/commands/release/set-commits.ts +++ b/packages/cli/src/commands/release/set-commits.ts @@ -12,6 +12,7 @@ import { setCommitsWithRefs, } from "../../lib/api-client.js"; import { buildCommand, numberParser } from "../../lib/command.js"; +import { parseFilePath, parseText } from "../../lib/completion-target.js"; import { getDatabase } from "../../lib/db/index.js"; import { clearMetadata, getMetadata, setMetadata } from "../../lib/db/utils.js"; import { @@ -422,21 +423,21 @@ export const setCommitsCommand = buildCommand({ }, commit: { kind: "parsed", - parse: String, + parse: parseText, brief: "Explicit commit as REPO@SHA or REPO@PREV..SHA (comma-separated)", optional: true, }, path: { kind: "parsed", - parse: String, + parse: parseFilePath, brief: "Filter commits to these paths (comma-separated). Implies --local.", optional: true, }, from: { kind: "parsed", - parse: String, + parse: parseText, brief: "Read the local range ..HEAD (e.g. previous release tag). Implies --local.", optional: true, diff --git a/packages/cli/src/commands/replay/download.ts b/packages/cli/src/commands/replay/download.ts index 6637115b2..dbfe1a2ce 100644 --- a/packages/cli/src/commands/replay/download.ts +++ b/packages/cli/src/commands/replay/download.ts @@ -13,6 +13,7 @@ import { resolveReplay, } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; +import { parseFilePath } from "../../lib/completion-target.js"; import { ApiError, ResolutionError } from "../../lib/errors.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { @@ -87,7 +88,7 @@ export const downloadCommand = buildCommand({ flags: { output: { kind: "parsed", - parse: String, + parse: parseFilePath, brief: "Output path (default: .rrweb.json in the current directory)", optional: true, diff --git a/packages/cli/src/commands/replay/list.ts b/packages/cli/src/commands/replay/list.ts index 7c46dbbd0..82f93acaa 100644 --- a/packages/cli/src/commands/replay/list.ts +++ b/packages/cli/src/commands/replay/list.ts @@ -11,6 +11,11 @@ import { type ReplaySortValue, } from "../../lib/api-client.js"; import { validateLimit } from "../../lib/arg-parsing.js"; +import { + choiceParser, + parseText, + textParser, +} from "../../lib/completion-target.js"; import { advancePaginationState, buildPaginationContextKey, @@ -326,7 +331,7 @@ export const listCommand = buildListCommand("replay", { flags: { limit: { kind: "parsed", - parse: parseLimit, + parse: textParser(parseLimit), brief: `Number of replays (${LIST_MIN_LIMIT}-${LIST_MAX_LIMIT})`, default: String(LIST_DEFAULT_LIMIT), }, @@ -338,14 +343,14 @@ export const listCommand = buildListCommand("replay", { }, environment: { kind: "parsed", - parse: String, + parse: parseText, brief: "Filter by environment (repeatable, comma-separated)", variadic: true, optional: true, }, sort: { kind: "parsed", - parse: parseSort, + parse: choiceParser(Object.keys(SORT_MAP), parseSort), brief: "Sort by: date, oldest, duration, errors, activity, or a raw replay sort field", default: "date", diff --git a/packages/cli/src/commands/schema.ts b/packages/cli/src/commands/schema.ts index d2593cfde..4af45de56 100644 --- a/packages/cli/src/commands/schema.ts +++ b/packages/cli/src/commands/schema.ts @@ -507,7 +507,7 @@ export const schemaCommand = buildCommand({ }, search: { kind: "parsed", - parse: String, + parse: parseText, brief: "Search endpoints by keyword", optional: true, }, diff --git a/packages/cli/src/commands/send-envelope.ts b/packages/cli/src/commands/send-envelope.ts index 123aa2628..97e09b29f 100644 --- a/packages/cli/src/commands/send-envelope.ts +++ b/packages/cli/src/commands/send-envelope.ts @@ -7,6 +7,7 @@ import type { SentryContext } from "../context.js"; import { buildCommand } from "../lib/command.js"; +import { parseFilePath, parseText } from "../lib/completion-target.js"; import { CliError, EXIT } from "../lib/errors.js"; export const sendEnvelopeCommand = buildCommand({ @@ -26,14 +27,14 @@ export const sendEnvelopeCommand = buildCommand({ kind: "array", parameter: { brief: "Path(s) to envelope file(s)", - parse: String, + parse: parseFilePath, optional: true, }, }, flags: { dsn: { kind: "parsed", - parse: String, + parse: parseText, brief: "DSN", optional: true, }, diff --git a/packages/cli/src/commands/snapshots/diff.ts b/packages/cli/src/commands/snapshots/diff.ts index d297dc0f8..75c5cfa5f 100644 --- a/packages/cli/src/commands/snapshots/diff.ts +++ b/packages/cli/src/commands/snapshots/diff.ts @@ -13,7 +13,7 @@ import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { dirname, extname, join, resolve } from "node:path"; import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; -import { parseDirectoryPath } from "../../lib/completion-target.js"; +import { parseDirectoryPath, textParser } from "../../lib/completion-target.js"; import { ValidationError } from "../../lib/errors.js"; import { colorTag, @@ -273,13 +273,13 @@ export const diffCommand = buildCommand({ flags: { output: { kind: "parsed", - parse: String, + parse: parseDirectoryPath, brief: `Directory for diff mask images (default: ${DEFAULT_OUTPUT})`, optional: true, }, threshold: { kind: "parsed", - parse: parseThreshold, + parse: textParser(parseThreshold), brief: "Pixel color difference threshold (0.0-1.0)", default: "0.01", }, diff --git a/packages/cli/src/commands/snapshots/download.ts b/packages/cli/src/commands/snapshots/download.ts index 3d49626c0..31b5c88b9 100644 --- a/packages/cli/src/commands/snapshots/download.ts +++ b/packages/cli/src/commands/snapshots/download.ts @@ -18,6 +18,7 @@ import { waitForSnapshotArchive, } from "../../lib/api/preprod-artifacts.js"; import { buildCommand } from "../../lib/command.js"; +import { parseDirectoryPath, parseText } from "../../lib/completion-target.js"; import { getDefaultProject } from "../../lib/db/defaults.js"; import { ApiError, @@ -179,26 +180,26 @@ export const downloadCommand = buildCommand({ flags: { "app-id": { kind: "parsed", - parse: String, + parse: parseText, brief: "App identifier (e.g. my-app) to resolve the latest baseline; mutually exclusive with --snapshot-id", optional: true, }, "snapshot-id": { kind: "parsed", - parse: String, + parse: parseText, brief: "Direct snapshot artifact ID; mutually exclusive with --app-id", optional: true, }, branch: { kind: "parsed", - parse: String, + parse: parseText, brief: "Git branch filter (only with --app-id)", optional: true, }, output: { kind: "parsed", - parse: String, + parse: parseDirectoryPath, brief: `Directory for extracted images (default: ${DEFAULT_OUTPUT})`, optional: true, }, diff --git a/packages/cli/src/commands/snapshots/upload.ts b/packages/cli/src/commands/snapshots/upload.ts index 05b067442..b4c6f79d5 100644 --- a/packages/cli/src/commands/snapshots/upload.ts +++ b/packages/cli/src/commands/snapshots/upload.ts @@ -22,7 +22,12 @@ import { } from "../../lib/build/vcs.js"; import { isCi } from "../../lib/ci.js"; import { buildCommand } from "../../lib/command.js"; -import { parseDirectoryPath } from "../../lib/completion-target.js"; +import { + parseDirectoryPath, + parseFilePath, + parseText, + textParser, +} from "../../lib/completion-target.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { colorTag, @@ -378,12 +383,12 @@ export const uploadCommand = buildCommand({ flags: { "app-id": { kind: "parsed", - parse: String, + parse: parseText, brief: "The application identifier", }, "diff-threshold": { kind: "parsed", - parse: parseDiffThreshold, + parse: textParser(parseDiffThreshold), brief: "Only report an image as changed when its difference exceeds this fraction (0.0–1.0, e.g. 0.01 = 1%)", optional: true, @@ -396,66 +401,66 @@ export const uploadCommand = buildCommand({ }, "all-image-file-names": { kind: "parsed", - parse: String, + parse: parseText, brief: "Comma-separated list of all image names in the full suite (for selective uploads; implies --selective)", optional: true, }, "all-image-file-names-file": { kind: "parsed", - parse: String, + parse: parseFilePath, brief: "Path to a file listing all image names, one per line (for selective uploads; implies --selective)", optional: true, }, "head-sha": { kind: "parsed", - parse: String, + parse: parseText, brief: "VCS commit SHA (defaults to the current commit)", optional: true, }, "base-sha": { kind: "parsed", - parse: String, + parse: parseText, brief: "VCS base commit SHA (defaults to the merge-base with the base ref)", optional: true, }, "vcs-provider": { kind: "parsed", - parse: String, + parse: parseText, brief: "VCS provider (defaults to the current remote's provider)", optional: true, }, "head-repo-name": { kind: "parsed", - parse: String, + parse: parseText, brief: "Head repository name, e.g. owner/repo (defaults to the current)", optional: true, }, "base-repo-name": { kind: "parsed", - parse: String, + parse: parseText, brief: "Base repository name, e.g. owner/repo (for forks)", optional: true, }, "head-ref": { kind: "parsed", - parse: String, + parse: parseText, brief: "Head branch/reference (defaults to the current branch)", optional: true, }, "base-ref": { kind: "parsed", - parse: String, + parse: parseText, brief: "Base branch/reference (defaults to the merge-base tracking ref)", optional: true, }, "pr-number": { kind: "parsed", - parse: parsePrNumber, + parse: textParser(parsePrNumber), brief: "Pull request number (auto-detected in pull_request GitHub Actions runs)", optional: true, diff --git a/packages/cli/src/commands/sourcemap/inject.ts b/packages/cli/src/commands/sourcemap/inject.ts index aa7ade604..5ec164dae 100644 --- a/packages/cli/src/commands/sourcemap/inject.ts +++ b/packages/cli/src/commands/sourcemap/inject.ts @@ -7,7 +7,11 @@ import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; -import { parseDirectoryPath } from "../../lib/completion-target.js"; +import { + parseDirectoryPath, + parseFilePath, + parseText, +} from "../../lib/completion-target.js"; import { colorTag, mdKvTable, @@ -104,20 +108,20 @@ export const injectCommand = buildCommand({ flags: { ext: { kind: "parsed", - parse: String, + parse: parseText, brief: "Comma-separated file extensions to process (default: .js,.cjs,.mjs)", optional: true, }, ignore: { kind: "parsed", - parse: String, + parse: parseText, brief: "Comma-separated glob patterns to exclude (gitignore-style)", optional: true, }, "ignore-file": { kind: "parsed", - parse: String, + parse: parseFilePath, brief: "Path to a file with gitignore-style patterns to exclude", optional: true, }, diff --git a/packages/cli/src/commands/sourcemap/resolve.ts b/packages/cli/src/commands/sourcemap/resolve.ts index 7a97b8ba4..da75fdd8a 100644 --- a/packages/cli/src/commands/sourcemap/resolve.ts +++ b/packages/cli/src/commands/sourcemap/resolve.ts @@ -10,7 +10,11 @@ import { relative, resolve as resolvePath } from "node:path"; import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; -import { parseDirectoryPath } from "../../lib/completion-target.js"; +import { + parseDirectoryPath, + parseFilePath, + parseText, +} from "../../lib/completion-target.js"; import { colorTag, escapeMarkdownCell, @@ -147,20 +151,20 @@ export const resolveCommand = buildCommand({ flags: { ext: { kind: "parsed", - parse: String, + parse: parseText, brief: "Comma-separated file extensions to process (default: .js,.cjs,.mjs)", optional: true, }, ignore: { kind: "parsed", - parse: String, + parse: parseText, brief: "Comma-separated glob patterns to exclude (gitignore-style)", optional: true, }, "ignore-file": { kind: "parsed", - parse: String, + parse: parseFilePath, brief: "Path to a file with gitignore-style patterns to exclude", optional: true, }, diff --git a/packages/cli/src/commands/sourcemap/upload.ts b/packages/cli/src/commands/sourcemap/upload.ts index 9e9b7325d..5b4416dc5 100644 --- a/packages/cli/src/commands/sourcemap/upload.ts +++ b/packages/cli/src/commands/sourcemap/upload.ts @@ -17,7 +17,11 @@ import { uploadSourcemaps, } from "../../lib/api/sourcemaps.js"; import { buildCommand } from "../../lib/command.js"; -import { parseDirectoryPath } from "../../lib/completion-target.js"; +import { + parseDirectoryPath, + parseFilePath, + parseText, +} from "../../lib/completion-target.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { mdKvTable, renderMarkdown } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -263,46 +267,46 @@ export const uploadCommand = buildCommand({ flags: { release: { kind: "parsed", - parse: String, + parse: parseText, brief: "Release version to associate with the upload", optional: true, }, dist: { kind: "parsed", - parse: String, + parse: parseText, brief: "Distribution identifier to disambiguate builds within a release", optional: true, }, "url-prefix": { kind: "parsed", - parse: String, + parse: parseText, brief: "URL prefix for uploaded files", optional: true, default: "~/", }, ext: { kind: "parsed", - parse: String, + parse: parseText, brief: "Comma-separated file extensions to process (default: .js,.cjs,.mjs)", optional: true, }, ignore: { kind: "parsed", - parse: String, + parse: parseText, brief: "Comma-separated glob patterns to exclude (gitignore-style)", optional: true, }, "ignore-file": { kind: "parsed", - parse: String, + parse: parseFilePath, brief: "Path to a file with gitignore-style patterns to exclude", optional: true, }, "strip-prefix": { kind: "parsed", - parse: String, + parse: parseText, brief: "Strip a prefix from uploaded file paths (e.g. 'build/')", optional: true, }, diff --git a/packages/cli/src/commands/span/list.ts b/packages/cli/src/commands/span/list.ts index 28c83633e..cf9c076f4 100644 --- a/packages/cli/src/commands/span/list.ts +++ b/packages/cli/src/commands/span/list.ts @@ -19,7 +19,11 @@ import { parseOrgProjectInput, validateLimit, } from "../../lib/arg-parsing.js"; -import { ORG_PROJECT_TARGET } from "../../lib/completion-target.js"; +import { + choiceParser, + ORG_PROJECT_TARGET, + textParser, +} from "../../lib/completion-target.js"; import { advancePaginationState, buildPaginationContextKey, @@ -666,7 +670,7 @@ export const listCommand = buildListCommand("span", { flags: { limit: { kind: "parsed", - parse: parseLimit, + parse: textParser(parseLimit), brief: `Number of spans (<=${LIST_MAX_LIMIT})`, default: String(LIST_DEFAULT_LIMIT), }, @@ -679,7 +683,7 @@ export const listCommand = buildListCommand("span", { }, sort: { kind: "parsed", - parse: parseSort, + parse: choiceParser(VALID_SORT_VALUES, parseSort), brief: `Sort order: ${VALID_SORT_VALUES.join(", ")}`, default: DEFAULT_SORT, }, diff --git a/packages/cli/src/commands/status/show.ts b/packages/cli/src/commands/status/show.ts index 8990e2417..de5adbf5c 100644 --- a/packages/cli/src/commands/status/show.ts +++ b/packages/cli/src/commands/status/show.ts @@ -12,6 +12,7 @@ import { SENTRY_STATUS_PAGE_URL, } from "../../lib/api/status-page.js"; import { buildCommand } from "../../lib/command.js"; +import { parseUrl } from "../../lib/completion-target.js"; import { formatSentryStatus } from "../../lib/formatters/human.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -53,7 +54,7 @@ export const showCommand = buildCommand({ flags: { url: { kind: "parsed", - parse: String, + parse: parseUrl, brief: "Status page base URL to query", default: SENTRY_STATUS_PAGE_URL, }, diff --git a/packages/cli/src/commands/trace/list.ts b/packages/cli/src/commands/trace/list.ts index 16998ea52..ef826c769 100644 --- a/packages/cli/src/commands/trace/list.ts +++ b/packages/cli/src/commands/trace/list.ts @@ -7,6 +7,7 @@ import type { SentryContext } from "../../context.js"; import { listTransactions } from "../../lib/api-client.js"; import { validateLimit } from "../../lib/arg-parsing.js"; +import { choiceParser, textParser } from "../../lib/completion-target.js"; import { advancePaginationState, buildPaginationContextKey, @@ -245,7 +246,7 @@ export const listCommand = buildListCommand("trace", { flags: { limit: { kind: "parsed", - parse: parseLimit, + parse: textParser(parseLimit), brief: `Number of traces (${LIST_MIN_LIMIT}-${LIST_MAX_LIMIT})`, default: String(LIST_DEFAULT_LIMIT), }, @@ -257,7 +258,7 @@ export const listCommand = buildListCommand("trace", { }, sort: { kind: "parsed", - parse: parseSort, + parse: choiceParser(VALID_SORT_VALUES, parseSort), brief: "Sort by: date, duration", default: "date" as const, }, diff --git a/packages/cli/src/commands/trace/logs.ts b/packages/cli/src/commands/trace/logs.ts index afe1e97b4..8e55e2385 100644 --- a/packages/cli/src/commands/trace/logs.ts +++ b/packages/cli/src/commands/trace/logs.ts @@ -13,7 +13,7 @@ import { } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; -import { ORG_PROJECT_TARGET } from "../../lib/completion-target.js"; +import { ORG_PROJECT_TARGET, textParser } from "../../lib/completion-target.js"; import { toSearchQueryError } from "../../lib/errors.js"; import { filterFields } from "../../lib/formatters/json.js"; import { formatLogTable } from "../../lib/formatters/log.js"; @@ -216,7 +216,7 @@ export const logsCommand = buildCommand({ }, limit: { kind: "parsed", - parse: parseLimit, + parse: textParser(parseLimit), brief: `Number of log entries (<=${LIST_MAX_LIMIT})`, default: "100", // Logs are high-volume; 25 is too stingy for debugging }, diff --git a/packages/cli/src/commands/wasm-split.ts b/packages/cli/src/commands/wasm-split.ts index 9acd33487..e8e52b7ca 100644 --- a/packages/cli/src/commands/wasm-split.ts +++ b/packages/cli/src/commands/wasm-split.ts @@ -19,7 +19,11 @@ import { readFile, writeFile } from "node:fs/promises"; import { basename } from "node:path"; import type { SentryContext } from "../context.js"; import { buildCommand } from "../lib/command.js"; -import { parseFilePath } from "../lib/completion-target.js"; +import { + parseFilePath, + parseText, + parseUrl, +} from "../lib/completion-target.js"; import { ValidationError } from "../lib/errors.js"; import { CommandOutput } from "../lib/formatters/output.js"; import { formatBuildId, uuidToBytes } from "../lib/wasm/build-id.js"; @@ -113,13 +117,13 @@ export const wasmSplitCommand = buildCommand({ flags: { out: { kind: "parsed", - parse: String, + parse: parseFilePath, brief: "Path to the output wasm file (default: modify input in place)", optional: true, }, "debug-out": { kind: "parsed", - parse: String, + parse: parseFilePath, brief: "Path to the output debug wasm file (default: debug data stays in the input)", optional: true, @@ -144,13 +148,13 @@ export const wasmSplitCommand = buildCommand({ }, "build-id": { kind: "parsed", - parse: String, + parse: parseText, brief: "Explicit build id to use, as a UUID", optional: true, }, "external-dwarf-url": { kind: "parsed", - parse: String, + parse: parseUrl, brief: "URL for browsers to fetch the separate DWARF debug symbol file", optional: true, }, diff --git a/packages/cli/src/lib/arg-parsing.ts b/packages/cli/src/lib/arg-parsing.ts index c133dd0b6..73a55f5fb 100644 --- a/packages/cli/src/lib/arg-parsing.ts +++ b/packages/cli/src/lib/arg-parsing.ts @@ -475,11 +475,61 @@ export function validateLimit(value: string, min = 1, max = 1000): number { return num; } +// --------------------------------------------------------------------------- +// Cursor flag parsing (shared by list commands) +// --------------------------------------------------------------------------- + +/** Matches strings that are all digits — used to detect invalid cursor values */ +const ALL_DIGITS_RE = /^\d+$/; + +/** The `--cursor` navigation keywords shell completion offers. */ +export const CURSOR_NAVIGATION = ["next", "prev", "first"] as const; + +/** + * Navigation keywords accepted by `--cursor`. + * + * - `"next"` / `"last"` — advance to the next page + * - `"prev"` / `"previous"` — go back to the previous page + * - `"first"` — jump back to the first page + * + * `"last"` is a silent alias for `"next"` preserved for muscle-memory compat. + */ +export const CURSOR_KEYWORDS: ReadonlySet = new Set([ + ...CURSOR_NAVIGATION, + "last", + "previous", +]); + +/** + * Parse and validate a `--cursor` flag value. + * + * Accepts navigation keywords (`"next"`, `"prev"`, `"previous"`, `"first"`, + * `"last"`) and opaque Sentry cursor strings (e.g. `"1735689600:0:0"`). + * Rejects bare integers early — they are never valid cursors and would + * produce a cryptic 400 from the API. + * + * Shared by `LIST_CURSOR_FLAG` (`list-command.ts`) and commands that define + * their own cursor flag with a custom `brief`. + * + * @throws Error when value is a bare integer + */ +export function parseCursorFlag(value: string): string { + if (CURSOR_KEYWORDS.has(value)) { + return value; + } + if (ALL_DIGITS_RE.test(value)) { + throw new Error( + `'${value}' is not a valid cursor. Cursors look like "1735689600:0:0". Use "next" / "prev" to navigate pages.`, + ); + } + return value; +} + // --------------------------------------------------------------------------- // Log sort direction parsing (shared by log list, trace logs) // --------------------------------------------------------------------------- -const VALID_LOG_SORT_DIRECTIONS: readonly LogSortDirection[] = [ +export const VALID_LOG_SORT_DIRECTIONS: readonly LogSortDirection[] = [ "newest", "oldest", ]; @@ -501,6 +551,9 @@ export function parseLogSort(value: string): LogSortDirection { /** Default span depth when no value is provided */ const DEFAULT_SPAN_DEPTH = 3; +/** The words `--spans` accepts besides a depth. */ +export const SPAN_DEPTH_KEYWORDS = ["all", "no"] as const; + /** * Parse span depth flag value. * diff --git a/packages/cli/src/lib/binary.ts b/packages/cli/src/lib/binary.ts index 1cc14c337..1773264da 100644 --- a/packages/cli/src/lib/binary.ts +++ b/packages/cli/src/lib/binary.ts @@ -167,7 +167,7 @@ export const UPGRADE_SOURCES = [ export const PRIMARY_UPGRADE_SOURCE = UPGRADE_SOURCES[0]; /** Valid methods that can be specified via --method flag */ -const VALID_METHODS: InstallationMethod[] = [ +export const INSTALLATION_METHODS: readonly InstallationMethod[] = [ "curl", "brew", "npm", @@ -186,9 +186,9 @@ const VALID_METHODS: InstallationMethod[] = [ export function parseInstallationMethod(value: string): InstallationMethod { const normalized = value.toLowerCase() as InstallationMethod; - if (!VALID_METHODS.includes(normalized)) { + if (!INSTALLATION_METHODS.includes(normalized)) { throw new Error( - `Invalid method: ${value}. Must be one of: ${VALID_METHODS.join(", ")}`, + `Invalid method: ${value}. Must be one of: ${INSTALLATION_METHODS.join(", ")}`, ); } diff --git a/packages/cli/src/lib/command.ts b/packages/cli/src/lib/command.ts index 840300dcf..d1dd972a3 100644 --- a/packages/cli/src/lib/command.ts +++ b/packages/cli/src/lib/command.ts @@ -40,6 +40,12 @@ import { import type { Writer } from "../types/index.js"; import { appendCacheHint } from "./cache-hint.js"; import { type CommandExample, exampleSteps } from "./command-example.js"; +import { + choiceListParser, + ORG_TARGET, + parseText, + withCompletionTarget, +} from "./completion-target.js"; import { getAuthConfig, hasUsableStoredAccount } from "./db/auth.js"; import { getEnv } from "./env.js"; import { AuthError, CliError, OutputError } from "./errors.js"; @@ -49,6 +55,7 @@ import { ClearScreen, CommandOutput, type CommandReturn, + extractSchemaFieldPaths, extractSchemaFields, formatSchemaForHelp, type HumanRenderer, @@ -265,7 +272,7 @@ export const JSON_FLAG = { */ export const FIELDS_FLAG = { kind: "parsed" as const, - parse: String, + parse: parseText, brief: "Comma-separated fields to include in JSON output (dot.notation supported)", optional: true as const, @@ -285,7 +292,7 @@ export const FIELDS_FLAG = { */ const ORG_FLAG = { kind: "parsed" as const, - parse: String, + parse: withCompletionTarget(ORG_TARGET, (input: string) => input), brief: "Organization slug", optional: true as const, hidden: true as const, @@ -299,7 +306,7 @@ const ORG_FLAG = { */ const PROJECT_FLAG = { kind: "parsed" as const, - parse: String, + parse: parseText, brief: "Project slug", optional: true as const, hidden: true as const, @@ -404,7 +411,8 @@ export function applyOrgProjectFlags( /** * Build the `--fields` flag definition, enriched with available field names - * when a schema is registered on the output config. + * when a schema is registered on the output config. Its values complete + * every field path of the schema, one list item at a time. */ // oxlint-disable-next-line typescript/no-explicit-any -- OutputConfig type is erased at the builder level function buildFieldsFlag(outputConfig?: OutputConfig) { @@ -418,6 +426,7 @@ function buildFieldsFlag(outputConfig?: OutputConfig) { const fieldNames = schemaFields.map((f) => f.name).join(", "); return { ...FIELDS_FLAG, + parse: choiceListParser(extractSchemaFieldPaths(outputConfig.schema)), brief: `${FIELDS_FLAG.brief}. Available: ${fieldNames}`, }; } diff --git a/packages/cli/src/lib/complete.ts b/packages/cli/src/lib/complete.ts index 35a07bb43..37bfc1812 100644 --- a/packages/cli/src/lib/complete.ts +++ b/packages/cli/src/lib/complete.ts @@ -132,7 +132,7 @@ const GENERATED = generatedTargets as { type CompleterContext = { /** Positionals before the word being completed, flags removed. */ positionals: string[]; - /** The values a `choice` argument offers. */ + /** The values a `choice` or `choice-list` argument offers. */ choices?: readonly string[]; }; @@ -283,8 +283,16 @@ const COMPLETERS = Object.freeze({ .filter((value) => value.startsWith(partial)) .map((value) => ({ value })), /** - * Free text with nothing to suggest (messages, queries, URLs, names of - * new things). Declared, so that every positional says how it completes. + * The values its parser was made with by `choiceListParser`, as the next + * item of a comma-separated list (`--fields id,ti` → `id,title`). + */ + "choice-list": (partial, slot) => completeListItem(partial, slot.choices), + /** Platform identifiers (`javascript-react`, `python-django`, …). */ + platform: (partial) => completePlatforms(partial).map((value) => ({ value })), + /** + * Free text or numbers, with nothing to suggest (messages, queries, URLs, + * names of new things, limits). Declared, so that every argument says + * how it completes. */ text: () => [], } satisfies Record); @@ -372,6 +380,31 @@ function completePaths( return completions.sort((a, b) => a.value.localeCompare(b.value)); } +/** + * Complete the last item of a comma-separated list with the `values` that + * start with it and are not in the list yet, keeping the items before it. + */ +function completeListItem( + partial: string, + values: readonly string[] = [], +): Completion[] { + const head = partial.slice(0, partial.lastIndexOf(",") + 1); + const item = partial.slice(head.length); + const chosen = new Set(head.split(",")); + return values + .filter((value) => value.startsWith(item) && !chosen.has(value)) + .map((value) => ({ value: `${head}${value}` })); +} + +/** + * Platform identifiers matching the partial, best match first: the common + * ones for an empty partial, any valid one otherwise. + */ +function completePlatforms(partial: string): string[] { + const candidates = partial === "" ? COMMON_PLATFORMS : VALID_PLATFORMS; + return fuzzyMatch(partial, candidates, { mode: "search" }); +} + const DIGITS_RE = /^\d+$/; /** @@ -443,15 +476,10 @@ export function completeProjectCreateSpec(partial: string): Completion[] { } const specPrefix = partial.slice(0, colonIdx + 1); - const platformPartial = partial.slice(colonIdx + 1); - const candidates = - platformPartial === "" ? COMMON_PLATFORMS : VALID_PLATFORMS; - return fuzzyMatch(platformPartial, candidates, { mode: "search" }).map( - (platform) => ({ - value: `${specPrefix}${platform}`, - description: "Platform", - }), - ); + return completePlatforms(partial.slice(colonIdx + 1)).map((platform) => ({ + value: `${specPrefix}${platform}`, + description: "Platform", + })); } if (partial.includes("/")) { diff --git a/packages/cli/src/lib/completion-target.ts b/packages/cli/src/lib/completion-target.ts index 2bc61bcc8..b0d6a5f19 100644 --- a/packages/cli/src/lib/completion-target.ts +++ b/packages/cli/src/lib/completion-target.ts @@ -13,13 +13,26 @@ * and `positional-parse.ts`, which parse the value too, or, for arguments * that are not Sentry targets, from the pre-tagged parsers here * ({@link parseFilePath}, {@link parseText}, {@link choiceParser}, …). + * A command's own validating parser keeps its checks when wrapped: + * `choiceParser(VALUES, parseSort)`, `textParser(parsePort)`. */ import { ISSUE_IS_VALUES } from "@sentry/toolkit-core/search-query"; +import { numberParser } from "@stricli/core"; import type { CompletionTarget } from "./complete.js"; import { ISSUE_LEVELS } from "../types/sentry.js"; +import { + CURSOR_NAVIGATION, + parseCursorFlag, + parseLogSort, + parseSpanDepth, + SPAN_DEPTH_KEYWORDS, + VALID_LOG_SORT_DIRECTIONS, +} from "./arg-parsing.js"; +import { INSTALLATION_METHODS, parseInstallationMethod } from "./binary.js"; import { parseLoginEmail } from "./db/accounts.js"; -import { sanitizeIssueQuery } from "./search-query.js"; +import { parseReleaseChannel, RELEASE_CHANNELS } from "./db/release-channel.js"; +import { sanitizeIssueQuery, sanitizeQuery } from "./search-query.js"; import { parsePeriod, UNIT_SECONDS } from "./time-range.js"; /** What shell completion suggests for an argument: a key of the completer map. */ @@ -68,6 +81,12 @@ export const COMMAND_PATH_TARGET = "command-path" satisfies CompletionTarget; /** The values its parser was made with by {@link choiceParser}. */ export const CHOICE_TARGET = "choice" satisfies CompletionTarget; +/** Comma-separated values its parser was made with by {@link choiceListParser}. */ +export const CHOICE_LIST_TARGET = "choice-list" satisfies CompletionTarget; + +/** Platform identifiers (`javascript-react`, `python-django`, …). */ +export const PLATFORM_TARGET = "platform" satisfies CompletionTarget; + /** Free text with nothing to suggest. */ export const TEXT_TARGET = "text" satisfies CompletionTarget; @@ -88,9 +107,27 @@ export function withCompletionTarget

unknown>( return parse; } +/** + * A new parser that calls `parse`, tagged with `kind` and the `values` it + * completes. A new function, so tagging never touches a shared parser. + */ +function taggedParser( + kind: CompletionTarget, + parse: (input: string) => T, + values?: readonly string[], +): (input: string) => T { + const tagged = withCompletionTarget(kind, (input: string) => parse(input)); + if (values) { + Object.defineProperty(tagged, COMPLETION_CHOICES_KEY, { + value: [...values], + }); + } + return tagged; +} + /** A parser that returns its input unchanged, tagged with `kind`. */ function identityParser(kind: CompletionTarget): (input: string) => string { - return withCompletionTarget(kind, (input: string) => input); + return taggedParser(kind, (input) => input); } /** Parses a file or directory path; completes paths. */ @@ -114,31 +151,79 @@ export const parseCommandPath = identityParser(COMMAND_PATH_TARGET); /** Parses a stored-account selector; completes stored accounts. */ export const parseAccountSelector = identityParser(ACCOUNT_TARGET); +/** Parses a platform identifier; completes platforms. */ +export const parsePlatform = identityParser(PLATFORM_TARGET); + /** - * Build a parser that completes `values`. It returns its input unchanged, - * so the command still validates it: the values are suggestions, and a - * command may accept others (`cli upgrade 0.5.0` next to `nightly`). + * Wrap a command's own parser of free text or numbers; completes nothing. + * The value is still parsed and validated by `parse`. + */ +export function textParser( + parse: (input: string) => T, +): (input: string) => T { + return taggedParser(TEXT_TARGET, parse); +} + +/** + * Build a parser that completes `values`. Without `parse` it returns its + * input unchanged, so the command still validates it: the values are + * suggestions, and a command may accept others (`cli upgrade 0.5.0` next + * to `nightly`). With `parse`, the command's own parser validates it. */ export function choiceParser( values: readonly string[], -): (input: string) => string { - const parse = identityParser(CHOICE_TARGET); - Object.defineProperty(parse, COMPLETION_CHOICES_KEY, { value: [...values] }); - return parse; +): (input: string) => string; +export function choiceParser( + values: readonly string[], + parse: (input: string) => T, +): (input: string) => T; +export function choiceParser( + values: readonly string[], + parse: (input: string) => unknown = (input) => input, +): (input: string) => unknown { + return taggedParser(CHOICE_TARGET, parse, values); +} + +/** + * Build a parser of a comma-separated list that completes `values`, one + * list item at a time, leaving out the ones already in the list + * (`--fields id,ti` → `id,title`). Like {@link choiceParser}, it returns + * its input unless given the command's own `parse`. + */ +export function choiceListParser( + values: readonly string[], +): (input: string) => string; +export function choiceListParser( + values: readonly string[], + parse: (input: string) => T, +): (input: string) => T; +export function choiceListParser( + values: readonly string[], + parse: (input: string) => unknown = (input) => input, +): (input: string) => unknown { + return taggedParser(CHOICE_LIST_TARGET, parse, values); } /** - * Shared value parsers and what their arguments complete. Listed here, not - * tagged where they are defined, so those modules know nothing about - * completion: every flag that parses with one completes the same way. + * Shared value parsers and what their arguments complete: a kind, or the + * fixed values they accept, completed as {@link CHOICE_TARGET}. Listed + * here, not tagged where they are defined, so those modules know nothing + * about completion: every flag that parses with one completes the same way. */ -const PARSER_TARGETS: ReadonlyMap = new Map< +const PARSER_TARGETS: ReadonlyMap< unknown, - CompletionTarget ->([ + CompletionTarget | readonly string[] +> = new Map([ [sanitizeIssueQuery, ISSUE_QUERY_TARGET], + [sanitizeQuery, TEXT_TARGET], [parsePeriod, PERIOD_TARGET], [parseLoginEmail, ACCOUNT_TARGET], + [numberParser, TEXT_TARGET], + [parseCursorFlag, CURSOR_NAVIGATION], + [parseLogSort, VALID_LOG_SORT_DIRECTIONS], + [parseSpanDepth, SPAN_DEPTH_KEYWORDS], + [parseInstallationMethod, INSTALLATION_METHODS], + [parseReleaseChannel, RELEASE_CHANNELS], ]); /** @@ -154,26 +239,33 @@ export function getCompletionTarget( if (typeof parse !== "function") { return; } + const listed = PARSER_TARGETS.get(parse); return ( (parse as { [COMPLETION_TARGET_KEY]?: CompletionTarget })[ COMPLETION_TARGET_KEY - ] ?? PARSER_TARGETS.get(parse) + ] ?? (typeof listed === "string" ? listed : listed && CHOICE_TARGET) ); } /** - * Read the values a {@link choiceParser} parser completes. + * Read the values a parser completes: those of a {@link choiceParser} or + * {@link choiceListParser} parser, or of a shared parser listed with its + * values in {@link PARSER_TARGETS}. * * @returns The values, or `undefined` for any other parser */ export function getCompletionChoices( parse: unknown, ): readonly string[] | undefined { - return typeof parse === "function" - ? (parse as { [COMPLETION_CHOICES_KEY]?: readonly string[] })[ - COMPLETION_CHOICES_KEY - ] - : undefined; + if (typeof parse !== "function") { + return; + } + const listed = PARSER_TARGETS.get(parse); + return ( + (parse as { [COMPLETION_CHOICES_KEY]?: readonly string[] })[ + COMPLETION_CHOICES_KEY + ] ?? (typeof listed === "string" ? undefined : listed) + ); } /** Fixed values the completers offer (see {@link COMPLETION_VALUES}). */ diff --git a/packages/cli/src/lib/db/pagination.ts b/packages/cli/src/lib/db/pagination.ts index 0f70515cc..581dc3f4d 100644 --- a/packages/cli/src/lib/db/pagination.ts +++ b/packages/cli/src/lib/db/pagination.ts @@ -13,7 +13,7 @@ */ import { ValidationError } from "../errors.js"; -import { CURSOR_KEYWORDS } from "../list-command.js"; +import { CURSOR_KEYWORDS } from "../arg-parsing.js"; import type { ResolvedTarget } from "../resolve-target.js"; import { getApiBaseUrl } from "../sentry-client.js"; import { getDatabase } from "./index.js"; diff --git a/packages/cli/src/lib/db/release-channel.ts b/packages/cli/src/lib/db/release-channel.ts index 1b2146cab..393756c5e 100644 --- a/packages/cli/src/lib/db/release-channel.ts +++ b/packages/cli/src/lib/db/release-channel.ts @@ -15,8 +15,11 @@ import { clearVersionCheckCache } from "./version-check.js"; const KEY = "release_channel"; +/** The release channels a user can track. */ +export const RELEASE_CHANNELS = ["stable", "nightly"] as const; + /** The release channel a user tracks for upgrades and version-check notifications. */ -export type ReleaseChannel = "stable" | "nightly"; +export type ReleaseChannel = (typeof RELEASE_CHANNELS)[number]; /** * Get the persisted release channel. @@ -60,8 +63,10 @@ export function setReleaseChannel(channel: ReleaseChannel): void { */ export function parseReleaseChannel(value: string): ReleaseChannel { const normalized = value.toLowerCase(); - if (normalized === "stable" || normalized === "nightly") { - return normalized; + if ((RELEASE_CHANNELS as readonly string[]).includes(normalized)) { + return normalized as ReleaseChannel; } - throw new Error(`Invalid channel: ${value}. Must be one of: stable, nightly`); + throw new Error( + `Invalid channel: ${value}. Must be one of: ${RELEASE_CHANNELS.join(", ")}`, + ); } diff --git a/packages/cli/src/lib/formatters/output.ts b/packages/cli/src/lib/formatters/output.ts index d47b6f438..ab1f943eb 100644 --- a/packages/cli/src/lib/formatters/output.ts +++ b/packages/cli/src/lib/formatters/output.ts @@ -500,6 +500,46 @@ export function extractSchemaFields(schema: GenericSchema): SchemaFieldInfo[] { }); } +/** Wrapper types {@link objectsOf} looks through to the schema they hold. */ +const WRAPPER_TYPES = new Set(["optional", "nullable", "nullish", "array"]); + +/** + * The object schemas a value of `schema` can be: through pipes, wrappers, + * array items (`--fields` paths reach into arrays), and union members. + */ +function objectsOf(schema: ValibotSchemaNode): ValibotSchemaNode[] { + const node = unwrapPipe(schema); + if (WRAPPER_TYPES.has(node.type)) { + const inner = node.wrapped ?? node.item; + return inner ? objectsOf(inner) : []; + } + if (node.type === "union") { + return (node.options as ValibotSchemaNode[]).flatMap(objectsOf); + } + return node.entries ? [node] : []; +} + +/** + * Every field path a valibot schema describes, in `--fields` dot notation: + * each top-level field, then the fields of nested objects + * (`metadata.title`), parents before children. + * + * @param schema - A valibot schema (typically `object({...})`) + */ +export function extractSchemaFieldPaths(schema: GenericSchema): string[] { + const paths = new Set(); + const visit = (node: ValibotSchemaNode, prefix: string) => { + for (const { entries = {} } of objectsOf(node)) { + for (const [name, field] of Object.entries(entries)) { + paths.add(`${prefix}${name}`); + visit(field, `${prefix}${name}.`); + } + } + }; + visit(schema as unknown as ValibotSchemaNode, ""); + return [...paths]; +} + /** * Find a `description()` pipe action for a field schema. * diff --git a/packages/cli/src/lib/introspect.ts b/packages/cli/src/lib/introspect.ts index 822b16d1e..98b440793 100644 --- a/packages/cli/src/lib/introspect.ts +++ b/packages/cli/src/lib/introspect.ts @@ -422,8 +422,9 @@ type CommandCompletion = { */ flags: Record; /** - * Values of each `choice` argument, keyed by positional index (`"0"`) or - * flag spelling (`"--format"`). Omitted when the command has none. + * Values of each `choice` and `choice-list` argument, keyed by positional + * index (`"0"`) or flag spelling (`"--format"`). Omitted when the command + * has none. */ choices?: Record; }; @@ -432,17 +433,19 @@ type CommandCompletion = { export type CommandCompletions = Record; /** - * The kind and, for `choice`, the values of one argument: those of an - * `enum` flag, or of a `choiceParser` parser. + * The kind and, for `choice` and `choice-list`, the values of one + * argument: those of an `enum` flag, or the ones its parser completes. */ function argumentCompletion( parse: unknown, enumValues?: readonly string[], ): { kind: CompletionTarget | null; values?: readonly string[] } { - const values = enumValues ?? getCompletionChoices(parse); - return values - ? { kind: CHOICE_TARGET, values } - : { kind: getCompletionTarget(parse) ?? null }; + return enumValues + ? { kind: CHOICE_TARGET, values: enumValues } + : { + kind: getCompletionTarget(parse) ?? null, + values: getCompletionChoices(parse), + }; } /** Sort an object's entries by key, for stable generated output. */ diff --git a/packages/cli/src/lib/list-command.ts b/packages/cli/src/lib/list-command.ts index 388b6bc01..c1445984d 100644 --- a/packages/cli/src/lib/list-command.ts +++ b/packages/cli/src/lib/list-command.ts @@ -19,7 +19,7 @@ import type { SentryContext } from "../context.js"; const _require = createRequire(import.meta.url); -import type { ListTargetOptions } from "./arg-parsing.js"; +import { type ListTargetOptions, parseCursorFlag } from "./arg-parsing.js"; import { buildCommand, type CommandDocumentation, @@ -239,51 +239,6 @@ export function applyFreshFlag(flags: { readonly fresh: boolean }): void { } } -/** Matches strings that are all digits — used to detect invalid cursor values */ -const ALL_DIGITS_RE = /^\d+$/; - -/** - * Navigation keywords accepted by `--cursor`. - * - * - `"next"` / `"last"` — advance to the next page - * - `"prev"` / `"previous"` — go back to the previous page - * - `"first"` — jump back to the first page - * - * `"last"` is a silent alias for `"next"` preserved for muscle-memory compat. - */ -export const CURSOR_KEYWORDS = new Set([ - "next", - "last", - "prev", - "previous", - "first", -]); - -/** - * Parse and validate a `--cursor` flag value. - * - * Accepts navigation keywords (`"next"`, `"prev"`, `"previous"`, `"first"`, - * `"last"`) and opaque Sentry cursor strings (e.g. `"1735689600:0:0"`). - * Rejects bare integers early — they are never valid cursors and would - * produce a cryptic 400 from the API. - * - * Shared by {@link LIST_CURSOR_FLAG} and commands that define their own - * cursor flag with a custom `brief`. - * - * @throws Error when value is a bare integer - */ -export function parseCursorFlag(value: string): string { - if (CURSOR_KEYWORDS.has(value)) { - return value; - } - if (ALL_DIGITS_RE.test(value)) { - throw new Error( - `'${value}' is not a valid cursor. Cursors look like "1735689600:0:0". Use "next" / "prev" to navigate pages.`, - ); - } - return value; -} - /** * The `--cursor` / `-c` flag shared by all list commands. * diff --git a/packages/cli/src/types/dashboard.ts b/packages/cli/src/types/dashboard.ts index 5e73981e3..d80b21bb3 100644 --- a/packages/cli/src/types/dashboard.ts +++ b/packages/cli/src/types/dashboard.ts @@ -529,7 +529,10 @@ export const GRID_COLUMNS = 6; * - `dense` — First-fit packing: scan top-to-bottom, left-to-right * and place in the first available gap. Produces compact layouts. */ -export type WidgetLayoutMode = "sequential" | "dense"; +export type WidgetLayoutMode = (typeof WIDGET_LAYOUT_MODES)[number]; + +/** The {@link WidgetLayoutMode} values, which `--layout` accepts. */ +export const WIDGET_LAYOUT_MODES = ["sequential", "dense"] as const; /** Default widget dimensions by displayType */ const DEFAULT_WIDGET_SIZE: Partial< diff --git a/packages/cli/test/lib/arg-parsing.test.ts b/packages/cli/test/lib/arg-parsing.test.ts index d465fb589..64c046427 100644 --- a/packages/cli/test/lib/arg-parsing.test.ts +++ b/packages/cli/test/lib/arg-parsing.test.ts @@ -12,6 +12,7 @@ import { detectSwappedViewArgs, looksLikeIssueShortId, normalizeSlug, + parseCursorFlag, parseListTargetInput, parseOrgProjectArg, parseSlashSeparatedArg, @@ -1934,3 +1935,25 @@ describe("splitNewlineArg", () => { expect(splitNewlineArg(" \n \n ")).toEqual([]); }); }); + +describe("parseCursorFlag", () => { + test("passes through 'last' keyword", () => { + expect(parseCursorFlag("last")).toBe("last"); + }); + + test("passes through valid cursor strings", () => { + expect(parseCursorFlag("1735689600:0:0")).toBe("1735689600:0:0"); + expect(parseCursorFlag("abc:def:0")).toBe("abc:def:0"); + }); + + test("rejects bare integer strings", () => { + expect(() => parseCursorFlag("12345")).toThrow("not a valid cursor"); + expect(() => parseCursorFlag("0")).toThrow("not a valid cursor"); + expect(() => parseCursorFlag("999999999")).toThrow("not a valid cursor"); + }); + + test("accepts strings with digits and other characters", () => { + expect(parseCursorFlag("123abc")).toBe("123abc"); + expect(parseCursorFlag("abc123")).toBe("abc123"); + }); +}); diff --git a/packages/cli/test/lib/complete.test.ts b/packages/cli/test/lib/complete.test.ts index acb2036fc..a2f50bc49 100644 --- a/packages/cli/test/lib/complete.test.ts +++ b/packages/cli/test/lib/complete.test.ts @@ -101,8 +101,8 @@ describe("getCompletions: context detection", () => { test("returns nothing for the value of a flag without a completer", async () => { await seedOrgs([{ slug: "my-org", name: "My Organization" }]); - expect(await getCompletions(["issue", "list", "--cursor"], "")).toEqual([]); - expect(await getCompletions(["issue", "list", "-c"], "")).toEqual([]); + expect(await getCompletions(["issue", "list", "--limit"], "")).toEqual([]); + expect(await getCompletions(["issue", "list", "-n"], "")).toEqual([]); // Once the value is given, the next word is a target again. expect( (await getCompletions(["issue", "list", "--period", "7d"], "")).length, @@ -831,6 +831,53 @@ describe("getCompletions: fixed values", () => { test("text positionals complete nothing", async () => { expect(await getCompletions(["cli", "feedback"], "")).toEqual([]); }); + + test("flags of shared parsers complete the values those parsers accept", async () => { + expect(await values(["issue", "list", "--cursor"], "")).toEqual([ + "next", + "prev", + "first", + ]); + expect(await values(["log", "list", "--sort"], "o")).toEqual(["oldest"]); + expect(await values(["trace", "view", "--spans"], "")).toEqual([ + "all", + "no", + ]); + expect(await values(["cli", "setup", "--channel"], "n")).toEqual([ + "nightly", + ]); + }); + + test("flags of a command's own parsers complete their values", async () => { + expect(await values(["issue", "list", "--sort"], "f")).toEqual(["freq"]); + expect(await values(["api", "-X"], "P")).toEqual(["POST", "PUT", "PATCH"]); + }); + + test("--fields completes the next field of the list", async () => { + expect(await values(["issue", "view", "--fields"], "id,ti")).toEqual([ + "id,title", + ]); + expect( + await values(["issue", "view", "--fields"], "id,title,"), + ).not.toContain("id,title,id"); + expect(await values(["issue", "view", "--fields"], "project.s")).toEqual([ + "project.slug", + ]); + }); + + test("list flags complete fixed values one item at a time", async () => { + expect( + await values(["auth", "login", "--scope"], "org:read,org:wr"), + ).toEqual(["org:read,org:write"]); + }); + + test("platform flags complete platforms", async () => { + const [best] = await values(["project", "list", "--platform"], "python-dj"); + expect(best).toBe("python-django"); + expect(await values(["event", "send", "--platform"], "")).toContain( + "javascript", + ); + }); }); describe("getCompletions: command paths", () => { diff --git a/packages/cli/test/lib/completion-coverage.test.ts b/packages/cli/test/lib/completion-coverage.test.ts index 73f9fcb1d..5b4916e3b 100644 --- a/packages/cli/test/lib/completion-coverage.test.ts +++ b/packages/cli/test/lib/completion-coverage.test.ts @@ -1,58 +1,64 @@ /** - * Every positional declares how shell completion handles it. + * Every positional and value-taking flag declares how shell completion + * handles it. * * Walks the real route tree, the one `generate:completions` writes for the - * `__complete` fast path. A positional without a completion kind fails - * here, naming the command and placeholder. Give it the bound parser of - * its target, or one of the pre-tagged parsers in + * `__complete` fast path. A visible positional or flag without a completion + * kind fails here, naming the command and the placeholder or flag. Give it + * the bound parser of its target, or one of the pre-tagged parsers in * `src/lib/completion-target.ts` (`parseFilePath`, `parseText`, - * `choiceParser(…)`, …); see "Shell completion" in `packages/cli/AGENTS.md`. + * `choiceParser(…)`, `textParser(…)`, …); see "Shell completion" in + * `packages/cli/AGENTS.md`. */ import { describe, expect, test } from "vitest"; import { routes } from "../../src/app.js"; import { + type Command, extractCommandCompletions, isCommand, isRouteMap, type RouteMap, } from "../../src/lib/introspect.js"; -/** Every visible positional: its command path, index, and placeholder. */ -function visiblePositionals( +/** Every visible command with its path. */ +function visibleCommands( routeMap: RouteMap, prefix: readonly string[] = [], -): { path: string; index: number; placeholder: string }[] { +): { path: string; command: Command }[] { return routeMap.getAllEntries().flatMap((entry) => { if (entry.hidden) { return []; } const segments = [...prefix, entry.name.original]; if (isRouteMap(entry.target)) { - return visiblePositionals(entry.target, segments); + return visibleCommands(entry.target, segments); } - if (!isCommand(entry.target)) { - return []; - } - const positional = entry.target.parameters.positional; - const params = positional - ? positional.kind === "tuple" - ? positional.parameters - : [positional.parameter] + return isCommand(entry.target) + ? [{ path: segments.join(" "), command: entry.target }] : []; - return params.map((param, index) => ({ - path: segments.join(" "), - index, - placeholder: param.placeholder ?? `arg${index}`, - })); }); } describe("shell completion coverage", () => { + const routeMap = routes as unknown as RouteMap; + const completions = extractCommandCompletions(routeMap); + const commands = visibleCommands(routeMap); + test("every visible positional declares a completion kind", () => { - const routeMap = routes as unknown as RouteMap; - const completions = extractCommandCompletions(routeMap); - const positionals = visiblePositionals(routeMap); + const positionals = commands.flatMap(({ path, command }) => { + const positional = command.parameters.positional; + const params = positional + ? positional.kind === "tuple" + ? positional.parameters + : [positional.parameter] + : []; + return params.map((param, index) => ({ + path, + index, + placeholder: param.placeholder ?? `arg${index}`, + })); + }); const missing = positionals .filter(({ path, index }) => !completions[path]?.positionals[index]) .map(({ path, placeholder }) => `sentry ${path} <${placeholder}>`); @@ -60,4 +66,18 @@ describe("shell completion coverage", () => { expect(positionals.length).toBeGreaterThan(0); expect(missing).toEqual([]); }); + + test("every visible value-taking flag declares a completion kind", () => { + const flags = commands.flatMap(({ path, command }) => + Object.entries(command.parameters.flags ?? {}) + .filter(([, def]) => def.kind !== "boolean" && !def.hidden) + .map(([name]) => ({ path, flag: `--${name}` })), + ); + const missing = flags + .filter(({ path, flag }) => !completions[path]?.flags[flag]) + .map(({ path, flag }) => `sentry ${path} ${flag}`); + + expect(flags.length).toBeGreaterThan(0); + expect(missing).toEqual([]); + }); }); diff --git a/packages/cli/test/lib/formatters/output.test.ts b/packages/cli/test/lib/formatters/output.test.ts index a091593b9..7d1e33655 100644 --- a/packages/cli/test/lib/formatters/output.test.ts +++ b/packages/cli/test/lib/formatters/output.test.ts @@ -1,5 +1,7 @@ +import { array, nullable, object, optional, string } from "valibot"; import { describe, expect, test } from "vitest"; import { + extractSchemaFieldPaths, type OutputConfig, renderCommandOutput, resolveRenderer, @@ -360,3 +362,26 @@ describe("renderCommandOutput", () => { expect(renderer.finalize).toBeUndefined(); }); }); + +describe("extractSchemaFieldPaths", () => { + test("lists nested fields in dot notation, through wrappers and arrays", () => { + const schema = object({ + id: string(), + metadata: optional(nullable(object({ title: string() }))), + tags: array(object({ key: string(), value: string() })), + }); + + expect(extractSchemaFieldPaths(schema)).toEqual([ + "id", + "metadata", + "metadata.title", + "tags", + "tags.key", + "tags.value", + ]); + }); + + test("returns nothing for a schema that is not an object", () => { + expect(extractSchemaFieldPaths(string())).toEqual([]); + }); +}); diff --git a/packages/cli/test/lib/introspect.test.ts b/packages/cli/test/lib/introspect.test.ts index 9d9c416d6..eb061fbeb 100644 --- a/packages/cli/test/lib/introspect.test.ts +++ b/packages/cli/test/lib/introspect.test.ts @@ -5,10 +5,14 @@ * and `script/generate-skill.ts`. */ +import { numberParser } from "@stricli/core"; import { describe, expect, test } from "vitest"; +import { parseLogSort } from "../../src/lib/arg-parsing.js"; import { + CHOICE_LIST_TARGET, CHOICE_TARGET, type CompletionTarget, + choiceListParser, choiceParser, DASHBOARD_TARGET, FILE_TARGET, @@ -17,6 +21,8 @@ import { ORG_PROJECT_TARGET, ORG_TARGET, parseFilePath, + TEXT_TARGET, + textParser, withCompletionTarget, } from "../../src/lib/completion-target.js"; import type { @@ -583,6 +589,50 @@ describe("extractCommandCompletions", () => { }, }); }); + + test("reads list flags, wrapped parsers, and shared parsers", () => { + const list = makeCommand({ + parameters: { + flags: { + fields: { kind: "parsed", parse: choiceListParser(["id", "title"]) }, + limit: { kind: "parsed", parse: numberParser }, + port: { kind: "parsed", parse: textParser(Number) }, + sort: { kind: "parsed", parse: parseLogSort }, + }, + }, + }); + + expect( + extractCommandCompletions(makeRouteMap([makeEntry("list", list)])), + ).toEqual({ + list: { + positionals: [], + variadic: false, + flags: { + "--fields": CHOICE_LIST_TARGET, + "--limit": TEXT_TARGET, + "--port": TEXT_TARGET, + "--sort": CHOICE_TARGET, + }, + choices: { + "--fields": ["id", "title"], + "--sort": ["newest", "oldest"], + }, + }, + }); + }); + + test("wrapped parsers still parse and validate", () => { + const parse = choiceParser(["a", "b"], (value) => { + if (value !== "a" && value !== "b") { + throw new Error(`bad ${value}`); + } + return value.toUpperCase(); + }); + expect(parse("a")).toBe("A"); + expect(() => parse("c")).toThrow("bad c"); + expect(textParser(Number)("42")).toBe(42); + }); }); describe("extractRouteGroups", () => { diff --git a/packages/cli/test/lib/list-command.test.ts b/packages/cli/test/lib/list-command.test.ts index 903c8a094..550edf182 100644 --- a/packages/cli/test/lib/list-command.test.ts +++ b/packages/cli/test/lib/list-command.test.ts @@ -1,8 +1,7 @@ /** * Tests for the shared list-command building blocks. * - * Tests `parseCursorFlag` validation and `buildOrgListCommand` delegation - * to `dispatchOrgScopedList`. + * Tests `buildOrgListCommand` delegation to `dispatchOrgScopedList`. */ import { execFileSync } from "node:child_process"; @@ -16,7 +15,6 @@ import { appendQueryHint, buildOrgListCommand, type OrgListCommandDocs, - parseCursorFlag, } from "../../src/lib/list-command.js"; import type { OrgListConfig } from "../../src/lib/org-list.js"; // oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for spyOn mocking @@ -75,28 +73,6 @@ describe("query pagination hints", () => { }); }); -describe("parseCursorFlag", () => { - test("passes through 'last' keyword", () => { - expect(parseCursorFlag("last")).toBe("last"); - }); - - test("passes through valid cursor strings", () => { - expect(parseCursorFlag("1735689600:0:0")).toBe("1735689600:0:0"); - expect(parseCursorFlag("abc:def:0")).toBe("abc:def:0"); - }); - - test("rejects bare integer strings", () => { - expect(() => parseCursorFlag("12345")).toThrow("not a valid cursor"); - expect(() => parseCursorFlag("0")).toThrow("not a valid cursor"); - expect(() => parseCursorFlag("999999999")).toThrow("not a valid cursor"); - }); - - test("accepts strings with digits and other characters", () => { - expect(parseCursorFlag("123abc")).toBe("123abc"); - expect(parseCursorFlag("abc123")).toBe("abc123"); - }); -}); - // --------------------------------------------------------------------------- // buildOrgListCommand: integration with dispatchOrgScopedList // --------------------------------------------------------------------------- From c7939cfa46882ac88231fdc5eb895c3a698d2350 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:44:14 +0000 Subject: [PATCH 2/8] fix(local): Generate CLI parsers before the viewer tests The viewer integration tests import `commands/local/server.ts`, which now reaches `lib/arg-parsing.ts` through the tagged flag parsers in `completion-target.ts`. That module imports the generated grammar, so a fresh CI checkout with only the API schema generated failed to resolve `org-project-target-grammar.js`. `ensure-generated.mjs` now takes the groups to check, and the viewer test asks for the schema and the grammar. --- apps/local/package.json | 2 +- packages/cli/script/ensure-generated.mjs | 15 +++++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/local/package.json b/apps/local/package.json index 4b6aa1f92..7fccafb12 100644 --- a/apps/local/package.json +++ b/apps/local/package.json @@ -8,7 +8,7 @@ "build": "tsc -b && vite build", "lint": "oxlint", "preview": "vite preview", - "test": "pnpm --filter sentry exec node script/ensure-generated.mjs schema && vitest run", + "test": "pnpm --filter sentry exec node script/ensure-generated.mjs schema grammar && vitest run", "test:integration": "pnpm run test src/App.integration.test.tsx", "typecheck": "tsc -b", "sample": "tsx scripts/replay.ts" diff --git a/packages/cli/script/ensure-generated.mjs b/packages/cli/script/ensure-generated.mjs index 29adccd49..74baffaa0 100644 --- a/packages/cli/script/ensure-generated.mjs +++ b/packages/cli/script/ensure-generated.mjs @@ -9,8 +9,9 @@ * step to document or forget. * * Usage: - * node script/ensure-generated.mjs # everything the CLI imports - * node script/ensure-generated.mjs schema # only the API schema + * node script/ensure-generated.mjs # everything the CLI imports + * node script/ensure-generated.mjs schema # only the API schema + * node script/ensure-generated.mjs schema grammar # the schema and parsers * * Plain Node (no tsx) so the check adds no noticeable startup time. */ @@ -42,12 +43,18 @@ function run(script) { execSync(`pnpm run ${script}`, { stdio: "inherit" }); } +/** The groups named on the command line, or every group when none are. */ +const groups = process.argv.slice(2); +function wants(group) { + return groups.length === 0 || groups.includes(group); +} + if (!SCHEMA_OUTPUTS.every(existsSync)) { run("generate:schema"); } -if (process.argv[2] !== "schema" && !GRAMMAR_OUTPUTS.every(existsSync)) { +if (wants("grammar") && !GRAMMAR_OUTPUTS.every(existsSync)) { run("generate:grammar"); } -if (process.argv[2] !== "schema" && !DOCS_OUTPUTS.every(existsSync)) { +if (wants("docs") && !DOCS_OUTPUTS.every(existsSync)) { run("generate:docs"); } From e37b64951f8ac64c3e1f0f97e041a9e03ff3f446 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:25:51 +0000 Subject: [PATCH 3/8] feat(cli): Lint command parsers that cannot declare a completion kind Add sentry-cli/require-completion-parser. In src/commands/ and src/lib/ it rejects, on command positionals and parsed flags, the parsers that can never carry a completion kind: built-ins (String, Number, Boolean, BigInt, Date, parseInt, parseFloat, JSON.parse, Number.parseInt, ...), inline functions, and boundArguments without a kind. Named parsers pass lint; completion-coverage.test.ts still checks at runtime that each one is tagged. Parameters that must stay without a kind go in lint-rules/completion-exemptions.json, with file, command, parameter and reason. The lint rule and the coverage test both read it, and the test fails on an entry whose parameter is covered or gone. The list starts empty: no current parameter needs it, and the rule finds nothing in the current code. An oxlint-disable comment silences only the lint rule. The coverage test cannot see comments, so the parameter still needs a kind or an entry. Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- .oxlintrc.json | 1 + packages/cli/AGENTS.md | 17 ++- packages/cli/lint-rules/cli-oxlint-plugin.js | 118 ++++++++++++++++- .../cli/lint-rules/completion-exemptions.json | 4 + .../cli/test/lib/completion-coverage.test.ts | 105 +++++++++++---- scripts/cli-lint-rules.test.mjs | 120 ++++++++++++++++++ 6 files changed, 332 insertions(+), 33 deletions(-) create mode 100644 packages/cli/lint-rules/completion-exemptions.json diff --git a/.oxlintrc.json b/.oxlintrc.json index d1b380f4b..623164394 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -70,6 +70,7 @@ "sentry-cli/no-direct-target-resolution": "error", "sentry-cli/require-target-resolution": "error", "sentry-cli/no-ad-hoc-id-arguments": "error", + "sentry-cli/require-completion-parser": "error", "sentry-cli/no-generic-is-record": "error", "sentry-cli/prefer-paginate-helper": "error", "sentry-cli/no-manual-pagination": "error", diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index d3dd69caa..15f7445e4 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -395,9 +395,20 @@ input; errors come from `parseIdArguments`, never Peggy's own syntax errors. value-taking flag declares a completion kind, a key of `COMPLETERS` in `src/lib/complete.ts`; `test/lib/completion-coverage.test.ts` walks the route tree and fails, naming the command and the placeholder or flag, for -any without one. There is no allow-list: a flag with nothing to suggest -says so with `parseText` or `textParser`. Pick the parser by what the -argument takes: +any without one. The `sentry-cli/require-completion-parser` lint rule +rejects, in `src/commands/` and `src/lib/`, the parsers that cannot carry a +kind: built-ins (`parse: String`, `Number`, `Boolean`, `Date`, `parseInt`, +`JSON.parse`, …), inline functions (`parse: (value) => …`), and +`boundArguments` without a `kind`. A flag with nothing to suggest says so +with `parseText` or `textParser(parse)`. The rare parameter that must stay +without a kind goes in `lint-rules/completion-exemptions.json` with its +file, command, parameter (`--flag` or ``), and reason; both the +lint rule and the coverage test read it, and the test fails on an entry +that no longer needs exempting. An `oxlint-disable-next-line +sentry-cli/require-completion-parser -- reason` comment silences only the +lint rule; the coverage test cannot see comments, so the parameter still +needs a kind or an allow-list entry. Pick the parser by what the argument +takes: | Argument | Parser | Completes | |----------|--------|-----------| diff --git a/packages/cli/lint-rules/cli-oxlint-plugin.js b/packages/cli/lint-rules/cli-oxlint-plugin.js index 763b1d50c..77ebb4b9d 100644 --- a/packages/cli/lint-rules/cli-oxlint-plugin.js +++ b/packages/cli/lint-rules/cli-oxlint-plugin.js @@ -1,3 +1,4 @@ +import { readFileSync } from "node:fs"; import { marked } from "marked"; const name = "sentry-cli"; @@ -14,9 +15,9 @@ function call(node, property) { return node.type === "CallExpression" && member(node.callee, property); } -function rule(message, visitors) { +function rule(message, visitors, schema = []) { return { - meta: { type: "problem", docs: { description: message }, schema: [] }, + meta: { type: "problem", docs: { description: message }, schema }, create(context) { const filename = context.filename.replaceAll("\\", "/"); if (!filename.includes("/packages/cli/")) return {}; @@ -226,6 +227,73 @@ function isIdKind(node) { ); } +/** + * Parameters exempt from shell completion coverage, shared with + * test/lib/completion-coverage.test.ts. + */ +const COMPLETION_EXEMPTIONS = JSON.parse( + readFileSync( + new URL("./completion-exemptions.json", import.meta.url), + "utf8", + ), +).exemptions; + +/** Built-in conversions, which carry no completion kind. */ +const BUILTIN_PARSERS = new Set([ + "String", + "Number", + "Boolean", + "BigInt", + "Date", + "parseInt", + "parseFloat", +]); + +/** Objects whose static parsers (`JSON.parse`, `Number.parseInt`) are built-ins. */ +const BUILTIN_PARSER_OBJECTS = new Set(["JSON", "Number", "Date", "BigInt"]); + +/** The non-computed property `key` of an object literal. */ +function objectProperty(node, key) { + return node.properties.find( + (p) => + p.type === "Property" && + !p.computed && + (p.key.name ?? p.key.value) === key, + ); +} + +/** + * The parameter an object literal defines, as the coverage test names it: + * `` for a positional, `--name` for a flag. + */ +function parameterName(node) { + const placeholder = objectProperty(node, "placeholder")?.value; + if (placeholder?.type === "Literal" && typeof placeholder.value === "string") + return `<${placeholder.value}>`; + const parent = node.parent; + if (parent?.type === "Property" && parent.value === node && !parent.computed) + return `--${parent.key.name ?? parent.key.value}`; + return undefined; +} + +/** Why a `parse` value declares no completion kind, or `undefined`. */ +function untaggedParser(value) { + if (value.type === "Identifier" && BUILTIN_PARSERS.has(value.name)) + return `\`${value.name}\` has no completion kind. Use parseText, textParser(${value.name}), or another tagged parser from src/lib/completion-target.ts.`; + if ( + value.type === "MemberExpression" && + value.object.type === "Identifier" && + BUILTIN_PARSER_OBJECTS.has(value.object.name) + ) + return `\`${value.object.name}.${value.property.name}\` has no completion kind. Wrap it in textParser() or choiceParser().`; + if ( + value.type === "ArrowFunctionExpression" || + value.type === "FunctionExpression" + ) + return "An inline parser has no completion kind. Wrap it in textParser(), choiceParser(), or choiceListParser(), or use a bound parser."; + return undefined; +} + const rules = { "no-silent-catch": rule( "Handle or report caught errors instead of discarding them.", @@ -417,6 +485,52 @@ const rules = { }; }, ), + "require-completion-parser": rule( + "Give every positional and value-taking flag a parser that declares its completion kind.", + (context, file, report) => { + if (!file.includes("/src/commands/") && !file.includes("/src/lib/")) + return {}; + const exemptions = + context.options[0]?.exemptions ?? COMPLETION_EXEMPTIONS; + const exempt = (node) => { + const parameter = parameterName(node); + return exemptions.some( + (entry) => + file.endsWith(`/packages/cli/${entry.file}`) && + entry.parameter === parameter, + ); + }; + return { + ObjectExpression(node) { + const parse = objectProperty(node, "parse"); + if (!parse) return; + // boundArguments({ kind, parse, resolve }): `kind` is the + // completion kind, and `parse` reads every argument at once. + if (objectProperty(node, "resolve")) { + if (!objectProperty(node, "kind") && !exempt(node)) + report( + node, + "Give boundArguments a completion `kind` (ORG_PROJECT_TARGET, …).", + ); + return; + } + const kind = objectProperty(node, "kind")?.value; + const parsedFlag = + kind?.type === "Literal" && kind.value === "parsed"; + if (!parsedFlag && !objectProperty(node, "brief")) return; + const reason = untaggedParser(parse.value); + if (reason && !exempt(node)) report(parse.value, reason); + }, + }; + }, + [ + { + type: "object", + properties: { exemptions: { type: "array" } }, + additionalProperties: false, + }, + ], + ), "no-generic-is-record": rule( "Use a shape-specific guard instead of isRecord().", (_context, file, report) => diff --git a/packages/cli/lint-rules/completion-exemptions.json b/packages/cli/lint-rules/completion-exemptions.json new file mode 100644 index 000000000..bdbb1e078 --- /dev/null +++ b/packages/cli/lint-rules/completion-exemptions.json @@ -0,0 +1,4 @@ +{ + "$comment": "Parameters exempt from shell completion coverage: the sentry-cli/require-completion-parser lint rule and test/lib/completion-coverage.test.ts both read this list. Each entry names the file that defines the parameter, the command path, the parameter (--flag or ), and why it has no completion kind. Prefer parseText or textParser(parse) over an entry here; see \"Shell completion\" in packages/cli/AGENTS.md.", + "exemptions": [] +} diff --git a/packages/cli/test/lib/completion-coverage.test.ts b/packages/cli/test/lib/completion-coverage.test.ts index 5b4916e3b..e81cfe6ff 100644 --- a/packages/cli/test/lib/completion-coverage.test.ts +++ b/packages/cli/test/lib/completion-coverage.test.ts @@ -9,8 +9,14 @@ * `src/lib/completion-target.ts` (`parseFilePath`, `parseText`, * `choiceParser(…)`, `textParser(…)`, …); see "Shell completion" in * `packages/cli/AGENTS.md`. + * + * `lint-rules/completion-exemptions.json` lists the parameters exempt from + * coverage; the `sentry-cli/require-completion-parser` lint rule reads the + * same list. An entry must name a visible parameter that still has no + * completion kind, so the list cannot go stale. */ +import { readFileSync } from "node:fs"; import { describe, expect, test } from "vitest"; import { routes } from "../../src/app.js"; import { @@ -21,6 +27,33 @@ import { type RouteMap, } from "../../src/lib/introspect.js"; +/** A parameter exempt from completion coverage. */ +type CompletionExemption = { + /** Source file defining the parameter, relative to `packages/cli` */ + readonly file: string; + /** Command path, without `sentry` */ + readonly command: string; + /** `--flag` or `` */ + readonly parameter: string; + readonly reason: string; +}; + +const { exemptions } = JSON.parse( + readFileSync( + new URL("../../lint-rules/completion-exemptions.json", import.meta.url), + "utf8", + ), +) as { exemptions: readonly CompletionExemption[] }; + +/** How the coverage report names a parameter. */ +function label(command: string, parameter: string): string { + return `sentry ${command} ${parameter}`; +} + +const exempt = new Set( + exemptions.map(({ command, parameter }) => label(command, parameter)), +); + /** Every visible command with its path. */ function visibleCommands( routeMap: RouteMap, @@ -45,39 +78,55 @@ describe("shell completion coverage", () => { const completions = extractCommandCompletions(routeMap); const commands = visibleCommands(routeMap); - test("every visible positional declares a completion kind", () => { - const positionals = commands.flatMap(({ path, command }) => { - const positional = command.parameters.positional; - const params = positional - ? positional.kind === "tuple" - ? positional.parameters - : [positional.parameter] - : []; - return params.map((param, index) => ({ - path, - index, - placeholder: param.placeholder ?? `arg${index}`, - })); - }); - const missing = positionals - .filter(({ path, index }) => !completions[path]?.positionals[index]) - .map(({ path, placeholder }) => `sentry ${path} <${placeholder}>`); + const positionals = commands.flatMap(({ path, command }) => { + const positional = command.parameters.positional; + const params = positional + ? positional.kind === "tuple" + ? positional.parameters + : [positional.parameter] + : []; + return params.map((param, index) => ({ + path, + index, + placeholder: param.placeholder ?? `arg${index}`, + })); + }); + const flags = commands.flatMap(({ path, command }) => + Object.entries(command.parameters.flags ?? {}) + .filter(([, def]) => def.kind !== "boolean" && !def.hidden) + .map(([name]) => ({ path, flag: `--${name}` })), + ); + /** Positionals and flags without a completion kind, exempt or not. */ + const uncoveredPositionals = positionals + .filter(({ path, index }) => !completions[path]?.positionals[index]) + .map(({ path, placeholder }) => label(path, `<${placeholder}>`)); + const uncoveredFlags = flags + .filter(({ path, flag }) => !completions[path]?.flags[flag]) + .map(({ path, flag }) => label(path, flag)); + + test("every visible positional declares a completion kind", () => { expect(positionals.length).toBeGreaterThan(0); - expect(missing).toEqual([]); + expect(uncoveredPositionals.filter((name) => !exempt.has(name))).toEqual( + [], + ); }); test("every visible value-taking flag declares a completion kind", () => { - const flags = commands.flatMap(({ path, command }) => - Object.entries(command.parameters.flags ?? {}) - .filter(([, def]) => def.kind !== "boolean" && !def.hidden) - .map(([name]) => ({ path, flag: `--${name}` })), - ); - const missing = flags - .filter(({ path, flag }) => !completions[path]?.flags[flag]) - .map(({ path, flag }) => `sentry ${path} ${flag}`); - expect(flags.length).toBeGreaterThan(0); - expect(missing).toEqual([]); + expect(uncoveredFlags.filter((name) => !exempt.has(name))).toEqual([]); + }); + + test("every exemption names an uncovered parameter and says why", () => { + const uncovered = new Set([...uncoveredPositionals, ...uncoveredFlags]); + const stale = exemptions + .map(({ command, parameter }) => label(command, parameter)) + .filter((name) => !uncovered.has(name)); + const unexplained = exemptions + .filter(({ file, reason }) => !(file.trim() && reason.trim())) + .map(({ command, parameter }) => label(command, parameter)); + + expect(stale).toEqual([]); + expect(unexplained).toEqual([]); }); }); diff --git a/scripts/cli-lint-rules.test.mjs b/scripts/cli-lint-rules.test.mjs index 9895655df..90514f807 100644 --- a/scripts/cli-lint-rules.test.mjs +++ b/scripts/cli-lint-rules.test.mjs @@ -211,6 +211,126 @@ tester.run( }, ); +tester.run( + "require-completion-parser", + plugin.rules["require-completion-parser"], + { + valid: [ + { + filename: commands, + code: "const flags = { limit: { kind: 'parsed', parse: textParser(Number), brief: 'Limit' } };", + }, + { + filename: commands, + code: "const flags = { query: { kind: 'parsed', parse: parseText, brief: 'Query' }, sort: { kind: 'parsed', parse: choiceParser(SORTS, parseSort), brief: 'Sort' } };", + }, + { + // boundArguments declares its completion kind next to its parser + filename: commands, + code: "boundArguments({ placeholder: 'issue', brief: 'Issue', kind: ISSUE_TARGET, parse: (raw) => parseIssues(raw), resolve });", + }, + { + // Not a command parameter + filename: commands, + code: "const schema = { parse: (value) => value };", + }, + { + // Outside command definitions and their helpers + filename: "/repo/packages/cli/script/example.ts", + code: "const flags = { port: { kind: 'parsed', parse: Number, brief: 'Port' } };", + }, + { + filename: commands, + code: "const flags = { port: { kind: 'parsed', parse: Number, brief: 'Port' } };", + options: [ + { + exemptions: [ + { + file: "src/commands/example.ts", + command: "example", + parameter: "--port", + reason: "test", + }, + ], + }, + ], + }, + { + filename: commands, + code: "const positional = { kind: 'tuple', parameters: [{ placeholder: 'when', brief: 'When', parse: Date }] };", + options: [ + { + exemptions: [ + { + file: "src/commands/example.ts", + command: "example", + parameter: "", + reason: "test", + }, + ], + }, + ], + }, + ], + invalid: [ + { + filename: commands, + code: "const flags = { port: { kind: 'parsed', parse: Number, brief: 'Port' } };", + errors: 1, + }, + { + filename: commands, + code: "const flags = { name: { kind: 'parsed', parse: String, brief: 'Name' } };", + errors: 1, + }, + { + filename: commands, + code: "const flags = { data: { kind: 'parsed', parse: JSON.parse, brief: 'Data' } };", + errors: 1, + }, + { + filename: commands, + code: "const flags = { port: { kind: 'parsed', parse: (value) => Number(value), brief: 'Port' } };", + errors: 1, + }, + { + filename: commands, + code: "const flags = { port: { kind: 'parsed', parse(value) { return Number(value); }, brief: 'Port' } };", + errors: 1, + }, + { + // Positionals too, and lib helpers that build parameters + filename: source, + code: "const positional = { kind: 'tuple', parameters: [{ placeholder: 'count', brief: 'Count', parse: parseInt }] };", + errors: 1, + }, + { + filename: commands, + code: "boundArguments({ placeholder: 'issue', brief: 'Issue', parse: (raw) => parseIssues(raw), resolve });", + errors: 1, + }, + { + // An exemption covers only the parameter it names + filename: commands, + code: "const flags = { port: { kind: 'parsed', parse: Number, brief: 'Port' } };", + options: [ + { + exemptions: [ + { + file: "src/commands/example.ts", + command: "example", + parameter: "--limit", + reason: "test", + }, + ], + }, + ], + errors: 1, + }, + ], + }, +); + tester.run("no-raw-metadata-queries", plugin.rules["no-raw-metadata-queries"], { valid: [ { From 2d9c4238078d1db0e8dd898b9bcb15b6c76ad8a6 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:31:24 +0000 Subject: [PATCH 4/8] fix(cli): Name flags with a placeholder by their flag name in exemptions require-completion-parser named any parameter with a `placeholder` as a positional (``), but flags such as `api --method` declare placeholders too, and the coverage test names them `--method`. An exemption written as `--method` never matched in the lint rule, and one written as `` was flagged as stale by the test. Name a parameter by its property key unless that key is an array positional's `parameter`, and fall back to the placeholder only for positionals. Add RuleTester cases for an exempt flag with a placeholder and an exempt array positional. --- packages/cli/lint-rules/cli-oxlint-plugin.js | 16 +++++++--- scripts/cli-lint-rules.test.mjs | 33 ++++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/packages/cli/lint-rules/cli-oxlint-plugin.js b/packages/cli/lint-rules/cli-oxlint-plugin.js index 77ebb4b9d..bcc20a2b0 100644 --- a/packages/cli/lint-rules/cli-oxlint-plugin.js +++ b/packages/cli/lint-rules/cli-oxlint-plugin.js @@ -264,15 +264,23 @@ function objectProperty(node, key) { /** * The parameter an object literal defines, as the coverage test names it: - * `` for a positional, `--name` for a flag. + * `--name` for a flag, keyed by its name (flags can have a `placeholder` + * too), and `` for a positional, which sits in a tuple's + * `parameters` array or an array positional's `parameter`. */ function parameterName(node) { + const parent = node.parent; + if ( + parent?.type === "Property" && + parent.value === node && + !parent.computed + ) { + const key = parent.key.name ?? parent.key.value; + if (key !== "parameter") return `--${key}`; + } const placeholder = objectProperty(node, "placeholder")?.value; if (placeholder?.type === "Literal" && typeof placeholder.value === "string") return `<${placeholder.value}>`; - const parent = node.parent; - if (parent?.type === "Property" && parent.value === node && !parent.computed) - return `--${parent.key.name ?? parent.key.value}`; return undefined; } diff --git a/scripts/cli-lint-rules.test.mjs b/scripts/cli-lint-rules.test.mjs index 90514f807..528e17588 100644 --- a/scripts/cli-lint-rules.test.mjs +++ b/scripts/cli-lint-rules.test.mjs @@ -271,6 +271,39 @@ tester.run( }, ], }, + { + // A flag with a placeholder is still named by its flag name + filename: commands, + code: "const flags = { method: { kind: 'parsed', parse: String, brief: 'Method', placeholder: 'method' } };", + options: [ + { + exemptions: [ + { + file: "src/commands/example.ts", + command: "example", + parameter: "--method", + reason: "test", + }, + ], + }, + ], + }, + { + filename: commands, + code: "const positional = { kind: 'array', parameter: { placeholder: 'path', brief: 'Path', parse: String } };", + options: [ + { + exemptions: [ + { + file: "src/commands/example.ts", + command: "example", + parameter: "", + reason: "test", + }, + ], + }, + ], + }, ], invalid: [ { From e83e535c5d99a22118e640c098275c80526afb66 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:32:11 +0000 Subject: [PATCH 5/8] feat(cli): Complete flag values that are not free text Many flags parsed with parseText take values that are not free text: things that exist in Sentry, git refs, fixed sets, or a grammar. Give them kinds that complete them: - Cached entities as flag values (environments, releases, teams, team: owners, monitors, alert workflows, native integrations, installed Sentry Apps and their issue-link form fields, DSNs, project lists), scoped to the org the command's target names. completion-seed now lets one response seed several kinds (teams give --team and --owner). - issue merge --into completes issues; issue archive --until completes its grammar (forever, auto, 10x, 10u, durations, 10x/5m). - Git refs, commit SHAs, remote owner/repo names, and --commit REPO@SHA lists from the repository in the shell's directory. - api endpoints complete API schema paths one segment at a time, with cached orgs and projects for their placeholders; schema completes API resources; auth --url completes stored accounts' servers. - Fixed sets from the modules that validate them: metric alert time windows, dashboard grid columns and widths, preprod VCS providers (Sentry's SUPPORTED_VCS_PROVIDERS), tracemetrics aggregations, Local loopback hosts, sourcemap extensions, event send --user keys. Shells no longer add a space after a suggestion that continues (name=, repo@, id:), as they already did after org/. --- packages/cli/AGENTS.md | 16 +- .../cli/src/commands/alert/issues/create.ts | 11 +- .../cli/src/commands/alert/issues/edit.ts | 11 +- .../cli/src/commands/alert/metrics/create.ts | 20 +- .../cli/src/commands/alert/metrics/edit.ts | 20 +- .../cli/src/commands/alert/mutation-utils.ts | 3 + packages/cli/src/commands/api.ts | 3 +- packages/cli/src/commands/auth/login.ts | 5 +- packages/cli/src/commands/auth/logout.ts | 5 +- packages/cli/src/commands/auth/switch.ts | 5 +- packages/cli/src/commands/bash-hook.ts | 11 +- packages/cli/src/commands/build/upload.ts | 19 +- .../cli/src/commands/code-mappings/upload.ts | 10 +- .../cli/src/commands/dashboard/widget/add.ts | 6 +- .../cli/src/commands/dashboard/widget/edit.ts | 6 +- packages/cli/src/commands/event/send.ts | 13 +- packages/cli/src/commands/explore.ts | 7 +- packages/cli/src/commands/init.ts | 3 +- packages/cli/src/commands/issue/archive.ts | 4 +- packages/cli/src/commands/issue/link-utils.ts | 10 +- packages/cli/src/commands/issue/link.ts | 4 +- packages/cli/src/commands/issue/merge.ts | 4 +- packages/cli/src/commands/issue/tags.ts | 4 +- packages/cli/src/commands/local/loopback.ts | 7 +- packages/cli/src/commands/local/management.ts | 9 +- packages/cli/src/commands/local/run.ts | 3 +- packages/cli/src/commands/local/server.ts | 4 +- packages/cli/src/commands/monitor/run.ts | 9 +- packages/cli/src/commands/org/list.ts | 4 +- packages/cli/src/commands/platform/list.ts | 4 +- packages/cli/src/commands/project/create.ts | 4 +- .../cli/src/commands/react-native/gradle.ts | 8 +- packages/cli/src/commands/release/create.ts | 10 +- packages/cli/src/commands/release/deploy.ts | 8 +- packages/cli/src/commands/release/list.ts | 10 +- .../cli/src/commands/release/set-commits.ts | 10 +- packages/cli/src/commands/replay/list.ts | 4 +- packages/cli/src/commands/schema.ts | 6 +- .../cli/src/commands/snapshots/download.ts | 8 +- packages/cli/src/commands/snapshots/upload.ts | 19 +- packages/cli/src/commands/sourcemap/inject.ts | 6 +- .../cli/src/commands/sourcemap/resolve.ts | 4 +- packages/cli/src/commands/sourcemap/upload.ts | 9 +- packages/cli/src/lib/api/completion-seed.ts | 307 ++++++++++++++-- packages/cli/src/lib/build/vcs.ts | 13 + packages/cli/src/lib/complete.ts | 327 ++++++++++++++++++ packages/cli/src/lib/completion-target.ts | 157 +++++++++ packages/cli/src/lib/completions.ts | 13 +- packages/cli/src/lib/db/completion-cache.ts | 41 ++- .../cli/src/lib/envelope/event-builder.ts | 7 +- packages/cli/src/lib/git.ts | 132 +++++++ packages/cli/src/lib/metrics-transform.ts | 2 +- packages/cli/src/lib/sourcemap/inject.ts | 2 +- packages/cli/src/types/dashboard.ts | 11 + packages/cli/test/lib/complete-git.test.ts | 77 +++++ packages/cli/test/lib/complete.test.ts | 231 +++++++++++++ .../cli/test/lib/db/completion-cache.test.ts | 117 +++++++ packages/cli/test/lib/git-completion.test.ts | 90 +++++ 58 files changed, 1709 insertions(+), 164 deletions(-) create mode 100644 packages/cli/test/lib/complete-git.test.ts create mode 100644 packages/cli/test/lib/git-completion.test.ts diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 15f7445e4..249dd5e47 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -419,10 +419,14 @@ takes: | Fixed values | `choiceParser(values)`, or `choiceParser(values, parse)` to keep a command's own validating parser (`choiceParser(VALID_SORT_VALUES, parseSort)`). Take the values from the module that defines them | those values; a variadic positional completes only its first word (`cli defaults `). Without `parse` the parser returns its input, so the command still validates it | | Comma-separated fixed values (`--scope`, `--features`) | `choiceListParser(values)` (or `(values, parse)`) | the next item of the list, leaving out the items already in it (`org:read,org:w` → `org:read,org:write`) | | Platform | `parsePlatform` | platform identifiers, common ones first | +| Something that already exists in Sentry, as a flag value (`--environment`, `--release`, `--team`, `--owner`, `--integration`, `--app`, `--dsn`, `--workflow`, `--project` lists) | `parseEnvironment`, `parseEnvironmentList`, `parseRelease`, `parseTeam`, `parseOwner` (`team:`), `parseIntegration`, `parseSentryApp`, `parseAppLinkField` (`name=value`), `parseDsn`, `parseWorkflowList`, `parseProjectList` | cached entities of the org the command's target names, else of every org, each once; the `*List` parsers complete the last item of a comma-separated list | +| Issue as a flag value (`issue merge --into`) | `parseIssueReference` | what `ISSUE_TARGET` completes | +| Git ref, commit, or repository (`--base-ref`, `--head-sha`, `--repo`, `--commit REPO@SHA`, `set-commits --from`) | `parseVcsRef` (branch and tag names as the remote knows them), `parseGitRef` (refs git resolves locally), `parseGitCommit`, `parseGitRepo` (`owner/repo` of the remotes), `parseRepoCommitList` | read from git in the shell's directory; nothing outside a work tree | +| A structured value with its own grammar (`issue archive --until`, `api `) | `parseArchiveUntil`, `parseApiEndpoint` | the grammar: `forever`, `auto`, `10x`, `10x/5m`, …; API schema paths one segment at a time, with cached orgs and projects for their placeholders | | Stored account | `parseAccountSelector` | stored accounts (`auth switch`, `auth logout`, `org list --account`) | | CLI command path | `parseCommandPath` | route names, one segment per word (`help`) | | URL | `parseUrl` (the command validates it) | nothing | -| Free text, IDs with no cache, names of new things | `parseText` | nothing | +| Free text, IDs with no cache, names of new things | `parseText` | nothing. Before reaching for it, check whether the value is really free: a value with a fixed set (in the module that validates it), a grammar, or a source (cache, git, schema) gets a kind that completes it | | Numbers and other values a command parses itself (`parsePort`, `parseLimit`) | `textParser(parse)` | nothing | Import the pre-tagged parsers; never tag `String` or write @@ -445,13 +449,17 @@ nothing, and `parseLoginEmail` completes stored accounts. Fixed values through `COMPLETION_VALUES`. A new shared parser goes in `PARSER_TARGETS`; its module must not import `completion-target.ts`, or the two would load in a cycle. -Issue and dashboard suggestions come from the `completion_cache` table. The +Cached entities (issues, dashboards, environments, releases, teams, +monitors, alert workflows, integrations, Sentry Apps and their issue-link +form fields, DSNs) come from the `completion_cache` table. The authenticated fetch in `src/lib/sentry-client.ts` offers every API response to `src/lib/api/completion-seed.ts`, which stores entities from the `@sentry/api` operations it lists (by URL template, type-checked against the SDK), so API functions never seed by hand. To complete a new entity type, add it to -`CompletionEntityKind`, its operations in `completion-seed.ts`, a tagged -parser, and an `entityCompleter` entry in `src/lib/complete.ts`. To add a +`CompletionEntityKind`, its operations in `completion-seed.ts` (one +response may seed several kinds), a tagged parser, and an `entityCompleter` +(target positionals) or `flagEntityCompleter` (flag values) entry in +`src/lib/complete.ts`. To add a kind that is not an entity, add its entry to `COMPLETERS` (`src/lib/complete.ts`), whose keys are the `CompletionTarget` type, a `*_TARGET` name for it in `completion-target.ts`, and a pre-tagged parser. diff --git a/packages/cli/src/commands/alert/issues/create.ts b/packages/cli/src/commands/alert/issues/create.ts index 757c79c64..bd1298c90 100644 --- a/packages/cli/src/commands/alert/issues/create.ts +++ b/packages/cli/src/commands/alert/issues/create.ts @@ -11,7 +11,12 @@ import { } from "../../../lib/api-client.js"; import type { ParsedOrgProject } from "../../../lib/arg-parsing.js"; import { buildCommand, numberParser } from "../../../lib/command.js"; -import { choiceParser, parseText } from "../../../lib/completion-target.js"; +import { + choiceParser, + parseEnvironment, + parseOwner, + parseText, +} from "../../../lib/completion-target.js"; import { ContextError, stringifyUnknown, @@ -177,7 +182,7 @@ export const createCommand = buildCommand({ }, environment: { kind: "parsed", - parse: parseText, + parse: parseEnvironment, optional: true, brief: "Environment filter", }, @@ -198,7 +203,7 @@ export const createCommand = buildCommand({ }, owner: { kind: "parsed", - parse: parseText, + parse: parseOwner, optional: true, brief: "Owner (team:user style value accepted by Sentry API)", }, diff --git a/packages/cli/src/commands/alert/issues/edit.ts b/packages/cli/src/commands/alert/issues/edit.ts index b5f097526..edbda8b52 100644 --- a/packages/cli/src/commands/alert/issues/edit.ts +++ b/packages/cli/src/commands/alert/issues/edit.ts @@ -11,7 +11,12 @@ import { updateIssueAlertRule, } from "../../../lib/api-client.js"; import { buildCommand, numberParser } from "../../../lib/command.js"; -import { choiceParser, parseText } from "../../../lib/completion-target.js"; +import { + choiceParser, + parseEnvironment, + parseOwner, + parseText, +} from "../../../lib/completion-target.js"; import { ContextError, stringifyUnknown, @@ -245,7 +250,7 @@ export const editCommand = buildCommand({ }, environment: { kind: "parsed", - parse: parseText, + parse: parseEnvironment, optional: true, brief: "Environment value (pass empty string to clear)", }, @@ -266,7 +271,7 @@ export const editCommand = buildCommand({ }, owner: { kind: "parsed", - parse: parseText, + parse: parseOwner, optional: true, brief: "Owner value (pass empty string to clear)", }, diff --git a/packages/cli/src/commands/alert/metrics/create.ts b/packages/cli/src/commands/alert/metrics/create.ts index d504eec63..a87e14aea 100644 --- a/packages/cli/src/commands/alert/metrics/create.ts +++ b/packages/cli/src/commands/alert/metrics/create.ts @@ -8,7 +8,14 @@ import type { SentryContext } from "../../../context.js"; import { createMetricAlertRule } from "../../../lib/api-client.js"; import type { ParsedOrgProject } from "../../../lib/arg-parsing.js"; import { buildCommand, numberParser } from "../../../lib/command.js"; -import { choiceParser, parseText } from "../../../lib/completion-target.js"; +import { + choiceParser, + parseEnvironment, + parseOwner, + parseProjectList, + parseText, + parseWorkflowList, +} from "../../../lib/completion-target.js"; import { stringifyUnknown, ValidationError } from "../../../lib/errors.js"; import { warning } from "../../../lib/formatters/colors.js"; import { CommandOutput } from "../../../lib/formatters/output.js"; @@ -24,6 +31,7 @@ import { parseMetricTriggers, } from "../../../types/metric-alert.js"; import { + METRIC_TIME_WINDOW_VALUES, normalizeProjectList, parseJsonObjectList, parseWorkflowIds, @@ -146,7 +154,7 @@ export const createCommand = buildCommand({ }, "time-window": { kind: "parsed", - parse: numberParser, + parse: choiceParser(METRIC_TIME_WINDOW_VALUES, numberParser), brief: "Evaluation window in minutes", }, trigger: { @@ -158,26 +166,26 @@ export const createCommand = buildCommand({ }, project: { kind: "parsed", - parse: parseText, + parse: parseProjectList, variadic: true, optional: true, brief: "Project slug filter (repeatable or comma-separated)", }, environment: { kind: "parsed", - parse: parseText, + parse: parseEnvironment, optional: true, brief: "Environment filter", }, owner: { kind: "parsed", - parse: parseText, + parse: parseOwner, optional: true, brief: "Owner value accepted by Sentry API", }, workflow: { kind: "parsed", - parse: parseText, + parse: parseWorkflowList, variadic: true, optional: true, brief: "Alert (workflow) id to notify (repeatable or comma-separated)", diff --git a/packages/cli/src/commands/alert/metrics/edit.ts b/packages/cli/src/commands/alert/metrics/edit.ts index e59811ad7..6a45d24e1 100644 --- a/packages/cli/src/commands/alert/metrics/edit.ts +++ b/packages/cli/src/commands/alert/metrics/edit.ts @@ -10,7 +10,14 @@ import { putMetricAlertRule, } from "../../../lib/api-client.js"; import { buildCommand, numberParser } from "../../../lib/command.js"; -import { choiceParser, parseText } from "../../../lib/completion-target.js"; +import { + choiceParser, + parseEnvironment, + parseOwner, + parseProjectList, + parseText, + parseWorkflowList, +} from "../../../lib/completion-target.js"; import { stringifyUnknown, ValidationError } from "../../../lib/errors.js"; import { warning } from "../../../lib/formatters/colors.js"; import { CommandOutput } from "../../../lib/formatters/output.js"; @@ -21,6 +28,7 @@ import { parseMetricTriggers, } from "../../../types/metric-alert.js"; import { + METRIC_TIME_WINDOW_VALUES, normalizeMetricDataset, normalizeProjectList, parseJsonObjectList, @@ -247,7 +255,7 @@ export const editCommand = buildCommand({ }, "time-window": { kind: "parsed", - parse: numberParser, + parse: choiceParser(METRIC_TIME_WINDOW_VALUES, numberParser), optional: true, brief: "Evaluation window in minutes", }, @@ -260,26 +268,26 @@ export const editCommand = buildCommand({ }, project: { kind: "parsed", - parse: parseText, + parse: parseProjectList, variadic: true, optional: true, brief: "Project slug filter (repeatable or comma-separated)", }, environment: { kind: "parsed", - parse: parseText, + parse: parseEnvironment, optional: true, brief: "Environment value (pass empty string to clear)", }, owner: { kind: "parsed", - parse: parseText, + parse: parseOwner, optional: true, brief: "Owner value (pass empty string to clear)", }, workflow: { kind: "parsed", - parse: parseText, + parse: parseWorkflowList, variadic: true, optional: true, brief: diff --git a/packages/cli/src/commands/alert/mutation-utils.ts b/packages/cli/src/commands/alert/mutation-utils.ts index 8ef281da4..af03ba5b0 100644 --- a/packages/cli/src/commands/alert/mutation-utils.ts +++ b/packages/cli/src/commands/alert/mutation-utils.ts @@ -25,6 +25,9 @@ const METRIC_TIME_WINDOWS = new Set([ 1, 5, 10, 15, 30, 60, 120, 240, 360, 720, 1440, ]); +/** The `--time-window` values a metric alert accepts, offered by completion. */ +export const METRIC_TIME_WINDOW_VALUES = [...METRIC_TIME_WINDOWS].map(String); + /** Parse and validate an "all" | "any" match mode flag. Returns `undefined` when absent. */ export function parseMatchMode( value: string | undefined, diff --git a/packages/cli/src/commands/api.ts b/packages/cli/src/commands/api.ts index 62738e2a4..da0a03d68 100644 --- a/packages/cli/src/commands/api.ts +++ b/packages/cli/src/commands/api.ts @@ -22,6 +22,7 @@ import { import { buildCommand } from "../lib/command.js"; import { choiceParser, + parseApiEndpoint, parseFilePath, parseText, } from "../lib/completion-target.js"; @@ -1697,7 +1698,7 @@ export const apiCommand = buildCommand({ parameters: [ { brief: "API endpoint relative to /api/0/, or an absolute HTTP(S) URL", - parse: parseText, + parse: parseApiEndpoint, placeholder: "endpoint", }, ], diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 854740d00..4b5f32c46 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -10,9 +10,10 @@ import { import { normalizeAuthToken } from "../../lib/auth-header.js"; import { buildCommand, numberParser } from "../../lib/command.js"; import { + ACCOUNT_SERVER_TARGET, choiceListParser, + completingParser, parseText, - textParser, } from "../../lib/completion-target.js"; import { normalizeUrl } from "../../lib/constants.js"; import { @@ -565,7 +566,7 @@ export const loginCommand = buildCommand({ }, url: { kind: "parsed", - parse: textParser(parseLoginUrl), + parse: completingParser(ACCOUNT_SERVER_TARGET, parseLoginUrl), brief: "Sentry instance URL to authenticate against (e.g. https://sentry.example.com). " + "Required for self-hosted; defaults to SaaS (https://sentry.io).", diff --git a/packages/cli/src/commands/auth/logout.ts b/packages/cli/src/commands/auth/logout.ts index a88af7dd6..a99a8c4d0 100644 --- a/packages/cli/src/commands/auth/logout.ts +++ b/packages/cli/src/commands/auth/logout.ts @@ -11,8 +11,9 @@ import { } from "../../lib/account-selection.js"; import { buildCommand } from "../../lib/command.js"; import { + ACCOUNT_SERVER_TARGET, + completingParser, parseAccountSelector, - textParser, } from "../../lib/completion-target.js"; import { type AccountSummary, @@ -127,7 +128,7 @@ export const logoutCommand = buildCommand({ flags: { url: { kind: "parsed", - parse: textParser(parseLoginUrl), + parse: completingParser(ACCOUNT_SERVER_TARGET, parseLoginUrl), brief: "Only match accounts on this server (disambiguates the same email on several servers)", optional: true, diff --git a/packages/cli/src/commands/auth/switch.ts b/packages/cli/src/commands/auth/switch.ts index 365e3e238..88a1a2858 100644 --- a/packages/cli/src/commands/auth/switch.ts +++ b/packages/cli/src/commands/auth/switch.ts @@ -12,8 +12,9 @@ import { } from "../../lib/account-selection.js"; import { buildCommand } from "../../lib/command.js"; import { + ACCOUNT_SERVER_TARGET, + completingParser, parseAccountSelector, - textParser, } from "../../lib/completion-target.js"; import { type AccountSummary, @@ -108,7 +109,7 @@ export const switchCommand = buildCommand({ flags: { url: { kind: "parsed", - parse: textParser(parseLoginUrl), + parse: completingParser(ACCOUNT_SERVER_TARGET, parseLoginUrl), brief: "Only consider accounts on this server (disambiguates the same email on several servers)", optional: true, diff --git a/packages/cli/src/commands/bash-hook.ts b/packages/cli/src/commands/bash-hook.ts index b67485f5f..0cb4c1df8 100644 --- a/packages/cli/src/commands/bash-hook.ts +++ b/packages/cli/src/commands/bash-hook.ts @@ -19,7 +19,12 @@ import { createEventEnvelope, makeDsn, serializeEnvelope } from "@sentry/core"; import type { SentryContext } from "../context.js"; import { buildBashErrorEvent } from "../lib/bash-hook/traceback.js"; import { buildCommand } from "../lib/command.js"; -import { parseFilePath, parseText } from "../lib/completion-target.js"; +import { + parseDsn, + parseFilePath, + parseRelease, + parseText, +} from "../lib/completion-target.js"; import { parseKeyValue } from "../lib/envelope/event-builder.js"; import { requireDsn, sendEnvelopeRequest } from "../lib/envelope/transport.js"; import { ValidationError } from "../lib/errors.js"; @@ -288,13 +293,13 @@ export const bashHookCommand = buildCommand({ }, release: { kind: "parsed", - parse: parseText, + parse: parseRelease, brief: "Set the release version for the event", optional: true, }, dsn: { kind: "parsed", - parse: parseText, + parse: parseDsn, brief: "DSN to send events to (overrides SENTRY_DSN env var)", optional: true, }, diff --git a/packages/cli/src/commands/build/upload.ts b/packages/cli/src/commands/build/upload.ts index e938d6cb6..4e8c2fcc6 100644 --- a/packages/cli/src/commands/build/upload.ts +++ b/packages/cli/src/commands/build/upload.ts @@ -27,14 +27,19 @@ import { } from "../../lib/build/index.js"; import { collectVcsMetadata, + VCS_PROVIDERS, type VcsFlags, vcsInfoToBody, } from "../../lib/build/vcs.js"; import { isCi } from "../../lib/ci.js"; import { buildCommand } from "../../lib/command.js"; import { + choiceParser, parseFilePath, + parseGitCommit, + parseGitRepo, parseText, + parseVcsRef, textParser, } from "../../lib/completion-target.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; @@ -266,45 +271,45 @@ export const uploadCommand = buildCommand({ }, "head-sha": { kind: "parsed", - parse: parseText, + parse: parseGitCommit, brief: "VCS commit SHA (defaults to the current commit)", optional: true, }, "base-sha": { kind: "parsed", - parse: parseText, + parse: parseGitCommit, brief: "VCS base commit SHA (defaults to the merge-base with the base ref)", optional: true, }, "vcs-provider": { kind: "parsed", - parse: parseText, + parse: choiceParser(VCS_PROVIDERS), brief: "VCS provider (defaults to the current remote's provider)", optional: true, }, "head-repo-name": { kind: "parsed", - parse: parseText, + parse: parseGitRepo, brief: "Head repository name, e.g. owner/repo (defaults to the current)", optional: true, }, "base-repo-name": { kind: "parsed", - parse: parseText, + parse: parseGitRepo, brief: "Base repository name, e.g. owner/repo (for forks)", optional: true, }, "head-ref": { kind: "parsed", - parse: parseText, + parse: parseVcsRef, brief: "Head branch/reference (defaults to the current branch)", optional: true, }, "base-ref": { kind: "parsed", - parse: parseText, + parse: parseVcsRef, brief: "Base branch/reference (defaults to the merge-base tracking ref)", optional: true, diff --git a/packages/cli/src/commands/code-mappings/upload.ts b/packages/cli/src/commands/code-mappings/upload.ts index a0c51cebf..4801936b6 100644 --- a/packages/cli/src/commands/code-mappings/upload.ts +++ b/packages/cli/src/commands/code-mappings/upload.ts @@ -23,7 +23,11 @@ import { uploadCodeMappings, } from "../../lib/api/code-mappings.js"; import { buildCommand } from "../../lib/command.js"; -import { parseFilePath, parseText } from "../../lib/completion-target.js"; +import { + parseFilePath, + parseGitRepo, + parseVcsRef, +} from "../../lib/completion-target.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { mdKvTable, renderMarkdown } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -213,14 +217,14 @@ export const uploadCommand = buildCommand({ flags: { repo: { kind: "parsed", - parse: parseText, + parse: parseGitRepo, brief: "Repository name (e.g., owner/repo). Auto-detected from git remote if omitted.", optional: true, }, "default-branch": { kind: "parsed", - parse: parseText, + parse: parseVcsRef, brief: "Default branch name. Auto-detected from git remote HEAD if omitted.", optional: true, diff --git a/packages/cli/src/commands/dashboard/widget/add.ts b/packages/cli/src/commands/dashboard/widget/add.ts index ec2c5aa11..bf1374bc0 100644 --- a/packages/cli/src/commands/dashboard/widget/add.ts +++ b/packages/cli/src/commands/dashboard/widget/add.ts @@ -26,6 +26,8 @@ import { type DashboardWidget, DISPLAY_TYPES, FALLBACK_LAYOUT, + GRID_COLUMN_POSITIONS, + GRID_WIDTHS, prepareDashboardForUpdate, validateWidgetLayout, WIDGET_LAYOUT_MODES, @@ -220,7 +222,7 @@ export const addCommand = buildCommand({ }, col: { kind: "parsed", - parse: numberParser, + parse: choiceParser(GRID_COLUMN_POSITIONS, numberParser), brief: "Grid column position (0-based, 0–5)", optional: true, }, @@ -232,7 +234,7 @@ export const addCommand = buildCommand({ }, width: { kind: "parsed", - parse: numberParser, + parse: choiceParser(GRID_WIDTHS, numberParser), brief: "Widget width in grid columns (1–6)", optional: true, }, diff --git a/packages/cli/src/commands/dashboard/widget/edit.ts b/packages/cli/src/commands/dashboard/widget/edit.ts index de08ab83f..847afd131 100644 --- a/packages/cli/src/commands/dashboard/widget/edit.ts +++ b/packages/cli/src/commands/dashboard/widget/edit.ts @@ -18,6 +18,8 @@ import { type DashboardWidgetQuery, DISPLAY_TYPES, FALLBACK_LAYOUT, + GRID_COLUMN_POSITIONS, + GRID_WIDTHS, parseAggregate, parseSortExpression, parseWidgetInput, @@ -297,7 +299,7 @@ export const editCommand = buildCommand({ }, col: { kind: "parsed", - parse: numberParser, + parse: choiceParser(GRID_COLUMN_POSITIONS, numberParser), brief: "Grid column position (0-based, 0–5)", optional: true, }, @@ -309,7 +311,7 @@ export const editCommand = buildCommand({ }, width: { kind: "parsed", - parse: numberParser, + parse: choiceParser(GRID_WIDTHS, numberParser), brief: "Widget width in grid columns (1–6)", optional: true, }, diff --git a/packages/cli/src/commands/event/send.ts b/packages/cli/src/commands/event/send.ts index 316934013..e8623d56b 100644 --- a/packages/cli/src/commands/event/send.ts +++ b/packages/cli/src/commands/event/send.ts @@ -14,6 +14,7 @@ import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; import { buildEventFromFlags, + KNOWN_USER_FIELDS, type SendEventFlags, } from "../../lib/envelope/event-builder.js"; import { @@ -25,14 +26,20 @@ import { sendEnvelopeRequest, } from "../../lib/envelope/transport.js"; import { + choiceParser, + parseEnvironment, parseFilePath, parsePlatform, + parseRelease, parseText, } from "../../lib/completion-target.js"; import { ConfigError, ValidationError } from "../../lib/errors.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { logger } from "../../lib/logger.js"; +/** `--user` keys with their own User field, offered as `:` prefixes. */ +const USER_FIELD_PREFIXES = [...KNOWN_USER_FIELDS].map((key) => `${key}:`); + const log = logger.withTag("event.send"); /** Shape of the data yielded to the output layer. */ @@ -237,7 +244,7 @@ built entirely from the file contents. }, release: { kind: "parsed", - parse: parseText, + parse: parseRelease, brief: "Release version", optional: true, }, @@ -249,7 +256,7 @@ built entirely from the file contents. }, env: { kind: "parsed", - parse: parseText, + parse: parseEnvironment, brief: "Environment name (e.g. production, staging)", optional: true, }, @@ -275,7 +282,7 @@ built entirely from the file contents. }, user: { kind: "parsed", - parse: parseText, + parse: choiceParser(USER_FIELD_PREFIXES), brief: "User info as KEY:VALUE — id, email, username, ip_address, or custom", variadic: true, diff --git a/packages/cli/src/commands/explore.ts b/packages/cli/src/commands/explore.ts index 57399da9a..434340a2a 100644 --- a/packages/cli/src/commands/explore.ts +++ b/packages/cli/src/commands/explore.ts @@ -23,6 +23,7 @@ import { import { validateLimit } from "../lib/arg-parsing.js"; import { choiceParser, + parseEnvironmentList, parseText, textParser, } from "../lib/completion-target.js"; @@ -59,7 +60,7 @@ import { targetPatternExplanation, } from "../lib/list-command.js"; import { logger } from "../lib/logger.js"; -import { resolveMetricField } from "../lib/metrics-transform.js"; +import { resolveMetricField, VALID_AGGS } from "../lib/metrics-transform.js"; import { withProgress } from "../lib/polling.js"; import { DEFAULT_REPLAY_EXPLORE_FIELDS, @@ -787,7 +788,7 @@ export const exploreCommand = buildListCommand("explore", { }, agg: { kind: "parsed", - parse: parseText, + parse: choiceParser([...VALID_AGGS]), brief: "Aggregation for --metric (sum, avg, count, p50, p95, etc.)", default: "sum", }, @@ -811,7 +812,7 @@ export const exploreCommand = buildListCommand("explore", { }, environment: { kind: "parsed", - parse: parseText, + parse: parseEnvironmentList, brief: "Environment filter (repeatable, comma-separated)", variadic: true, optional: true, diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index be68c60fc..e65d2d32d 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -29,6 +29,7 @@ import { buildCommand } from "../lib/command.js"; import { choiceListParser, ORG_PROJECT_OR_DIRECTORY_TARGET, + parseTeam, parseText, } from "../lib/completion-target.js"; import { refreshToken } from "../lib/db/auth.js"; @@ -428,7 +429,7 @@ export const initCommand = buildCommand< }, team: { kind: "parsed", - parse: parseText, + parse: parseTeam, brief: "Team slug to create the project under", optional: true, }, diff --git a/packages/cli/src/commands/issue/archive.ts b/packages/cli/src/commands/issue/archive.ts index d9f5c7f20..d6ca7c363 100644 --- a/packages/cli/src/commands/issue/archive.ts +++ b/packages/cli/src/commands/issue/archive.ts @@ -11,7 +11,7 @@ import { updateIssueStatus, } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; -import { parseText } from "../../lib/completion-target.js"; +import { parseArchiveUntil } from "../../lib/completion-target.js"; import { ValidationError } from "../../lib/errors.js"; import { formatIssueDetails, muted } from "../../lib/formatters/index.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -399,7 +399,7 @@ export const archiveCommand = buildCommand({ flags: { until: { kind: "parsed", - parse: parseText, + parse: parseArchiveUntil, brief: "Condition for unarchival: forever, auto, 30m, 10x, 10u, 10x/5m, etc.", optional: true, diff --git a/packages/cli/src/commands/issue/link-utils.ts b/packages/cli/src/commands/issue/link-utils.ts index 46bc30e0c..fa0feb9b1 100644 --- a/packages/cli/src/commands/issue/link-utils.ts +++ b/packages/cli/src/commands/issue/link-utils.ts @@ -1,6 +1,10 @@ /** Shared arguments for external issue association commands. */ -import { parseText, parseUrl } from "../../lib/completion-target.js"; +import { + parseIntegration, + parseSentryApp, + parseUrl, +} from "../../lib/completion-target.js"; import { ValidationError } from "../../lib/errors.js"; import { issueParameter } from "./positional.js"; @@ -23,13 +27,13 @@ export function externalIssuePositionals(command: string) { export const EXTERNAL_ISSUE_FLAGS = { integration: { kind: "parsed", - parse: parseText, + parse: parseIntegration, brief: "Native integration ID, when multiple installations match", optional: true, }, app: { kind: "parsed", - parse: parseText, + parse: parseSentryApp, brief: "Sentry App slug (automatically detected for Linear URLs)", optional: true, }, diff --git a/packages/cli/src/commands/issue/link.ts b/packages/cli/src/commands/issue/link.ts index 90086ab74..1f46bed53 100644 --- a/packages/cli/src/commands/issue/link.ts +++ b/packages/cli/src/commands/issue/link.ts @@ -2,7 +2,7 @@ import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; -import { parseText } from "../../lib/completion-target.js"; +import { parseAppLinkField } from "../../lib/completion-target.js"; import { formatIssueLinkResult } from "../../lib/formatters/issue-links.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { linkExternalIssue } from "../../lib/issue-links.js"; @@ -72,7 +72,7 @@ export const linkCommand = buildCommand({ "dry-run": DRY_RUN_FLAG, field: { kind: "parsed", - parse: parseText, + parse: parseAppLinkField, brief: "Additional Sentry App link form field (name=value, repeatable)", variadic: true, optional: true, diff --git a/packages/cli/src/commands/issue/merge.ts b/packages/cli/src/commands/issue/merge.ts index b00d0fde1..1be384c7d 100644 --- a/packages/cli/src/commands/issue/merge.ts +++ b/packages/cli/src/commands/issue/merge.ts @@ -23,7 +23,7 @@ import type { SentryContext } from "../../context.js"; import { type MergeIssuesResult, mergeIssues } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; -import { parseText } from "../../lib/completion-target.js"; +import { parseIssueReference } from "../../lib/completion-target.js"; import { ApiError, CliError, @@ -303,7 +303,7 @@ export const mergeCommand = buildCommand({ flags: { into: { kind: "parsed", - parse: parseText, + parse: parseIssueReference, brief: "Prefer this issue as the canonical parent (included in the merge if not already listed)", optional: true, diff --git a/packages/cli/src/commands/issue/tags.ts b/packages/cli/src/commands/issue/tags.ts index dc70faee1..a2d76c39b 100644 --- a/packages/cli/src/commands/issue/tags.ts +++ b/packages/cli/src/commands/issue/tags.ts @@ -10,7 +10,7 @@ import { description, object, pipe } from "valibot"; import type { SentryContext } from "../../context.js"; import { getIssueTag } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; -import { parseText } from "../../lib/completion-target.js"; +import { parseEnvironment, parseText } from "../../lib/completion-target.js"; import { ContextError } from "../../lib/errors.js"; import { formatIssueTags } from "../../lib/formatters/issue-tags.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -104,7 +104,7 @@ export const tagsCommand = buildCommand({ flags: { environment: { kind: "parsed", - parse: parseText, + parse: parseEnvironment, brief: "Filter to one environment (default: all environments)", optional: true, }, diff --git a/packages/cli/src/commands/local/loopback.ts b/packages/cli/src/commands/local/loopback.ts index a8d4e3613..187437e9b 100644 --- a/packages/cli/src/commands/local/loopback.ts +++ b/packages/cli/src/commands/local/loopback.ts @@ -3,9 +3,10 @@ export function normalizeLocalHostname(host: string): string { return host.replace(/^\[|\]$/g, "").toLowerCase(); } +/** Hostnames of Local's supported loopback interfaces. */ +export const LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "::1"]; + /** Whether a hostname identifies one of Local's supported loopback interfaces. */ export function isLoopbackHost(host: string): boolean { - return ["localhost", "127.0.0.1", "::1"].includes( - normalizeLocalHostname(host), - ); + return LOOPBACK_HOSTS.includes(normalizeLocalHostname(host)); } diff --git a/packages/cli/src/commands/local/management.ts b/packages/cli/src/commands/local/management.ts index 4a9ebb598..36d71ab2d 100644 --- a/packages/cli/src/commands/local/management.ts +++ b/packages/cli/src/commands/local/management.ts @@ -3,7 +3,11 @@ import { basename } from "node:path"; import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; -import { parseText, textParser } from "../../lib/completion-target.js"; +import { + choiceParser, + parseText, + textParser, +} from "../../lib/completion-target.js"; import { CliError, ValidationError } from "../../lib/errors.js"; import { formatLocalControlResult } from "../../lib/formatters/local.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -24,6 +28,7 @@ import { MAX_ENVELOPES_FLAG, OPTIONAL_MAX_ENVELOPES_FLAG, } from "./retention-buffer.js"; +import { LOOPBACK_HOSTS } from "./loopback.js"; type DaemonFlags = { readonly foreground: boolean; @@ -55,7 +60,7 @@ const CONTROL_COMMAND_DEFAULTS = { const DAEMON_CONNECTION_FLAGS = { host: { kind: "parsed", - parse: parseText, + parse: choiceParser(LOOPBACK_HOSTS), brief: "Loopback host", default: "localhost", }, diff --git a/packages/cli/src/commands/local/run.ts b/packages/cli/src/commands/local/run.ts index 31c38a812..d1faf2d7d 100644 --- a/packages/cli/src/commands/local/run.ts +++ b/packages/cli/src/commands/local/run.ts @@ -68,6 +68,7 @@ import { createLocalRetentionBuffer, OPTIONAL_MAX_ENVELOPES_FLAG, } from "./retention-buffer.js"; +import { LOOPBACK_HOSTS } from "./loopback.js"; type RunFlags = { readonly port: number; @@ -460,7 +461,7 @@ export const runCommand = buildCommand({ }, host: { kind: "parsed", - parse: parseText, + parse: choiceParser(LOOPBACK_HOSTS), brief: "Hostname for the local server", default: "localhost", }, diff --git a/packages/cli/src/commands/local/server.ts b/packages/cli/src/commands/local/server.ts index dd919e49d..bdbf8b816 100644 --- a/packages/cli/src/commands/local/server.ts +++ b/packages/cli/src/commands/local/server.ts @@ -5,7 +5,7 @@ import { } from "../../lib/completion-target.js"; import { isHostedUiOrigin, LOCAL_SESSION_EVENT } from "./viewer-protocol.js"; export { isHostedUiOrigin } from "./viewer-protocol.js"; -import { isLoopbackHost } from "./loopback.js"; +import { isLoopbackHost, LOOPBACK_HOSTS } from "./loopback.js"; /** * sentry local serve * @@ -1062,7 +1062,7 @@ export const serverCommand = buildCommand({ }, host: { kind: "parsed", - parse: parseText, + parse: choiceParser(LOOPBACK_HOSTS), brief: "Hostname to bind to", default: "localhost", }, diff --git a/packages/cli/src/commands/monitor/run.ts b/packages/cli/src/commands/monitor/run.ts index 077fcaa81..f48a85d1a 100644 --- a/packages/cli/src/commands/monitor/run.ts +++ b/packages/cli/src/commands/monitor/run.ts @@ -36,6 +36,9 @@ import { } from "../../lib/envelope/transport.js"; import { choiceParser, + parseDsn, + parseEnvironment, + parseMonitorCommand, parseText, textParser, } from "../../lib/completion-target.js"; @@ -202,20 +205,20 @@ The wrapped command receives the \`SENTRY_MONITOR_SLUG\` environment variable.`, kind: "array", parameter: { brief: "Monitor slug followed by the command to run", - parse: parseText, + parse: parseMonitorCommand, placeholder: "monitor-slug command", }, }, flags: { dsn: { kind: "parsed", - parse: parseText, + parse: parseDsn, brief: "DSN to send check-ins to (overrides SENTRY_DSN env var)", optional: true, }, environment: { kind: "parsed", - parse: parseText, + parse: parseEnvironment, brief: "Environment of the monitor", default: "production", }, diff --git a/packages/cli/src/commands/org/list.ts b/packages/cli/src/commands/org/list.ts index aa7f230cc..8ba71a743 100644 --- a/packages/cli/src/commands/org/list.ts +++ b/packages/cli/src/commands/org/list.ts @@ -11,7 +11,7 @@ import { listOrganizationsUncached } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; import { parseAccountSelector, - parseText, + parseOrgSlug, } from "../../lib/completion-target.js"; import { DEFAULT_SENTRY_HOST } from "../../lib/constants.js"; import { @@ -464,7 +464,7 @@ export const listCommand = buildCommand({ placeholder: "filter", brief: "Only list organizations whose slug or name fuzzy-matches this text or matches this glob", - parse: parseText, + parse: parseOrgSlug, optional: true, }, ], diff --git a/packages/cli/src/commands/platform/list.ts b/packages/cli/src/commands/platform/list.ts index a7edc68aa..bb36a759d 100644 --- a/packages/cli/src/commands/platform/list.ts +++ b/packages/cli/src/commands/platform/list.ts @@ -14,7 +14,7 @@ import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; -import { parseText } from "../../lib/completion-target.js"; +import { parsePlatform } from "../../lib/completion-target.js"; import { OutputError } from "../../lib/errors.js"; import { muted } from "../../lib/formatters/colors.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -59,7 +59,7 @@ export const listCommand = buildCommand({ flags: { search: { kind: "parsed", - parse: parseText, + parse: parsePlatform, brief: "Filter platforms by substring", optional: true, }, diff --git a/packages/cli/src/commands/project/create.ts b/packages/cli/src/commands/project/create.ts index 70c9477f5..7fd2c49dd 100644 --- a/packages/cli/src/commands/project/create.ts +++ b/packages/cli/src/commands/project/create.ts @@ -28,7 +28,7 @@ import { } from "../../lib/api-client.js"; import { parseOrgProjectInput } from "../../lib/arg-parsing.js"; import { buildCommand } from "../../lib/command.js"; -import { parseText, PROJECT_SPEC_TARGET } from "../../lib/completion-target.js"; +import { parseTeam, PROJECT_SPEC_TARGET } from "../../lib/completion-target.js"; import { ApiError, CliError, @@ -729,7 +729,7 @@ export const createCommand = buildCommand({ flags: { team: { kind: "parsed", - parse: parseText, + parse: parseTeam, brief: "Team to create the project under", optional: true, }, diff --git a/packages/cli/src/commands/react-native/gradle.ts b/packages/cli/src/commands/react-native/gradle.ts index 38eee9b45..a2bac075c 100644 --- a/packages/cli/src/commands/react-native/gradle.ts +++ b/packages/cli/src/commands/react-native/gradle.ts @@ -18,7 +18,11 @@ import { uploadSourcemaps, } from "../../lib/api/sourcemaps.js"; import { buildCommand, numberParser } from "../../lib/command.js"; -import { parseFilePath, parseText } from "../../lib/completion-target.js"; +import { + parseFilePath, + parseRelease, + parseText, +} from "../../lib/completion-target.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { mdKvTable, renderMarkdown } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -162,7 +166,7 @@ export const gradleCommand = buildCommand({ }, release: { kind: "parsed", - parse: parseText, + parse: parseRelease, brief: "Release version to publish to", optional: true, }, diff --git a/packages/cli/src/commands/release/create.ts b/packages/cli/src/commands/release/create.ts index 5cb588d99..236a0c7e9 100644 --- a/packages/cli/src/commands/release/create.ts +++ b/packages/cli/src/commands/release/create.ts @@ -7,7 +7,11 @@ import type { SentryContext } from "../../context.js"; import { createRelease } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; -import { parseText, parseUrl } from "../../lib/completion-target.js"; +import { + parseProjectList, + parseUrl, + parseVcsRef, +} from "../../lib/completion-target.js"; import { colorTag, escapeMarkdownInline, @@ -121,7 +125,7 @@ export const createCommand = buildCommand({ flags: { project: { kind: "parsed", - parse: parseText, + parse: parseProjectList, brief: "Associate with project(s), comma-separated", optional: true, }, @@ -132,7 +136,7 @@ export const createCommand = buildCommand({ }, ref: { kind: "parsed", - parse: parseText, + parse: parseVcsRef, brief: "Git ref (branch or tag name)", optional: true, }, diff --git a/packages/cli/src/commands/release/deploy.ts b/packages/cli/src/commands/release/deploy.ts index bdd528609..4af686c51 100644 --- a/packages/cli/src/commands/release/deploy.ts +++ b/packages/cli/src/commands/release/deploy.ts @@ -8,7 +8,11 @@ import type { SentryContext } from "../../context.js"; import { createReleaseDeploy } from "../../lib/api-client.js"; import { buildCommand, numberParser } from "../../lib/command.js"; -import { parseText, parseUrl } from "../../lib/completion-target.js"; +import { + parseEnvironment, + parseText, + parseUrl, +} from "../../lib/completion-target.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { mdKvTable, @@ -112,7 +116,7 @@ export const deployCommand = buildCommand({ { placeholder: "environment", brief: "Deploy environment (e.g. production)", - parse: parseText, + parse: parseEnvironment, }, { placeholder: "name", diff --git a/packages/cli/src/commands/release/list.ts b/packages/cli/src/commands/release/list.ts index f65e52125..6bcea823e 100644 --- a/packages/cli/src/commands/release/list.ts +++ b/packages/cli/src/commands/release/list.ts @@ -14,7 +14,11 @@ import { type ReleaseSortValue, } from "../../lib/api-client.js"; import type { ParsedOrgProject } from "../../lib/arg-parsing.js"; -import { choiceParser, parseText } from "../../lib/completion-target.js"; +import { + choiceParser, + parseEnvironmentList, + parseRelativePeriod, +} from "../../lib/completion-target.js"; import { colorTag, escapeMarkdownInline, @@ -693,14 +697,14 @@ export const listCommand = buildListCommand("release", { }, environment: { kind: "parsed" as const, - parse: parseText, + parse: parseEnvironmentList, brief: "Filter by environment (repeatable, comma-separated)", variadic: true as const, optional: true as const, }, period: { kind: "parsed" as const, - parse: parseText, + parse: parseRelativePeriod, brief: "Health stats period (e.g., 24h, 7d, 14d, 90d)", default: "90d", }, diff --git a/packages/cli/src/commands/release/set-commits.ts b/packages/cli/src/commands/release/set-commits.ts index f2ff7a9de..28ec894a7 100644 --- a/packages/cli/src/commands/release/set-commits.ts +++ b/packages/cli/src/commands/release/set-commits.ts @@ -12,7 +12,11 @@ import { setCommitsWithRefs, } from "../../lib/api-client.js"; import { buildCommand, numberParser } from "../../lib/command.js"; -import { parseFilePath, parseText } from "../../lib/completion-target.js"; +import { + parseFilePath, + parseGitRef, + parseRepoCommitList, +} from "../../lib/completion-target.js"; import { getDatabase } from "../../lib/db/index.js"; import { clearMetadata, getMetadata, setMetadata } from "../../lib/db/utils.js"; import { @@ -423,7 +427,7 @@ export const setCommitsCommand = buildCommand({ }, commit: { kind: "parsed", - parse: parseText, + parse: parseRepoCommitList, brief: "Explicit commit as REPO@SHA or REPO@PREV..SHA (comma-separated)", optional: true, @@ -437,7 +441,7 @@ export const setCommitsCommand = buildCommand({ }, from: { kind: "parsed", - parse: parseText, + parse: parseGitRef, brief: "Read the local range ..HEAD (e.g. previous release tag). Implies --local.", optional: true, diff --git a/packages/cli/src/commands/replay/list.ts b/packages/cli/src/commands/replay/list.ts index 82f93acaa..2a0028c61 100644 --- a/packages/cli/src/commands/replay/list.ts +++ b/packages/cli/src/commands/replay/list.ts @@ -13,7 +13,7 @@ import { import { validateLimit } from "../../lib/arg-parsing.js"; import { choiceParser, - parseText, + parseEnvironmentList, textParser, } from "../../lib/completion-target.js"; import { @@ -343,7 +343,7 @@ export const listCommand = buildListCommand("replay", { }, environment: { kind: "parsed", - parse: parseText, + parse: parseEnvironmentList, brief: "Filter by environment (repeatable, comma-separated)", variadic: true, optional: true, diff --git a/packages/cli/src/commands/schema.ts b/packages/cli/src/commands/schema.ts index c579cbfe5..360d3e16d 100644 --- a/packages/cli/src/commands/schema.ts +++ b/packages/cli/src/commands/schema.ts @@ -12,6 +12,7 @@ import { findEndpointsByIdentifier, findEndpointsByPath, getAllEndpoints, + getAllResources, getEndpointsByResource, getResourceSummaries, parseEndpointQuery, @@ -20,7 +21,7 @@ import { searchEndpoints, } from "../lib/api-schema.js"; import { buildCommand } from "../lib/command.js"; -import { parseText } from "../lib/completion-target.js"; +import { choiceParser, parseText } from "../lib/completion-target.js"; import { OutputError, ResolutionError } from "../lib/errors.js"; import { bold, cyan, muted, yellow } from "../lib/formatters/colors.js"; import { filterFields } from "../lib/formatters/json.js"; @@ -519,7 +520,8 @@ export const schemaCommand = buildCommand({ parameter: { brief: "API concept, CLI command path, exact operation ID, or METHOD /path", - parse: parseText, + // The first word completes the API resources; the rest are free + parse: choiceParser(getAllResources()), placeholder: "query", }, }, diff --git a/packages/cli/src/commands/snapshots/download.ts b/packages/cli/src/commands/snapshots/download.ts index 31b5c88b9..57acc02e8 100644 --- a/packages/cli/src/commands/snapshots/download.ts +++ b/packages/cli/src/commands/snapshots/download.ts @@ -18,7 +18,11 @@ import { waitForSnapshotArchive, } from "../../lib/api/preprod-artifacts.js"; import { buildCommand } from "../../lib/command.js"; -import { parseDirectoryPath, parseText } from "../../lib/completion-target.js"; +import { + parseDirectoryPath, + parseText, + parseVcsRef, +} from "../../lib/completion-target.js"; import { getDefaultProject } from "../../lib/db/defaults.js"; import { ApiError, @@ -193,7 +197,7 @@ export const downloadCommand = buildCommand({ }, branch: { kind: "parsed", - parse: parseText, + parse: parseVcsRef, brief: "Git branch filter (only with --app-id)", optional: true, }, diff --git a/packages/cli/src/commands/snapshots/upload.ts b/packages/cli/src/commands/snapshots/upload.ts index b4c6f79d5..18e1b0aa6 100644 --- a/packages/cli/src/commands/snapshots/upload.ts +++ b/packages/cli/src/commands/snapshots/upload.ts @@ -16,6 +16,7 @@ import { } from "../../lib/api/preprod-artifacts.js"; import { collectVcsMetadata, + VCS_PROVIDERS, type VcsFlags, type VcsInfo, vcsInfoToBody, @@ -23,9 +24,13 @@ import { import { isCi } from "../../lib/ci.js"; import { buildCommand } from "../../lib/command.js"; import { + choiceParser, parseDirectoryPath, parseFilePath, + parseGitCommit, + parseGitRepo, parseText, + parseVcsRef, textParser, } from "../../lib/completion-target.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; @@ -415,45 +420,45 @@ export const uploadCommand = buildCommand({ }, "head-sha": { kind: "parsed", - parse: parseText, + parse: parseGitCommit, brief: "VCS commit SHA (defaults to the current commit)", optional: true, }, "base-sha": { kind: "parsed", - parse: parseText, + parse: parseGitCommit, brief: "VCS base commit SHA (defaults to the merge-base with the base ref)", optional: true, }, "vcs-provider": { kind: "parsed", - parse: parseText, + parse: choiceParser(VCS_PROVIDERS), brief: "VCS provider (defaults to the current remote's provider)", optional: true, }, "head-repo-name": { kind: "parsed", - parse: parseText, + parse: parseGitRepo, brief: "Head repository name, e.g. owner/repo (defaults to the current)", optional: true, }, "base-repo-name": { kind: "parsed", - parse: parseText, + parse: parseGitRepo, brief: "Base repository name, e.g. owner/repo (for forks)", optional: true, }, "head-ref": { kind: "parsed", - parse: parseText, + parse: parseVcsRef, brief: "Head branch/reference (defaults to the current branch)", optional: true, }, "base-ref": { kind: "parsed", - parse: parseText, + parse: parseVcsRef, brief: "Base branch/reference (defaults to the merge-base tracking ref)", optional: true, diff --git a/packages/cli/src/commands/sourcemap/inject.ts b/packages/cli/src/commands/sourcemap/inject.ts index 5ec164dae..e885ac781 100644 --- a/packages/cli/src/commands/sourcemap/inject.ts +++ b/packages/cli/src/commands/sourcemap/inject.ts @@ -8,6 +8,7 @@ import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; import { + choiceListParser, parseDirectoryPath, parseFilePath, parseText, @@ -22,10 +23,11 @@ import { assertDirectoryReadable, buildEmptyDiscoveryError, buildIgnoreMatcher, + DEFAULT_EXTENSIONS, diagnoseEmptyDiscovery, discoverFilePairs, - type InjectResult, injectDirectory, + type InjectResult, } from "../../lib/sourcemap/inject.js"; /** Result type for the inject command output. */ @@ -108,7 +110,7 @@ export const injectCommand = buildCommand({ flags: { ext: { kind: "parsed", - parse: parseText, + parse: choiceListParser([...DEFAULT_EXTENSIONS]), brief: "Comma-separated file extensions to process (default: .js,.cjs,.mjs)", optional: true, diff --git a/packages/cli/src/commands/sourcemap/resolve.ts b/packages/cli/src/commands/sourcemap/resolve.ts index da75fdd8a..e812826f0 100644 --- a/packages/cli/src/commands/sourcemap/resolve.ts +++ b/packages/cli/src/commands/sourcemap/resolve.ts @@ -11,6 +11,7 @@ import { relative, resolve as resolvePath } from "node:path"; import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; import { + choiceListParser, parseDirectoryPath, parseFilePath, parseText, @@ -25,6 +26,7 @@ import { CommandOutput } from "../../lib/formatters/output.js"; import { assertDirectoryReadable, buildIgnoreMatcher, + DEFAULT_EXTENSIONS, resolveDirectorySourcemaps, } from "../../lib/sourcemap/inject.js"; @@ -151,7 +153,7 @@ export const resolveCommand = buildCommand({ flags: { ext: { kind: "parsed", - parse: parseText, + parse: choiceListParser([...DEFAULT_EXTENSIONS]), brief: "Comma-separated file extensions to process (default: .js,.cjs,.mjs)", optional: true, diff --git a/packages/cli/src/commands/sourcemap/upload.ts b/packages/cli/src/commands/sourcemap/upload.ts index 5b4416dc5..443661bbb 100644 --- a/packages/cli/src/commands/sourcemap/upload.ts +++ b/packages/cli/src/commands/sourcemap/upload.ts @@ -18,8 +18,10 @@ import { } from "../../lib/api/sourcemaps.js"; import { buildCommand } from "../../lib/command.js"; import { + choiceListParser, parseDirectoryPath, parseFilePath, + parseRelease, parseText, } from "../../lib/completion-target.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; @@ -30,10 +32,11 @@ import { assertDirectoryReadable, buildEmptyDiscoveryError, buildIgnoreMatcher, + DEFAULT_EXTENSIONS, diagnoseEmptyDiscovery, discoverFilePairs, - type InjectResult, injectDirectory, + type InjectResult, } from "../../lib/sourcemap/inject.js"; /** Result type for the upload command. */ @@ -267,7 +270,7 @@ export const uploadCommand = buildCommand({ flags: { release: { kind: "parsed", - parse: parseText, + parse: parseRelease, brief: "Release version to associate with the upload", optional: true, }, @@ -287,7 +290,7 @@ export const uploadCommand = buildCommand({ }, ext: { kind: "parsed", - parse: parseText, + parse: choiceListParser([...DEFAULT_EXTENSIONS]), brief: "Comma-separated file extensions to process (default: .js,.cjs,.mjs)", optional: true, diff --git a/packages/cli/src/lib/api/completion-seed.ts b/packages/cli/src/lib/api/completion-seed.ts index c17743351..513c2ce82 100644 --- a/packages/cli/src/lib/api/completion-seed.ts +++ b/packages/cli/src/lib/api/completion-seed.ts @@ -6,9 +6,9 @@ * response that passes through the authenticated fetch in * `src/lib/sentry-client.ts` is offered to {@link seedCompletions}, * which matches its path against {@link COMPLETION_SOURCES}: the - * `@sentry/api` operations that return issues or dashboards, by their URL - * templates. Any command that - * lists or fetches issues or dashboards therefore seeds the cache, without + * `@sentry/api` operations that return a completed entity (issues, + * dashboards, environments, releases, teams, …), by their URL templates. + * Any command that lists or fetches them therefore seeds the cache, without * the API functions having to remember to. * * To complete a new entity type, add its kind to `db/completion-cache.ts` and @@ -22,8 +22,24 @@ import type { GetOrganizationDashboardData, GetOrganizationIssueData, + GetOrganizationMonitorData, + GetOrganizationReleaseData, + GetOrganizationWorkflowData, + GetProjectMonitorData, ListOrganizationDashboardsData, + ListOrganizationEnvironmentsData, + ListOrganizationIntegrationsData, ListOrganizationIssuesData, + ListOrganizationMonitorsData, + ListOrganizationReleasesData, + ListOrganizationSentryAppComponentsData, + ListOrganizationSentryAppInstallationsData, + ListOrganizationTeamsData, + ListOrganizationWorkflowsData, + ListProjectEnvironmentsData, + ListProjectKeysData, + ListProjectReleasesData, + ListProjectTeamsData, ResolveOrganizationShortIdData, } from "@sentry/api"; import { @@ -88,8 +104,130 @@ function dashboardEntities(body: unknown): CompletionEntity[] { }); } +/** An object property of an untyped JSON value. */ +function objectField(value: unknown, key: string): unknown { + return typeof value === "object" && value !== null + ? (value as Record)[key] + : undefined; +} + +/** + * Entities read from one string property of each item, with another as + * description when it differs (`name` next to a team's `slug`). + */ +function fieldEntities( + valueKey: string, + labelKey?: string, +): (body: unknown) => CompletionEntity[] { + return (body) => + items(body).flatMap((item) => { + const value = stringField(item, valueKey); + const label = labelKey ? stringField(item, labelKey) : undefined; + return value + ? [{ value, label: label && label !== value ? label : undefined }] + : []; + }); +} + +/** Integrations by ID, the form `--integration` takes, described by name. */ +function integrationEntities(body: unknown): CompletionEntity[] { + return items(body).flatMap((integration) => { + const value = stringField(integration, "id"); + const provider = stringField(objectField(integration, "provider"), "name"); + const name = stringField(integration, "name"); + return value + ? [{ value, label: [provider, name].filter(Boolean).join(": ") }] + : []; + }); +} + +/** Installed Sentry Apps by slug, the form `--app` takes. */ +function sentryAppEntities(body: unknown): CompletionEntity[] { + return items(body).flatMap((installation) => { + const app = objectField(installation, "app"); + const value = stringField(app, "slug"); + const name = stringField(app, "name"); + return value + ? [{ value, label: name && name !== value ? name : undefined }] + : []; + }); +} + +/** Teams as the `team:` actor that `--owner` takes, described by slug. */ +function teamOwnerEntities(body: unknown): CompletionEntity[] { + return items(body).flatMap((team) => { + const id = stringField(team, "id"); + const slug = stringField(team, "slug"); + return id ? [{ value: `team:${id}`, label: slug && `#${slug}` }] : []; + }); +} + +/** A choice of a form field: `[value, label]`. */ +function choiceValue(choice: unknown): string | undefined { + const value = Array.isArray(choice) ? choice[0] : undefined; + return typeof value === "string" || typeof value === "number" + ? String(value) + : undefined; +} + +/** + * The fields of the issue-link forms of installed Sentry Apps, the + * `name=value` pairs `issue link --field` takes: `name=` for each field, + * then `name=value` for each fixed choice of a select field. Choices an + * app loads from its own server (`uri`) are not in the response. + */ +function appLinkFieldEntities(body: unknown): CompletionEntity[] { + return items(body).flatMap((component) => { + if (stringField(component, "type") !== "issue-link") { + return []; + } + const app = stringField(objectField(component, "sentryApp"), "slug"); + const link = objectField(objectField(component, "schema"), "link"); + return (["required_fields", "optional_fields"] as const).flatMap( + (group) => { + const fields = objectField(link, group); + if (!Array.isArray(fields)) { + return []; + } + const label = [ + app, + group === "required_fields" ? "required" : undefined, + ] + .filter(Boolean) + .join(", "); + return fields.flatMap((field) => { + const name = stringField(field, "name"); + if (!name) { + return []; + } + const choices = + objectField(field, "choices") ?? objectField(field, "options"); + const values = Array.isArray(choices) + ? choices.flatMap((choice) => { + const value = choiceValue(choice); + return value === undefined ? [] : [`${name}=${value}`]; + }) + : []; + return [`${name}=`, ...values].map((value) => ({ + value, + label: label || undefined, + })); + }); + }, + ); + }); +} + +/** Public DSNs of client keys, described by the key name. */ +function dsnEntities(body: unknown): CompletionEntity[] { + return items(body).flatMap((key) => { + const value = stringField(objectField(key, "dsn"), "public"); + return value ? [{ value, label: stringField(key, "name") }] : []; + }); +} + /** - * Operations that return issues or dashboards. Each `satisfies` ties a + * Operations that return completed entities. Each `satisfies` ties a * template to the generated `url` of an `@sentry/api` operation, so an * endpoint the SDK renames or drops fails type checking. A template serves * every method on it (e.g. `getOrganizationIssue` and @@ -139,6 +277,96 @@ const COMPLETION_SOURCES: readonly CompletionSource[] = [ url: "/api/0/organizations/{organization_id_or_slug}/dashboards/{dashboard_id}/" satisfies GetOrganizationDashboardData["url"], extract: dashboardEntities, }, + { + kind: "environment", + url: "/api/0/organizations/{organization_id_or_slug}/environments/" satisfies ListOrganizationEnvironmentsData["url"], + extract: fieldEntities("name"), + }, + { + kind: "environment", + url: "/api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/environments/" satisfies ListProjectEnvironmentsData["url"], + extract: fieldEntities("name"), + }, + { + kind: "release", + url: "/api/0/organizations/{organization_id_or_slug}/releases/" satisfies ListOrganizationReleasesData["url"], + extract: fieldEntities("version", "shortVersion"), + }, + { + kind: "release", + url: "/api/0/organizations/{organization_id_or_slug}/releases/{version}/" satisfies GetOrganizationReleaseData["url"], + extract: fieldEntities("version", "shortVersion"), + }, + { + kind: "release", + url: "/api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/releases/" satisfies ListProjectReleasesData["url"], + extract: fieldEntities("version", "shortVersion"), + }, + { + kind: "team", + url: "/api/0/organizations/{organization_id_or_slug}/teams/" satisfies ListOrganizationTeamsData["url"], + extract: fieldEntities("slug", "name"), + }, + { + kind: "team", + url: "/api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/teams/" satisfies ListProjectTeamsData["url"], + extract: fieldEntities("slug", "name"), + }, + { + kind: "owner", + url: "/api/0/organizations/{organization_id_or_slug}/teams/" satisfies ListOrganizationTeamsData["url"], + extract: teamOwnerEntities, + }, + { + kind: "owner", + url: "/api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/teams/" satisfies ListProjectTeamsData["url"], + extract: teamOwnerEntities, + }, + { + kind: "monitor", + url: "/api/0/organizations/{organization_id_or_slug}/monitors/" satisfies ListOrganizationMonitorsData["url"], + extract: fieldEntities("slug", "name"), + }, + { + kind: "monitor", + url: "/api/0/organizations/{organization_id_or_slug}/monitors/{monitor_id_or_slug}/" satisfies GetOrganizationMonitorData["url"], + extract: fieldEntities("slug", "name"), + }, + { + kind: "monitor", + url: "/api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/monitors/{monitor_id_or_slug}/" satisfies GetProjectMonitorData["url"], + extract: fieldEntities("slug", "name"), + }, + { + kind: "workflow", + url: "/api/0/organizations/{organization_id_or_slug}/workflows/" satisfies ListOrganizationWorkflowsData["url"], + extract: fieldEntities("id", "name"), + }, + { + kind: "workflow", + url: "/api/0/organizations/{organization_id_or_slug}/workflows/{workflow_id}/" satisfies GetOrganizationWorkflowData["url"], + extract: fieldEntities("id", "name"), + }, + { + kind: "integration", + url: "/api/0/organizations/{organization_id_or_slug}/integrations/" satisfies ListOrganizationIntegrationsData["url"], + extract: integrationEntities, + }, + { + kind: "sentry-app", + url: "/api/0/organizations/{organization_id_or_slug}/sentry-app-installations/" satisfies ListOrganizationSentryAppInstallationsData["url"], + extract: sentryAppEntities, + }, + { + kind: "app-link-field", + url: "/api/0/organizations/{organization_id_or_slug}/sentry-app-components/" satisfies ListOrganizationSentryAppComponentsData["url"], + extract: appLinkFieldEntities, + }, + { + kind: "dsn", + url: "/api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/keys/" satisfies ListProjectKeysData["url"], + extract: dsnEntities, + }, ]; /** Path parameter in an `@sentry/api` URL template. */ @@ -161,25 +389,30 @@ const SOURCE_PATTERNS = COMPLETION_SOURCES.map((source) => ({ pattern: templatePattern(source.url), })); -/** The source for a URL and the organization it names, if any. */ -function matchSource( - url: string, -): { source: CompletionSource; orgSlug?: string } | undefined { +/** A source matching a request URL, with the organization its path names. */ +type SourceMatch = { source: CompletionSource; orgSlug?: string }; + +/** + * The sources for a URL and the organization each names, if any. One + * response can hold several kinds (a team list gives both `--team` slugs + * and `--owner` actors). + */ +function matchSources(url: string): SourceMatch[] { let pathname: string; try { pathname = new URL(url).pathname; } catch (error) { log.debug(`Not seeding completions from unparsable URL '${url}'`, error); - return; + return []; } - for (const { source, pattern } of SOURCE_PATTERNS) { + return SOURCE_PATTERNS.flatMap(({ source, pattern }) => { const match = pattern.exec(pathname); - if (match) { - const org = match[1]; - return { source, orgSlug: org ? decodeURIComponent(org) : undefined }; + if (!match) { + return []; } - } - return; + const org = match[1]; + return [{ source, orgSlug: org ? decodeURIComponent(org) : undefined }]; + }); } /** @@ -200,41 +433,43 @@ export async function seedCompletions( response: Response, ): Promise { if (response.ok && response.headers.get("Content-Type")?.includes("json")) { - const match = matchSource(url); - if (match) { - await seedFromBody(url, response, match); + const matches = matchSources(url); + if (matches.length > 0) { + await seedFromBody(url, response, matches); } } return response; } -/** Store the entities of a response whose URL matched `match`. */ +/** Store the entities of a response whose URL matched `matches`. */ async function seedFromBody( url: string, response: Response, - { source, orgSlug: pathOrg }: { source: CompletionSource; orgSlug?: string }, + matches: readonly SourceMatch[], ): Promise { let body: unknown; try { body = await response.clone().json(); } catch (error) { - log.debug(`Not seeding ${source.kind} completions from '${url}'`, error); - return; - } - const orgSlug = pathOrg ?? source.org?.(body); - if (!orgSlug) { - log.debug( - `Not seeding ${source.kind} completions without an org: '${url}'`, - ); + log.debug(`Not seeding completions from '${url}'`, error); return; } - try { - cacheCompletionEntities(source.kind, orgSlug, source.extract(body)); - } catch (error) { - log.debug( - `Failed to cache ${source.kind} completions for '${orgSlug}'`, - error, - ); - reportCliError(error); + for (const { source, orgSlug: pathOrg } of matches) { + const orgSlug = pathOrg ?? source.org?.(body); + if (!orgSlug) { + log.debug( + `Not seeding ${source.kind} completions without an org: '${url}'`, + ); + continue; + } + try { + cacheCompletionEntities(source.kind, orgSlug, source.extract(body)); + } catch (error) { + log.debug( + `Failed to cache ${source.kind} completions for '${orgSlug}'`, + error, + ); + reportCliError(error); + } } } diff --git a/packages/cli/src/lib/build/vcs.ts b/packages/cli/src/lib/build/vcs.ts index dcea44d87..fca04aded 100644 --- a/packages/cli/src/lib/build/vcs.ts +++ b/packages/cli/src/lib/build/vcs.ts @@ -119,6 +119,19 @@ function readGithubEventSha( } } +/** + * The VCS providers Sentry accepts for preprod uploads, by integration + * slug (`SUPPORTED_VCS_PROVIDERS` in getsentry/sentry's + * `organization_preprod_artifact_assemble.py`), offered by `--vcs-provider`. + */ +export const VCS_PROVIDERS = [ + "github", + "github_enterprise", + "gitlab", + "bitbucket", + "bitbucket_server", +]; + /** Extract a provider name (e.g. `github`) from a git remote URL's host. */ function providerFromRemoteUrl(url: string): string | undefined { let host: string | undefined; diff --git a/packages/cli/src/lib/complete.ts b/packages/cli/src/lib/complete.ts index 2e361166e..8220137cc 100644 --- a/packages/cli/src/lib/complete.ts +++ b/packages/cli/src/lib/complete.ts @@ -39,6 +39,7 @@ import { getCachedProjectsForOrg } from "./db/project-cache.js"; import { getCachedOrganizations } from "./db/regions.js"; import { silenceReadonlyHandling } from "./db/resilient.js"; import { getEnv } from "./env.js"; +import type { GitCompletion } from "./git.js"; import { ValidationError } from "./errors.js"; import { fuzzyMatch } from "./fuzzy.js"; import type { CommandCompletions } from "./introspect.js"; @@ -341,6 +342,73 @@ const COMPLETERS = Object.freeze({ "choice-list": (partial, slot) => completeListItem(partial, slot.choices), /** Platform identifiers (`javascript-react`, `python-django`, …). */ platform: (partial) => completePlatforms(partial).map((value) => ({ value })), + /** Cached environment names. */ + environment: flagEntityCompleter("environment"), + /** Comma-separated cached environment names. */ + "environment-list": flagEntityCompleter("environment", { list: true }), + /** Cached release versions. */ + release: flagEntityCompleter("release"), + /** Cached team slugs. */ + team: flagEntityCompleter("team"), + /** Cached teams as the `team:` actor an owner flag takes. */ + owner: flagEntityCompleter("owner"), + /** Comma-separated project slugs of the org typed, else of every org. */ + "project-list": (partial, slot) => completeProjectList(partial, slot), + /** Comma-separated cached alert (workflow) IDs, described by name. */ + "workflow-list": flagEntityCompleter("workflow", { list: true }), + /** Cached monitor slugs in the first word; the command to run after it. */ + "monitor-command": (partial, slot) => + slot.positionals.length > 0 ? [] : completeMonitors(partial, slot), + /** Cached native integration IDs, described by provider and name. */ + integration: flagEntityCompleter("integration"), + /** Cached slugs of installed Sentry Apps. */ + "sentry-app": flagEntityCompleter("sentry-app"), + /** + * `name=` of cached Sentry App issue-link form fields, then, once the + * name is typed, that field's fixed choices (`priority=high`). + */ + "app-link-field": async (partial, slot) => { + const fields = await completeAppLinkFields(partial, slot); + return partial.includes("=") + ? fields.filter(({ value }) => value.startsWith(partial)) + : fields; + }, + /** Cached public DSNs, described by the client key name. */ + dsn: flagEntityCompleter("dsn"), + /** Servers of stored accounts. */ + "account-server": (partial) => completeAccountServers(partial), + /** + * `issue archive --until` conditions: `forever`, `auto`, durations, and + * `x` / `u` counts, alone or as `/`. + */ + "archive-until": (partial) => completeArchiveUntil(partial), + /** API paths relative to `/api/0/`, one segment at a time. */ + "api-endpoint": (partial) => completeApiEndpoint(partial), + /** Refs git resolves locally: branches, tags, remote-tracking branches. */ + "git-ref": async (partial) => { + const { listLocalRefs } = await import("./git.js"); + return completeGit(partial, listLocalRefs()); + }, + /** Branch and tag names as the remote knows them (`origin/main` → `main`). */ + "vcs-ref": async (partial) => { + const { listVcsRefs } = await import("./git.js"); + return completeGit(partial, listVcsRefs()); + }, + /** Full SHAs of the latest commits of `HEAD`, described by subject. */ + "git-commit": async (partial) => { + const { listRecentCommits } = await import("./git.js"); + return completeGit(partial, listRecentCommits()); + }, + /** `owner/repo` names of the git remotes. */ + "git-repo": async (partial) => { + const { listRemoteRepositories } = await import("./git.js"); + return completeGit(partial, listRemoteRepositories()); + }, + /** + * Comma-separated `REPO@SHA` or `REPO@PREV..SHA` commits: remote + * `owner/repo` names, then the latest commits of `HEAD`. + */ + "repo-commit-list": (partial) => completeRepoCommitList(partial), /** * Free text or numbers, with nothing to suggest (messages, queries, URLs, * names of new things, limits). Declared, so that every argument says @@ -699,6 +767,265 @@ export function completeAliases(partial: string): Completion[] { }); } +/** + * The org named by the first positional that names one (`my-org/`, + * `my-org/project`, `my-org/CLI-1`), read like an entity target. Flag + * values are scoped to it, since they belong to the command's target. + */ +async function positionalOrg( + positionals: readonly string[], +): Promise { + for (const word of positionals) { + const scope = await parseTargetScope(word); + if (scope?.org) { + return scope.org; + } + } + return; +} + +/** The orgs whose entities a flag value completes. */ +async function flagOrgs(positionals: readonly string[]): Promise { + const org = await positionalOrg(positionals); + return org ? [org] : getCompletionOrganizations().map(({ slug }) => slug); +} + +/** + * A partial split at its last comma, for values of a comma-separated list: + * the items before it, kept as typed, and the one being completed. + */ +function splitListItem( + partial: string, + list: boolean, +): { head: string; item: string; chosen: Set } { + const head = list ? partial.slice(0, partial.lastIndexOf(",") + 1) : ""; + return { + head, + item: partial.slice(head.length), + chosen: new Set(head.split(",")), + }; +} + +/** + * Build the completer of a flag (or positional) whose value is a cached + * entity written bare, as an API filter or body field takes it + * (`--environment production`, `--team backend`). + * + * The entities are those of the org the command's target names (see + * {@link positionalOrg}), else of every cached org, each value once. + * + * @param kind - Entity type to complete + * @param options.list - The value is a comma-separated list: complete its + * last item, leaving out those already in it + */ +function flagEntityCompleter( + kind: CompletionEntityKind, + options: { list?: boolean } = {}, +): Completer { + return async (partial, slot) => { + const { head, item, chosen } = splitListItem(partial, !!options.list); + const seen = new Set(chosen); + return matchCachedEntities(kind, item, await flagOrgs(slot.positionals)) + .filter(({ value }) => !seen.has(value) && !!seen.add(value)) + .map(({ value, label }) => ({ + value: `${head}${value}`, + description: label, + })); + }; +} + +/** + * Complete a comma-separated list of project slugs: those of the org the + * target names, else of every cached org, leaving out those already given. + */ +async function completeProjectList( + partial: string, + slot: CompleterContext, +): Promise { + const { head, item, chosen } = splitListItem(partial, true); + const names = new Map(); + for (const org of await flagOrgs(slot.positionals)) { + for (const { projectSlug, projectName } of getCachedProjectsForOrg(org)) { + if (!(chosen.has(projectSlug) || names.has(projectSlug))) { + names.set(projectSlug, projectName); + } + } + } + return fuzzyMatch(item, [...names.keys()], { mode: "search" }).map( + (slug) => ({ value: `${head}${slug}`, description: names.get(slug) }), + ); +} + +const completeMonitors = flagEntityCompleter("monitor"); + +const completeAppLinkFields = flagEntityCompleter("app-link-field"); + +/** Servers of stored accounts, described by the accounts on each. */ +function completeAccountServers(partial: string): Completion[] { + const accounts = new Map(); + for (const account of listAccounts()) { + if (account.host) { + const who = account.email ?? account.username ?? account.id; + accounts.set(account.host, [...(accounts.get(account.host) ?? []), who]); + } + } + return [...accounts] + .filter(([server]) => server.startsWith(partial)) + .map(([value, who]) => ({ value, description: who.join(", ") })); +} + +/** Count suffixes of `--until`: events and users. */ +const UNTIL_COUNTS: Readonly> = { + x: "more events", + u: "more users", +}; + +/** A `--until` count alone (`10x`), which can still take a `/`. */ +const UNTIL_COUNT_RE = /^\d+[xu]$/i; + +/** + * Complete an `issue archive --until` condition. A bare number completes + * to each count (`10x`, `10u`) and duration (`10m`, `10h`, …); a count + * offers its `/` window, which completes like a duration. Anything else + * completes the keywords and the duration presets that start with it. + */ +function completeArchiveUntil(partial: string): Completion[] { + const slash = partial.indexOf("/"); + if (slash !== -1) { + const head = partial.slice(0, slash + 1); + return completePeriods(partial.slice(slash + 1)).map( + ({ value, description }) => ({ + value: `${head}${value}`, + description: description && `Within the ${description.toLowerCase()}`, + }), + ); + } + if (DIGITS_RE.test(partial)) { + return [ + ...Object.entries(UNTIL_COUNTS).map(([suffix, what]) => ({ + value: `${partial}${suffix}`, + description: `Until ${partial} ${what}`, + })), + ...completePeriods(partial).map(({ value }) => ({ + value, + description: `For ${value}`, + })), + ]; + } + if (UNTIL_COUNT_RE.test(partial)) { + return [ + { value: partial }, + { value: `${partial}/`, description: "Within a time window" }, + ]; + } + return [ + { value: "forever", description: "Never unarchive" }, + { value: "auto", description: "Until Sentry detects escalation" }, + ...completePeriods(partial), + ].filter(({ value }) => value.startsWith(partial.toLowerCase())); +} + +/** Placeholder segment of an API path template (`{organization_id_or_slug}`). */ +const PATH_PARAM_RE = /^\{(\w+)\}$/; + +/** Prefixes `api` strips from an endpoint, kept as typed. */ +const API_PREFIX_RE = /^\/?(?:api\/0\/)?/; + +/** + * Complete an `api` endpoint one path segment at a time from the API + * schema's paths. A literal segment completes as itself; an organization + * or project placeholder completes to the cached orgs or that org's + * projects; any other placeholder (`{issue_id}`) is typed by the user, so + * the paths after it match whatever was typed there. + */ +function completeApiEndpoint(partial: string): Completion[] { + const prefix = API_PREFIX_RE.exec(partial)?.[0] ?? ""; + const typed = partial.slice(prefix.length).split("/"); + const segment = typed.pop() ?? ""; + const done = typed.join("/"); + const head = `${prefix}${done ? `${done}/` : ""}`; + const next = new Map(); + const add = (value: string, description?: string) => { + if (value.startsWith(segment) && !next.has(value)) { + next.set(value, description); + } + }; + for (const path of GENERATED.values.apiPaths) { + const template = path.split("/"); + // Paths end in `/`, so the last element is empty + if (template.length - 1 <= typed.length) { + continue; + } + const matches = typed.every((word, index) => { + const part = template[index] ?? ""; + return PATH_PARAM_RE.test(part) ? word !== "" : part === word; + }); + const part = template[typed.length] ?? ""; + const param = PATH_PARAM_RE.exec(part)?.[1]; + if (!matches) { + continue; + } + if (param === "organization_id_or_slug") { + for (const { value, description } of completeOrgSlugs("")) { + add(value, description); + } + } else if (param === "project_id_or_slug") { + const org = typed[template.indexOf("{organization_id_or_slug}")]; + for (const { projectSlug, projectName } of org + ? getCachedProjectsForOrg(org) + : []) { + add(projectSlug, projectName); + } + } else if (!param) { + add(part); + } + } + return [...next].map(([value, description]) => ({ + value: `${head}${value}/`, + description, + })); +} + +/** Git names that start with the partial, described by their commit subject. */ +function completeGit( + partial: string, + names: readonly GitCompletion[], +): Completion[] { + return names + .filter(({ name }) => name.startsWith(partial)) + .map(({ name, subject }) => ({ + value: name, + description: subject || undefined, + })); +} + +/** + * Complete the last commit of a `--commit` list: a remote `owner/repo` + * with `@`, then a commit SHA after `@` or after `..` in a range. + */ +async function completeRepoCommitList(partial: string): Promise { + const { listRecentCommits, listRemoteRepositories } = + await import("./git.js"); + const { head, item } = splitListItem(partial, true); + const at = item.indexOf("@"); + if (at === -1) { + return completeGit(item, listRemoteRepositories()).map( + ({ value, description }) => ({ + value: `${head}${value}@`, + description, + }), + ); + } + const range = item.indexOf("..", at); + const sha = range === -1 ? at + 1 : range + 2; + return completeGit(item.slice(sha), listRecentCommits()).map( + ({ value, description }) => ({ + value: `${head}${item.slice(0, sha)}${value}`, + description, + }), + ); +} + /** Most cached-entity suggestions returned for one completion. */ const MAX_ENTITY_COMPLETIONS = 50; diff --git a/packages/cli/src/lib/completion-target.ts b/packages/cli/src/lib/completion-target.ts index b0d6a5f19..5eacca12c 100644 --- a/packages/cli/src/lib/completion-target.ts +++ b/packages/cli/src/lib/completion-target.ts @@ -19,6 +19,7 @@ import { ISSUE_IS_VALUES } from "@sentry/toolkit-core/search-query"; import { numberParser } from "@stricli/core"; +import { getAllEndpoints } from "./api-schema.js"; import type { CompletionTarget } from "./complete.js"; import { ISSUE_LEVELS } from "../types/sentry.js"; import { @@ -90,6 +91,71 @@ export const PLATFORM_TARGET = "platform" satisfies CompletionTarget; /** Free text with nothing to suggest. */ export const TEXT_TARGET = "text" satisfies CompletionTarget; +/** Cached environment names. */ +export const ENVIRONMENT_TARGET = "environment" satisfies CompletionTarget; + +/** Comma-separated cached environment names. */ +export const ENVIRONMENT_LIST_TARGET = + "environment-list" satisfies CompletionTarget; + +/** Cached release versions. */ +export const RELEASE_TARGET = "release" satisfies CompletionTarget; + +/** Cached team slugs. */ +export const TEAM_TARGET = "team" satisfies CompletionTarget; + +/** Cached teams as `team:` owner actors. */ +export const OWNER_TARGET = "owner" satisfies CompletionTarget; + +/** Comma-separated project slugs. */ +export const PROJECT_LIST_TARGET = "project-list" satisfies CompletionTarget; + +/** Comma-separated cached alert (workflow) IDs. */ +export const WORKFLOW_LIST_TARGET = "workflow-list" satisfies CompletionTarget; + +/** `monitor run`'s cached monitor slug, then the command to run. */ +export const MONITOR_COMMAND_TARGET = + "monitor-command" satisfies CompletionTarget; + +/** Cached native integration IDs. */ +export const INTEGRATION_TARGET = "integration" satisfies CompletionTarget; + +/** Cached installed Sentry App slugs. */ +export const SENTRY_APP_TARGET = "sentry-app" satisfies CompletionTarget; + +/** Cached Sentry App issue-link form fields, as `name=value`. */ +export const APP_LINK_FIELD_TARGET = + "app-link-field" satisfies CompletionTarget; + +/** Cached public DSNs. */ +export const DSN_TARGET = "dsn" satisfies CompletionTarget; + +/** Servers of stored accounts. */ +export const ACCOUNT_SERVER_TARGET = + "account-server" satisfies CompletionTarget; + +/** `issue archive --until` conditions. */ +export const ARCHIVE_UNTIL_TARGET = "archive-until" satisfies CompletionTarget; + +/** API paths relative to `/api/0/`, from the API schema. */ +export const API_ENDPOINT_TARGET = "api-endpoint" satisfies CompletionTarget; + +/** Refs git resolves locally (`main`, `v1.0.0`, `origin/main`). */ +export const GIT_REF_TARGET = "git-ref" satisfies CompletionTarget; + +/** Branch and tag names as the git remote knows them. */ +export const VCS_REF_TARGET = "vcs-ref" satisfies CompletionTarget; + +/** Full SHAs of recent commits. */ +export const GIT_COMMIT_TARGET = "git-commit" satisfies CompletionTarget; + +/** `owner/repo` names of the git remotes. */ +export const GIT_REPO_TARGET = "git-repo" satisfies CompletionTarget; + +/** Comma-separated `REPO@SHA` / `REPO@PREV..SHA` commits. */ +export const REPO_COMMIT_LIST_TARGET = + "repo-commit-list" satisfies CompletionTarget; + const COMPLETION_TARGET_KEY = Symbol("completionTarget"); const COMPLETION_CHOICES_KEY = Symbol("completionChoices"); @@ -154,6 +220,75 @@ export const parseAccountSelector = identityParser(ACCOUNT_TARGET); /** Parses a platform identifier; completes platforms. */ export const parsePlatform = identityParser(PLATFORM_TARGET); +// Parsers of values that name something the user does not make up. Each +// returns its input unchanged: the command (or the API) validates it. + +/** Parses an organization slug; completes cached organizations. */ +export const parseOrgSlug = identityParser(ORG_TARGET); + +/** Parses an issue reference the command resolves; completes issues. */ +export const parseIssueReference = identityParser(ISSUE_TARGET); + +/** Parses a relative period the API validates; completes periods. */ +export const parseRelativePeriod = identityParser(PERIOD_TARGET); + +/** Parses an environment name; completes cached environments. */ +export const parseEnvironment = identityParser(ENVIRONMENT_TARGET); + +/** Parses comma-separated environment names; completes cached ones. */ +export const parseEnvironmentList = identityParser(ENVIRONMENT_LIST_TARGET); + +/** Parses a release version; completes cached releases. */ +export const parseRelease = identityParser(RELEASE_TARGET); + +/** Parses a team slug; completes cached teams. */ +export const parseTeam = identityParser(TEAM_TARGET); + +/** Parses an owner actor (`team:`, `user:`); completes cached teams. */ +export const parseOwner = identityParser(OWNER_TARGET); + +/** Parses comma-separated project slugs; completes cached projects. */ +export const parseProjectList = identityParser(PROJECT_LIST_TARGET); + +/** Parses comma-separated alert (workflow) IDs; completes cached ones. */ +export const parseWorkflowList = identityParser(WORKFLOW_LIST_TARGET); + +/** Parses `monitor run`'s words; completes a cached monitor slug first. */ +export const parseMonitorCommand = identityParser(MONITOR_COMMAND_TARGET); + +/** Parses a native integration ID; completes cached integrations. */ +export const parseIntegration = identityParser(INTEGRATION_TARGET); + +/** Parses an installed Sentry App slug; completes cached installations. */ +export const parseSentryApp = identityParser(SENTRY_APP_TARGET); + +/** Parses a Sentry App link form `name=value`; completes cached fields. */ +export const parseAppLinkField = identityParser(APP_LINK_FIELD_TARGET); + +/** Parses a DSN; completes cached client-key DSNs. */ +export const parseDsn = identityParser(DSN_TARGET); + +/** Parses an `issue archive --until` condition; completes its grammar. */ +export const parseArchiveUntil = identityParser(ARCHIVE_UNTIL_TARGET); + +/** Parses an `api` endpoint; completes API schema paths. */ +export const parseApiEndpoint = identityParser(API_ENDPOINT_TARGET); + +/** Parses a local git ref; completes branches, tags, and remote branches. */ +export const parseGitRef = identityParser(GIT_REF_TARGET); + +/** Parses a branch or tag name sent to Sentry; completes them from git. */ +export const parseVcsRef = identityParser(VCS_REF_TARGET); + +/** Parses a commit SHA; completes recent commits. */ +export const parseGitCommit = identityParser(GIT_COMMIT_TARGET); + +/** Parses an `owner/repo` name; completes those of the git remotes. */ +export const parseGitRepo = identityParser(GIT_REPO_TARGET); + +/** Parses `REPO@SHA` commits; completes remotes, then recent commits. */ +export const parseRepoCommitList = identityParser(REPO_COMMIT_LIST_TARGET); + /** * Wrap a command's own parser of free text or numbers; completes nothing. * The value is still parsed and validated by `parse`. @@ -164,6 +299,18 @@ export function textParser( return taggedParser(TEXT_TARGET, parse); } +/** + * Wrap a command's own parser of a value that completes `kind`, such as a + * validating URL parser whose values are stored accounts' servers. The + * value is still parsed and validated by `parse`. + */ +export function completingParser( + kind: CompletionTarget, + parse: (input: string) => T, +): (input: string) => T { + return taggedParser(kind, parse); +} + /** * Build a parser that completes `values`. Without `parse` it returns its * input unchanged, so the command still validates it: the values are @@ -268,6 +415,9 @@ export function getCompletionChoices( ); } +/** The prefix `api` adds to an endpoint, left out of {@link COMPLETION_VALUES}. */ +const API_PATH_PREFIX = /^\/api\/0\//; + /** Fixed values the completers offer (see {@link COMPLETION_VALUES}). */ export type CompletionValues = { /** Issue search keys with a fixed set of values, for {@link ISSUE_QUERY_TARGET} */ @@ -276,6 +426,8 @@ export type CompletionValues = { periodUnits: readonly string[]; /** Relative periods offered for {@link PERIOD_TARGET}, with descriptions */ periodPresets: Readonly>; + /** API schema paths without `/api/0/`, for {@link API_ENDPOINT_TARGET} */ + apiPaths: readonly string[]; }; /** @@ -296,4 +448,9 @@ export const COMPLETION_VALUES: CompletionValues = { "30d": "Last 30 days", "90d": "Last 90 days", }, + apiPaths: [ + ...new Set( + getAllEndpoints().map(({ path }) => path.replace(API_PATH_PREFIX, "")), + ), + ], }; diff --git a/packages/cli/src/lib/completions.ts b/packages/cli/src/lib/completions.ts index 8e6f49ff9..f085d3d67 100644 --- a/packages/cli/src/lib/completions.ts +++ b/packages/cli/src/lib/completions.ts @@ -96,8 +96,9 @@ function shellVarName(s: string): string { * * Completes the command and a group's subcommand from static word lists, * and every later word with `sentry __complete`. Suggestions are escaped - * for the shell, and a single suggestion ending in `/` (a directory, - * `org/`) takes no trailing space so the next TAB continues it. + * for the shell, and a single suggestion that continues (a directory, + * `org/`, `name=`, `repo@`, `id:`) takes no trailing space so the next TAB + * continues it. */ export function generateBashCompletion(binaryName: string): string { const { groups, standalone } = extractCommandTree(); @@ -171,7 +172,8 @@ ${subVars} fi COMPREPLY+=("\${line}") done < <(${binaryName} __complete "\${args[@]}" "\${partial}" 2>/dev/null) - if [[ \${#COMPREPLY[@]} -eq 1 && "\${COMPREPLY[0]}" == */ ]]; then + # A value that continues (\`org/\`, \`name=\`, \`repo@\`, \`id:\`) takes no space + if [[ \${#COMPREPLY[@]} -eq 1 && "\${COMPREPLY[0]}" == *[/=@:] ]]; then compopt -o nospace fi } @@ -185,7 +187,8 @@ complete -F _${binaryName}_completions ${binaryName} * * Completes the command and a group's subcommand from static arrays, and * every later word with `sentry __complete`, showing its descriptions. - * Suggestions ending in `/` (directories, `org/`) take no trailing space. + * Suggestions that continue (`org/`, directories, `name=`, `repo@`, `id:`) + * take no trailing space. */ export function generateZshCompletion(binaryName: string): string { const { groups, standalone } = extractCommandTree(); @@ -235,7 +238,7 @@ ${subArrays} local value desc ret=1 while IFS=$'\\t' read -r value desc; do value="\${value//:/\\\\:}" - if [[ $value == */ ]]; then + if [[ $value == *[/=@:] ]]; then slashed+=("\${value}\${desc:+:$desc}") else values+=("\${value}\${desc:+:$desc}") diff --git a/packages/cli/src/lib/db/completion-cache.ts b/packages/cli/src/lib/db/completion-cache.ts index 68b76d225..f4386d947 100644 --- a/packages/cli/src/lib/db/completion-cache.ts +++ b/packages/cli/src/lib/db/completion-cache.ts @@ -2,10 +2,11 @@ * Entities seen during normal CLI usage, kept for shell completion. * * The `__complete` fast path makes no API calls, so it can only suggest what - * earlier commands already fetched. The API layer records issue short IDs and - * dashboards here as they are listed or viewed; `src/lib/complete.ts` reads - * them back. Entries are best-effort hints: a stale one at worst suggests an - * issue that was since merged or a dashboard that was deleted. + * earlier commands already fetched. The API layer records the entities of + * {@link CompletionEntityKind} here as they are listed or viewed; + * `src/lib/complete.ts` reads them back. Entries are best-effort hints: a + * stale one at worst suggests an issue that was since merged or a release + * that was deleted. * * This module is imported by the `__complete` fast path, so it must not pull * in the Sentry SDK; best-effort error reporting for writes lives in @@ -21,10 +22,36 @@ import { runUpsert } from "./utils.js"; /** * Entity types stored for shell completion, named after the completion - * kind of the arguments that take them: issues by short ID, dashboards by - * numeric ID, both with the title as description. + * kind of the arguments that take them. Each is stored as the value those + * arguments read, with a description: + * + * - `issue`: short ID, with the title + * - `dashboard`: numeric ID, with the title + * - `environment`: name + * - `release`: version, with the short version + * - `team`: slug, with the name + * - `owner`: `team:` actor, with the team slug + * - `monitor`: slug, with the name + * - `workflow`: alert (workflow) ID, with the name + * - `integration`: native integration ID, with provider and name + * - `sentry-app`: installed Sentry App slug, with the name + * - `app-link-field`: `name=` of a field of a Sentry App's issue-link + * form, and `name=value` for its fixed choices, with the app + * - `dsn`: public DSN of a client key, with the key name */ -export type CompletionEntityKind = "issue" | "dashboard"; +export type CompletionEntityKind = + | "issue" + | "dashboard" + | "environment" + | "release" + | "team" + | "owner" + | "monitor" + | "workflow" + | "integration" + | "sentry-app" + | "app-link-field" + | "dsn"; /** Most recently seen entries kept per kind and org. */ export const MAX_ENTITIES_PER_ORG = 500; diff --git a/packages/cli/src/lib/envelope/event-builder.ts b/packages/cli/src/lib/envelope/event-builder.ts index 80f15832a..3487cabd9 100644 --- a/packages/cli/src/lib/envelope/event-builder.ts +++ b/packages/cli/src/lib/envelope/event-builder.ts @@ -31,7 +31,12 @@ export type SendEventFlags = { "no-environ"?: boolean; }; -const KNOWN_USER_FIELDS = new Set(["id", "email", "ip_address", "username"]); +export const KNOWN_USER_FIELDS = new Set([ + "id", + "email", + "ip_address", + "username", +]); /** * Parse a single KEY:VALUE (or KEY=VALUE) string, splitting on the first diff --git a/packages/cli/src/lib/git.ts b/packages/cli/src/lib/git.ts index bbc5634cb..ee1b42a36 100644 --- a/packages/cli/src/lib/git.ts +++ b/packages/cli/src/lib/git.ts @@ -443,3 +443,135 @@ export function inferDefaultBranch(remote: string, cwd?: string): string { return "main"; } } + +// --------------------------------------------------------------------------- +// Shell completion +// --------------------------------------------------------------------------- + +/** A ref or commit offered by shell completion. */ +export type GitCompletion = { + /** What the shell inserts: a ref name or a full commit SHA */ + name: string; + /** Subject line of the commit it points at */ + subject: string; +}; + +/** + * Field separator of `--format` output, NUL, which refs and SHAs cannot + * contain. Written `%00` (`for-each-ref`) or `%x00` (`log`) in the format, + * since an argument cannot hold a NUL byte. + */ +const FIELD_SEPARATOR = "\u0000"; + +/** + * Run git for shell completion, which suggests nothing outside a work tree + * or when git is missing rather than failing. + * + * @returns The output lines, split into their {@link FIELD_SEPARATOR} fields + */ +function gitLines(args: string[], cwd?: string): string[][] { + let output: string; + try { + output = git(args, cwd); + } catch (error) { + if (error instanceof Error) { + // Not a repository, or git is missing: there is nothing to complete + return []; + } + throw error; + } + return output + .split("\n") + .filter(Boolean) + .map((line) => line.split(FIELD_SEPARATOR)); +} + +/** + * `for-each-ref` of `patterns`, most recently committed first, each by the + * `name` format atom (`%(refname:short)`, …). + */ +function forEachRef( + name: string, + patterns: string[], + cwd?: string, +): GitCompletion[] { + return gitLines( + [ + "for-each-ref", + "--sort=-committerdate", + `--format=%(refname)%00${name}%00%(subject)`, + ...patterns, + ], + cwd, + ).flatMap(([ref = "", short, subject = ""]) => + // `refs/remotes/origin/HEAD` names whichever branch the remote's HEAD + // points at + short && !ref.endsWith("/HEAD") ? [{ name: short, subject }] : [], + ); +} + +/** + * Branches, tags, and remote-tracking branches as git resolves them + * locally (`main`, `v1.2.0`, `origin/main`), most recent first: the refs + * a local range such as `..HEAD` can start from. + * + * @param cwd - Working directory + */ +export function listLocalRefs(cwd?: string): GitCompletion[] { + return forEachRef( + "%(refname:short)", + ["refs/heads", "refs/tags", "refs/remotes"], + cwd, + ); +} + +/** + * Branch and tag names as the hosting provider knows them, for flags that + * send a ref to Sentry: local branches, remote-tracking branches without + * their remote (`origin/main` → `main`), then tags. Each name once. + * + * @param cwd - Working directory + */ +export function listVcsRefs(cwd?: string): GitCompletion[] { + const refs = [ + ...forEachRef("%(refname:lstrip=2)", ["refs/heads"], cwd), + ...forEachRef("%(refname:lstrip=3)", ["refs/remotes"], cwd), + ...forEachRef("%(refname:lstrip=2)", ["refs/tags"], cwd), + ]; + const seen = new Set(); + return refs.filter(({ name }) => !seen.has(name) && seen.add(name)); +} + +/** Most recent commits of `HEAD` offered for completion. */ +const MAX_COMPLETED_COMMITS = 50; + +/** + * The latest commits of `HEAD` by full SHA, newest first. + * + * @param cwd - Working directory + */ +export function listRecentCommits(cwd?: string): GitCompletion[] { + return gitLines( + ["log", `--max-count=${MAX_COMPLETED_COMMITS}`, "--format=%H%x00%s"], + cwd, + ).flatMap(([sha, subject = ""]) => (sha ? [{ name: sha, subject }] : [])); +} + +/** + * The `owner/repo` names of every remote, with the remote they belong to. + * + * @param cwd - Working directory + * @returns Each name once, in `git remote -v` order + */ +export function listRemoteRepositories(cwd?: string): GitCompletion[] { + const seen = new Set(); + return gitLines(["remote", "-v"], cwd).flatMap(([line = ""]) => { + const [remote, url] = line.split(/\s+/); + const name = url ? parseRemoteUrl(url) : undefined; + if (!(remote && name) || seen.has(name)) { + return []; + } + seen.add(name); + return [{ name, subject: remote }]; + }); +} diff --git a/packages/cli/src/lib/metrics-transform.ts b/packages/cli/src/lib/metrics-transform.ts index ae6b34edd..aea79068c 100644 --- a/packages/cli/src/lib/metrics-transform.ts +++ b/packages/cli/src/lib/metrics-transform.ts @@ -11,7 +11,7 @@ import { ResolutionError } from "./errors.js"; import { fuzzyMatch } from "./fuzzy.js"; /** Valid tracemetrics aggregation functions. */ -const VALID_AGGS = new Set([ +export const VALID_AGGS = new Set([ "sum", "avg", "count", diff --git a/packages/cli/src/lib/sourcemap/inject.ts b/packages/cli/src/lib/sourcemap/inject.ts index e3a12cd3f..a8c405275 100644 --- a/packages/cli/src/lib/sourcemap/inject.ts +++ b/packages/cli/src/lib/sourcemap/inject.ts @@ -29,7 +29,7 @@ import { const log = logger.withTag("sourcemap.inject"); /** Default JavaScript file extensions to scan. */ -const DEFAULT_EXTENSIONS = new Set([".js", ".cjs", ".mjs"]); +export const DEFAULT_EXTENSIONS = new Set([".js", ".cjs", ".mjs"]); /** * The location of a JavaScript file's sourcemap. diff --git a/packages/cli/src/types/dashboard.ts b/packages/cli/src/types/dashboard.ts index d80b21bb3..a759bf1ce 100644 --- a/packages/cli/src/types/dashboard.ts +++ b/packages/cli/src/types/dashboard.ts @@ -521,6 +521,17 @@ export function prepareWidgetQueries( /** Sentry dashboard grid column count */ export const GRID_COLUMNS = 6; +/** The values `--col` accepts: each 0-based grid column. */ +export const GRID_COLUMN_POSITIONS = Array.from( + { length: GRID_COLUMNS }, + (_, column) => String(column), +); + +/** The values `--width` accepts: 1 to {@link GRID_COLUMNS} columns. */ +export const GRID_WIDTHS = Array.from({ length: GRID_COLUMNS }, (_, column) => + String(column + 1), +); + /** * Controls how the auto-placer positions new widgets in the grid. * diff --git a/packages/cli/test/lib/complete-git.test.ts b/packages/cli/test/lib/complete-git.test.ts new file mode 100644 index 000000000..591453579 --- /dev/null +++ b/packages/cli/test/lib/complete-git.test.ts @@ -0,0 +1,77 @@ +/** + * Completers of flags that take git refs, commits, and repositories. Git + * is read from the shell's working directory, so its helpers are mocked + * with a known repository (`test/lib/git-completion.test.ts` covers them). + */ + +import { describe, expect, test, vi } from "vitest"; + +vi.mock("../../src/lib/git.js", () => ({ + listLocalRefs: () => [ + { name: "main", subject: "Latest" }, + { name: "origin/main", subject: "Latest" }, + ], + listVcsRefs: () => [ + { name: "main", subject: "Latest" }, + { name: "v1.0.0", subject: "Release" }, + ], + listRecentCommits: () => [ + { name: "abc123", subject: "Latest" }, + { name: "def456", subject: "Older" }, + ], + listRemoteRepositories: () => [{ name: "acme/app", subject: "origin" }], +})); + +import { getCompletions } from "../../src/lib/complete.js"; +import { useTestConfigDir } from "../helpers.js"; + +useTestConfigDir("test-complete-git-"); + +async function values(words: string[], partial: string): Promise { + return (await getCompletions(words, partial)).map((c) => c.value); +} + +describe("getCompletions: git", () => { + test("refs sent to Sentry complete as the remote names them", async () => { + expect(await values(["build", "upload", "--base-ref"], "")).toEqual([ + "main", + "v1.0.0", + ]); + expect(await values(["release", "create", "--ref"], "v")).toEqual([ + "v1.0.0", + ]); + }); + + test("a local range starts from a ref git resolves", async () => { + expect(await values(["release", "set-commits", "--from"], "or")).toEqual([ + "origin/main", + ]); + }); + + test("SHAs complete recent commits with their subjects", async () => { + expect( + await getCompletions(["snapshots", "upload", "--head-sha"], "de"), + ).toEqual([{ value: "def456", description: "Older" }]); + }); + + test("repository names complete from the remotes", async () => { + expect(await values(["code-mappings", "upload", "--repo"], "")).toEqual([ + "acme/app", + ]); + }); + + test("--commit completes REPO@ and then commits, in ranges and lists", async () => { + const commit = ["release", "set-commits", "--commit"]; + expect(await values(commit, "ac")).toEqual(["acme/app@"]); + expect(await values(commit, "acme/app@")).toEqual([ + "acme/app@abc123", + "acme/app@def456", + ]); + expect(await values(commit, "acme/app@def456..ab")).toEqual([ + "acme/app@def456..abc123", + ]); + expect(await values(commit, "acme/app@abc123,ac")).toEqual([ + "acme/app@abc123,acme/app@", + ]); + }); +}); diff --git a/packages/cli/test/lib/complete.test.ts b/packages/cli/test/lib/complete.test.ts index 5504c6afd..50507229a 100644 --- a/packages/cli/test/lib/complete.test.ts +++ b/packages/cli/test/lib/complete.test.ts @@ -964,3 +964,234 @@ describe("getCompletions: accounts", () => { ]); }); }); + +describe("getCompletions: flag values that name cached entities", () => { + test("completes a flag from the org the target names, else every org", async () => { + await seedOrgs([ + { slug: "acme", name: "Acme" }, + { slug: "beta", name: "Beta" }, + ]); + cacheCompletionEntities("environment", "acme", [{ value: "production" }]); + cacheCompletionEntities("environment", "beta", [ + { value: "staging" }, + { value: "production" }, + ]); + const create = ["alert", "metrics", "create"]; + + expect(await values([...create, "acme/cli", "--environment"], "")).toEqual([ + "production", + ]); + expect((await values([...create, "--environment"], "")).sort()).toEqual([ + "production", + "staging", + ]); + }); + + test("completes the last item of a comma-separated list", async () => { + await seedOrgs([{ slug: "acme", name: "Acme" }]); + cacheCompletionEntities("environment", "acme", [ + { value: "production" }, + { value: "staging" }, + ]); + + expect( + await values(["release", "list", "--environment"], "production,"), + ).toEqual(["production,staging"]); + }); + + test("completes owners as team actors, matched by team slug", async () => { + await seedOrgs([{ slug: "acme", name: "Acme" }]); + cacheCompletionEntities("owner", "acme", [ + { value: "team:7", label: "#backend" }, + { value: "team:9", label: "#frontend" }, + ]); + + expect( + await getCompletions(["alert", "issues", "create", "--owner"], "back"), + ).toEqual([{ value: "team:7", description: "#backend" }]); + }); + + test("issue link completes integrations, apps, and app form fields", async () => { + await seedOrgs([{ slug: "acme", name: "Acme" }]); + cacheCompletionEntities("integration", "acme", [ + { value: "99", label: "Jira: acme-jira" }, + ]); + cacheCompletionEntities("sentry-app", "acme", [ + { value: "linear", label: "Linear" }, + ]); + cacheCompletionEntities("app-link-field", "acme", [ + { value: "priority=", label: "tracker, required" }, + { value: "priority=high", label: "tracker, required" }, + ]); + + expect( + await getCompletions(["issue", "link", "--integration"], "jira"), + ).toEqual([{ value: "99", description: "Jira: acme-jira" }]); + expect(await values(["issue", "unlink", "--app"], "")).toEqual(["linear"]); + expect(await values(["issue", "link", "--field"], "prio")).toContain( + "priority=", + ); + expect(await values(["issue", "link", "--field"], "priority=h")).toEqual([ + "priority=high", + ]); + }); + + test("issue merge --into completes issues", async () => { + await seedOrgs([{ slug: "acme", name: "Acme" }]); + cacheCompletionEntities("issue", "acme", [{ value: "CLI-1A" }]); + + expect( + await values(["issue", "merge", "CLI-1A", "CLI-2B", "--into"], "CLI"), + ).toContain("CLI-1A"); + }); + + test("monitor run completes a monitor slug, then nothing for the command", async () => { + await seedOrgs([{ slug: "acme", name: "Acme" }]); + cacheCompletionEntities("monitor", "acme", [ + { value: "nightly", label: "Nightly job" }, + ]); + + expect(await values(["monitor", "run"], "")).toEqual(["nightly"]); + expect(await values(["monitor", "run", "nightly", "--"], "")).toEqual([]); + }); + + test("completes comma-separated project slugs of the target org", async () => { + await seedOrgs([{ slug: "acme", name: "Acme" }]); + await seedProjects([ + { + orgId: "1", + projectId: "10", + orgSlug: "acme", + projectSlug: "cli", + projectName: "CLI", + }, + { + orgId: "1", + projectId: "11", + orgSlug: "acme", + projectSlug: "web", + projectName: "Web", + }, + ]); + + expect( + await values(["release", "create", "acme/1.0.0", "--project"], "cli,"), + ).toEqual(["cli,web"]); + }); +}); + +describe("getCompletions: issue archive --until", () => { + const until = (partial: string) => + values(["issue", "archive", "CLI-1", "--until"], partial); + + test("offers the keywords and duration presets", async () => { + expect(await until("")).toEqual( + expect.arrayContaining(["forever", "auto", "1h", "7d"]), + ); + expect(await until("f")).toEqual(["forever"]); + }); + + test("completes a number to counts and durations", async () => { + expect(await until("10")).toEqual([ + "10x", + "10u", + "10s", + "10m", + "10h", + "10d", + "10w", + ]); + }); + + test("offers a count's window, which completes like a duration", async () => { + expect(await until("10x")).toEqual(["10x", "10x/"]); + expect(await until("10x/")).toEqual( + expect.arrayContaining(["10x/1h", "10x/24h"]), + ); + expect(await until("10u/5")).toEqual( + expect.arrayContaining(["10u/5m", "10u/5h"]), + ); + }); +}); + +describe("getCompletions: api endpoints", () => { + test("completes API paths one segment at a time", async () => { + await seedOrgs([{ slug: "acme", name: "Acme" }]); + + expect(await values(["api"], "organiz")).toEqual(["organizations/"]); + expect(await getCompletions(["api"], "organizations/")).toEqual([ + { value: "organizations/acme/", description: "Acme" }, + ]); + const orgPaths = await values(["api"], "organizations/acme/iss"); + expect(orgPaths).toContain("organizations/acme/issues/"); + expect( + orgPaths.every((path) => path.startsWith("organizations/acme/iss")), + ).toBe(true); + }); + + test("keeps the /api/0/ prefix as typed", async () => { + expect(await values(["api"], "/api/0/organiz")).toEqual([ + "/api/0/organizations/", + ]); + }); +}); + +describe("getCompletions: account servers", () => { + test("completes the servers of stored accounts", async () => { + setAuthToken("token-1", undefined, undefined, { + host: "https://sentry.example.com", + newAccount: true, + }); + setUserInfo({ userId: "1", email: "jane@acme.com" }); + + expect( + await getCompletions(["auth", "switch", "--url"], "https://sentry.ex"), + ).toEqual([ + { value: "https://sentry.example.com", description: "jane@acme.com" }, + ]); + }); +}); + +describe("getCompletions: fixed values from the modules that validate them", () => { + test("metric alert windows, grid positions, providers, and aggregations", async () => { + expect( + await values(["alert", "metrics", "create", "--time-window"], "1"), + ).toEqual(["1", "10", "15", "120", "1440"]); + expect(await values(["dashboard", "widget", "add", "--col"], "")).toEqual([ + "0", + "1", + "2", + "3", + "4", + "5", + ]); + expect( + await values(["dashboard", "widget", "edit", "--width"], "6"), + ).toEqual(["6"]); + expect(await values(["build", "upload", "--vcs-provider"], "git")).toEqual([ + "github", + "github_enterprise", + "gitlab", + ]); + expect(await values(["explore", "--agg"], "p9")).toEqual([ + "p90", + "p95", + "p99", + ]); + }); + + test("event send --user offers the keys with their own User field", async () => { + expect(await values(["event", "send", "--user"], "")).toEqual([ + "id:", + "email:", + "ip_address:", + "username:", + ]); + }); + + test("sourcemap --ext completes the default extensions as a list", async () => { + expect(await values(["sourcemap", "inject", "--ext"], ".js,.m")).toEqual([ + ".js,.mjs", + ]); + }); +}); diff --git a/packages/cli/test/lib/db/completion-cache.test.ts b/packages/cli/test/lib/db/completion-cache.test.ts index ffaf9f491..d378f5aa3 100644 --- a/packages/cli/test/lib/db/completion-cache.test.ts +++ b/packages/cli/test/lib/db/completion-cache.test.ts @@ -131,6 +131,123 @@ describe("completion seeding from API responses", () => { ]); }); + test("team lists are stored as slugs and as team: owners", async () => { + await seedCompletions( + `${API}/teams/`, + json([{ id: "7", slug: "backend", name: "Backend" }]), + ); + + expect(getCachedCompletionEntities("team", ["acme"])).toEqual([ + { orgSlug: "acme", value: "backend", label: "Backend" }, + ]); + expect(getCachedCompletionEntities("owner", ["acme"])).toEqual([ + { orgSlug: "acme", value: "team:7", label: "#backend" }, + ]); + }); + + test("environments, releases, monitors, and workflows keep the value their flags read", async () => { + await seedCompletions( + "https://us.sentry.io/api/0/projects/acme/cli/environments/", + json([{ id: "1", name: "production" }]), + ); + await seedCompletions( + `${API}/releases/`, + json([{ version: "cli@1.2.0+abc", shortVersion: "1.2.0" }]), + ); + await seedCompletions( + `${API}/monitors/nightly/`, + json({ slug: "nightly", name: "Nightly job" }), + ); + await seedCompletions( + `${API}/workflows/`, + json([{ id: "42", name: "Page on-call" }]), + ); + + expect(getCachedCompletionEntities("environment", ["acme"])).toEqual([ + { orgSlug: "acme", value: "production", label: undefined }, + ]); + expect(getCachedCompletionEntities("release", ["acme"])).toEqual([ + { orgSlug: "acme", value: "cli@1.2.0+abc", label: "1.2.0" }, + ]); + expect(getCachedCompletionEntities("monitor", ["acme"])).toEqual([ + { orgSlug: "acme", value: "nightly", label: "Nightly job" }, + ]); + expect(getCachedCompletionEntities("workflow", ["acme"])).toEqual([ + { orgSlug: "acme", value: "42", label: "Page on-call" }, + ]); + }); + + test("integrations by ID and installed Sentry Apps by slug", async () => { + await seedCompletions( + `${API}/integrations/`, + json([{ id: "99", name: "acme-jira", provider: { name: "Jira" } }]), + ); + await seedCompletions( + `${API}/sentry-app-installations/`, + json([{ app: { slug: "linear", name: "Linear" } }]), + ); + + expect(getCachedCompletionEntities("integration", ["acme"])).toEqual([ + { orgSlug: "acme", value: "99", label: "Jira: acme-jira" }, + ]); + expect(getCachedCompletionEntities("sentry-app", ["acme"])).toEqual([ + { orgSlug: "acme", value: "linear", label: "Linear" }, + ]); + }); + + test("Sentry App issue-link forms are stored as name= and their fixed choices", async () => { + await seedCompletions( + `${API}/sentry-app-components/?filter=issue-link`, + json([ + { + type: "issue-link", + sentryApp: { slug: "tracker" }, + schema: { + link: { + uri: "/link", + required_fields: [ + { + name: "priority", + type: "select", + choices: [ + ["high", "High"], + [2, "Low"], + ], + }, + ], + optional_fields: [{ name: "note", type: "text" }], + }, + }, + }, + { type: "alert-rule-action", schema: { link: {} } }, + ]), + ); + + expect(getCachedCompletionEntities("app-link-field", ["acme"])).toEqual([ + { orgSlug: "acme", value: "priority=", label: "tracker, required" }, + { orgSlug: "acme", value: "priority=high", label: "tracker, required" }, + { orgSlug: "acme", value: "priority=2", label: "tracker, required" }, + { orgSlug: "acme", value: "note=", label: "tracker" }, + ]); + }); + + test("client keys are stored by public DSN", async () => { + await seedCompletions( + "https://us.sentry.io/api/0/projects/acme/cli/keys/", + json([ + { name: "Default", dsn: { public: "https://k@o1.ingest.sentry.io/1" } }, + ]), + ); + + expect(getCachedCompletionEntities("dsn", ["acme"])).toEqual([ + { + orgSlug: "acme", + value: "https://k@o1.ingest.sentry.io/1", + label: "Default", + }, + ]); + }); + test("ignores other endpoints, failed responses, and bodies that are not JSON", async () => { const issue = { shortId: "CLI-1A", title: "Boom" }; await seedCompletions(`${API}/issues/123/events/`, json([issue])); diff --git a/packages/cli/test/lib/git-completion.test.ts b/packages/cli/test/lib/git-completion.test.ts new file mode 100644 index 000000000..751617164 --- /dev/null +++ b/packages/cli/test/lib/git-completion.test.ts @@ -0,0 +1,90 @@ +/** + * Git refs, commits, and remotes read for shell completion, against a real + * repository. + */ + +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { + listLocalRefs, + listRecentCommits, + listRemoteRepositories, + listVcsRefs, +} from "../../src/lib/git.js"; + +let repo: string; +let outside: string; + +function git(...args: string[]): string { + return execFileSync("git", args, { cwd: repo, encoding: "utf8" }).trim(); +} + +beforeAll(() => { + repo = mkdtempSync(join(tmpdir(), "git-completion-")); + outside = mkdtempSync(join(tmpdir(), "git-completion-outside-")); + git("init", "--quiet", "--initial-branch=main"); + git("config", "user.email", "test@example.com"); + git("config", "user.name", "Test"); + git("config", "commit.gpgsign", "false"); + writeFileSync(join(repo, "a.txt"), "a"); + git("add", "a.txt"); + git("commit", "--quiet", "-m", "First commit"); + git("tag", "v1.0.0"); + writeFileSync(join(repo, "b.txt"), "b"); + git("add", "b.txt"); + git("commit", "--quiet", "-m", "Second commit"); + git("branch", "feature"); + git("remote", "add", "origin", "git@github.com:acme/app.git"); + git("remote", "add", "fork", "https://github.com/jane/app.git"); + // A remote-tracking branch and the remote's HEAD, as a fetch leaves them + git("update-ref", "refs/remotes/origin/main", "HEAD"); + git("update-ref", "refs/remotes/origin/release", "v1.0.0"); + git("symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main"); +}); + +afterAll(() => { + rmSync(repo, { recursive: true, force: true }); + rmSync(outside, { recursive: true, force: true }); +}); + +describe("git completion", () => { + test("local refs are named as git resolves them, without remote HEADs", () => { + expect( + listLocalRefs(repo) + .map(({ name }) => name) + .sort(), + ).toEqual(["feature", "main", "origin/main", "origin/release", "v1.0.0"]); + }); + + test("VCS refs drop the remote and list each name once, branches first", () => { + const refs = listVcsRefs(repo).map(({ name }) => name); + expect([...refs].sort()).toEqual(["feature", "main", "release", "v1.0.0"]); + expect(refs.at(-1)).toBe("v1.0.0"); + }); + + test("recent commits are full SHAs of HEAD, newest first, with subjects", () => { + const commits = listRecentCommits(repo); + expect(commits.map(({ subject }) => subject)).toEqual([ + "Second commit", + "First commit", + ]); + expect(commits[0]?.name).toBe(git("rev-parse", "HEAD")); + }); + + test("remote repositories are owner/repo names with their remote", () => { + expect(listRemoteRepositories(repo)).toEqual([ + { name: "jane/app", subject: "fork" }, + { name: "acme/app", subject: "origin" }, + ]); + }); + + test("nothing is completed outside a repository", () => { + expect(listLocalRefs(outside)).toEqual([]); + expect(listVcsRefs(outside)).toEqual([]); + expect(listRecentCommits(outside)).toEqual([]); + expect(listRemoteRepositories(outside)).toEqual([]); + }); +}); From 342cd5800e433bfec87379d91ea98c6bd24a42e9 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:45:37 +0000 Subject: [PATCH 6/8] feat(cli): Complete project slugs, issue tag keys, and widget aggregates Finish the audit of values still parsed as free text: - The hidden --project every command takes completes cached projects of the target's org, like the hidden --org completes orgs. - issue tags completes the tag keys issue details list (their `tags`), cached from both scoped and unscoped issue lookups. - dashboard widget add/edit --query and --sort complete the aggregate function names and aliases validateAggregateNames accepts. --- packages/cli/AGENTS.md | 4 +- .../cli/src/commands/dashboard/widget/add.ts | 5 ++- .../cli/src/commands/dashboard/widget/edit.ts | 5 ++- packages/cli/src/commands/issue/tags.ts | 4 +- packages/cli/src/lib/api/completion-seed.ts | 31 ++++++++++++-- packages/cli/src/lib/command.ts | 3 +- packages/cli/src/lib/complete.ts | 40 +++++++++++-------- packages/cli/src/lib/completion-target.ts | 12 ++++++ packages/cli/src/lib/db/completion-cache.ts | 4 +- packages/cli/src/types/dashboard.ts | 12 ++++++ packages/cli/test/lib/complete.test.ts | 22 ++++++++++ .../cli/test/lib/db/completion-cache.test.ts | 27 +++++++++++++ 12 files changed, 138 insertions(+), 31 deletions(-) diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 249dd5e47..3c149d4fb 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -419,7 +419,7 @@ takes: | Fixed values | `choiceParser(values)`, or `choiceParser(values, parse)` to keep a command's own validating parser (`choiceParser(VALID_SORT_VALUES, parseSort)`). Take the values from the module that defines them | those values; a variadic positional completes only its first word (`cli defaults `). Without `parse` the parser returns its input, so the command still validates it | | Comma-separated fixed values (`--scope`, `--features`) | `choiceListParser(values)` (or `(values, parse)`) | the next item of the list, leaving out the items already in it (`org:read,org:w` → `org:read,org:write`) | | Platform | `parsePlatform` | platform identifiers, common ones first | -| Something that already exists in Sentry, as a flag value (`--environment`, `--release`, `--team`, `--owner`, `--integration`, `--app`, `--dsn`, `--workflow`, `--project` lists) | `parseEnvironment`, `parseEnvironmentList`, `parseRelease`, `parseTeam`, `parseOwner` (`team:`), `parseIntegration`, `parseSentryApp`, `parseAppLinkField` (`name=value`), `parseDsn`, `parseWorkflowList`, `parseProjectList` | cached entities of the org the command's target names, else of every org, each once; the `*List` parsers complete the last item of a comma-separated list | +| Something that already exists in Sentry, as a flag value (`--environment`, `--release`, `--team`, `--owner`, `--integration`, `--app`, `--dsn`, `--workflow`, `--project`, `issue tags `) | `parseEnvironment`, `parseEnvironmentList`, `parseRelease`, `parseTeam`, `parseOwner` (`team:`), `parseIntegration`, `parseSentryApp`, `parseAppLinkField` (`name=value`), `parseDsn`, `parseWorkflowList`, `parseProjectSlug`, `parseProjectList`, `parseTagKey` | cached entities of the org the command's target names, else of every org, each once; the `*List` parsers complete the last item of a comma-separated list | | Issue as a flag value (`issue merge --into`) | `parseIssueReference` | what `ISSUE_TARGET` completes | | Git ref, commit, or repository (`--base-ref`, `--head-sha`, `--repo`, `--commit REPO@SHA`, `set-commits --from`) | `parseVcsRef` (branch and tag names as the remote knows them), `parseGitRef` (refs git resolves locally), `parseGitCommit`, `parseGitRepo` (`owner/repo` of the remotes), `parseRepoCommitList` | read from git in the shell's directory; nothing outside a work tree | | A structured value with its own grammar (`issue archive --until`, `api `) | `parseArchiveUntil`, `parseApiEndpoint` | the grammar: `forever`, `auto`, `10x`, `10x/5m`, …; API schema paths one segment at a time, with cached orgs and projects for their placeholders | @@ -451,7 +451,7 @@ its module must not import `completion-target.ts`, or the two would load in a cycle. Cached entities (issues, dashboards, environments, releases, teams, monitors, alert workflows, integrations, Sentry Apps and their issue-link -form fields, DSNs) come from the `completion_cache` table. The +form fields, DSNs, issue tag keys) come from the `completion_cache` table. The authenticated fetch in `src/lib/sentry-client.ts` offers every API response to `src/lib/api/completion-seed.ts`, which stores entities from the `@sentry/api` operations it lists (by URL template, type-checked against the SDK), so API diff --git a/packages/cli/src/commands/dashboard/widget/add.ts b/packages/cli/src/commands/dashboard/widget/add.ts index bf1374bc0..a3c5846db 100644 --- a/packages/cli/src/commands/dashboard/widget/add.ts +++ b/packages/cli/src/commands/dashboard/widget/add.ts @@ -34,6 +34,7 @@ import { WIDGET_TYPES, type WidgetLayoutFlags, type WidgetLayoutMode, + AGGREGATE_FUNCTION_NAMES, } from "../../../types/dashboard.js"; import { type DashboardInput, @@ -190,7 +191,7 @@ export const addCommand = buildCommand({ }, query: { kind: "parsed", - parse: parseText, + parse: choiceParser(AGGREGATE_FUNCTION_NAMES), brief: "Aggregate expression (e.g. count, p95:span.duration)", variadic: true, optional: true, @@ -210,7 +211,7 @@ export const addCommand = buildCommand({ }, sort: { kind: "parsed", - parse: parseText, + parse: choiceParser(AGGREGATE_FUNCTION_NAMES), brief: "Order by (prefix - for desc, e.g. -count)", optional: true, }, diff --git a/packages/cli/src/commands/dashboard/widget/edit.ts b/packages/cli/src/commands/dashboard/widget/edit.ts index 847afd131..ea5ac34d1 100644 --- a/packages/cli/src/commands/dashboard/widget/edit.ts +++ b/packages/cli/src/commands/dashboard/widget/edit.ts @@ -29,6 +29,7 @@ import { validateWidgetLayout, WIDGET_TYPES, type WidgetLayoutFlags, + AGGREGATE_FUNCTION_NAMES, } from "../../../types/dashboard.js"; import { type DashboardArguments, dashboardArguments } from "../arguments.js"; import { @@ -267,7 +268,7 @@ export const editCommand = buildCommand({ }, query: { kind: "parsed", - parse: parseText, + parse: choiceParser(AGGREGATE_FUNCTION_NAMES), brief: "Aggregate expression (e.g. count, p95:span.duration)", variadic: true, optional: true, @@ -287,7 +288,7 @@ export const editCommand = buildCommand({ }, sort: { kind: "parsed", - parse: parseText, + parse: choiceParser(AGGREGATE_FUNCTION_NAMES), brief: "Order by (prefix - for desc, e.g. -count)", optional: true, }, diff --git a/packages/cli/src/commands/issue/tags.ts b/packages/cli/src/commands/issue/tags.ts index a2d76c39b..414a518cf 100644 --- a/packages/cli/src/commands/issue/tags.ts +++ b/packages/cli/src/commands/issue/tags.ts @@ -10,7 +10,7 @@ import { description, object, pipe } from "valibot"; import type { SentryContext } from "../../context.js"; import { getIssueTag } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; -import { parseEnvironment, parseText } from "../../lib/completion-target.js"; +import { parseEnvironment, parseTagKey } from "../../lib/completion-target.js"; import { ContextError } from "../../lib/errors.js"; import { formatIssueTags } from "../../lib/formatters/issue-tags.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -97,7 +97,7 @@ export const tagsCommand = buildCommand({ { placeholder: "key", brief: "Tag key, e.g. release, server_name, browser.name", - parse: parseText, + parse: parseTagKey, }, ], }, diff --git a/packages/cli/src/lib/api/completion-seed.ts b/packages/cli/src/lib/api/completion-seed.ts index 513c2ce82..03ade6807 100644 --- a/packages/cli/src/lib/api/completion-seed.ts +++ b/packages/cli/src/lib/api/completion-seed.ts @@ -218,6 +218,21 @@ function appLinkFieldEntities(body: unknown): CompletionEntity[] { }); } +/** + * Keys of the tags an issue's details list (`tags`: the issue's tag keys, + * as `issue tags ` takes them), described by their labels. + */ +function tagKeyEntities(body: unknown): CompletionEntity[] { + const tags = objectField(body, "tags"); + return Array.isArray(tags) ? fieldEntities("key", "name")(tags) : []; +} + +/** The organization of an issue, from its permalink. */ +function permalinkOrg(body: unknown): string | undefined { + const permalink = stringField(body, "permalink"); + return permalink ? parseSentryUrl(permalink)?.org : undefined; +} + /** Public DSNs of client keys, described by the key name. */ function dsnEntities(body: unknown): CompletionEntity[] { return items(body).flatMap((key) => { @@ -245,6 +260,11 @@ const COMPLETION_SOURCES: readonly CompletionSource[] = [ url: "/api/0/organizations/{organization_id_or_slug}/issues/{issue_id}/" satisfies GetOrganizationIssueData["url"], extract: issueEntities, }, + { + kind: "tag-key", + url: "/api/0/organizations/{organization_id_or_slug}/issues/{issue_id}/" satisfies GetOrganizationIssueData["url"], + extract: tagKeyEntities, + }, { kind: "issue", url: "/api/0/organizations/{organization_id_or_slug}/shortids/{issue_id}/" satisfies ResolveOrganizationShortIdData["url"], @@ -262,10 +282,13 @@ const COMPLETION_SOURCES: readonly CompletionSource[] = [ kind: "issue", url: "/api/0/issues/{issue_id}/", extract: issueEntities, - org: (body) => { - const permalink = stringField(body, "permalink"); - return permalink ? parseSentryUrl(permalink)?.org : undefined; - }, + org: permalinkOrg, + }, + { + kind: "tag-key", + url: "/api/0/issues/{issue_id}/", + extract: tagKeyEntities, + org: permalinkOrg, }, { kind: "dashboard", diff --git a/packages/cli/src/lib/command.ts b/packages/cli/src/lib/command.ts index d1dd972a3..4812f78f7 100644 --- a/packages/cli/src/lib/command.ts +++ b/packages/cli/src/lib/command.ts @@ -43,6 +43,7 @@ import { type CommandExample, exampleSteps } from "./command-example.js"; import { choiceListParser, ORG_TARGET, + parseProjectSlug, parseText, withCompletionTarget, } from "./completion-target.js"; @@ -306,7 +307,7 @@ const ORG_FLAG = { */ const PROJECT_FLAG = { kind: "parsed" as const, - parse: parseText, + parse: parseProjectSlug, brief: "Project slug", optional: true as const, hidden: true as const, diff --git a/packages/cli/src/lib/complete.ts b/packages/cli/src/lib/complete.ts index 8220137cc..6c76f23a4 100644 --- a/packages/cli/src/lib/complete.ts +++ b/packages/cli/src/lib/complete.ts @@ -352,8 +352,10 @@ const COMPLETERS = Object.freeze({ team: flagEntityCompleter("team"), /** Cached teams as the `team:` actor an owner flag takes. */ owner: flagEntityCompleter("owner"), + /** Project slugs of the org typed, else of every org. */ + project: projectCompleter({ list: false }), /** Comma-separated project slugs of the org typed, else of every org. */ - "project-list": (partial, slot) => completeProjectList(partial, slot), + "project-list": projectCompleter({ list: true }), /** Comma-separated cached alert (workflow) IDs, described by name. */ "workflow-list": flagEntityCompleter("workflow", { list: true }), /** Cached monitor slugs in the first word; the command to run after it. */ @@ -375,6 +377,8 @@ const COMPLETERS = Object.freeze({ }, /** Cached public DSNs, described by the client key name. */ dsn: flagEntityCompleter("dsn"), + /** Cached tag keys of issues, described by their labels. */ + "tag-key": flagEntityCompleter("tag-key"), /** Servers of stored accounts. */ "account-server": (partial) => completeAccountServers(partial), /** @@ -835,25 +839,27 @@ function flagEntityCompleter( } /** - * Complete a comma-separated list of project slugs: those of the org the - * target names, else of every cached org, leaving out those already given. + * Build the completer of project slugs: those of the org the target names, + * else of every cached org, each once. + * + * @param options.list - The value is a comma-separated list: complete its + * last item, leaving out those already in it */ -async function completeProjectList( - partial: string, - slot: CompleterContext, -): Promise { - const { head, item, chosen } = splitListItem(partial, true); - const names = new Map(); - for (const org of await flagOrgs(slot.positionals)) { - for (const { projectSlug, projectName } of getCachedProjectsForOrg(org)) { - if (!(chosen.has(projectSlug) || names.has(projectSlug))) { - names.set(projectSlug, projectName); +function projectCompleter(options: { list: boolean }): Completer { + return async (partial, slot) => { + const { head, item, chosen } = splitListItem(partial, options.list); + const names = new Map(); + for (const org of await flagOrgs(slot.positionals)) { + for (const { projectSlug, projectName } of getCachedProjectsForOrg(org)) { + if (!(chosen.has(projectSlug) || names.has(projectSlug))) { + names.set(projectSlug, projectName); + } } } - } - return fuzzyMatch(item, [...names.keys()], { mode: "search" }).map( - (slug) => ({ value: `${head}${slug}`, description: names.get(slug) }), - ); + return fuzzyMatch(item, [...names.keys()], { mode: "search" }).map( + (slug) => ({ value: `${head}${slug}`, description: names.get(slug) }), + ); + }; } const completeMonitors = flagEntityCompleter("monitor"); diff --git a/packages/cli/src/lib/completion-target.ts b/packages/cli/src/lib/completion-target.ts index 5eacca12c..a7a4ce2f7 100644 --- a/packages/cli/src/lib/completion-target.ts +++ b/packages/cli/src/lib/completion-target.ts @@ -107,6 +107,9 @@ export const TEAM_TARGET = "team" satisfies CompletionTarget; /** Cached teams as `team:` owner actors. */ export const OWNER_TARGET = "owner" satisfies CompletionTarget; +/** Cached project slugs. */ +export const PROJECT_TARGET = "project" satisfies CompletionTarget; + /** Comma-separated project slugs. */ export const PROJECT_LIST_TARGET = "project-list" satisfies CompletionTarget; @@ -130,6 +133,9 @@ export const APP_LINK_FIELD_TARGET = /** Cached public DSNs. */ export const DSN_TARGET = "dsn" satisfies CompletionTarget; +/** Cached tag keys of issues. */ +export const TAG_KEY_TARGET = "tag-key" satisfies CompletionTarget; + /** Servers of stored accounts. */ export const ACCOUNT_SERVER_TARGET = "account-server" satisfies CompletionTarget; @@ -247,6 +253,9 @@ export const parseTeam = identityParser(TEAM_TARGET); /** Parses an owner actor (`team:`, `user:`); completes cached teams. */ export const parseOwner = identityParser(OWNER_TARGET); +/** Parses a project slug; completes cached projects. */ +export const parseProjectSlug = identityParser(PROJECT_TARGET); + /** Parses comma-separated project slugs; completes cached projects. */ export const parseProjectList = identityParser(PROJECT_LIST_TARGET); @@ -268,6 +277,9 @@ export const parseAppLinkField = identityParser(APP_LINK_FIELD_TARGET); /** Parses a DSN; completes cached client-key DSNs. */ export const parseDsn = identityParser(DSN_TARGET); +/** Parses an issue tag key; completes cached tag keys. */ +export const parseTagKey = identityParser(TAG_KEY_TARGET); + /** Parses an `issue archive --until` condition; completes its grammar. */ export const parseArchiveUntil = identityParser(ARCHIVE_UNTIL_TARGET); diff --git a/packages/cli/src/lib/db/completion-cache.ts b/packages/cli/src/lib/db/completion-cache.ts index f4386d947..67d0fcc74 100644 --- a/packages/cli/src/lib/db/completion-cache.ts +++ b/packages/cli/src/lib/db/completion-cache.ts @@ -38,6 +38,7 @@ import { runUpsert } from "./utils.js"; * - `app-link-field`: `name=` of a field of a Sentry App's issue-link * form, and `name=value` for its fixed choices, with the app * - `dsn`: public DSN of a client key, with the key name + * - `tag-key`: key of a tag seen on an issue, with its label */ export type CompletionEntityKind = | "issue" @@ -51,7 +52,8 @@ export type CompletionEntityKind = | "integration" | "sentry-app" | "app-link-field" - | "dsn"; + | "dsn" + | "tag-key"; /** Most recently seen entries kept per kind and org. */ export const MAX_ENTITIES_PER_ORG = 500; diff --git a/packages/cli/src/types/dashboard.ts b/packages/cli/src/types/dashboard.ts index a759bf1ce..735527229 100644 --- a/packages/cli/src/types/dashboard.ts +++ b/packages/cli/src/types/dashboard.ts @@ -294,6 +294,18 @@ export const ERROR_AGGREGATE_FUNCTIONS = [ export type ErrorAggregateFunction = (typeof ERROR_AGGREGATE_FUNCTIONS)[number]; +/** + * The function names `--query` and `--sort` complete: those of every + * dataset's aggregates and their aliases, as {@link parseAggregate} reads + * them (`count`, then `p95:span.duration` once a field follows). A word + * starting with `-` completes flag names, so a descending sort's `-` is + * typed after the name is completed. + */ +export const AGGREGATE_FUNCTION_NAMES: readonly string[] = [ + ...ERROR_AGGREGATE_FUNCTIONS, + ...Object.keys(AGGREGATE_ALIASES), +]; + /** Valibot schema for validating a span aggregate function name */ export const SpanAggregateFunctionSchema = picklist(SPAN_AGGREGATE_FUNCTIONS); diff --git a/packages/cli/test/lib/complete.test.ts b/packages/cli/test/lib/complete.test.ts index 50507229a..fe126bbfc 100644 --- a/packages/cli/test/lib/complete.test.ts +++ b/packages/cli/test/lib/complete.test.ts @@ -1077,6 +1077,22 @@ describe("getCompletions: flag values that name cached entities", () => { expect( await values(["release", "create", "acme/1.0.0", "--project"], "cli,"), ).toEqual(["cli,web"]); + // The hidden --project every command takes completes one slug + expect(await values(["issue", "list", "acme/", "--project"], "w")).toEqual([ + "web", + ]); + }); + + test("issue tags completes the tag keys seen on issues", async () => { + await seedOrgs([{ slug: "acme", name: "Acme" }]); + cacheCompletionEntities("tag-key", "acme", [ + { value: "browser.name", label: "Browser.Name" }, + { value: "release", label: "Release" }, + ]); + + expect( + await getCompletions(["issue", "tags", "acme/CLI-1A"], "brow"), + ).toEqual([{ value: "browser.name", description: "Browser.Name" }]); }); }); @@ -1178,6 +1194,12 @@ describe("getCompletions: fixed values from the modules that validate them", () "p95", "p99", ]); + expect( + await values(["dashboard", "widget", "add", "--query"], "count"), + ).toEqual(["count", "count_unique", "count_if", "count_at_least"]); + expect( + await values(["dashboard", "widget", "edit", "--sort"], "ep"), + ).toEqual(["eps", "epm"]); }); test("event send --user offers the keys with their own User field", async () => { diff --git a/packages/cli/test/lib/db/completion-cache.test.ts b/packages/cli/test/lib/db/completion-cache.test.ts index d378f5aa3..63e1112a4 100644 --- a/packages/cli/test/lib/db/completion-cache.test.ts +++ b/packages/cli/test/lib/db/completion-cache.test.ts @@ -117,6 +117,33 @@ describe("completion seeding from API responses", () => { ]); }); + test("issue details store their tag keys, scoped and unscoped", async () => { + await seedCompletions( + `${API}/issues/123/`, + json({ + shortId: "CLI-1A", + tags: [ + { key: "browser.name", name: "Browser.Name" }, + { name: "No key" }, + ], + }), + ); + await seedCompletions( + "https://sentry.io/api/0/issues/456/", + json({ + shortId: "CLI-2B", + permalink: "https://acme.sentry.io/issues/456/", + tags: [{ key: "release", name: "Release" }], + }), + ); + await seedCompletions(`${API}/issues/`, json([{ shortId: "CLI-3C" }])); + + expect(getCachedCompletionEntities("tag-key", ["acme"])).toEqual([ + { orgSlug: "acme", value: "release", label: "Release" }, + { orgSlug: "acme", value: "browser.name", label: "Browser.Name" }, + ]); + }); + test("dashboards are stored by numeric ID only", async () => { await seedCompletions( `${API}/dashboards/`, From 49258900325659654c5ce6784ca7db0621cc5ecc Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:52:41 +0000 Subject: [PATCH 7/8] perf(cli): Collect API completion paths in the generator only COMPLETION_VALUES walked the API schema when completion-target.ts was imported, which every command does. Only generate:completions needs the paths; the __complete fast path reads them from the generated JSON. Move the walk to getApiCompletionPaths in api-schema.ts, called by the generator (and the test that checks the generated file). --- .../cli/script/generate-completion-targets.ts | 3 ++- packages/cli/src/lib/api-schema.ts | 13 +++++++++++++ packages/cli/src/lib/completion-target.ts | 18 +++++++----------- .../cli/test/lib/completions.property.test.ts | 3 ++- 4 files changed, 24 insertions(+), 13 deletions(-) diff --git a/packages/cli/script/generate-completion-targets.ts b/packages/cli/script/generate-completion-targets.ts index f17fea4c6..8512fe98e 100644 --- a/packages/cli/script/generate-completion-targets.ts +++ b/packages/cli/script/generate-completion-targets.ts @@ -44,6 +44,7 @@ const { routes } = await import("../src/app.js"); const { extractCommandCompletions, extractRouteGroups } = await import("../src/lib/introspect.js"); const { COMPLETION_VALUES } = await import("../src/lib/completion-target.js"); +const { getApiCompletionPaths } = await import("../src/lib/api-schema.js"); const OUTPUT_PATH = "src/generated/completion-targets.json"; @@ -56,7 +57,7 @@ const groups = extractRouteGroups(routeMap); mkdirSync("src/generated", { recursive: true }); await writeFile( OUTPUT_PATH, - `${JSON.stringify({ commands, groups, values: COMPLETION_VALUES }, null, 2)}\n`, + `${JSON.stringify({ commands, groups, values: { ...COMPLETION_VALUES, apiPaths: getApiCompletionPaths() } }, null, 2)}\n`, "utf8", ); process.stdout.write( diff --git a/packages/cli/src/lib/api-schema.ts b/packages/cli/src/lib/api-schema.ts index 2cd5f5c0b..a9e749af0 100644 --- a/packages/cli/src/lib/api-schema.ts +++ b/packages/cli/src/lib/api-schema.ts @@ -452,6 +452,19 @@ export function getAllEndpoints(): readonly ApiEndpoint[] { return schema; } +/** The prefix `api` adds to an endpoint, left out of completed paths. */ +const API_PATH_PREFIX = /^\/api\/0\//; + +/** + * Every schema path without its `/api/0/` prefix, each once: the endpoints + * `sentry api ` completes (see `CompletionValues.apiPaths`). + */ +export function getApiCompletionPaths(): string[] { + return [ + ...new Set(schema.map(({ path }) => path.replace(API_PATH_PREFIX, ""))), + ]; +} + /** Get resource summary: name, endpoint count, and methods for each resource */ export function getResourceSummaries(): ResourceSummary[] { const map = new Map< diff --git a/packages/cli/src/lib/completion-target.ts b/packages/cli/src/lib/completion-target.ts index a7a4ce2f7..f9d81725a 100644 --- a/packages/cli/src/lib/completion-target.ts +++ b/packages/cli/src/lib/completion-target.ts @@ -19,7 +19,6 @@ import { ISSUE_IS_VALUES } from "@sentry/toolkit-core/search-query"; import { numberParser } from "@stricli/core"; -import { getAllEndpoints } from "./api-schema.js"; import type { CompletionTarget } from "./complete.js"; import { ISSUE_LEVELS } from "../types/sentry.js"; import { @@ -427,9 +426,6 @@ export function getCompletionChoices( ); } -/** The prefix `api` adds to an endpoint, left out of {@link COMPLETION_VALUES}. */ -const API_PATH_PREFIX = /^\/api\/0\//; - /** Fixed values the completers offer (see {@link COMPLETION_VALUES}). */ export type CompletionValues = { /** Issue search keys with a fixed set of values, for {@link ISSUE_QUERY_TARGET} */ @@ -438,7 +434,12 @@ export type CompletionValues = { periodUnits: readonly string[]; /** Relative periods offered for {@link PERIOD_TARGET}, with descriptions */ periodPresets: Readonly>; - /** API schema paths without `/api/0/`, for {@link API_ENDPOINT_TARGET} */ + /** + * API schema paths without `/api/0/`, for {@link API_ENDPOINT_TARGET}. + * Not in {@link COMPLETION_VALUES}: the generator adds them from + * `getApiCompletionPaths`, so importing this module (as every command + * does) does not walk the API schema. + */ apiPaths: readonly string[]; }; @@ -447,7 +448,7 @@ export type CompletionValues = { * them. The generator writes them next to the command metadata so the * `__complete` fast path gets them without loading those modules. */ -export const COMPLETION_VALUES: CompletionValues = { +export const COMPLETION_VALUES: Omit = { issueSearch: { is: ISSUE_IS_VALUES, level: ISSUE_LEVELS }, periodUnits: Object.keys(UNIT_SECONDS), // The presets of Sentry's time range selector (`DEFAULT_RELATIVE_PERIODS` @@ -460,9 +461,4 @@ export const COMPLETION_VALUES: CompletionValues = { "30d": "Last 30 days", "90d": "Last 90 days", }, - apiPaths: [ - ...new Set( - getAllEndpoints().map(({ path }) => path.replace(API_PATH_PREFIX, "")), - ), - ], }; diff --git a/packages/cli/test/lib/completions.property.test.ts b/packages/cli/test/lib/completions.property.test.ts index 56b3c3d89..1a62a1e3f 100644 --- a/packages/cli/test/lib/completions.property.test.ts +++ b/packages/cli/test/lib/completions.property.test.ts @@ -24,6 +24,7 @@ import { COMPLETION_VALUES, PROJECT_SPEC_TARGET, } from "../../src/lib/completion-target.js"; +import { getApiCompletionPaths } from "../../src/lib/api-schema.js"; import { parsePeriod } from "../../src/lib/time-range.js"; import { extractCommandTree, @@ -528,7 +529,7 @@ describe("complete.ts: generated completion targets", () => { expect(generatedTargets).toEqual({ commands, groups, - values: COMPLETION_VALUES, + values: { ...COMPLETION_VALUES, apiPaths: getApiCompletionPaths() }, }); }); From f0fad35fe0d6307095ed5c12794c6590b943e6f4 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:58:01 +0000 Subject: [PATCH 8/8] feat(cli): Complete Local bind hosts from the network adapters local serve and local run --host bind any address, not only loopback. Complete the loopback hosts first (the only ones that keep the UI actions), then 0.0.0.0 and the address of each network adapter from os.networkInterfaces(), described by adapter. IPv6 link-local addresses are left out: they cannot be bound without a zone. The daemon's --host, which must be loopback, still completes only the loopback hosts. --- packages/cli/src/commands/local/run.ts | 4 +- packages/cli/src/commands/local/server.ts | 5 +- packages/cli/src/lib/complete.ts | 29 +++++++- packages/cli/src/lib/completion-target.ts | 13 ++++ .../cli/test/lib/complete-bind-host.test.ts | 66 +++++++++++++++++++ 5 files changed, 112 insertions(+), 5 deletions(-) create mode 100644 packages/cli/test/lib/complete-bind-host.test.ts diff --git a/packages/cli/src/commands/local/run.ts b/packages/cli/src/commands/local/run.ts index d1faf2d7d..09762f320 100644 --- a/packages/cli/src/commands/local/run.ts +++ b/packages/cli/src/commands/local/run.ts @@ -24,6 +24,7 @@ import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; import { choiceParser, + parseBindHost, parseText, textParser, } from "../../lib/completion-target.js"; @@ -68,7 +69,6 @@ import { createLocalRetentionBuffer, OPTIONAL_MAX_ENVELOPES_FLAG, } from "./retention-buffer.js"; -import { LOOPBACK_HOSTS } from "./loopback.js"; type RunFlags = { readonly port: number; @@ -461,7 +461,7 @@ export const runCommand = buildCommand({ }, host: { kind: "parsed", - parse: choiceParser(LOOPBACK_HOSTS), + parse: parseBindHost, brief: "Hostname for the local server", default: "localhost", }, diff --git a/packages/cli/src/commands/local/server.ts b/packages/cli/src/commands/local/server.ts index bdbf8b816..b199b854d 100644 --- a/packages/cli/src/commands/local/server.ts +++ b/packages/cli/src/commands/local/server.ts @@ -1,11 +1,12 @@ import { choiceParser, + parseBindHost, parseText, textParser, } from "../../lib/completion-target.js"; import { isHostedUiOrigin, LOCAL_SESSION_EVENT } from "./viewer-protocol.js"; export { isHostedUiOrigin } from "./viewer-protocol.js"; -import { isLoopbackHost, LOOPBACK_HOSTS } from "./loopback.js"; +import { isLoopbackHost } from "./loopback.js"; /** * sentry local serve * @@ -1062,7 +1063,7 @@ export const serverCommand = buildCommand({ }, host: { kind: "parsed", - parse: choiceParser(LOOPBACK_HOSTS), + parse: parseBindHost, brief: "Hostname to bind to", default: "localhost", }, diff --git a/packages/cli/src/lib/complete.ts b/packages/cli/src/lib/complete.ts index 6c76f23a4..46cf430d1 100644 --- a/packages/cli/src/lib/complete.ts +++ b/packages/cli/src/lib/complete.ts @@ -16,7 +16,7 @@ */ import { readdirSync, statSync } from "node:fs"; -import { homedir } from "node:os"; +import { homedir, networkInterfaces } from "node:os"; import { join } from "node:path"; import generatedTargets from "../generated/completion-targets.json"; import type { CompletionValues } from "./completion-target.js"; @@ -377,6 +377,8 @@ const COMPLETERS = Object.freeze({ }, /** Cached public DSNs, described by the client key name. */ dsn: flagEntityCompleter("dsn"), + /** Loopback hosts, every interface, then the addresses of local adapters. */ + "bind-host": (partial) => completeBindHosts(partial), /** Cached tag keys of issues, described by their labels. */ "tag-key": flagEntityCompleter("tag-key"), /** Servers of stored accounts. */ @@ -866,6 +868,31 @@ const completeMonitors = flagEntityCompleter("monitor"); const completeAppLinkFields = flagEntityCompleter("app-link-field"); +/** IPv6 link-local addresses, which cannot be bound without a zone. */ +const LINK_LOCAL_IPV6_RE = /^fe80:/i; + +/** + * Hosts a Local server can bind: the loopback hosts (the only ones that + * keep its UI actions), `0.0.0.0` for every interface, then the address of + * each network adapter, described by the adapter's name. + */ +function completeBindHosts(partial: string): Completion[] { + const hosts = new Map( + GENERATED.values.loopbackHosts.map((host) => [host, "loopback"]), + ); + hosts.set("0.0.0.0", "every IPv4 interface"); + for (const [name, addresses] of Object.entries(networkInterfaces())) { + for (const { address, family } of addresses ?? []) { + if (!(hosts.has(address) || LINK_LOCAL_IPV6_RE.test(address))) { + hosts.set(address, `${name} (${family})`); + } + } + } + return [...hosts] + .filter(([host]) => host.startsWith(partial)) + .map(([value, description]) => ({ value, description })); +} + /** Servers of stored accounts, described by the accounts on each. */ function completeAccountServers(partial: string): Completion[] { const accounts = new Map(); diff --git a/packages/cli/src/lib/completion-target.ts b/packages/cli/src/lib/completion-target.ts index f9d81725a..362c0abca 100644 --- a/packages/cli/src/lib/completion-target.ts +++ b/packages/cli/src/lib/completion-target.ts @@ -29,6 +29,7 @@ import { SPAN_DEPTH_KEYWORDS, VALID_LOG_SORT_DIRECTIONS, } from "./arg-parsing.js"; +import { LOOPBACK_HOSTS } from "../commands/local/loopback.js"; import { INSTALLATION_METHODS, parseInstallationMethod } from "./binary.js"; import { parseLoginEmail } from "./db/accounts.js"; import { parseReleaseChannel, RELEASE_CHANNELS } from "./db/release-channel.js"; @@ -142,6 +143,9 @@ export const ACCOUNT_SERVER_TARGET = /** `issue archive --until` conditions. */ export const ARCHIVE_UNTIL_TARGET = "archive-until" satisfies CompletionTarget; +/** Addresses a Local server can bind: loopback, then this machine's adapters. */ +export const BIND_HOST_TARGET = "bind-host" satisfies CompletionTarget; + /** API paths relative to `/api/0/`, from the API schema. */ export const API_ENDPOINT_TARGET = "api-endpoint" satisfies CompletionTarget; @@ -282,6 +286,12 @@ export const parseTagKey = identityParser(TAG_KEY_TARGET); /** Parses an `issue archive --until` condition; completes its grammar. */ export const parseArchiveUntil = identityParser(ARCHIVE_UNTIL_TARGET); +/** + * Parses the host a Local server binds; completes the loopback hosts, then + * every interface (`0.0.0.0`) and the addresses of this machine's adapters. + */ +export const parseBindHost = identityParser(BIND_HOST_TARGET); + /** Parses an `api` endpoint; completes API schema paths. */ export const parseApiEndpoint = identityParser(API_ENDPOINT_TARGET); @@ -434,6 +444,8 @@ export type CompletionValues = { periodUnits: readonly string[]; /** Relative periods offered for {@link PERIOD_TARGET}, with descriptions */ periodPresets: Readonly>; + /** Local's loopback hosts, offered first for {@link BIND_HOST_TARGET} */ + loopbackHosts: readonly string[]; /** * API schema paths without `/api/0/`, for {@link API_ENDPOINT_TARGET}. * Not in {@link COMPLETION_VALUES}: the generator adds them from @@ -461,4 +473,5 @@ export const COMPLETION_VALUES: Omit = { "30d": "Last 30 days", "90d": "Last 90 days", }, + loopbackHosts: LOOPBACK_HOSTS, }; diff --git a/packages/cli/test/lib/complete-bind-host.test.ts b/packages/cli/test/lib/complete-bind-host.test.ts new file mode 100644 index 000000000..c698584e2 --- /dev/null +++ b/packages/cli/test/lib/complete-bind-host.test.ts @@ -0,0 +1,66 @@ +/** + * The `--host` a Local server binds completes from this machine's network + * adapters, so `node:os` is mocked with a known set of interfaces. + */ + +import type { NetworkInterfaceInfo } from "node:os"; +import { describe, expect, test, vi } from "vitest"; + +function adapter( + address: string, + family: "IPv4" | "IPv6", + internal = false, +): NetworkInterfaceInfo { + return { + address, + family, + internal, + netmask: "", + mac: "", + cidr: null, + ...(family === "IPv6" ? { scopeid: 0 } : {}), + } as NetworkInterfaceInfo; +} + +vi.mock("node:os", async (importOriginal) => ({ + ...(await importOriginal()), + networkInterfaces: () => ({ + lo: [adapter("127.0.0.1", "IPv4", true), adapter("::1", "IPv6", true)], + en0: [ + adapter("192.168.1.20", "IPv4"), + adapter("fe80::1", "IPv6"), + adapter("2001:db8::20", "IPv6"), + ], + }), +})); + +import { getCompletions } from "../../src/lib/complete.js"; +import { useTestConfigDir } from "../helpers.js"; + +useTestConfigDir("test-complete-bind-host-"); + +describe("getCompletions: Local bind hosts", () => { + test("loopback hosts first, then every interface and each adapter", async () => { + expect(await getCompletions(["local", "serve", "--host"], "")).toEqual([ + { value: "localhost", description: "loopback" }, + { value: "127.0.0.1", description: "loopback" }, + { value: "::1", description: "loopback" }, + { value: "0.0.0.0", description: "every IPv4 interface" }, + { value: "192.168.1.20", description: "en0 (IPv4)" }, + { value: "2001:db8::20", description: "en0 (IPv6)" }, + ]); + expect( + (await getCompletions(["local", "run", "--host"], "192")).map( + (c) => c.value, + ), + ).toEqual(["192.168.1.20"]); + }); + + test("the daemon's loopback-only --host keeps to loopback hosts", async () => { + expect( + (await getCompletions(["local", "daemon", "start", "--host"], "")).map( + (c) => c.value, + ), + ).toEqual(["localhost", "127.0.0.1", "::1"]); + }); +});