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/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/AGENTS.md b/packages/cli/AGENTS.md index 6cd79d627..3c149d4fb 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -391,42 +391,75 @@ 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. 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 | |----------|--------|-----------| | 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 | +| 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 | +| 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 -`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`. -Issue and dashboard suggestions come from the `completion_cache` table. The +`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. +Cached entities (issues, dashboards, environments, releases, teams, +monitors, alert workflows, integrations, Sentry Apps and their issue-link +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 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/lint-rules/cli-oxlint-plugin.js b/packages/cli/lint-rules/cli-oxlint-plugin.js index 763b1d50c..bcc20a2b0 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,81 @@ 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: + * `--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}>`; + 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 +493,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/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"); } 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/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..bd1298c90 100644 --- a/packages/cli/src/commands/alert/issues/create.ts +++ b/packages/cli/src/commands/alert/issues/create.ts @@ -11,6 +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, + parseEnvironment, + parseOwner, + parseText, +} from "../../../lib/completion-target.js"; import { ContextError, stringifyUnknown, @@ -27,6 +33,7 @@ import { orgProjectTarget, } from "../../../lib/target-positional.js"; import { + ISSUE_MATCH_MODES, matchToLogicType, parseJsonObjectList, parseMatchMode, @@ -150,19 +157,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 +182,28 @@ export const createCommand = buildCommand({ }, environment: { kind: "parsed", - parse: String, + parse: parseEnvironment, 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: 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 0456820cf..edbda8b52 100644 --- a/packages/cli/src/commands/alert/issues/edit.ts +++ b/packages/cli/src/commands/alert/issues/edit.ts @@ -11,6 +11,12 @@ import { updateIssueAlertRule, } from "../../../lib/api-client.js"; import { buildCommand, numberParser } from "../../../lib/command.js"; +import { + choiceParser, + parseEnvironment, + parseOwner, + parseText, +} from "../../../lib/completion-target.js"; import { ContextError, stringifyUnknown, @@ -19,10 +25,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 +218,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 +250,28 @@ export const editCommand = buildCommand({ }, environment: { kind: "parsed", - parse: String, + parse: parseEnvironment, 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: parseOwner, 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..a87e14aea 100644 --- a/packages/cli/src/commands/alert/metrics/create.ts +++ b/packages/cli/src/commands/alert/metrics/create.ts @@ -8,6 +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, + 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"; @@ -19,9 +27,11 @@ import { } from "../../../lib/target-positional.js"; import { METRIC_ALERT_DATASET_BRIEF, + METRIC_ALERT_DATASET_NAMES, parseMetricTriggers, } from "../../../types/metric-alert.js"; import { + METRIC_TIME_WINDOW_VALUES, normalizeProjectList, parseJsonObjectList, parseWorkflowIds, @@ -124,58 +134,58 @@ 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": { kind: "parsed", - parse: numberParser, + parse: choiceParser(METRIC_TIME_WINDOW_VALUES, numberParser), brief: "Evaluation window in minutes", }, 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: parseProjectList, variadic: true, optional: true, brief: "Project slug filter (repeatable or comma-separated)", }, environment: { kind: "parsed", - parse: String, + parse: parseEnvironment, optional: true, brief: "Environment filter", }, owner: { kind: "parsed", - parse: String, + parse: parseOwner, optional: true, brief: "Owner value accepted by Sentry API", }, workflow: { kind: "parsed", - parse: String, + 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 f29e345e9..6a45d24e1 100644 --- a/packages/cli/src/commands/alert/metrics/edit.ts +++ b/packages/cli/src/commands/alert/metrics/edit.ts @@ -10,21 +10,32 @@ import { putMetricAlertRule, } from "../../../lib/api-client.js"; import { buildCommand, numberParser } from "../../../lib/command.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"; import { resolveOrgOnlyTarget } from "../../../lib/resolve-target.js"; import { METRIC_ALERT_DATASET_BRIEF, + METRIC_ALERT_DATASET_NAMES, parseMetricTriggers, } from "../../../types/metric-alert.js"; import { + METRIC_TIME_WINDOW_VALUES, normalizeMetricDataset, normalizeProjectList, parseJsonObjectList, parseStatusFlag, parseWorkflowIds, resolveMetricDataset, + RULE_STATUSES, statusToMetricValue, validateMetricDataset, validateMetricTimeWindow, @@ -214,69 +225,69 @@ 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, }, "time-window": { kind: "parsed", - parse: numberParser, + parse: choiceParser(METRIC_TIME_WINDOW_VALUES, numberParser), optional: true, brief: "Evaluation window in minutes", }, 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: parseProjectList, variadic: true, optional: true, brief: "Project slug filter (repeatable or comma-separated)", }, environment: { kind: "parsed", - parse: String, + parse: parseEnvironment, optional: true, brief: "Environment value (pass empty string to clear)", }, owner: { kind: "parsed", - parse: String, + parse: parseOwner, optional: true, brief: "Owner value (pass empty string to clear)", }, workflow: { kind: "parsed", - parse: String, + parse: parseWorkflowList, 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..af03ba5b0 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"]); @@ -24,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, @@ -33,8 +37,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 +76,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..da0a03d68 100644 --- a/packages/cli/src/commands/api.ts +++ b/packages/cli/src/commands/api.ts @@ -20,7 +20,12 @@ import { rawApiRequest, } from "../lib/api-client.js"; import { buildCommand } from "../lib/command.js"; -import { parseText } from "../lib/completion-target.js"; +import { + choiceParser, + parseApiEndpoint, + parseFilePath, + parseText, +} from "../lib/completion-target.js"; import { applyCustomHeaders } from "../lib/custom-headers.js"; import { getIdentityFingerprint } from "../lib/db/auth.js"; import { @@ -1693,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", }, ], @@ -1701,7 +1706,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 +1714,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..4b5f32c46 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -9,6 +9,12 @@ import { } from "../../lib/api-client.js"; import { normalizeAuthToken } from "../../lib/auth-header.js"; import { buildCommand, numberParser } from "../../lib/command.js"; +import { + ACCOUNT_SERVER_TARGET, + choiceListParser, + completingParser, + parseText, +} from "../../lib/completion-target.js"; import { normalizeUrl } from "../../lib/constants.js"; import { getAccountIdToReplace, @@ -62,6 +68,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 +547,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 +566,7 @@ export const loginCommand = buildCommand({ }, url: { kind: "parsed", - parse: 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).", @@ -574,7 +581,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..a99a8c4d0 100644 --- a/packages/cli/src/commands/auth/logout.ts +++ b/packages/cli/src/commands/auth/logout.ts @@ -10,7 +10,11 @@ import { resolveAccountSelector, } from "../../lib/account-selection.js"; import { buildCommand } from "../../lib/command.js"; -import { parseAccountSelector } from "../../lib/completion-target.js"; +import { + ACCOUNT_SERVER_TARGET, + completingParser, + parseAccountSelector, +} from "../../lib/completion-target.js"; import { type AccountSummary, getAccountRow, @@ -124,7 +128,7 @@ export const logoutCommand = buildCommand({ flags: { url: { kind: "parsed", - parse: 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/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..88a1a2858 100644 --- a/packages/cli/src/commands/auth/switch.ts +++ b/packages/cli/src/commands/auth/switch.ts @@ -11,7 +11,11 @@ import { resolveAccountSelector, } from "../../lib/account-selection.js"; import { buildCommand } from "../../lib/command.js"; -import { parseAccountSelector } from "../../lib/completion-target.js"; +import { + ACCOUNT_SERVER_TARGET, + completingParser, + parseAccountSelector, +} from "../../lib/completion-target.js"; import { type AccountSummary, getAccountRow, @@ -105,7 +109,7 @@ export const switchCommand = buildCommand({ flags: { url: { kind: "parsed", - parse: 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 59cab3058..0cb4c1df8 100644 --- a/packages/cli/src/commands/bash-hook.ts +++ b/packages/cli/src/commands/bash-hook.ts @@ -19,6 +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 { + 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"; @@ -274,26 +280,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: parseRelease, brief: "Set the release version for the event", optional: true, }, dsn: { kind: "parsed", - parse: String, + parse: parseDsn, brief: "DSN to send events to (overrides SENTRY_DSN env var)", optional: true, }, @@ -307,14 +313,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..4e8c2fcc6 100644 --- a/packages/cli/src/commands/build/upload.ts +++ b/packages/cli/src/commands/build/upload.ts @@ -27,12 +27,21 @@ 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 { parseFilePath } from "../../lib/completion-target.js"; +import { + choiceParser, + parseFilePath, + parseGitCommit, + parseGitRepo, + parseText, + parseVcsRef, + textParser, +} from "../../lib/completion-target.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { colorTag, @@ -233,20 +242,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 +263,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 +271,52 @@ export const uploadCommand = buildCommand({ }, "head-sha": { kind: "parsed", - parse: String, + parse: parseGitCommit, brief: "VCS commit SHA (defaults to the current commit)", optional: true, }, "base-sha": { kind: "parsed", - parse: String, + parse: parseGitCommit, brief: "VCS base commit SHA (defaults to the merge-base with the base ref)", optional: true, }, "vcs-provider": { kind: "parsed", - parse: String, + parse: choiceParser(VCS_PROVIDERS), brief: "VCS provider (defaults to the current remote's provider)", optional: true, }, "head-repo-name": { kind: "parsed", - parse: String, + parse: parseGitRepo, brief: "Head repository name, e.g. owner/repo (defaults to the current)", optional: true, }, "base-repo-name": { kind: "parsed", - parse: String, + parse: parseGitRepo, brief: "Base repository name, e.g. owner/repo (for forks)", optional: true, }, "head-ref": { kind: "parsed", - parse: String, + parse: parseVcsRef, brief: "Head branch/reference (defaults to the current branch)", optional: true, }, "base-ref": { kind: "parsed", - parse: String, + parse: parseVcsRef, 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..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 } 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: String, + parse: parseGitRepo, brief: "Repository name (e.g., owner/repo). Auto-detected from git remote if omitted.", optional: true, }, "default-branch": { kind: "parsed", - parse: String, + parse: parseVcsRef, 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..a3c5846db 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,11 +24,17 @@ import { assignDefaultLayout, type DashboardDetail, type DashboardWidget, + DISPLAY_TYPES, FALLBACK_LAYOUT, + GRID_COLUMN_POSITIONS, + GRID_WIDTHS, prepareDashboardForUpdate, validateWidgetLayout, + WIDGET_LAYOUT_MODES, + WIDGET_TYPES, type WidgetLayoutFlags, type WidgetLayoutMode, + AGGREGATE_FUNCTION_NAMES, } from "../../../types/dashboard.js"; import { type DashboardInput, @@ -168,40 +178,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: choiceParser(AGGREGATE_FUNCTION_NAMES), 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: choiceParser(AGGREGATE_FUNCTION_NAMES), brief: "Order by (prefix - for desc, e.g. -count)", optional: true, }, @@ -213,7 +223,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, }, @@ -225,7 +235,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, }, @@ -237,7 +247,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 +296,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..ea5ac34d1 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,7 +16,10 @@ import { type DashboardDetail, type DashboardWidget, type DashboardWidgetQuery, + DISPLAY_TYPES, FALLBACK_LAYOUT, + GRID_COLUMN_POSITIONS, + GRID_WIDTHS, parseAggregate, parseSortExpression, parseWidgetInput, @@ -23,7 +27,9 @@ import { prepareWidgetQueries, validateAggregateNames, validateWidgetLayout, + WIDGET_TYPES, type WidgetLayoutFlags, + AGGREGATE_FUNCTION_NAMES, } from "../../../types/dashboard.js"; import { type DashboardArguments, dashboardArguments } from "../arguments.js"; import { @@ -236,53 +242,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: choiceParser(AGGREGATE_FUNCTION_NAMES), 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: choiceParser(AGGREGATE_FUNCTION_NAMES), brief: "Order by (prefix - for desc, e.g. -count)", optional: true, }, @@ -294,7 +300,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, }, @@ -306,7 +312,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/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..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 { @@ -24,11 +25,21 @@ import { readFileBytes, sendEnvelopeRequest, } from "../../lib/envelope/transport.js"; -import { parseFilePath } from "../../lib/completion-target.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. */ @@ -212,14 +223,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 +244,45 @@ built entirely from the file contents. }, release: { kind: "parsed", - parse: String, + parse: parseRelease, brief: "Release version", optional: true, }, dist: { kind: "parsed", - parse: String, + parse: parseText, brief: "Distribution identifier", optional: true, }, env: { kind: "parsed", - parse: String, + parse: parseEnvironment, 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: choiceParser(USER_FIELD_PREFIXES), brief: "User info as KEY:VALUE — id, email, username, ip_address, or custom", variadic: true, @@ -279,14 +290,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 +309,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..434340a2a 100644 --- a/packages/cli/src/commands/explore.ts +++ b/packages/cli/src/commands/explore.ts @@ -21,6 +21,12 @@ import { queryMetricsMeta, } from "../lib/api-client.js"; import { validateLimit } from "../lib/arg-parsing.js"; +import { + choiceParser, + parseEnvironmentList, + parseText, + textParser, +} from "../lib/completion-target.js"; import { advancePaginationState, buildPaginationContextKey, @@ -54,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, @@ -767,7 +773,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 +781,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: choiceParser([...VALID_AGGS]), 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: parseEnvironmentList, 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..e65d2d32d 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -26,7 +26,12 @@ 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, + parseTeam, + 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 +421,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 +429,13 @@ export const initCommand = buildCommand< }, team: { kind: "parsed", - parse: String, + parse: parseTeam, 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..d6ca7c363 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 { 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"; @@ -398,7 +399,7 @@ export const archiveCommand = buildCommand({ flags: { until: { kind: "parsed", - parse: String, + 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 5f096e2ea..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 { 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: String, + parse: parseIntegration, brief: "Native integration ID, when multiple installations match", optional: true, }, app: { kind: "parsed", - parse: String, + 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 6fb77cefd..1f46bed53 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 { 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"; @@ -71,7 +72,7 @@ export const linkCommand = buildCommand({ "dry-run": DRY_RUN_FLAG, field: { kind: "parsed", - parse: String, + parse: parseAppLinkField, 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..1be384c7d 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 { parseIssueReference } from "../../lib/completion-target.js"; import { ApiError, CliError, @@ -302,7 +303,7 @@ export const mergeCommand = buildCommand({ flags: { into: { kind: "parsed", - parse: String, + 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/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..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 { 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,14 +97,14 @@ export const tagsCommand = buildCommand({ { placeholder: "key", brief: "Tag key, e.g. release, server_name, browser.name", - parse: parseText, + parse: parseTagKey, }, ], }, flags: { environment: { kind: "parsed", - parse: String, + 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 5ba2c9bd7..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 } 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,13 +60,13 @@ const CONTROL_COMMAND_DEFAULTS = { const DAEMON_CONNECTION_FLAGS = { host: { kind: "parsed", - parse: String, + parse: choiceParser(LOOPBACK_HOSTS), brief: "Loopback host", default: "localhost", }, port: { kind: "parsed", - parse: parsePort, + parse: textParser(parsePort), brief: "Control-plane port", default: "8969", }, @@ -215,7 +220,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..09762f320 100644 --- a/packages/cli/src/commands/local/run.ts +++ b/packages/cli/src/commands/local/run.ts @@ -22,16 +22,23 @@ 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, + parseBindHost, + 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 +455,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: parseBindHost, 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 +480,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 +504,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..b199b854d 100644 --- a/packages/cli/src/commands/local/server.ts +++ b/packages/cli/src/commands/local/server.ts @@ -1,3 +1,9 @@ +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 } from "./loopback.js"; @@ -1051,13 +1057,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: parseBindHost, brief: "Hostname to bind to", default: "localhost", }, @@ -1068,7 +1074,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 +1082,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 +1100,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..f48a85d1a 100644 --- a/packages/cli/src/commands/monitor/run.ts +++ b/packages/cli/src/commands/monitor/run.ts @@ -34,7 +34,14 @@ import { resolveIngestDsn, sendEnvelopeRequest, } from "../../lib/envelope/transport.js"; -import { parseText } from "../../lib/completion-target.js"; +import { + choiceParser, + parseDsn, + parseEnvironment, + parseMonitorCommand, + parseText, + textParser, +} from "../../lib/completion-target.js"; import { CliError, ConfigError, ValidationError } from "../../lib/errors.js"; import { logger } from "../../lib/logger.js"; @@ -198,61 +205,61 @@ 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: String, + parse: parseDsn, brief: "DSN to send check-ins to (overrides SENTRY_DSN env var)", optional: true, }, environment: { kind: "parsed", - parse: String, + parse: parseEnvironment, 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..8ba71a743 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, + parseOrgSlug, +} 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, @@ -461,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 0ba9beac5..bb36a759d 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 { 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"; @@ -58,7 +59,7 @@ export const listCommand = buildCommand({ flags: { search: { kind: "parsed", - parse: String, + parse: parsePlatform, 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..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 { 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: String, + parse: parseTeam, 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..a2bac075c 100644 --- a/packages/cli/src/commands/react-native/gradle.ts +++ b/packages/cli/src/commands/react-native/gradle.ts @@ -17,7 +17,12 @@ import { resolveUploadWait, uploadSourcemaps, } from "../../lib/api/sourcemaps.js"; -import { buildCommand } from "../../lib/command.js"; +import { buildCommand, numberParser } from "../../lib/command.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"; @@ -151,23 +156,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: parseRelease, 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 +184,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..236a0c7e9 100644 --- a/packages/cli/src/commands/release/create.ts +++ b/packages/cli/src/commands/release/create.ts @@ -7,6 +7,11 @@ import type { SentryContext } from "../../context.js"; import { createRelease } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; +import { + parseProjectList, + parseUrl, + parseVcsRef, +} from "../../lib/completion-target.js"; import { colorTag, escapeMarkdownInline, @@ -120,7 +125,7 @@ export const createCommand = buildCommand({ flags: { project: { kind: "parsed", - parse: String, + parse: parseProjectList, brief: "Associate with project(s), comma-separated", optional: true, }, @@ -131,13 +136,13 @@ export const createCommand = buildCommand({ }, ref: { kind: "parsed", - parse: String, + parse: parseVcsRef, 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..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 } 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", @@ -126,19 +130,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..6bcea823e 100644 --- a/packages/cli/src/commands/release/list.ts +++ b/packages/cli/src/commands/release/list.ts @@ -14,6 +14,11 @@ import { type ReleaseSortValue, } from "../../lib/api-client.js"; import type { ParsedOrgProject } from "../../lib/arg-parsing.js"; +import { + choiceParser, + parseEnvironmentList, + parseRelativePeriod, +} from "../../lib/completion-target.js"; import { colorTag, escapeMarkdownInline, @@ -77,6 +82,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 +690,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: parseEnvironmentList, brief: "Filter by environment (repeatable, comma-separated)", variadic: true as const, optional: true as const, }, period: { kind: "parsed" as const, - parse: String, + parse: parseRelativePeriod, 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..28ec894a7 100644 --- a/packages/cli/src/commands/release/set-commits.ts +++ b/packages/cli/src/commands/release/set-commits.ts @@ -12,6 +12,11 @@ import { setCommitsWithRefs, } from "../../lib/api-client.js"; import { buildCommand, numberParser } from "../../lib/command.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 { @@ -422,21 +427,21 @@ export const setCommitsCommand = buildCommand({ }, commit: { kind: "parsed", - parse: String, + parse: parseRepoCommitList, 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: parseGitRef, 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..2a0028c61 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, + parseEnvironmentList, + 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: parseEnvironmentList, 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 2826b9d1a..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"; @@ -508,7 +509,7 @@ export const schemaCommand = buildCommand({ }, search: { kind: "parsed", - parse: String, + parse: parseText, brief: "Search endpoints by keyword", optional: true, }, @@ -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/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..57acc02e8 100644 --- a/packages/cli/src/commands/snapshots/download.ts +++ b/packages/cli/src/commands/snapshots/download.ts @@ -18,6 +18,11 @@ import { waitForSnapshotArchive, } from "../../lib/api/preprod-artifacts.js"; import { buildCommand } from "../../lib/command.js"; +import { + parseDirectoryPath, + parseText, + parseVcsRef, +} from "../../lib/completion-target.js"; import { getDefaultProject } from "../../lib/db/defaults.js"; import { ApiError, @@ -179,26 +184,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: parseVcsRef, 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..18e1b0aa6 100644 --- a/packages/cli/src/commands/snapshots/upload.ts +++ b/packages/cli/src/commands/snapshots/upload.ts @@ -16,13 +16,23 @@ import { } from "../../lib/api/preprod-artifacts.js"; import { collectVcsMetadata, + VCS_PROVIDERS, type VcsFlags, type VcsInfo, vcsInfoToBody, } 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 { + choiceParser, + parseDirectoryPath, + parseFilePath, + parseGitCommit, + parseGitRepo, + parseText, + parseVcsRef, + textParser, +} from "../../lib/completion-target.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { colorTag, @@ -378,12 +388,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 +406,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: parseGitCommit, brief: "VCS commit SHA (defaults to the current commit)", optional: true, }, "base-sha": { kind: "parsed", - parse: String, + parse: parseGitCommit, brief: "VCS base commit SHA (defaults to the merge-base with the base ref)", optional: true, }, "vcs-provider": { kind: "parsed", - parse: String, + parse: choiceParser(VCS_PROVIDERS), brief: "VCS provider (defaults to the current remote's provider)", optional: true, }, "head-repo-name": { kind: "parsed", - parse: String, + parse: parseGitRepo, brief: "Head repository name, e.g. owner/repo (defaults to the current)", optional: true, }, "base-repo-name": { kind: "parsed", - parse: String, + parse: parseGitRepo, brief: "Base repository name, e.g. owner/repo (for forks)", optional: true, }, "head-ref": { kind: "parsed", - parse: String, + parse: parseVcsRef, brief: "Head branch/reference (defaults to the current branch)", optional: true, }, "base-ref": { kind: "parsed", - parse: String, + parse: parseVcsRef, 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..e885ac781 100644 --- a/packages/cli/src/commands/sourcemap/inject.ts +++ b/packages/cli/src/commands/sourcemap/inject.ts @@ -7,7 +7,12 @@ import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; -import { parseDirectoryPath } from "../../lib/completion-target.js"; +import { + choiceListParser, + parseDirectoryPath, + parseFilePath, + parseText, +} from "../../lib/completion-target.js"; import { colorTag, mdKvTable, @@ -18,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. */ @@ -104,20 +110,20 @@ export const injectCommand = buildCommand({ flags: { ext: { kind: "parsed", - parse: String, + parse: choiceListParser([...DEFAULT_EXTENSIONS]), 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..e812826f0 100644 --- a/packages/cli/src/commands/sourcemap/resolve.ts +++ b/packages/cli/src/commands/sourcemap/resolve.ts @@ -10,7 +10,12 @@ 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 { + choiceListParser, + parseDirectoryPath, + parseFilePath, + parseText, +} from "../../lib/completion-target.js"; import { colorTag, escapeMarkdownCell, @@ -21,6 +26,7 @@ import { CommandOutput } from "../../lib/formatters/output.js"; import { assertDirectoryReadable, buildIgnoreMatcher, + DEFAULT_EXTENSIONS, resolveDirectorySourcemaps, } from "../../lib/sourcemap/inject.js"; @@ -147,20 +153,20 @@ export const resolveCommand = buildCommand({ flags: { ext: { kind: "parsed", - parse: String, + parse: choiceListParser([...DEFAULT_EXTENSIONS]), 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..443661bbb 100644 --- a/packages/cli/src/commands/sourcemap/upload.ts +++ b/packages/cli/src/commands/sourcemap/upload.ts @@ -17,7 +17,13 @@ import { uploadSourcemaps, } from "../../lib/api/sourcemaps.js"; import { buildCommand } from "../../lib/command.js"; -import { parseDirectoryPath } from "../../lib/completion-target.js"; +import { + choiceListParser, + parseDirectoryPath, + 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"; @@ -26,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. */ @@ -263,46 +270,46 @@ export const uploadCommand = buildCommand({ flags: { release: { kind: "parsed", - parse: String, + parse: parseRelease, 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: choiceListParser([...DEFAULT_EXTENSIONS]), 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/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/api/completion-seed.ts b/packages/cli/src/lib/api/completion-seed.ts index c17743351..03ade6807 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,145 @@ 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, + })); + }); + }, + ); + }); +} + +/** + * 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) => { + 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 @@ -107,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"], @@ -124,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", @@ -139,6 +300,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 +412,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 +456,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/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/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/command.ts b/packages/cli/src/lib/command.ts index 840300dcf..4812f78f7 100644 --- a/packages/cli/src/lib/command.ts +++ b/packages/cli/src/lib/command.ts @@ -40,6 +40,13 @@ 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, + parseProjectSlug, + 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 +56,7 @@ import { ClearScreen, CommandOutput, type CommandReturn, + extractSchemaFieldPaths, extractSchemaFields, formatSchemaForHelp, type HumanRenderer, @@ -265,7 +273,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 +293,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 +307,7 @@ const ORG_FLAG = { */ const PROJECT_FLAG = { kind: "parsed" as const, - parse: String, + parse: parseProjectSlug, brief: "Project slug", optional: true as const, hidden: true as const, @@ -404,7 +412,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 +427,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 663fde930..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"; @@ -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"; @@ -149,7 +150,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[]; }; @@ -335,8 +336,89 @@ 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 })), + /** 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"), + /** 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": 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. */ + "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"), + /** 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. */ + "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 + * how it completes. */ text: () => [], } satisfies Record); @@ -424,6 +506,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+$/; /** @@ -499,15 +606,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("/")) { @@ -671,6 +773,292 @@ 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, + })); + }; +} + +/** + * 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 + */ +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) }), + ); + }; +} + +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(); + 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 2bc61bcc8..362c0abca 100644 --- a/packages/cli/src/lib/completion-target.ts +++ b/packages/cli/src/lib/completion-target.ts @@ -13,13 +13,27 @@ * 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 { LOOPBACK_HOSTS } from "../commands/local/loopback.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,9 +82,89 @@ 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; +/** 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; + +/** Cached project slugs. */ +export const PROJECT_TARGET = "project" 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; + +/** 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; + +/** `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; + +/** 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"); @@ -88,9 +182,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 +226,172 @@ 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); + +// 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 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); + +/** 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 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); + +/** + * 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); + +/** 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`. + */ +export function textParser( + parse: (input: string) => T, +): (input: string) => T { + return taggedParser(TEXT_TARGET, parse); +} + /** - * 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 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 + * 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); } /** - * 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. + * 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`. */ -const PARSER_TARGETS: ReadonlyMap = new Map< +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: 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< 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 +407,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}). */ @@ -184,6 +444,15 @@ 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 + * `getApiCompletionPaths`, so importing this module (as every command + * does) does not walk the API schema. + */ + apiPaths: readonly string[]; }; /** @@ -191,7 +460,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` @@ -204,4 +473,5 @@ export const COMPLETION_VALUES: CompletionValues = { "30d": "Last 30 days", "90d": "Last 90 days", }, + loopbackHosts: LOOPBACK_HOSTS, }; 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..67d0fcc74 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,38 @@ 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 + * - `tag-key`: key of a tag seen on an issue, with its label */ -export type CompletionEntityKind = "issue" | "dashboard"; +export type CompletionEntityKind = + | "issue" + | "dashboard" + | "environment" + | "release" + | "team" + | "owner" + | "monitor" + | "workflow" + | "integration" + | "sentry-app" + | "app-link-field" + | "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/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/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/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/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/introspect.ts b/packages/cli/src/lib/introspect.ts index af3b65a72..f9a2d3354 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; /** @@ -446,17 +447,19 @@ type FlagName = { 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 0e3550e64..dfea38bc9 100644 --- a/packages/cli/src/lib/list-command.ts +++ b/packages/cli/src/lib/list-command.ts @@ -15,7 +15,7 @@ import type { Aliases, Command, CommandContext } from "@stricli/core"; import type { SentryContext } from "../context.js"; -import type { ListTargetOptions } from "./arg-parsing.js"; +import { type ListTargetOptions, parseCursorFlag } from "./arg-parsing.js"; import { buildCommand, type CommandDocumentation, @@ -232,51 +232,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/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 5e73981e3..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); @@ -521,6 +533,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. * @@ -529,7 +552,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-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"]); + }); +}); 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 94f4efac9..fe126bbfc 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", () => { @@ -917,3 +964,256 @@ 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"]); + // 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" }]); + }); +}); + +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", + ]); + 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 () => { + 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/completion-coverage.test.ts b/packages/cli/test/lib/completion-coverage.test.ts index 73f9fcb1d..e81cfe6ff 100644 --- a/packages/cli/test/lib/completion-coverage.test.ts +++ b/packages/cli/test/lib/completion-coverage.test.ts @@ -1,63 +1,132 @@ /** - * 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`. + * + * `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 { + type Command, extractCommandCompletions, isCommand, isRouteMap, type RouteMap, } from "../../src/lib/introspect.js"; -/** Every visible positional: its command path, index, and placeholder. */ -function visiblePositionals( +/** 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, 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; + return isCommand(entry.target) + ? [{ path: segments.join(" "), command: entry.target }] + : []; + }); +} + +describe("shell completion coverage", () => { + const routeMap = routes as unknown as RouteMap; + const completions = extractCommandCompletions(routeMap); + const commands = visibleCommands(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: segments.join(" "), + 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}` })), + ); -describe("shell completion coverage", () => { - test("every visible positional declares a completion kind", () => { - const routeMap = routes as unknown as RouteMap; - const completions = extractCommandCompletions(routeMap); - const positionals = visiblePositionals(routeMap); - const missing = positionals - .filter(({ path, index }) => !completions[path]?.positionals[index]) - .map(({ path, placeholder }) => `sentry ${path} <${placeholder}>`); + /** 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", () => { + expect(flags.length).toBeGreaterThan(0); + 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/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() }, }); }); diff --git a/packages/cli/test/lib/db/completion-cache.test.ts b/packages/cli/test/lib/db/completion-cache.test.ts index ffaf9f491..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/`, @@ -131,6 +158,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/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/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([]); + }); +}); diff --git a/packages/cli/test/lib/introspect.test.ts b/packages/cli/test/lib/introspect.test.ts index 0b1113c17..7bc76e56d 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 { @@ -616,6 +622,56 @@ 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"], + }, + flagNames: { + "--fields": { brief: "" }, + "--limit": { brief: "" }, + "--port": { brief: "" }, + "--sort": { brief: "" }, + }, + }, + }); + }); + + 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 // --------------------------------------------------------------------------- diff --git a/scripts/cli-lint-rules.test.mjs b/scripts/cli-lint-rules.test.mjs index 9895655df..528e17588 100644 --- a/scripts/cli-lint-rules.test.mjs +++ b/scripts/cli-lint-rules.test.mjs @@ -211,6 +211,159 @@ 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", + }, + ], + }, + ], + }, + { + // 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: [ + { + 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: [ {