From d69f0bded4dd54f6c395c1dd0f02e6417fc4f509 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 00:35:07 +0000 Subject: [PATCH 1/6] ref(cli): Remove the completion-only target parsers No command uses `parseOrgTarget`, `parseOrgProjectTarget`, or `parseIssueTarget` since #1564 moved every target and ID positional onto bound parsers, which carry the completion kind themselves. Remove them, and drop them and the already-removed `parseDashboardTarget` from the lint rules that banned them in commands. The lint rule now bans `withCompletionTarget` in commands instead, so a command cannot recreate an identity parser that only tags the kind. The introspection tests tag their own fixture parsers. Part of #1564 Co-Authored-By: BYK --- packages/cli/AGENTS.md | 16 +++++------- packages/cli/lint-rules/cli-oxlint-plugin.js | 18 ++++++-------- packages/cli/src/lib/completion-target.ts | 24 ++---------------- packages/cli/src/lib/target-positional.ts | 2 +- packages/cli/test/lib/introspect.test.ts | 26 +++++++++++--------- scripts/cli-lint-rules.test.mjs | 25 ++----------------- 6 files changed, 32 insertions(+), 79 deletions(-) diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 19d40661a..d8afc02d2 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -311,11 +311,10 @@ with `await target.resolved`. It never calls `parseOrgProjectArg` or a resolver itself, and an omitted target arrives as auto-detect. The `sentry-cli/require-target-resolution` lint rule fails a function that takes one of these types and never reads `.resolved`. It also rejects, in commands, -what leaves a target for `func` to parse: the completion-only parsers -`parseOrgTarget` and `parseOrgProjectTarget`, `parseOrgProjectArg`, and the -`resolve*FromArg` helpers. Neither rule has an allow-list: no command may -parse a target or an ID by hand. `sentry-cli/no-ad-hoc-id-arguments` rejects -the completion-only `parseIssueTarget` and `parseDashboardTarget` too. +what leaves a target for `func` to parse: `parseOrgProjectArg`, the +`resolve*FromArg` helpers, and `withCompletionTarget` (the bound parsers tag +the kind). Neither rule has an allow-list: no command may +parse a target or an ID by hand. **Release positionals:** `[/]` uses `releaseParameter(USAGE_HINT, brief)` from `src/commands/release/parse.ts` @@ -390,11 +389,8 @@ dashboard are tagged with a completion kind from `src/lib/completion-target.ts` `DASHBOARD_TARGET` cached dashboards). The bound parsers above (`boundPositional(kind, …)`, `boundArguments({ kind, … })`) carry the kind, so the `target-positional.ts` parsers behind `listTargetPositional`, -`issuePositional`, and `releaseParameter` complete the right values. The -completion-only parsers (`parseOrgProjectTarget`, `parseOrgTarget`, -`parseIssueTarget`) are rejected in commands by the lint rules above; commands -use a bound parser. Shared value -parsers are not tagged where they live; `PARSER_TARGETS` in +`issuePositional`, and `releaseParameter` complete the right values. 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`) and every flag that parses with `parsePeriod` diff --git a/packages/cli/lint-rules/cli-oxlint-plugin.js b/packages/cli/lint-rules/cli-oxlint-plugin.js index 872edec55..763b1d50c 100644 --- a/packages/cli/lint-rules/cli-oxlint-plugin.js +++ b/packages/cli/lint-rules/cli-oxlint-plugin.js @@ -170,30 +170,26 @@ function regexSource(node) { /** * Helpers that parse ID positionals by hand. Commands parse IDs with * `parseIdArguments()` (src/lib/id-arguments.ts), so every command takes - * the same forms. `parseIssueTarget` and `parseDashboardTarget` only tag a - * positional for completion, leaving the parsing to `func`; issues take - * `issuePositional` or `issuesPositional` (src/commands/issue/positional.ts). + * the same forms; issues take `issuePositional` or `issuesPositional` + * (src/commands/issue/positional.ts). */ const AD_HOC_ID_PARSING = new Set([ "parseSlashSeparatedArg", "detectSwappedViewArgs", "HEX_ID_RE", "SPAN_ID_RE", - "parseIssueTarget", - "parseDashboardTarget", ]); /** * Helpers that leave a target positional for `func` to parse: - * `parseOrgTarget` and `parseOrgProjectTarget` only tag it for completion, - * and the others parse a raw string (applying a pasted URL's server as they - * go). Commands take targets as bound positionals instead + * `withCompletionTarget` tags a parser that can leave the value raw, and the + * others parse a raw string (applying a pasted URL's server as they go). + * Commands take targets as bound positionals instead * (src/lib/target-positional.ts, `boundArguments`), which parse before the - * command runs and resolve on `target.resolved`. + * command runs, carry the completion kind, and resolve on `target.resolved`. */ const UNBOUND_TARGET_PARSING = new Set([ - "parseOrgTarget", - "parseOrgProjectTarget", + "withCompletionTarget", "parseOrgProjectArg", "resolveOrgOnlyFromArg", "resolveOrgOptionalFromArg", diff --git a/packages/cli/src/lib/completion-target.ts b/packages/cli/src/lib/completion-target.ts index cb4855114..0df325589 100644 --- a/packages/cli/src/lib/completion-target.ts +++ b/packages/cli/src/lib/completion-target.ts @@ -8,10 +8,8 @@ * `__complete` fast path cannot boot Stricli to walk the route tree, and * `src/lib/complete.ts` maps each kind to its completer. * - * {@link parseOrgTarget}, {@link parseOrgProjectTarget}, and - * {@link parseIssueTarget} return their input unchanged and only mark the - * kind. Parsers that also parse the value, - * such as those in `target-positional.ts`, use the same tag. + * Commands get the tag from the bound parsers in `target-positional.ts` + * and `positional-parse.ts`, which parse the value too. */ import { ISSUE_IS_VALUES } from "@sentry/toolkit-core/search-query"; @@ -65,24 +63,6 @@ export function withCompletionTarget

unknown>( return parse; } -/** Parser for positionals whose leading segment is an organization slug. */ -export const parseOrgTarget = withCompletionTarget( - ORG_TARGET, - (input: string): string => input, -); - -/** Parser for positionals that start with `org/project`, a project, or an alias. */ -export const parseOrgProjectTarget = withCompletionTarget( - ORG_PROJECT_TARGET, - (input: string): string => input, -); - -/** Parser for positionals that name an issue (short ID, `org/ID`, ...). */ -export const parseIssueTarget = withCompletionTarget( - ISSUE_TARGET, - (input: string): string => input, -); - /** * Shared value parsers and what their arguments complete. Listed here, not * tagged where they are defined, so those modules know nothing about diff --git a/packages/cli/src/lib/target-positional.ts b/packages/cli/src/lib/target-positional.ts index 0795f1ce5..c59f9bd96 100644 --- a/packages/cli/src/lib/target-positional.ts +++ b/packages/cli/src/lib/target-positional.ts @@ -80,7 +80,7 @@ function commandNameOf(context: TargetContext): string { /** * Positional parser for an `[/][]` target with its own - * resolver. Completes like `parseOrgProjectTarget`. In a destructive command + * resolver. Completes `ORG_PROJECT_TARGET`. In a destructive command * an auto-detected target fails `requireExplicitTarget` before `func` runs. * * The parse has no side effects; the server of a pasted Sentry URL is diff --git a/packages/cli/test/lib/introspect.test.ts b/packages/cli/test/lib/introspect.test.ts index ee4c3e856..44bac3c07 100644 --- a/packages/cli/test/lib/introspect.test.ts +++ b/packages/cli/test/lib/introspect.test.ts @@ -7,14 +7,12 @@ import { describe, expect, test } from "vitest"; import { + type CompletionTarget, DASHBOARD_TARGET, ISSUE_QUERY_TARGET, ISSUE_TARGET, ORG_PROJECT_TARGET, ORG_TARGET, - parseIssueTarget, - parseOrgProjectTarget, - parseOrgTarget, withCompletionTarget, } from "../../src/lib/completion-target.js"; import type { @@ -414,6 +412,13 @@ describe("extractAllRoutes", () => { }); describe("extractCommandCompletions", () => { + /** A fresh parser tagged with `kind`, as the bound parsers are. */ + const tagged = (kind: CompletionTarget) => + withCompletionTarget(kind, (input: string) => input); + const orgParser = tagged(ORG_TARGET); + const orgProjectParser = tagged(ORG_PROJECT_TARGET); + const issueParser = tagged(ISSUE_TARGET); + /** Command whose positionals use the given parsers. */ function commandWith(...parsers: unknown[]): Command { return makeCommand({ @@ -434,24 +439,24 @@ describe("extractCommandCompletions", () => { parameters: { positional: { kind: "array", - parameter: { placeholder: "issue", parse: parseIssueTarget }, + parameter: { placeholder: "issue", parse: issueParser }, }, }, }); const nested = makeRouteMap([ - makeEntry("view", commandWith(parseOrgProjectTarget)), + makeEntry("view", commandWith(orgProjectParser)), ]); const topLevel = makeRouteMap([ makeEntry( "issue", makeRouteMap([ makeEntry("merge", variadic), - makeEntry("link", commandWith(parseIssueTarget, String)), + makeEntry("link", commandWith(issueParser, String)), makeEntry("help", commandWith(String)), makeEntry("rules", nested), ]), ), - makeEntry("trial", commandWith(String, parseOrgTarget)), + makeEntry("trial", commandWith(String, orgParser)), makeEntry("api", makeCommand()), ]); @@ -479,10 +484,7 @@ describe("extractCommandCompletions", () => { parameters: [ { placeholder: "target", - parse: withCompletionTarget( - DASHBOARD_TARGET, - (input: string) => input, - ), + parse: tagged(DASHBOARD_TARGET), }, ], }, @@ -524,7 +526,7 @@ describe("extractCommandCompletions", () => { }); test("skips hidden commands, hidden groups, and commands without kinds", () => { - const annotated = commandWith(parseOrgTarget); + const annotated = commandWith(orgParser); const hiddenGroup = makeRouteMap([makeEntry("list", annotated)]); const topLevel = makeRouteMap([ makeEntry("secret", annotated, true), diff --git a/scripts/cli-lint-rules.test.mjs b/scripts/cli-lint-rules.test.mjs index d22f2ee01..9895655df 100644 --- a/scripts/cli-lint-rules.test.mjs +++ b/scripts/cli-lint-rules.test.mjs @@ -153,23 +153,6 @@ tester.run("no-ad-hoc-id-arguments", plugin.rules["no-ad-hoc-id-arguments"], { errors: 1, }, { filename: commands, code: "HEX_ID_RE.test(value);", errors: 1 }, - { - // Only tags the positional for completion; func would parse it - filename: commands, - code: "const issue = { placeholder: 'issue', parse: parseIssueTarget };", - errors: 1, - }, - { - filename: commands, - code: "const dashboard = { parse: parseDashboardTarget };", - errors: 1, - }, - { - // No command is exempt, not even those taking several issues - filename: "/repo/packages/cli/src/commands/issue/merge.ts", - code: "const issue = { placeholder: 'issue', parse: parseIssueTarget };", - errors: 1, - }, { filename: source, code: "parseIdArguments(args, { label: 'Thing ID', isId }, options);", @@ -209,13 +192,9 @@ tester.run( errors: 1, }, { + // Tagging a raw parser leaves the target for func to parse filename: commands, - code: "const org = { placeholder: 'org', parse: parseOrgTarget };", - errors: 1, - }, - { - filename: commands, - code: "const target = { parse: parseOrgProjectTarget };", + code: "const org = { parse: withCompletionTarget(ORG_TARGET, String) };", errors: 1, }, { From b70c84ddd37fd5ea2ac9650156abe1b3724a58eb Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:10:51 +0000 Subject: [PATCH 2/6] feat(cli): Declare a completion kind for every positional Every visible positional now says how shell completion handles it, and test/lib/completion-coverage.test.ts fails, naming the command and placeholder, when a new one does not. New kinds in completion-target.ts, with pre-tagged parsers commands import instead of tagging String: parseFilePath, parseDirectoryPath, parseText, parseCommandPath, parseAccountSelector, and choiceParser(values). project create's name:platform completer becomes the project-spec kind instead of a special case in getCompletions, and parseLoginEmail moves to db/accounts.ts so PARSER_TARGETS can list it. The generator also writes choice values (including enum flag values, so zsh and fish get them through __complete) and the subcommands of every route group. __complete uses them to complete nested subcommands (alert issues ) and help's command path, and lists files and directories for path arguments. Co-Authored-By: Claude --- packages/cli/AGENTS.md | 59 ++++--- .../cli/script/generate-completion-targets.ts | 12 +- packages/cli/src/commands/api.ts | 3 +- packages/cli/src/commands/attribute/list.ts | 3 +- packages/cli/src/commands/auth/login.ts | 52 +------ packages/cli/src/commands/auth/logout.ts | 3 +- packages/cli/src/commands/auth/switch.ts | 3 +- packages/cli/src/commands/build/download.ts | 3 +- packages/cli/src/commands/build/upload.ts | 3 +- packages/cli/src/commands/cli/completion.ts | 3 +- packages/cli/src/commands/cli/defaults.ts | 3 +- packages/cli/src/commands/cli/feedback.ts | 3 +- packages/cli/src/commands/cli/upgrade.ts | 3 +- .../cli/src/commands/code-mappings/upload.ts | 3 +- .../src/commands/dart-symbol-map/upload.ts | 3 +- .../src/commands/debug-files/bundle-jvm.ts | 3 +- .../commands/debug-files/bundle-sources.ts | 3 +- .../cli/src/commands/debug-files/check.ts | 3 +- packages/cli/src/commands/debug-files/find.ts | 3 +- .../src/commands/debug-files/print-sources.ts | 3 +- .../cli/src/commands/debug-files/upload.ts | 3 +- packages/cli/src/commands/docs/list.ts | 3 +- packages/cli/src/commands/docs/query.ts | 3 +- packages/cli/src/commands/event/send.ts | 3 +- packages/cli/src/commands/help.ts | 3 +- packages/cli/src/commands/init.ts | 2 + packages/cli/src/commands/issue/link-utils.ts | 3 +- packages/cli/src/commands/issue/tags.ts | 3 +- packages/cli/src/commands/local/management.ts | 7 +- packages/cli/src/commands/local/run.ts | 3 +- packages/cli/src/commands/monitor/run.ts | 3 +- packages/cli/src/commands/org/list.ts | 3 +- packages/cli/src/commands/proguard/upload.ts | 3 +- packages/cli/src/commands/proguard/uuid.ts | 3 +- packages/cli/src/commands/project/create.ts | 2 + .../cli/src/commands/react-native/xcode.ts | 3 +- packages/cli/src/commands/release/deploy.ts | 5 +- packages/cli/src/commands/schema.ts | 3 +- packages/cli/src/commands/snapshots/diff.ts | 5 +- packages/cli/src/commands/snapshots/upload.ts | 3 +- packages/cli/src/commands/sourcemap/inject.ts | 3 +- .../cli/src/commands/sourcemap/resolve.ts | 3 +- packages/cli/src/commands/sourcemap/upload.ts | 3 +- packages/cli/src/commands/wasm-split.ts | 3 +- packages/cli/src/lib/complete.ts | 146 +++++++++++++++--- packages/cli/src/lib/completion-target.ts | 87 ++++++++++- packages/cli/src/lib/completions.ts | 7 +- packages/cli/src/lib/db/accounts.ts | 50 ++++++ packages/cli/src/lib/introspect.ts | 97 ++++++++++-- packages/cli/test/commands/auth/login.test.ts | 4 +- packages/cli/test/lib/complete.test.ts | 126 ++++++++++++++- .../cli/test/lib/completion-coverage.test.ts | 63 ++++++++ .../cli/test/lib/completions.property.test.ts | 21 ++- packages/cli/test/lib/introspect.test.ts | 74 +++++++++ 54 files changed, 766 insertions(+), 161 deletions(-) create mode 100644 packages/cli/test/lib/completion-coverage.test.ts diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index d8afc02d2..3b2852946 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -382,33 +382,52 @@ 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:** Target positionals that take an org, project, issue, or -dashboard are tagged with a completion kind from `src/lib/completion-target.ts` -(`ORG_PROJECT_TARGET` completes `org/`, `org/project`, and project aliases; -`ORG_TARGET` org slugs only; `ISSUE_TARGET` adds cached issue short IDs; -`DASHBOARD_TARGET` cached dashboards). The bound parsers above -(`boundPositional(kind, …)`, `boundArguments({ kind, … })`) carry the kind, so -the `target-positional.ts` parsers behind `listTargetPositional`, -`issuePositional`, and `releaseParameter` complete the right values. Shared -value parsers are not tagged where they live; `PARSER_TARGETS` in +**Shell completion:** Every visible positional declares a completion kind +from `src/lib/completion-target.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: + +| 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 `/` | +| Directory | `parseDirectoryPath` | directories only | +| 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`) | +| CLI command path | `parseCommandPath` | route names, one segment per word (`help`) | +| Free text, IDs with no cache, names of new things | `parseText` | 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`. + +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`) and every flag that parses with `parsePeriod` -completes relative periods. Fixed values (search keys, period units) come -from the modules that define them through `COMPLETION_VALUES`. +`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 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`. -`pnpm run generate:completions` collects these into the gitignored -`src/generated/completion-targets.json` that the `__complete` fast path reads. -Completers that need a parser (`arg-parsing.ts`, the search grammar) import -it when called, so the fast path only loads it for the arguments that need -it. `project create` keeps its dedicated `name:platform` completer in -`src/lib/complete.ts`. +parser, and an `entityCompleter` entry in `src/lib/complete.ts`. To add a +kind that is not an entity, add it to `COMPLETION_TARGETS`, a pre-tagged +parser, and its entry in `COMPLETERS` (`src/lib/complete.ts`; the record +type makes a missing completer a type error). +`pnpm run generate:completions` collects every command's kinds and choice +values, and every route group's subcommands, into the gitignored +`src/generated/completion-targets.json` that the `__complete` fast path +reads. Words that name a nested route group (`alert issues`) complete its +subcommands; the bash, zsh, and fish scripts embed only the first two +levels. Completers must stay light (synchronous `node:fs` reads, SQLite, +no network); ones that need a parser (`arg-parsing.ts`, the search grammar) +import it when called, so the fast path only loads it for the arguments +that need it. ### Multiple Values in Arguments diff --git a/packages/cli/script/generate-completion-targets.ts b/packages/cli/script/generate-completion-targets.ts index 1697e0827..f17fea4c6 100644 --- a/packages/cli/script/generate-completion-targets.ts +++ b/packages/cli/script/generate-completion-targets.ts @@ -7,7 +7,8 @@ * complete which arguments, and it must not load the modules that define * the values it offers. This script walks the route tree at build time and * writes, per command, the completion kind of each positional and - * value-taking flag as JSON, plus those values (`COMPLETION_VALUES`). Kinds + * value-taking flag as JSON, the subcommands of every route group, and the + * values completers offer (`COMPLETION_VALUES`). Kinds * come from the parsers the arguments already use * (src/lib/completion-target.ts); flags without one are still listed so * `__complete` can skip their values. @@ -40,19 +41,22 @@ if (!skillContentExists) { import type { RouteMap } from "../src/lib/introspect.js"; const { routes } = await import("../src/app.js"); -const { extractCommandCompletions } = await import("../src/lib/introspect.js"); +const { extractCommandCompletions, extractRouteGroups } = + await import("../src/lib/introspect.js"); const { COMPLETION_VALUES } = await import("../src/lib/completion-target.js"); const OUTPUT_PATH = "src/generated/completion-targets.json"; -const commands = extractCommandCompletions(routes as unknown as RouteMap); +const routeMap = routes as unknown as RouteMap; +const commands = extractCommandCompletions(routeMap); +const groups = extractRouteGroups(routeMap); // Plain JSON (like api-schema.json) so the output is produced by a real // serializer rather than hand-assembled source text. mkdirSync("src/generated", { recursive: true }); await writeFile( OUTPUT_PATH, - `${JSON.stringify({ commands, values: COMPLETION_VALUES }, null, 2)}\n`, + `${JSON.stringify({ commands, groups, values: COMPLETION_VALUES }, null, 2)}\n`, "utf8", ); process.stdout.write( diff --git a/packages/cli/src/commands/api.ts b/packages/cli/src/commands/api.ts index 9f49e04b5..cf3450f4e 100644 --- a/packages/cli/src/commands/api.ts +++ b/packages/cli/src/commands/api.ts @@ -11,6 +11,7 @@ import * as Sentry from "@sentry/node"; import type { SentryContext } from "../context.js"; import { appendSearchParams, rawApiRequest } from "../lib/api-client.js"; import { buildCommand } from "../lib/command.js"; +import { parseText } from "../lib/completion-target.js"; import { OutputError, stringifyUnknown, @@ -1612,7 +1613,7 @@ export const apiCommand = buildCommand({ parameters: [ { brief: "API endpoint relative to /api/0/, or an absolute HTTP(S) URL", - parse: String, + parse: parseText, placeholder: "endpoint", }, ], diff --git a/packages/cli/src/commands/attribute/list.ts b/packages/cli/src/commands/attribute/list.ts index 8c49adfc7..7b4c8fafb 100644 --- a/packages/cli/src/commands/attribute/list.ts +++ b/packages/cli/src/commands/attribute/list.ts @@ -21,6 +21,7 @@ import { parseAttributeTypes, } from "../../lib/attribute-search.js"; import { validateLimit } from "../../lib/arg-parsing.js"; +import { parseText } from "../../lib/completion-target.js"; import { buildDatasetParser, datasetDisplayName } from "../../lib/datasets.js"; import { advancePaginationState, @@ -283,7 +284,7 @@ export const listCommand = buildListCommand("attribute", { placeholder: "filter", brief: "Text in attribute names and/or has: terms, e.g. 'gen_ai has:http.method'", - parse: String, + parse: parseText, optional: true, }, ], diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 214142a87..b62913008 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -24,7 +24,8 @@ import { import { getAccountRow, isAccountRowUsable, - splitAccountSelector, + parseLoginEmail, + splitLoginTarget, } from "../../lib/db/accounts.js"; import { getDbPath } from "../../lib/db/index.js"; import { getUserInfo, setUserInfo } from "../../lib/db/user.js"; @@ -157,55 +158,6 @@ function resolveLoginScope(flags: LoginFlags): string | undefined { * and full URLs; returns the normalized origin. */ /** @internal exported for testing */ -/** - * Parse the optional `email` argument of `sentry auth login`: an email, or - * `:` (the notation `sentry org list` shows, e.g. - * `jane@acme.com:sentry.acme.com`) to also pick the server like `--url`. - * Returns the trimmed argument; {@link splitLoginTarget} splits it. - * - * @throws {ValidationError} when the value is not an email address, or the - * server part is not a valid URL - */ -export function parseLoginEmail(raw: string): string { - const target = raw.trim(); - splitLoginTarget(target, raw); - return target; -} - -/** - * Split a `sentry auth login` target into its email and, for - * `:`, the server's normalized origin. Uses the same - * {@link splitAccountSelector} as `--account`, plus an email check. - * - * @throws {ValidationError} when either part is invalid - */ -export function splitLoginTarget( - target: string, - raw = target, -): { email: string; url?: string } { - const parts = splitAccountSelector(target); - if (!(parts && isEmailAddress(parts.who))) { - throw new ValidationError( - `Expected an email address, or : with a valid server, to log in as; got '${raw}'.`, - "email", - ); - } - return parts.server - ? { email: parts.who, url: parts.server } - : { email: parts.who }; -} - -/** One `@` with text on both sides, and no whitespace. */ -function isEmailAddress(value: string): boolean { - const at = value.indexOf("@"); - return ( - at > 0 && - at < value.length - 1 && - value.indexOf("@", at + 1) === -1 && - !/\s/.test(value) - ); -} - /** * Apply the server of an `:` login target as `--url`. * diff --git a/packages/cli/src/commands/auth/logout.ts b/packages/cli/src/commands/auth/logout.ts index 6d271f081..c16bce23a 100644 --- a/packages/cli/src/commands/auth/logout.ts +++ b/packages/cli/src/commands/auth/logout.ts @@ -10,6 +10,7 @@ import { resolveAccountSelector, } from "../../lib/account-selection.js"; import { buildCommand } from "../../lib/command.js"; +import { parseAccountSelector } from "../../lib/completion-target.js"; import { type AccountSummary, getAccountRow, @@ -135,7 +136,7 @@ export const logoutCommand = buildCommand({ placeholder: "account", brief: "Accounts to log out of (email, username, user ID, or account ID; comma-separated), or 'all'", - parse: String, + parse: parseAccountSelector, }, }, }, diff --git a/packages/cli/src/commands/auth/switch.ts b/packages/cli/src/commands/auth/switch.ts index c38c1894a..281f4b4da 100644 --- a/packages/cli/src/commands/auth/switch.ts +++ b/packages/cli/src/commands/auth/switch.ts @@ -11,6 +11,7 @@ import { resolveAccountSelector, } from "../../lib/account-selection.js"; import { buildCommand } from "../../lib/command.js"; +import { parseAccountSelector } from "../../lib/completion-target.js"; import { type AccountSummary, getAccountRow, @@ -116,7 +117,7 @@ export const switchCommand = buildCommand({ { placeholder: "account", brief: "Email, username, user ID, or account ID to switch to", - parse: String, + parse: parseAccountSelector, optional: true, }, ], diff --git a/packages/cli/src/commands/build/download.ts b/packages/cli/src/commands/build/download.ts index a5ea67fa8..7cc3f4004 100644 --- a/packages/cli/src/commands/build/download.ts +++ b/packages/cli/src/commands/build/download.ts @@ -17,6 +17,7 @@ import { toBinaryDownloadUrl, } from "../../lib/api/preprod-artifacts.js"; import { buildCommand } from "../../lib/command.js"; +import { 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"; @@ -80,7 +81,7 @@ export const downloadCommand = buildCommand({ parameters: [ { brief: "ID of the build to download", - parse: String, + parse: parseText, placeholder: "build-id", }, ], diff --git a/packages/cli/src/commands/build/upload.ts b/packages/cli/src/commands/build/upload.ts index 20cea601b..e475722b9 100644 --- a/packages/cli/src/commands/build/upload.ts +++ b/packages/cli/src/commands/build/upload.ts @@ -32,6 +32,7 @@ import { vcsInfoToBody, } from "../../lib/build/vcs.js"; import { buildCommand } from "../../lib/command.js"; +import { parseFilePath } from "../../lib/completion-target.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { colorTag, @@ -225,7 +226,7 @@ export const uploadCommand = buildCommand({ parameter: { brief: "Path(s) to the build(s) to upload (APK, AAB, IPA, or XCArchive)", - parse: String, + parse: parseFilePath, placeholder: "path", }, }, diff --git a/packages/cli/src/commands/cli/completion.ts b/packages/cli/src/commands/cli/completion.ts index 9f5684f8d..695a140de 100644 --- a/packages/cli/src/commands/cli/completion.ts +++ b/packages/cli/src/commands/cli/completion.ts @@ -14,6 +14,7 @@ import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; +import { choiceParser } from "../../lib/completion-target.js"; import { getCompletionScript } from "../../lib/completions.js"; import { ValidationError } from "../../lib/errors.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -60,7 +61,7 @@ export const completionCommand = buildCommand({ { placeholder: "shell", brief: "Shell to generate completions for (bash, zsh, or fish)", - parse: String, + parse: choiceParser(["bash", "zsh", "fish"]), optional: true, }, ], diff --git a/packages/cli/src/commands/cli/defaults.ts b/packages/cli/src/commands/cli/defaults.ts index 7e90c57d6..f9e4afc70 100644 --- a/packages/cli/src/commands/cli/defaults.ts +++ b/packages/cli/src/commands/cli/defaults.ts @@ -14,6 +14,7 @@ import { resolve } from "node:path"; import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; +import { choiceParser } from "../../lib/completion-target.js"; import { normalizeUrl } from "../../lib/constants.js"; import { readCaCertFile } from "../../lib/custom-ca.js"; import { parseCustomHeaders } from "../../lib/custom-headers.js"; @@ -326,7 +327,7 @@ export const defaultsCommand = buildCommand({ kind: "array", parameter: { brief: "Setting key and optional value", - parse: String, + parse: choiceParser(Object.keys(DEFAULTS_REGISTRY)), placeholder: "key value", }, }, diff --git a/packages/cli/src/commands/cli/feedback.ts b/packages/cli/src/commands/cli/feedback.ts index da547149a..b5cff851d 100644 --- a/packages/cli/src/commands/cli/feedback.ts +++ b/packages/cli/src/commands/cli/feedback.ts @@ -13,6 +13,7 @@ import { isatty } from "node:tty"; import * as Sentry from "@sentry/node"; import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; +import { parseText } from "../../lib/completion-target.js"; import { ConfigError, ValidationError } from "../../lib/errors.js"; import { formatFeedbackResult } from "../../lib/formatters/human.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -51,7 +52,7 @@ export const feedbackCommand = buildCommand({ kind: "array", parameter: { brief: "Your feedback message", - parse: String, + parse: parseText, placeholder: "message", }, }, diff --git a/packages/cli/src/commands/cli/upgrade.ts b/packages/cli/src/commands/cli/upgrade.ts index cd640400c..83048238f 100644 --- a/packages/cli/src/commands/cli/upgrade.ts +++ b/packages/cli/src/commands/cli/upgrade.ts @@ -30,6 +30,7 @@ import { type UpgradeSource, } from "../../lib/binary.js"; import { buildCommand } from "../../lib/command.js"; +import { choiceParser } from "../../lib/completion-target.js"; import { CLI_VERSION } from "../../lib/constants.js"; import { backupDatabase, @@ -1200,7 +1201,7 @@ export const upgradeCommand = buildCommand({ { brief: 'Specific version (e.g. 0.5.0), "nightly"/"stable" to switch channel, or "undo" to return to the previous version; omit to update within current channel', - parse: String, + parse: choiceParser([...CHANNEL_VERSIONS, MAGIC_VERSION_FOR_UNDO]), placeholder: "version", optional: true, }, diff --git a/packages/cli/src/commands/code-mappings/upload.ts b/packages/cli/src/commands/code-mappings/upload.ts index 2c550dc61..bc0aba3fc 100644 --- a/packages/cli/src/commands/code-mappings/upload.ts +++ b/packages/cli/src/commands/code-mappings/upload.ts @@ -23,6 +23,7 @@ import { uploadCodeMappings, } from "../../lib/api/code-mappings.js"; import { buildCommand } from "../../lib/command.js"; +import { parseFilePath } 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 +205,7 @@ export const uploadCommand = buildCommand({ parameters: [ { brief: "Path to the code mappings JSON file", - parse: String, + parse: parseFilePath, placeholder: "path", }, ], diff --git a/packages/cli/src/commands/dart-symbol-map/upload.ts b/packages/cli/src/commands/dart-symbol-map/upload.ts index 521167747..0d0b16681 100644 --- a/packages/cli/src/commands/dart-symbol-map/upload.ts +++ b/packages/cli/src/commands/dart-symbol-map/upload.ts @@ -15,6 +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 { ContextError, ValidationError } from "../../lib/errors.js"; import { mdKvTable, renderMarkdown } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -195,7 +196,7 @@ export const uploadCommand = buildCommand({ parameters: [ { brief: "Path to the dart symbol map JSON file", - parse: String, + parse: parseFilePath, placeholder: "path", }, ], diff --git a/packages/cli/src/commands/debug-files/bundle-jvm.ts b/packages/cli/src/commands/debug-files/bundle-jvm.ts index bf81e10a6..e7272d748 100644 --- a/packages/cli/src/commands/debug-files/bundle-jvm.ts +++ b/packages/cli/src/commands/debug-files/bundle-jvm.ts @@ -12,6 +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 { ValidationError } from "../../lib/errors.js"; import { mdKvTable, renderMarkdown } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -83,7 +84,7 @@ export const bundleJvmCommand = buildCommand({ parameters: [ { brief: "Directory containing JVM source files", - parse: String, + parse: parseDirectoryPath, placeholder: "path", }, ], diff --git a/packages/cli/src/commands/debug-files/bundle-sources.ts b/packages/cli/src/commands/debug-files/bundle-sources.ts index 25351ae5b..e3203e81f 100644 --- a/packages/cli/src/commands/debug-files/bundle-sources.ts +++ b/packages/cli/src/commands/debug-files/bundle-sources.ts @@ -20,6 +20,7 @@ import { basename, dirname, resolve } from "node:path"; import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; import { createSourceBundle } from "../../lib/dif/index.js"; +import { parseFilePath } from "../../lib/completion-target.js"; import { ValidationError } from "../../lib/errors.js"; import { colorTag, @@ -102,7 +103,7 @@ export const bundleSourcesCommand = buildCommand({ parameters: [ { brief: "Path to the debug information file", - parse: String, + parse: parseFilePath, placeholder: "path", }, ], diff --git a/packages/cli/src/commands/debug-files/check.ts b/packages/cli/src/commands/debug-files/check.ts index 69a445eda..213be9eee 100644 --- a/packages/cli/src/commands/debug-files/check.ts +++ b/packages/cli/src/commands/debug-files/check.ts @@ -15,6 +15,7 @@ import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; import { type DifArchiveInfo, parseDebugFile } from "../../lib/dif/index.js"; +import { parseFilePath } from "../../lib/completion-target.js"; import { ValidationError } from "../../lib/errors.js"; import { colorTag, @@ -147,7 +148,7 @@ export const checkCommand = buildCommand({ parameters: [ { brief: "Path to the debug information file", - parse: String, + parse: parseFilePath, placeholder: "path", }, ], diff --git a/packages/cli/src/commands/debug-files/find.ts b/packages/cli/src/commands/debug-files/find.ts index cfa1b7089..fb1a817c2 100644 --- a/packages/cli/src/commands/debug-files/find.ts +++ b/packages/cli/src/commands/debug-files/find.ts @@ -19,6 +19,7 @@ import { type FindResult, findDebugFiles, } from "../../lib/dif/find.js"; +import { 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"; @@ -144,7 +145,7 @@ export const findCommand = buildCommand({ parameter: { placeholder: "id", brief: "Debug identifier(s) to search for", - parse: String, + parse: parseText, }, }, flags: { diff --git a/packages/cli/src/commands/debug-files/print-sources.ts b/packages/cli/src/commands/debug-files/print-sources.ts index c27ce4495..1dcb24fff 100644 --- a/packages/cli/src/commands/debug-files/print-sources.ts +++ b/packages/cli/src/commands/debug-files/print-sources.ts @@ -19,6 +19,7 @@ import { listSources, selectBundledObject, } from "../../lib/dif/index.js"; +import { parseFilePath } from "../../lib/completion-target.js"; import { ValidationError } from "../../lib/errors.js"; import { colorTag, @@ -132,7 +133,7 @@ export const printSourcesCommand = buildCommand({ parameters: [ { brief: "Path to the debug information file", - parse: String, + parse: parseFilePath, placeholder: "path", }, ], diff --git a/packages/cli/src/commands/debug-files/upload.ts b/packages/cli/src/commands/debug-files/upload.ts index 3911f7233..7bcad5eff 100644 --- a/packages/cli/src/commands/debug-files/upload.ts +++ b/packages/cli/src/commands/debug-files/upload.ts @@ -48,6 +48,7 @@ import { prepareDifs, scanPaths, } from "../../lib/dif/scan.js"; +import { parseFilePath } from "../../lib/completion-target.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { colorTag, @@ -605,7 +606,7 @@ export const uploadCommand = buildCommand({ kind: "array", parameter: { brief: "Files or directories to scan for debug information files", - parse: String, + parse: parseFilePath, placeholder: "path", }, }, diff --git a/packages/cli/src/commands/docs/list.ts b/packages/cli/src/commands/docs/list.ts index f1ca06ce9..072846f1e 100644 --- a/packages/cli/src/commands/docs/list.ts +++ b/packages/cli/src/commands/docs/list.ts @@ -1,6 +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 { type DocsListResponse, listDocs } from "../../lib/docs-service.js"; import { ValidationError } from "../../lib/errors.js"; import { muted } from "../../lib/formatters/colors.js"; @@ -67,7 +68,7 @@ export const listCommand = buildCommand({ kind: "array", parameter: { brief: "Documentation keywords", - parse: String, + parse: parseText, placeholder: "keywords", }, }, diff --git a/packages/cli/src/commands/docs/query.ts b/packages/cli/src/commands/docs/query.ts index ad81a7d4e..213a3864e 100644 --- a/packages/cli/src/commands/docs/query.ts +++ b/packages/cli/src/commands/docs/query.ts @@ -1,5 +1,6 @@ import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; +import { parseText } from "../../lib/completion-target.js"; import { detectDocsContext } from "../../lib/docs-context.js"; import { queryDocs } from "../../lib/docs-service.js"; import { ValidationError } from "../../lib/errors.js"; @@ -57,7 +58,7 @@ export const queryCommand = buildCommand({ kind: "array", parameter: { brief: "Natural-language docs question", - parse: String, + parse: parseText, placeholder: "question", }, }, diff --git a/packages/cli/src/commands/event/send.ts b/packages/cli/src/commands/event/send.ts index 91663e108..12d990225 100644 --- a/packages/cli/src/commands/event/send.ts +++ b/packages/cli/src/commands/event/send.ts @@ -24,6 +24,7 @@ import { readFileBytes, sendEnvelopeRequest, } from "../../lib/envelope/transport.js"; +import { parseFilePath } 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"; @@ -204,7 +205,7 @@ built entirely from the file contents. brief: "Optional DSN/project target followed by JSON event file path(s)", placeholder: "target-or-file", - parse: String, + parse: parseFilePath, optional: true, }, }, diff --git a/packages/cli/src/commands/help.ts b/packages/cli/src/commands/help.ts index 60ef0ccef..56a63391d 100644 --- a/packages/cli/src/commands/help.ts +++ b/packages/cli/src/commands/help.ts @@ -12,6 +12,7 @@ import type { SentryContext } from "../context.js"; import { buildCommand } from "../lib/command.js"; +import { parseCommandPath } from "../lib/completion-target.js"; import { OutputError } from "../lib/errors.js"; import { CommandOutput } from "../lib/formatters/output.js"; import { @@ -40,7 +41,7 @@ export const helpCommand = buildCommand({ kind: "array", parameter: { brief: "Command to get help for", - parse: String, + parse: parseCommandPath, placeholder: "command", }, }, diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 109511bdd..e04648595 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -26,6 +26,7 @@ import { parseOrgProjectInput, } from "../lib/arg-parsing.js"; import { buildCommand } from "../lib/command.js"; +import { DIRECTORY_TARGET } 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"; @@ -400,6 +401,7 @@ export const initCommand = buildCommand< parameters: { positional: boundArguments({ placeholder: "target/directory", + kind: DIRECTORY_TARGET, brief: "Optional target (/, /, or ) and directory (default: current directory)", parse: parseInitInput, diff --git a/packages/cli/src/commands/issue/link-utils.ts b/packages/cli/src/commands/issue/link-utils.ts index c33635fcf..b61631d70 100644 --- a/packages/cli/src/commands/issue/link-utils.ts +++ b/packages/cli/src/commands/issue/link-utils.ts @@ -1,5 +1,6 @@ /** Shared arguments for external issue association commands. */ +import { parseText } from "../../lib/completion-target.js"; import { ValidationError } from "../../lib/errors.js"; import { issueParameter } from "./positional.js"; @@ -11,7 +12,7 @@ export function externalIssuePositionals(command: string) { issueParameter({ command }), { placeholder: "url", - parse: String, + parse: parseText, brief: "URL of an existing tracker issue or GitHub pull request", }, ], diff --git a/packages/cli/src/commands/issue/tags.ts b/packages/cli/src/commands/issue/tags.ts index e753e334a..d72a88f5c 100644 --- a/packages/cli/src/commands/issue/tags.ts +++ b/packages/cli/src/commands/issue/tags.ts @@ -10,6 +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 { ContextError } from "../../lib/errors.js"; import { formatIssueTags } from "../../lib/formatters/issue-tags.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -96,7 +97,7 @@ export const tagsCommand = buildCommand({ { placeholder: "key", brief: "Tag key, e.g. release, server_name, browser.name", - parse: String, + parse: parseText, }, ], }, diff --git a/packages/cli/src/commands/local/management.ts b/packages/cli/src/commands/local/management.ts index f96359288..5ba2c9bd7 100644 --- a/packages/cli/src/commands/local/management.ts +++ b/packages/cli/src/commands/local/management.ts @@ -3,6 +3,7 @@ import { basename } from "node:path"; import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; +import { parseText } from "../../lib/completion-target.js"; import { CliError, ValidationError } from "../../lib/errors.js"; import { formatLocalControlResult } from "../../lib/formatters/local.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -70,7 +71,7 @@ const SESSION_REFERENCE_POSITIONAL = { kind: "array", parameter: { brief: "Session ID or unique name", - parse: String, + parse: parseText, placeholder: "session", }, } as const; @@ -225,7 +226,7 @@ const sessionCreateCommand = buildCommand({ kind: "array", parameter: { brief: "Optional session label", - parse: String, + parse: parseText, placeholder: "name", }, }, @@ -295,7 +296,7 @@ function buildTelemetryViewCommand( kind: "array", parameter: { brief: `Session and ${kind} ID`, - parse: String, + parse: parseText, placeholder: `session ${kind}`, }, }, diff --git a/packages/cli/src/commands/local/run.ts b/packages/cli/src/commands/local/run.ts index b2a4a91d2..b7d6cf9ce 100644 --- a/packages/cli/src/commands/local/run.ts +++ b/packages/cli/src/commands/local/run.ts @@ -22,6 +22,7 @@ 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 { detectDevCommand } from "../../lib/dev-script.js"; import { CliError, EXIT, ValidationError } from "../../lib/errors.js"; import { bold } from "../../lib/formatters/colors.js"; @@ -440,7 +441,7 @@ export const runCommand = buildCommand({ kind: "array", parameter: { brief: "Command to run", - parse: String, + parse: parseText, placeholder: "command", }, }, diff --git a/packages/cli/src/commands/monitor/run.ts b/packages/cli/src/commands/monitor/run.ts index 2d0ff197a..4598b949e 100644 --- a/packages/cli/src/commands/monitor/run.ts +++ b/packages/cli/src/commands/monitor/run.ts @@ -34,6 +34,7 @@ import { resolveIngestDsn, sendEnvelopeRequest, } from "../../lib/envelope/transport.js"; +import { parseText } from "../../lib/completion-target.js"; import { CliError, ConfigError, ValidationError } from "../../lib/errors.js"; import { logger } from "../../lib/logger.js"; @@ -197,7 +198,7 @@ The wrapped command receives the \`SENTRY_MONITOR_SLUG\` environment variable.`, kind: "array", parameter: { brief: "Monitor slug followed by the command to run", - parse: String, + parse: parseText, placeholder: "monitor-slug command", }, }, diff --git a/packages/cli/src/commands/org/list.ts b/packages/cli/src/commands/org/list.ts index 11cea5aff..3892dd7fe 100644 --- a/packages/cli/src/commands/org/list.ts +++ b/packages/cli/src/commands/org/list.ts @@ -9,6 +9,7 @@ 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 { DEFAULT_SENTRY_HOST } from "../../lib/constants.js"; import { type AccountSummary, @@ -460,7 +461,7 @@ export const listCommand = buildCommand({ placeholder: "filter", brief: "Only list organizations whose slug or name fuzzy-matches this text or matches this glob", - parse: String, + parse: parseText, optional: true, }, ], diff --git a/packages/cli/src/commands/proguard/upload.ts b/packages/cli/src/commands/proguard/upload.ts index 4ec2d6cfe..c667c69a4 100644 --- a/packages/cli/src/commands/proguard/upload.ts +++ b/packages/cli/src/commands/proguard/upload.ts @@ -13,6 +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 { ContextError, ValidationError } from "../../lib/errors.js"; import { mdKvTable, renderMarkdown } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -190,7 +191,7 @@ export const uploadCommand = buildCommand({ kind: "array", parameter: { brief: "Paths to ProGuard/R8 mapping files", - parse: String, + parse: parseFilePath, placeholder: "path", }, }, diff --git a/packages/cli/src/commands/proguard/uuid.ts b/packages/cli/src/commands/proguard/uuid.ts index 75058dfb1..5f31665a1 100644 --- a/packages/cli/src/commands/proguard/uuid.ts +++ b/packages/cli/src/commands/proguard/uuid.ts @@ -9,6 +9,7 @@ import { readFile } from "node:fs/promises"; import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; +import { parseFilePath } from "../../lib/completion-target.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { computeProguardUuid } from "../../lib/proguard.js"; @@ -54,7 +55,7 @@ export const uuidCommand = buildCommand({ parameters: [ { brief: "Path to the ProGuard mapping file", - parse: String, + parse: parseFilePath, placeholder: "path", }, ], diff --git a/packages/cli/src/commands/project/create.ts b/packages/cli/src/commands/project/create.ts index d317ded13..3bd128f1a 100644 --- a/packages/cli/src/commands/project/create.ts +++ b/packages/cli/src/commands/project/create.ts @@ -28,6 +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 { ApiError, CliError, @@ -719,6 +720,7 @@ export const createCommand = buildCommand({ parameters: { positional: boundArguments({ placeholder: "name:platform", + kind: PROJECT_SPEC_TARGET, brief: "One or more project name and platform pairs", parse: parseProjectSpecs, resolve: (input: ProjectSpecsInput, context: SentryContext) => diff --git a/packages/cli/src/commands/react-native/xcode.ts b/packages/cli/src/commands/react-native/xcode.ts index 338298fdd..4ddb5424f 100644 --- a/packages/cli/src/commands/react-native/xcode.ts +++ b/packages/cli/src/commands/react-native/xcode.ts @@ -27,6 +27,7 @@ import { uploadSourcemaps, } from "../../lib/api/sourcemaps.js"; import { buildCommand } from "../../lib/command.js"; +import { 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"; @@ -503,7 +504,7 @@ export const xcodeCommand = buildCommand({ parameter: { placeholder: "script-arg", brief: "Extra arguments passed to the build script", - parse: String, + parse: parseText, }, }, }, diff --git a/packages/cli/src/commands/release/deploy.ts b/packages/cli/src/commands/release/deploy.ts index 6685c4509..cf9dec607 100644 --- a/packages/cli/src/commands/release/deploy.ts +++ b/packages/cli/src/commands/release/deploy.ts @@ -8,6 +8,7 @@ import type { SentryContext } from "../../context.js"; import { createReleaseDeploy } from "../../lib/api-client.js"; import { buildCommand, numberParser } from "../../lib/command.js"; +import { parseText } from "../../lib/completion-target.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { mdKvTable, @@ -111,13 +112,13 @@ export const deployCommand = buildCommand({ { placeholder: "environment", brief: "Deploy environment (e.g. production)", - parse: String, + parse: parseText, }, { placeholder: "name", brief: 'Optional deploy name (quote multi-word names, e.g. "Deploy #42")', - parse: String, + parse: parseText, optional: true, }, ], diff --git a/packages/cli/src/commands/schema.ts b/packages/cli/src/commands/schema.ts index 1266a69b0..d2593cfde 100644 --- a/packages/cli/src/commands/schema.ts +++ b/packages/cli/src/commands/schema.ts @@ -20,6 +20,7 @@ import { searchEndpoints, } from "../lib/api-schema.js"; import { buildCommand } from "../lib/command.js"; +import { 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"; @@ -517,7 +518,7 @@ export const schemaCommand = buildCommand({ parameter: { brief: "API concept, CLI command path, exact operation ID, or METHOD /path", - parse: String, + parse: parseText, placeholder: "query", }, }, diff --git a/packages/cli/src/commands/snapshots/diff.ts b/packages/cli/src/commands/snapshots/diff.ts index 7699ad879..d297dc0f8 100644 --- a/packages/cli/src/commands/snapshots/diff.ts +++ b/packages/cli/src/commands/snapshots/diff.ts @@ -13,6 +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 { ValidationError } from "../../lib/errors.js"; import { colorTag, @@ -259,12 +260,12 @@ export const diffCommand = buildCommand({ parameters: [ { brief: "Path to the baseline image directory", - parse: String, + parse: parseDirectoryPath, placeholder: "base-dir", }, { brief: "Path to the head image directory", - parse: String, + parse: parseDirectoryPath, placeholder: "head-dir", }, ], diff --git a/packages/cli/src/commands/snapshots/upload.ts b/packages/cli/src/commands/snapshots/upload.ts index dbaf23390..629a4c98f 100644 --- a/packages/cli/src/commands/snapshots/upload.ts +++ b/packages/cli/src/commands/snapshots/upload.ts @@ -22,6 +22,7 @@ import { vcsInfoToBody, } from "../../lib/build/vcs.js"; import { buildCommand } from "../../lib/command.js"; +import { parseDirectoryPath } from "../../lib/completion-target.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { colorTag, @@ -369,7 +370,7 @@ export const uploadCommand = buildCommand({ parameters: [ { brief: "Path to the folder containing images to upload", - parse: String, + parse: parseDirectoryPath, placeholder: "path", }, ], diff --git a/packages/cli/src/commands/sourcemap/inject.ts b/packages/cli/src/commands/sourcemap/inject.ts index d4f0c60c8..aa7ade604 100644 --- a/packages/cli/src/commands/sourcemap/inject.ts +++ b/packages/cli/src/commands/sourcemap/inject.ts @@ -7,6 +7,7 @@ import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; +import { parseDirectoryPath } from "../../lib/completion-target.js"; import { colorTag, mdKvTable, @@ -95,7 +96,7 @@ export const injectCommand = buildCommand({ parameters: [ { brief: "Directory to scan for JS + sourcemap pairs", - parse: String, + parse: parseDirectoryPath, placeholder: "directory", }, ], diff --git a/packages/cli/src/commands/sourcemap/resolve.ts b/packages/cli/src/commands/sourcemap/resolve.ts index af6a23309..7a97b8ba4 100644 --- a/packages/cli/src/commands/sourcemap/resolve.ts +++ b/packages/cli/src/commands/sourcemap/resolve.ts @@ -10,6 +10,7 @@ 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 { colorTag, escapeMarkdownCell, @@ -138,7 +139,7 @@ export const resolveCommand = buildCommand({ parameters: [ { brief: "Directory to scan for JS files", - parse: String, + parse: parseDirectoryPath, placeholder: "directory", }, ], diff --git a/packages/cli/src/commands/sourcemap/upload.ts b/packages/cli/src/commands/sourcemap/upload.ts index abf915a4a..9e9b7325d 100644 --- a/packages/cli/src/commands/sourcemap/upload.ts +++ b/packages/cli/src/commands/sourcemap/upload.ts @@ -17,6 +17,7 @@ import { uploadSourcemaps, } from "../../lib/api/sourcemaps.js"; import { buildCommand } from "../../lib/command.js"; +import { parseDirectoryPath } 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"; @@ -254,7 +255,7 @@ export const uploadCommand = buildCommand({ parameters: [ { brief: "Directory containing sourcemaps", - parse: String, + parse: parseDirectoryPath, placeholder: "directory", }, ], diff --git a/packages/cli/src/commands/wasm-split.ts b/packages/cli/src/commands/wasm-split.ts index bc392ffa9..9acd33487 100644 --- a/packages/cli/src/commands/wasm-split.ts +++ b/packages/cli/src/commands/wasm-split.ts @@ -19,6 +19,7 @@ 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 { ValidationError } from "../lib/errors.js"; import { CommandOutput } from "../lib/formatters/output.js"; import { formatBuildId, uuidToBytes } from "../lib/wasm/build-id.js"; @@ -104,7 +105,7 @@ export const wasmSplitCommand = buildCommand({ parameters: [ { brief: "Path to the wasm file", - parse: String, + parse: parseFilePath, placeholder: "input", }, ], diff --git a/packages/cli/src/lib/complete.ts b/packages/cli/src/lib/complete.ts index e2bba5579..0d0377d7a 100644 --- a/packages/cli/src/lib/complete.ts +++ b/packages/cli/src/lib/complete.ts @@ -15,12 +15,21 @@ * Exit: 0 on success (even if no completions) */ +import { readdirSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; import generatedTargets from "../generated/completion-targets.json"; import type { CompletionTarget, CompletionValues, } from "./completion-target.js"; import { getConfiguredSentryUrl } from "./constants.js"; +import { + type AccountSummary, + accountSelectorOf, + accountServerOf, + listAccounts, +} from "./db/accounts.js"; import { getCredentialContext } from "./db/auth.js"; import { type CachedCompletionEntity, @@ -108,8 +117,8 @@ export async function handleComplete(args: string[]): Promise { /** * Command path → completion kind of each positional and value-taking flag, - * and the fixed values completers offer, from - * `src/generated/completion-targets.json`. + * route group path → its subcommands and their briefs, and the fixed + * values completers offer, from `src/generated/completion-targets.json`. * * Generated from the parsers the commands declare (see * `completion-target.ts`) by `script/generate-completion-targets.ts` @@ -118,6 +127,7 @@ export async function handleComplete(args: string[]): Promise { */ const GENERATED = generatedTargets as { commands: CommandCompletions; + groups: Record>; values: CompletionValues; }; @@ -126,6 +136,8 @@ type CompletionSlot = { kind: CompletionTarget | null; /** Positionals before the word being completed, flags removed. */ positionals: string[]; + /** The values a `choice` argument offers. */ + choices?: readonly string[]; }; /** @@ -160,11 +172,21 @@ function findSlot(words: string[]): CompletionSlot | undefined { } } if (flag !== undefined) { - return { kind: command.flags[flag] ?? null, positionals }; + return { + kind: command.flags[flag] ?? null, + positionals, + choices: command.choices?.[flag], + }; } - // A variadic positional is a single parameter that every word fills + // A variadic positional is a single parameter that every word fills. + // Its choices are for the first word; the rest are the chosen value's + // arguments (`cli defaults `). const index = command.variadic ? 0 : positionals.length; - return { kind: command.positionals[index] ?? null, positionals }; + const choices = + command.variadic && positionals.length > 0 + ? undefined + : command.choices?.[index]; + return { kind: command.positionals[index] ?? null, positionals, choices }; } return; } @@ -207,8 +229,99 @@ const COMPLETERS: Record = { ); }, period: (partial) => completePeriods(partial), + "project-spec": (partial) => completeProjectCreateSpec(partial), + account: (partial) => completeAccounts(partial), + file: (partial) => completePaths(partial, { directoriesOnly: false }), + directory: (partial) => completePaths(partial, { directoriesOnly: true }), + "command-path": (partial, slot) => + completeSubcommands(slot.positionals.join(" "), partial), + choice: (partial, slot) => + (slot.choices ?? []) + .filter((value) => value.startsWith(partial)) + .map((value) => ({ value })), + text: () => [], }; +/** + * Subcommands of the route group `group` (`""` for the top level) whose + * names start with the partial, with their briefs. + */ +function completeSubcommands(group: string, partial: string): Completion[] { + return Object.entries(GENERATED.groups[group] ?? {}) + .filter(([name]) => name.startsWith(partial)) + .map(([value, description]) => ({ value, description })); +} + +/** + * Complete stored accounts with fuzzy matching, written the way + * `auth switch` and `auth logout` take them: the email (else username, + * else ID), or `:` when the same user is stored for several + * servers. + */ +function completeAccounts(partial: string): Completion[] { + const accounts = listAccounts(); + const whoOf = (account: AccountSummary) => + account.email ?? account.username ?? account.id; + const counts = new Map(); + for (const account of accounts) { + counts.set(whoOf(account), (counts.get(whoOf(account)) ?? 0) + 1); + } + const servers = new Map( + accounts.map((account) => [ + counts.get(whoOf(account)) === 1 + ? whoOf(account) + : accountSelectorOf(account), + accountServerOf(account), + ]), + ); + return fuzzyMatch(partial, [...servers.keys()], { mode: "search" }).map( + (value) => ({ value, description: servers.get(value) }), + ); +} + +/** + * Complete the entries of the directory a path partial is in whose names + * start with its last segment. Directories end in `/`, so the next TAB + * goes into them; dotfiles are listed only once the segment starts with + * `.`. A leading `~/` is read from the home directory and kept as typed. + * + * @param options.directoriesOnly - Leave out everything but directories + */ +function completePaths( + partial: string, + options: { directoriesOnly: boolean }, +): Completion[] { + const parent = partial.slice(0, partial.lastIndexOf("/") + 1); + const name = partial.slice(parent.length); + const dir = parent.startsWith("~/") + ? join(homedir(), parent.slice(2)) + : parent || "."; + if (!statSync(dir, { throwIfNoEntry: false })?.isDirectory()) { + return []; + } + const completions: Completion[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if ( + !entry.name.startsWith(name) || + (entry.name.startsWith(".") && !name.startsWith(".")) + ) { + continue; + } + const isDirectory = + entry.isDirectory() || + (entry.isSymbolicLink() && + statSync(join(dir, entry.name), { + throwIfNoEntry: false, + })?.isDirectory()); + if (isDirectory) { + completions.push({ value: `${parent}${entry.name}/` }); + } else if (!options.directoriesOnly) { + completions.push({ value: `${parent}${entry.name}` }); + } + } + return completions.sort((a, b) => a.value.localeCompare(b.value)); +} + const DIGITS_RE = /^\d+$/; /** @@ -230,7 +343,9 @@ function completePeriods(partial: string): Completion[] { * Determine what completions to provide based on the command context. * * Finds the argument the partial fills (a positional or a flag value) and - * runs the completer for the kind its parser declares. + * runs the completer for the kind its parser declares. Words that name a + * nested route group (`alert issues`) complete its subcommands instead; + * the shell scripts embed the first two levels themselves. * * @param precedingWords - Words before the partial (determines context) * @param partial - The current partial word being completed @@ -239,20 +354,13 @@ export async function getCompletions( precedingWords: string[], partial: string, ): Promise { - // Build the command path from preceding words (e.g., "issue list") - const cmdPath = - precedingWords.length >= 2 - ? `${precedingWords[0]} ${precedingWords[1]}` - : ""; - - // `project create` takes `name:platform` specs, not an existing target, - // so it keeps a dedicated completer instead of a generated target kind. - if (cmdPath === "project create") { - return completeProjectCreateSpec(partial); - } - const slot = findSlot(precedingWords); - return slot?.kind ? await COMPLETERS[slot.kind](partial, slot) : []; + if (!slot) { + return precedingWords.length >= 2 + ? completeSubcommands(precedingWords.join(" "), partial) + : []; + } + return slot.kind ? await COMPLETERS[slot.kind](partial, slot) : []; } /** diff --git a/packages/cli/src/lib/completion-target.ts b/packages/cli/src/lib/completion-target.ts index 0df325589..516add262 100644 --- a/packages/cli/src/lib/completion-target.ts +++ b/packages/cli/src/lib/completion-target.ts @@ -9,11 +9,14 @@ * `src/lib/complete.ts` maps each kind to its completer. * * Commands get the tag from the bound parsers in `target-positional.ts` - * and `positional-parse.ts`, which parse the value too. + * 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}, …). */ import { ISSUE_IS_VALUES } from "@sentry/toolkit-core/search-query"; import { ISSUE_LEVELS } from "../types/sentry.js"; +import { parseLoginEmail } from "./db/accounts.js"; import { sanitizeIssueQuery } from "./search-query.js"; import { parsePeriod, UNIT_SECONDS } from "./time-range.js"; @@ -35,6 +38,30 @@ export const ISSUE_QUERY_TARGET = "issue-query"; /** Completes relative `--period` values (`24h`, `7d`, a number plus a unit). */ export const PERIOD_TARGET = "period"; +/** Completes `project create` `name:platform` specs: `org/` prefixes, then platforms. */ +export const PROJECT_SPEC_TARGET = "project-spec"; + +/** Completes stored accounts (`sentry auth list`) by email, username, or ID. */ +export const ACCOUNT_TARGET = "account"; + +/** Completes file and directory paths. */ +export const FILE_TARGET = "file"; + +/** Completes directory paths only. */ +export const DIRECTORY_TARGET = "directory"; + +/** Completes a CLI command path, one route segment per word (`help issue list`). */ +export const COMMAND_PATH_TARGET = "command-path"; + +/** Completes the values its parser was made with by {@link choiceParser}. */ +export const CHOICE_TARGET = "choice"; + +/** + * Free text with nothing to suggest (messages, queries, names of new + * things). Declared, so that every positional says how it completes. + */ +export const TEXT_TARGET = "text"; + /** Every completion target kind, in a stable order. */ export const COMPLETION_TARGETS = [ ORG_TARGET, @@ -43,6 +70,13 @@ export const COMPLETION_TARGETS = [ DASHBOARD_TARGET, ISSUE_QUERY_TARGET, PERIOD_TARGET, + PROJECT_SPEC_TARGET, + ACCOUNT_TARGET, + FILE_TARGET, + DIRECTORY_TARGET, + COMMAND_PATH_TARGET, + CHOICE_TARGET, + TEXT_TARGET, ] as const; /** What shell completion suggests for an argument. */ @@ -50,6 +84,8 @@ export type CompletionTarget = (typeof COMPLETION_TARGETS)[number]; const COMPLETION_TARGET_KEY = Symbol("completionTarget"); +const COMPLETION_CHOICES_KEY = Symbol("completionChoices"); + /** * Tag a positional or flag parser with the completion kind it accepts. * @@ -63,6 +99,39 @@ export function withCompletionTarget

unknown>( return parse; } +/** A parser that returns its input unchanged, tagged with `kind`. */ +function identityParser(kind: CompletionTarget): (input: string) => string { + return withCompletionTarget(kind, (input: string) => input); +} + +/** Parses a file or directory path; completes paths. */ +export const parseFilePath = identityParser(FILE_TARGET); + +/** Parses a directory path; completes directories. */ +export const parseDirectoryPath = identityParser(DIRECTORY_TARGET); + +/** Parses free text; completes nothing. */ +export const parseText = identityParser(TEXT_TARGET); + +/** Parses one segment of a CLI command path; completes route names. */ +export const parseCommandPath = identityParser(COMMAND_PATH_TARGET); + +/** Parses a stored-account selector; completes stored accounts. */ +export const parseAccountSelector = identityParser(ACCOUNT_TARGET); + +/** + * Build a parser that completes `values`. It returns its input unchanged, + * so the command still validates it: the values are suggestions, and a + * command may accept others (`cli upgrade 0.5.0` next to `nightly`). + */ +export function choiceParser( + values: readonly string[], +): (input: string) => string { + const parse = identityParser(CHOICE_TARGET); + Object.defineProperty(parse, COMPLETION_CHOICES_KEY, { value: [...values] }); + return parse; +} + /** * Shared value parsers and what their arguments complete. Listed here, not * tagged where they are defined, so those modules know nothing about @@ -74,6 +143,7 @@ const PARSER_TARGETS: ReadonlyMap = new Map< >([ [sanitizeIssueQuery, ISSUE_QUERY_TARGET], [parsePeriod, PERIOD_TARGET], + [parseLoginEmail, ACCOUNT_TARGET], ]); /** @@ -96,6 +166,21 @@ export function getCompletionTarget( ); } +/** + * Read the values a {@link choiceParser} parser completes. + * + * @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; +} + /** Fixed values the completers offer (see {@link COMPLETION_VALUES}). */ export type CompletionValues = { /** Issue search keys with a fixed set of values, for {@link ISSUE_QUERY_TARGET} */ diff --git a/packages/cli/src/lib/completions.ts b/packages/cli/src/lib/completions.ts index 13866aa21..2cb92ab7e 100644 --- a/packages/cli/src/lib/completions.ts +++ b/packages/cli/src/lib/completions.ts @@ -78,11 +78,8 @@ function extractFlagEntries( continue; } const entry: FlagEntry = { name, brief: def.brief ?? "" }; - if (def.kind === "enum" && "values" in def) { - const enumDef = def as FlagDef & { values?: readonly string[] }; - if (enumDef.values) { - entry.enumValues = [...enumDef.values]; - } + if (def.kind === "enum" && def.values) { + entry.enumValues = [...def.values]; } entries.push(entry); } diff --git a/packages/cli/src/lib/db/accounts.ts b/packages/cli/src/lib/db/accounts.ts index a9eb90c36..6863002f6 100644 --- a/packages/cli/src/lib/db/accounts.ts +++ b/packages/cli/src/lib/db/accounts.ts @@ -10,6 +10,7 @@ import { isSaaSTrustOrigin } from "@sentry/toolkit-core/sentry-origin"; import { uuidv7 } from "uuidv7"; import { normalizeUrl } from "../constants.js"; +import { ValidationError } from "../errors.js"; import { fuzzyMatch } from "../fuzzy.js"; import { normalizeHttpOrigin } from "../sentry-urls.js"; import { storedCredentialIdentity } from "./identity-hash.js"; @@ -250,6 +251,55 @@ export function splitAccountSelector( return who && server ? { who, server } : undefined; } +/** + * Parse the optional `email` argument of `sentry auth login`: an email, or + * `:` (the notation `sentry org list` shows, e.g. + * `jane@acme.com:sentry.acme.com`) to also pick the server like `--url`. + * Returns the trimmed argument; {@link splitLoginTarget} splits it. + * + * @throws {ValidationError} when the value is not an email address, or the + * server part is not a valid URL + */ +export function parseLoginEmail(raw: string): string { + const target = raw.trim(); + splitLoginTarget(target, raw); + return target; +} + +/** + * Split a `sentry auth login` target into its email and, for + * `:`, the server's normalized origin. Uses the same + * {@link splitAccountSelector} as `--account`, plus an email check. + * + * @throws {ValidationError} when either part is invalid + */ +export function splitLoginTarget( + target: string, + raw = target, +): { email: string; url?: string } { + const parts = splitAccountSelector(target); + if (!(parts && isEmailAddress(parts.who))) { + throw new ValidationError( + `Expected an email address, or : with a valid server, to log in as; got '${raw}'.`, + "email", + ); + } + return parts.server + ? { email: parts.who, url: parts.server } + : { email: parts.who }; +} + +/** One `@` with text on both sides, and no whitespace. */ +function isEmailAddress(value: string): boolean { + const at = value.indexOf("@"); + return ( + at > 0 && + at < value.length - 1 && + value.indexOf("@", at + 1) === -1 && + !/\s/.test(value) + ); +} + /** * The `:` selector naming an account, e.g. * `jane@acme.com:sentry.acme.com`: what `--account` and diff --git a/packages/cli/src/lib/introspect.ts b/packages/cli/src/lib/introspect.ts index a64c37216..822b16d1e 100644 --- a/packages/cli/src/lib/introspect.ts +++ b/packages/cli/src/lib/introspect.ts @@ -13,7 +13,9 @@ import generatedCommandApi from "../generated/command-api.json"; import { type CommandExample, exampleSteps } from "./command-example.js"; import { + CHOICE_TARGET, type CompletionTarget, + getCompletionChoices, getCompletionTarget, } from "./completion-target.js"; import type { ApiReference, CommandApiReference } from "./api-schema.js"; @@ -109,6 +111,8 @@ export type FlagDef = { hidden?: boolean; /** Stricli parser; completion-aware parsers carry a {@link CompletionTarget}. */ parse?: unknown; + /** Accepted values of an `enum` flag. */ + values?: readonly string[]; }; // --------------------------------------------------------------------------- @@ -414,14 +418,40 @@ type CommandCompletion = { /** * Every flag spelling that consumes the next word (`--name`, `-a`), mapped * to the kind of its value. Hidden flags count because the parser still - * accepts them. + * accepts them. `enum` flags complete their values as `choice`. */ flags: Record; + /** + * Values of each `choice` argument, keyed by positional index (`"0"`) or + * flag spelling (`"--format"`). Omitted when the command has none. + */ + choices?: Record; }; /** {@link CommandCompletion} per command path (without the `sentry` prefix). */ export type CommandCompletions = Record; +/** + * The kind and, for `choice`, the values of one argument: those of an + * `enum` flag, or of a `choiceParser` parser. + */ +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 }; +} + +/** Sort an object's entries by key, for stable generated output. */ +function sortedByKey(record: Record): Record { + return Object.fromEntries( + Object.entries(record).sort(([a], [b]) => a.localeCompare(b)), + ); +} + /** Read the completion metadata of one command's parameters. */ function getCommandCompletion(command: Command): CommandCompletion { const { positional, flags = {}, aliases = {} } = command.parameters; @@ -430,10 +460,25 @@ function getCommandCompletion(command: Command): CommandCompletion { ? positional.parameters : [positional.parameter] : []; + const choices: Record = {}; + const positionals = params.map((param, index) => { + const { kind, values } = argumentCompletion(param.parse); + if (values) { + choices[index] = values; + } + return kind; + }); const flagKinds: Record = {}; for (const [name, def] of Object.entries(flags)) { if (def.kind !== "boolean") { - flagKinds[`--${name}`] = getCompletionTarget(def.parse) ?? null; + const { kind, values } = argumentCompletion( + def.parse, + def.kind === "enum" ? def.values : undefined, + ); + flagKinds[`--${name}`] = kind; + if (values) { + choices[`--${name}`] = values; + } } } for (const [alias, name] of Object.entries(aliases)) { @@ -441,15 +486,18 @@ function getCommandCompletion(command: Command): CommandCompletion { if (kind !== undefined) { flagKinds[`-${alias}`] = kind; } + const values = choices[`--${name}`]; + if (values) { + choices[`-${alias}`] = values; + } } return { - positionals: params.map( - (param) => getCompletionTarget(param.parse) ?? null, - ), + positionals, variadic: positional?.kind === "array", - flags: Object.fromEntries( - Object.entries(flagKinds).sort(([a], [b]) => a.localeCompare(b)), - ), + flags: sortedByKey(flagKinds), + ...(Object.keys(choices).length > 0 + ? { choices: sortedByKey(choices) } + : {}), }; } @@ -488,9 +536,36 @@ export function extractCommandCompletions( } } } - return Object.fromEntries( - Object.entries(result).sort(([a], [b]) => a.localeCompare(b)), - ); + return sortedByKey(result); +} + +/** + * Visible subcommand names and briefs of every route group, by group path + * (without the `sentry` prefix; `""` is the top level), so `__complete` + * can complete nested subcommands and command paths. Sorted for stable + * generated output. + * + * @param routeMap - Top-level Stricli route map + * @param prefix - Route segments above `routeMap` (used for recursion) + */ +export function extractRouteGroups( + routeMap: RouteMap, + prefix: readonly string[] = [], +): Record> { + const subcommands: Record = {}; + const result: Record> = {}; + for (const entry of routeMap.getAllEntries()) { + if (entry.hidden) { + continue; + } + subcommands[entry.name.original] = entry.target.brief; + if (isRouteMap(entry.target)) { + const segments = [...prefix, entry.name.original]; + Object.assign(result, extractRouteGroups(entry.target, segments)); + } + } + result[prefix.join(" ")] = sortedByKey(subcommands); + return sortedByKey(result); } /** Maximum number of fuzzy suggestions to include in an UnresolvedPath. */ diff --git a/packages/cli/test/commands/auth/login.test.ts b/packages/cli/test/commands/auth/login.test.ts index b0da37917..6a63a465b 100644 --- a/packages/cli/test/commands/auth/login.test.ts +++ b/packages/cli/test/commands/auth/login.test.ts @@ -83,8 +83,10 @@ vi.mock("../../../src/lib/logger.js", () => ({ })); // Dynamic import: must run AFTER vi.mock() so login.ts picks up fakeLog. -const { loginCommand, parseLoginEmail, rcTokenHint, splitLoginTarget } = +const { loginCommand, rcTokenHint } = await import("../../../src/commands/auth/login.js"); +const { parseLoginEmail, splitLoginTarget } = + await import("../../../src/lib/db/accounts.js"); vi.mock("../../../src/lib/account-selection.js", async (importOriginal) => { const actual = diff --git a/packages/cli/test/lib/complete.test.ts b/packages/cli/test/lib/complete.test.ts index f0fcd4237..c5d1bc84f 100644 --- a/packages/cli/test/lib/complete.test.ts +++ b/packages/cli/test/lib/complete.test.ts @@ -6,7 +6,16 @@ * cache interaction tests. */ -import { describe, expect, test } from "vitest"; +import { + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { completeAliases, completeOrgSlashProject, @@ -15,9 +24,11 @@ import { completeProjectSlugs, getCompletions, } from "../../src/lib/complete.js"; +import { setAuthToken } from "../../src/lib/db/auth.js"; import { cacheCompletionEntities } from "../../src/lib/db/completion-cache.js"; import { setProjectAliases } from "../../src/lib/db/project-aliases.js"; import { setCachedProject } from "../../src/lib/db/project-cache.js"; +import { setUserInfo } from "../../src/lib/db/user.js"; import { type OrgRegionEntry, setOrgRegions, @@ -724,3 +735,116 @@ describe("getCompletions: dashboards", () => { } }); }); + +/** Values of the completions for `words` and `partial`. */ +async function values(words: string[], partial: string): Promise { + return (await getCompletions(words, partial)).map((c) => c.value); +} + +describe("getCompletions: paths", () => { + // A file, a dotfile, a directory, and a link to it + let dir: string; + beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), "complete-paths-")); + writeFileSync(join(dir, "app.wasm"), ""); + writeFileSync(join(dir, ".env"), ""); + mkdirSync(join(dir, "dist")); + symlinkSync(join(dir, "dist"), join(dir, "out")); + }); + afterAll(() => rmSync(dir, { recursive: true, force: true })); + + test("file positionals list files and directories, directories with a slash", async () => { + expect(await values(["wasm-split"], `${dir}/`)).toEqual([ + `${dir}/app.wasm`, + `${dir}/dist/`, + `${dir}/out/`, + ]); + expect(await values(["wasm-split"], `${dir}/a`)).toEqual([ + `${dir}/app.wasm`, + ]); + }); + + test("dotfiles are listed only for a partial starting with a dot", async () => { + expect(await values(["wasm-split"], `${dir}/.`)).toEqual([`${dir}/.env`]); + }); + + test("directory positionals list directories only", async () => { + expect(await values(["sourcemap", "upload"], `${dir}/`)).toEqual([ + `${dir}/dist/`, + `${dir}/out/`, + ]); + }); + + test("a partial in a missing directory completes nothing", async () => { + expect(await values(["wasm-split"], `${dir}/missing/a`)).toEqual([]); + }); +}); + +describe("getCompletions: fixed values", () => { + test("choice positionals complete their values by prefix", async () => { + expect(await values(["cli", "completion"], "")).toEqual([ + "bash", + "zsh", + "fish", + ]); + expect(await values(["cli", "completion"], "z")).toEqual(["zsh"]); + expect(await values(["cli", "upgrade"], "n")).toEqual(["nightly"]); + }); + + test("a variadic choice positional completes its first word only", async () => { + expect(await values(["cli", "defaults"], "tel")).toEqual(["telemetry"]); + expect(await values(["cli", "defaults", "telemetry"], "")).toEqual([]); + }); + + test("enum flags complete their values", async () => { + expect(await values(["event", "send", "--level"], "w")).toEqual([ + "warning", + ]); + }); + + test("text positionals complete nothing", async () => { + expect(await getCompletions(["cli", "feedback"], "")).toEqual([]); + }); +}); + +describe("getCompletions: command paths", () => { + test("help completes route names one segment per word", async () => { + expect(await getCompletions(["help"], "aler")).toEqual([ + { value: "alert", description: expect.any(String) }, + ]); + expect(await values(["help", "alert"], "")).toEqual(["issues", "metrics"]); + expect(await values(["help", "alert", "issues"], "l")).toEqual(["list"]); + expect(await values(["help", "alert", "issues", "list"], "")).toEqual([]); + }); + + test("words naming a nested route group complete its subcommands", async () => { + expect(await values(["alert", "issues"], "")).toContain("list"); + expect(await values(["dashboard", "widget"], "e")).toEqual(["edit"]); + // The shell scripts complete the first two levels themselves. + expect(await values(["alert"], "")).toEqual([]); + }); +}); + +describe("getCompletions: accounts", () => { + function addAccount(host: string, email: string, userId: string): void { + setAuthToken(`token-${userId}-${host}`, undefined, undefined, { + host, + newAccount: true, + }); + setUserInfo({ userId, email }); + } + + test("completes stored accounts, qualified by server when ambiguous", async () => { + addAccount("https://sentry.io", "jane@acme.com", "1"); + addAccount("https://sentry.io", "bob@other.dev", "2"); + addAccount("https://sentry.example.com", "bob@other.dev", "3"); + + expect(await getCompletions(["auth", "switch"], "jane")).toEqual([ + { value: "jane@acme.com", description: "sentry.io" }, + ]); + expect((await values(["auth", "logout"], "bob")).sort()).toEqual([ + "bob@other.dev:sentry.example.com", + "bob@other.dev:sentry.io", + ]); + }); +}); diff --git a/packages/cli/test/lib/completion-coverage.test.ts b/packages/cli/test/lib/completion-coverage.test.ts new file mode 100644 index 000000000..73f9fcb1d --- /dev/null +++ b/packages/cli/test/lib/completion-coverage.test.ts @@ -0,0 +1,63 @@ +/** + * Every positional 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 + * `src/lib/completion-target.ts` (`parseFilePath`, `parseText`, + * `choiceParser(…)`, …); see "Shell completion" in `packages/cli/AGENTS.md`. + */ + +import { describe, expect, test } from "vitest"; +import { routes } from "../../src/app.js"; +import { + extractCommandCompletions, + isCommand, + isRouteMap, + type RouteMap, +} from "../../src/lib/introspect.js"; + +/** Every visible positional: its command path, index, and placeholder. */ +function visiblePositionals( + routeMap: RouteMap, + prefix: readonly string[] = [], +): { path: string; index: number; placeholder: string }[] { + return routeMap.getAllEntries().flatMap((entry) => { + if (entry.hidden) { + return []; + } + const segments = [...prefix, entry.name.original]; + if (isRouteMap(entry.target)) { + return visiblePositionals(entry.target, segments); + } + if (!isCommand(entry.target)) { + return []; + } + const positional = entry.target.parameters.positional; + const params = positional + ? positional.kind === "tuple" + ? positional.parameters + : [positional.parameter] + : []; + return params.map((param, index) => ({ + path: segments.join(" "), + index, + placeholder: param.placeholder ?? `arg${index}`, + })); + }); +} + +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}>`); + + expect(positionals.length).toBeGreaterThan(0); + expect(missing).toEqual([]); + }); +}); diff --git a/packages/cli/test/lib/completions.property.test.ts b/packages/cli/test/lib/completions.property.test.ts index 1f3838772..fbb5a7ec6 100644 --- a/packages/cli/test/lib/completions.property.test.ts +++ b/packages/cli/test/lib/completions.property.test.ts @@ -18,7 +18,10 @@ import { constantFrom, assert as fcAssert, property } from "fast-check"; import { describe, expect, test } from "vitest"; import { app, routes } from "../../src/app.js"; import generatedTargets from "../../src/generated/completion-targets.json"; -import { COMPLETION_VALUES } from "../../src/lib/completion-target.js"; +import { + COMPLETION_VALUES, + PROJECT_SPEC_TARGET, +} from "../../src/lib/completion-target.js"; import { parsePeriod } from "../../src/lib/time-range.js"; import { extractCommandTree, @@ -28,6 +31,7 @@ import { } from "../../src/lib/completions.js"; import { extractCommandCompletions, + extractRouteGroups, type RouteMap, } from "../../src/lib/introspect.js"; import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; @@ -402,11 +406,16 @@ describe("complete.ts: generated completion targets", () => { // Use the ESM import (routes) instead of require() which fails under // vitest because Node's CJS require can't resolve .js→.ts for transitive imports. const commands = extractCommandCompletions(routes as unknown as RouteMap); + const groups = extractRouteGroups(routes as unknown as RouteMap); // A mismatch means src/generated/completion-targets.json is stale. // Regenerate with: pnpm run generate:completions test("generated completion targets match the route tree", () => { - expect(generatedTargets).toEqual({ commands, values: COMPLETION_VALUES }); + expect(generatedTargets).toEqual({ + commands, + groups, + values: COMPLETION_VALUES, + }); }); test("every suggested period is one --period accepts", () => { @@ -419,9 +428,9 @@ describe("complete.ts: generated completion targets", () => { } }); - test("project create keeps its dedicated name:platform completer", () => { - expect(Object.keys(generatedTargets.commands)).not.toContain( - "project create", - ); + test("project create completes name:platform specs", () => { + expect(generatedTargets.commands["project create"]?.positionals).toEqual([ + PROJECT_SPEC_TARGET, + ]); }); }); diff --git a/packages/cli/test/lib/introspect.test.ts b/packages/cli/test/lib/introspect.test.ts index 44bac3c07..9d9c416d6 100644 --- a/packages/cli/test/lib/introspect.test.ts +++ b/packages/cli/test/lib/introspect.test.ts @@ -7,12 +7,16 @@ import { describe, expect, test } from "vitest"; import { + CHOICE_TARGET, type CompletionTarget, + choiceParser, DASHBOARD_TARGET, + FILE_TARGET, ISSUE_QUERY_TARGET, ISSUE_TARGET, ORG_PROJECT_TARGET, ORG_TARGET, + parseFilePath, withCompletionTarget, } from "../../src/lib/completion-target.js"; import type { @@ -28,6 +32,7 @@ import { extractFlags, extractPositionals, extractRouteGroupCommands, + extractRouteGroups, getPositionalString, isCommand, isRouteMap, @@ -539,6 +544,75 @@ describe("extractCommandCompletions", () => { expect(extractCommandCompletions(topLevel)).toEqual({}); }); + + test("lists the values of choice positionals and enum flags", () => { + const shell = makeCommand({ + parameters: { + positional: { + kind: "tuple", + parameters: [ + { placeholder: "shell", parse: choiceParser(["bash", "zsh"]) }, + { placeholder: "out", parse: parseFilePath }, + ], + }, + flags: { + format: { kind: "enum", values: ["json", "text"] }, + mode: { kind: "parsed", parse: choiceParser(["fast"]) }, + }, + aliases: { f: "format" }, + }, + }); + + expect( + extractCommandCompletions(makeRouteMap([makeEntry("shell", shell)])), + ).toEqual({ + shell: { + positionals: [CHOICE_TARGET, FILE_TARGET], + variadic: false, + flags: { + "--format": CHOICE_TARGET, + "--mode": CHOICE_TARGET, + "-f": CHOICE_TARGET, + }, + choices: { + "--format": ["json", "text"], + "--mode": ["fast"], + "-f": ["json", "text"], + "0": ["bash", "zsh"], + }, + }, + }); + }); +}); + +describe("extractRouteGroups", () => { + test("lists the visible subcommands of every group, nested ones included", () => { + const topLevel = makeRouteMap([ + makeEntry( + "alert", + makeRouteMap( + [ + makeEntry( + "issues", + makeRouteMap([ + makeEntry("list", makeCommand({ brief: "List rules" })), + makeEntry("secret", makeCommand(), true), + ]), + ), + ], + { brief: "Manage alerts" }, + ), + ), + makeEntry("api", makeCommand({ brief: "Call the API" })), + makeEntry("internal", makeRouteMap([]), true), + ]); + + expect(extractRouteGroups(topLevel)).toEqual({ + "": { alert: "Manage alerts", api: "Call the API" }, + alert: { issues: "Test route" }, + "alert issues": { list: "List rules" }, + }); + }); }); // --------------------------------------------------------------------------- From 37dba0da3b0c7ecf134e967c01ecd743a79c258e Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:30:33 +0000 Subject: [PATCH 3/6] fix(cli): Keep init directory completions path-like init reads a word as its directory only when it looks like a path (`./`, `../`, `/`, `~/`); anything else is the target. Bare `name/` suggestions from the directory completer were therefore parsed as an org target. init now completes org/project targets, and directories only once the word looks like a path, keeping that prefix. Co-Authored-By: Claude --- packages/cli/AGENTS.md | 1 + packages/cli/src/commands/init.ts | 4 ++-- packages/cli/src/lib/complete.ts | 10 ++++++++++ packages/cli/src/lib/completion-target.ts | 8 ++++++++ packages/cli/test/lib/complete.test.ts | 20 ++++++++++++++++++++ 5 files changed, 41 insertions(+), 2 deletions(-) diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 3b2852946..1f039306c 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -392,6 +392,7 @@ positional without one. Pick the parser by what the argument takes: | 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 `/` | | 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`) | | CLI command path | `parseCommandPath` | route names, one segment per word (`help`) | diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index e04648595..d01e450eb 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -26,7 +26,7 @@ import { parseOrgProjectInput, } from "../lib/arg-parsing.js"; import { buildCommand } from "../lib/command.js"; -import { DIRECTORY_TARGET } from "../lib/completion-target.js"; +import { ORG_PROJECT_OR_DIRECTORY_TARGET } 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"; @@ -401,7 +401,7 @@ export const initCommand = buildCommand< parameters: { positional: boundArguments({ placeholder: "target/directory", - kind: DIRECTORY_TARGET, + kind: ORG_PROJECT_OR_DIRECTORY_TARGET, brief: "Optional target (/, /, or ) and directory (default: current directory)", parse: parseInitInput, diff --git a/packages/cli/src/lib/complete.ts b/packages/cli/src/lib/complete.ts index 0d0377d7a..f7a564fd2 100644 --- a/packages/cli/src/lib/complete.ts +++ b/packages/cli/src/lib/complete.ts @@ -233,6 +233,16 @@ const COMPLETERS: Record = { account: (partial) => completeAccounts(partial), file: (partial) => completePaths(partial, { directoriesOnly: false }), directory: (partial) => completePaths(partial, { directoriesOnly: true }), + "org-project-or-directory": async (partial) => { + const { looksLikePath } = await import("./arg-parsing.js"); + if (!looksLikePath(partial)) { + return completeOrgSlashProject(partial); + } + // `.` and `~` alone name a directory; list inside it so every + // suggestion still looks like a path + const path = partial === "." || partial === "~" ? `${partial}/` : partial; + return completePaths(path, { directoriesOnly: true }); + }, "command-path": (partial, slot) => completeSubcommands(slot.positionals.join(" "), partial), choice: (partial, slot) => diff --git a/packages/cli/src/lib/completion-target.ts b/packages/cli/src/lib/completion-target.ts index 516add262..5a9bc40f2 100644 --- a/packages/cli/src/lib/completion-target.ts +++ b/packages/cli/src/lib/completion-target.ts @@ -50,6 +50,13 @@ export const FILE_TARGET = "file"; /** Completes directory paths only. */ export const DIRECTORY_TARGET = "directory"; +/** + * Completes what {@link ORG_PROJECT_TARGET} does, or directories once the + * word looks like a path (`./`, `../`, `/`, `~/`), as `sentry init` reads + * its `[] []` arguments. + */ +export const ORG_PROJECT_OR_DIRECTORY_TARGET = "org-project-or-directory"; + /** Completes a CLI command path, one route segment per word (`help issue list`). */ export const COMMAND_PATH_TARGET = "command-path"; @@ -74,6 +81,7 @@ export const COMPLETION_TARGETS = [ ACCOUNT_TARGET, FILE_TARGET, DIRECTORY_TARGET, + ORG_PROJECT_OR_DIRECTORY_TARGET, COMMAND_PATH_TARGET, CHOICE_TARGET, TEXT_TARGET, diff --git a/packages/cli/test/lib/complete.test.ts b/packages/cli/test/lib/complete.test.ts index c5d1bc84f..4b4927c51 100644 --- a/packages/cli/test/lib/complete.test.ts +++ b/packages/cli/test/lib/complete.test.ts @@ -775,6 +775,26 @@ describe("getCompletions: paths", () => { ]); }); + test("init completes targets, or directories once the word looks like a path", async () => { + await seedOrgs([{ slug: "acme", name: "Acme Inc" }]); + expect(await values(["init"], "ac")).toEqual(["acme/"]); + expect(await values(["init"], `${dir}/`)).toEqual([ + `${dir}/dist/`, + `${dir}/out/`, + ]); + // Bare names would be read as a target, so a path keeps its prefix + const cwd = process.cwd(); + process.chdir(dir); + try { + expect(await values(["init", "acme/"], ".")).toEqual([ + "./dist/", + "./out/", + ]); + } finally { + process.chdir(cwd); + } + }); + test("a partial in a missing directory completes nothing", async () => { expect(await values(["wasm-split"], `${dir}/missing/a`)).toEqual([]); }); From 1f003060c895f2eb8ed6cc0d4ebeab13cc469c88 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:37:11 +0000 Subject: [PATCH 4/6] fix(cli): Make init completion aware of prior arguments init accepts at most one target and one directory, in either order. The completer now offers only a directory after a target, only a target after a directory, and nothing once both are present, so Tab never completes into a command init rejects. --- packages/cli/src/lib/complete.ts | 24 +++++++++++++++++++----- packages/cli/test/lib/complete.test.ts | 6 ++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/lib/complete.ts b/packages/cli/src/lib/complete.ts index f7a564fd2..17deda067 100644 --- a/packages/cli/src/lib/complete.ts +++ b/packages/cli/src/lib/complete.ts @@ -233,14 +233,28 @@ const COMPLETERS: Record = { account: (partial) => completeAccounts(partial), file: (partial) => completePaths(partial, { directoriesOnly: false }), directory: (partial) => completePaths(partial, { directoriesOnly: true }), - "org-project-or-directory": async (partial) => { + "org-project-or-directory": async (partial, slot) => { + // At most one target and one directory, in either order const { looksLikePath } = await import("./arg-parsing.js"); - if (!looksLikePath(partial)) { + const [previous, ...rest] = slot.positionals; + if (rest.length > 0) { + return []; + } + const afterTarget = previous !== undefined && !looksLikePath(previous); + if (!(afterTarget || looksLikePath(partial))) { return completeOrgSlashProject(partial); } - // `.` and `~` alone name a directory; list inside it so every - // suggestion still looks like a path - const path = partial === "." || partial === "~" ? `${partial}/` : partial; + if (previous !== undefined && !afterTarget) { + return []; + } + // Every suggestion must still look like a path: `.` and `~` alone + // list inside themselves, and a bare word after a target becomes `./…` + const path = + partial === "." || partial === "~" + ? `${partial}/` + : looksLikePath(partial) + ? partial + : `./${partial}`; return completePaths(path, { directoriesOnly: true }); }, "command-path": (partial, slot) => diff --git a/packages/cli/test/lib/complete.test.ts b/packages/cli/test/lib/complete.test.ts index 4b4927c51..acb2036fc 100644 --- a/packages/cli/test/lib/complete.test.ts +++ b/packages/cli/test/lib/complete.test.ts @@ -790,6 +790,12 @@ describe("getCompletions: paths", () => { "./dist/", "./out/", ]); + // After a target, only a directory can follow + expect(await values(["init", "acme/"], "d")).toEqual(["./dist/"]); + // After a directory, only a target can follow; after both, nothing + expect(await values(["init", "./dist"], "ac")).toEqual(["acme/"]); + expect(await values(["init", "./dist"], "./")).toEqual([]); + expect(await values(["init", "acme/", "./dist"], "")).toEqual([]); } finally { process.chdir(cwd); } From f2ee33a259427378bf0aadbf3099ea0bc1e4356a Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:39:54 +0000 Subject: [PATCH 5/6] ref(cli): Derive completion kinds from the completer map The kinds were a list of string constants plus an array that existed only to derive the CompletionTarget type, kept in step with COMPLETERS by hand. COMPLETERS is now the one definition: the type is its keys, so a parser tagged with a kind that has no completer is a type error, and commands name kinds as checked string literals ("org/project"). The issue link/unlink URL positional parses with a named parseUrl. It returns its input like before; link and unlink keep validating the URL with their own errors. The generated completion-targets.json is byte-identical. --- packages/cli/AGENTS.md | 15 +-- packages/cli/script/check-doc-examples.ts | 5 +- .../src/commands/agent-conversation/view.ts | 3 +- packages/cli/src/commands/alert/arguments.ts | 6 +- .../src/commands/alert/issues/rule-resolve.ts | 3 +- .../cli/src/commands/alert/metrics/list.ts | 3 +- .../commands/alert/metrics/rule-resolve.ts | 3 +- .../cli/src/commands/dashboard/arguments.ts | 8 +- packages/cli/src/commands/dashboard/create.ts | 3 +- .../cli/src/commands/dashboard/widget/add.ts | 3 +- packages/cli/src/commands/event/view.ts | 3 +- .../cli/src/commands/feedback/positional.ts | 3 +- packages/cli/src/commands/init.ts | 3 +- .../cli/src/commands/integration/delete.ts | 3 +- packages/cli/src/commands/issue/link-utils.ts | 4 +- packages/cli/src/commands/issue/positional.ts | 5 +- packages/cli/src/commands/log/list.ts | 3 +- packages/cli/src/commands/log/view.ts | 3 +- packages/cli/src/commands/project/create.ts | 3 +- packages/cli/src/commands/release/parse.ts | 3 +- packages/cli/src/commands/replay/view.ts | 3 +- packages/cli/src/commands/span/list.ts | 3 +- packages/cli/src/commands/span/view.ts | 3 +- packages/cli/src/commands/trace/logs.ts | 3 +- packages/cli/src/commands/trace/view.ts | 3 +- packages/cli/src/commands/trial/start.ts | 3 +- packages/cli/src/lib/complete.ts | 56 +++++++--- packages/cli/src/lib/completion-target.ts | 105 ++++-------------- packages/cli/src/lib/db/completion-cache.ts | 6 +- packages/cli/src/lib/introspect.ts | 3 +- packages/cli/src/lib/list-command.ts | 6 +- packages/cli/src/lib/target-positional.ts | 9 +- .../cli/test/lib/completions.property.test.ts | 7 +- packages/cli/test/lib/introspect.test.ts | 39 +++---- .../cli/test/lib/positional-parse.test.ts | 18 ++- 35 files changed, 133 insertions(+), 219 deletions(-) diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 1f039306c..54b03e1b6 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -382,20 +382,21 @@ 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 -from `src/lib/completion-target.ts`; `test/lib/completion-coverage.test.ts` +**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: | 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 | +| Org, project, issue, dashboard target | the bound parsers above (`boundPositional(kind, …)`, `boundArguments({ kind, … })`, `listTargetPositional`, `issuePositional`, `releaseParameter`) | `"org"` org slugs; `"org/project"` `org/`, `org/project`, aliases; `"issue"` plus cached issue short IDs; `"dashboard"` cached dashboards | | File or directory path | `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 | +| Target or directory (`init`) | `boundArguments({ kind: "org-project-or-directory", … })` | `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`) | | 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 | Import the pre-tagged parsers; never tag `String` or write @@ -417,9 +418,9 @@ 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 -kind that is not an entity, add it to `COMPLETION_TARGETS`, a pre-tagged -parser, and its entry in `COMPLETERS` (`src/lib/complete.ts`; the record -type makes a missing completer a type error). +kind that is not an entity, add its entry to `COMPLETERS` +(`src/lib/complete.ts`), whose keys are the `CompletionTarget` type, and a +pre-tagged parser. `pnpm run generate:completions` collects every command's kinds and choice values, and every route group's subcommands, into the gitignored `src/generated/completion-targets.json` that the `__complete` fast path diff --git a/packages/cli/script/check-doc-examples.ts b/packages/cli/script/check-doc-examples.ts index ad6689cbd..b31d90fc7 100644 --- a/packages/cli/script/check-doc-examples.ts +++ b/packages/cli/script/check-doc-examples.ts @@ -62,8 +62,7 @@ if (!skillContentExists) { const { routes } = await import("../src/app.js"); const { isCommand, isRouteMap } = await import("../src/lib/introspect.js"); -const { getCompletionTarget, ORG_PROJECT_TARGET } = - await import("../src/lib/completion-target.js"); +const { getCompletionTarget } = await import("../src/lib/completion-target.js"); /** Generated from the route tree, so not worth re-checking. */ const GENERATED_DOCS_DIR = `${DOCS_CONTENT}/commands`; @@ -195,7 +194,7 @@ function isTargetParam(param: PositionalParam | undefined): boolean { return false; } return ( - getCompletionTarget(param.parse) === ORG_PROJECT_TARGET || + getCompletionTarget(param.parse) === "org/project" || TARGET_PLACEHOLDER_RE.test(param.placeholder ?? "") ); } diff --git a/packages/cli/src/commands/agent-conversation/view.ts b/packages/cli/src/commands/agent-conversation/view.ts index a0af5f939..b122f0b4a 100644 --- a/packages/cli/src/commands/agent-conversation/view.ts +++ b/packages/cli/src/commands/agent-conversation/view.ts @@ -7,7 +7,6 @@ import type { SentryContext } from "../../context.js"; import { getConversationSpans } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; -import { ORG_TARGET } from "../../lib/completion-target.js"; import { buildTranscriptResult, formatTranscriptResult, @@ -134,7 +133,7 @@ export const viewCommand = buildCommand({ positional: boundArguments({ placeholder: "org/conversation-id", brief: "Organization slug (optional) and conversation ID", - kind: ORG_TARGET, + kind: "org", parse: parseConversationArguments, resolve: async ( input, diff --git a/packages/cli/src/commands/alert/arguments.ts b/packages/cli/src/commands/alert/arguments.ts index 927d76ca6..ce3016f08 100644 --- a/packages/cli/src/commands/alert/arguments.ts +++ b/packages/cli/src/commands/alert/arguments.ts @@ -15,10 +15,6 @@ import { type ParsedOrgProject, parseOrgProjectInput, } from "../../lib/arg-parsing.js"; -import type { - ORG_PROJECT_TARGET, - ORG_TARGET, -} from "../../lib/completion-target.js"; import { type BoundPositional, boundPositional, @@ -56,7 +52,7 @@ type RuleArgSplitter = ( export function alertRuleParameter(options: { readonly placeholder: string; readonly brief: string; - readonly kind: typeof ORG_TARGET | typeof ORG_PROJECT_TARGET; + readonly kind: "org" | "org/project"; readonly split: RuleArgSplitter; readonly usageHint: string; }) { diff --git a/packages/cli/src/commands/alert/issues/rule-resolve.ts b/packages/cli/src/commands/alert/issues/rule-resolve.ts index e7da9789b..1e371fd20 100644 --- a/packages/cli/src/commands/alert/issues/rule-resolve.ts +++ b/packages/cli/src/commands/alert/issues/rule-resolve.ts @@ -10,7 +10,6 @@ import { isNotFoundApiError, listIssueAlertsPaginated, } from "../../../lib/api-client.js"; -import { ORG_PROJECT_TARGET } from "../../../lib/completion-target.js"; import { ResolutionError, ValidationError } from "../../../lib/errors.js"; import { fuzzyMatch } from "../../../lib/fuzzy.js"; import { logger } from "../../../lib/logger.js"; @@ -77,7 +76,7 @@ export function issueRuleParameter(usageHint: string, brief: string) { return alertRuleParameter({ placeholder: "org/project/rule-id-or-name", brief, - kind: ORG_PROJECT_TARGET, + kind: "org/project", split: parseIssueRuleArg, usageHint, }); diff --git a/packages/cli/src/commands/alert/metrics/list.ts b/packages/cli/src/commands/alert/metrics/list.ts index c0aa78df9..a8c19b006 100644 --- a/packages/cli/src/commands/alert/metrics/list.ts +++ b/packages/cli/src/commands/alert/metrics/list.ts @@ -16,7 +16,6 @@ import { paginate } from "../../../lib/api/infrastructure.js"; import { listMetricAlertsPaginated } from "../../../lib/api-client.js"; import type { ParsedOrgProject } from "../../../lib/arg-parsing.js"; import { openInBrowser } from "../../../lib/browser.js"; -import { ORG_PROJECT_TARGET } from "../../../lib/completion-target.js"; import { advancePaginationState, buildMultiOrgContextKey, @@ -87,7 +86,7 @@ const METRIC_ALERT_TARGET_POSITIONAL = { { placeholder: "target", brief: "/, / (project ignored), or (search)", - parse: listTarget(ORG_PROJECT_TARGET), + parse: listTarget("org/project"), optional: true as const, }, ], diff --git a/packages/cli/src/commands/alert/metrics/rule-resolve.ts b/packages/cli/src/commands/alert/metrics/rule-resolve.ts index 60714ff62..fbf124bcb 100644 --- a/packages/cli/src/commands/alert/metrics/rule-resolve.ts +++ b/packages/cli/src/commands/alert/metrics/rule-resolve.ts @@ -10,7 +10,6 @@ import { listMetricAlertsPaginated, type MetricAlertRule, } from "../../../lib/api-client.js"; -import { ORG_TARGET } from "../../../lib/completion-target.js"; import { ResolutionError, ValidationError } from "../../../lib/errors.js"; import { fuzzyMatch } from "../../../lib/fuzzy.js"; import { logger } from "../../../lib/logger.js"; @@ -75,7 +74,7 @@ export function metricRuleParameter(usageHint: string, brief: string) { return alertRuleParameter({ placeholder: "org/rule-id-or-name", brief, - kind: ORG_TARGET, + kind: "org", split: parseMetricRuleArg, usageHint, }); diff --git a/packages/cli/src/commands/dashboard/arguments.ts b/packages/cli/src/commands/dashboard/arguments.ts index 8fa6a302e..76d18ad2f 100644 --- a/packages/cli/src/commands/dashboard/arguments.ts +++ b/packages/cli/src/commands/dashboard/arguments.ts @@ -10,10 +10,6 @@ import { type ParsedOrgProject, parseOrgProjectInput, } from "../../lib/arg-parsing.js"; -import { - DASHBOARD_TARGET, - ORG_PROJECT_TARGET, -} from "../../lib/completion-target.js"; import { parseDashboardArgs, parseDashboardListArgs, @@ -158,7 +154,7 @@ export function dashboardArguments(options: { return boundArguments({ placeholder, brief: "[] ", - kind: DASHBOARD_TARGET, + kind: "dashboard", parse: (args) => parseDashboardInput(args, usageHint), resolve: (input: DashboardInput, context: SentryContext) => resolveDashboardInput(input, context.cwd, usageHint), @@ -203,7 +199,7 @@ export function dashboardListArguments(usageHint: string) { return boundArguments({ placeholder: "org/title-filter", brief: "[] []", - kind: ORG_PROJECT_TARGET, + kind: "org/project", parse: parseDashboardListInput, resolve: (input: DashboardListInput, context: SentryContext) => { applyInputContext(input); diff --git a/packages/cli/src/commands/dashboard/create.ts b/packages/cli/src/commands/dashboard/create.ts index 4b657ccfd..b092ad4b8 100644 --- a/packages/cli/src/commands/dashboard/create.ts +++ b/packages/cli/src/commands/dashboard/create.ts @@ -11,7 +11,6 @@ import { parseOrgProjectInput, } from "../../lib/arg-parsing.js"; import { buildCommand } from "../../lib/command.js"; -import { ORG_PROJECT_TARGET } from "../../lib/completion-target.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { formatDashboardCreated } from "../../lib/formatters/human.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -186,7 +185,7 @@ export const createCommand = buildCommand({ positional: boundArguments({ placeholder: "org/project/title", brief: "[] ", - kind: ORG_PROJECT_TARGET, + kind: "org/project", parse: parseCreateInput, resolve: async (input: CreateInput, context: SentryContext) => { if (input.baseUrl) { diff --git a/packages/cli/src/commands/dashboard/widget/add.ts b/packages/cli/src/commands/dashboard/widget/add.ts index ab1c960f3..7b868ce4b 100644 --- a/packages/cli/src/commands/dashboard/widget/add.ts +++ b/packages/cli/src/commands/dashboard/widget/add.ts @@ -7,7 +7,6 @@ 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 { ValidationError } from "../../../lib/errors.js"; import { formatWidgetAdded } from "../../../lib/formatters/human.js"; import { CommandOutput } from "../../../lib/formatters/output.js"; @@ -157,7 +156,7 @@ export const addCommand = buildCommand({ positional: boundArguments({ placeholder: "org/project/dashboard/title", brief: "[<org/project>] <dashboard> <title>", - kind: DASHBOARD_TARGET, + kind: "dashboard", parse: (args): WidgetAddInput => { const { dashboardArgs, title } = parseAddPositionalArgs(args); return { ...parseDashboardInput(dashboardArgs, USAGE_HINT), title }; diff --git a/packages/cli/src/commands/event/view.ts b/packages/cli/src/commands/event/view.ts index 410fbb616..10512a798 100644 --- a/packages/cli/src/commands/event/view.ts +++ b/packages/cli/src/commands/event/view.ts @@ -26,7 +26,6 @@ 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 { ApiError, AuthError, @@ -1186,7 +1185,7 @@ export const viewCommand = buildCommand({ placeholder: "org/project/event-id", brief: "[<org>/<project>] <event-id> [<event-id>...] - Target (optional) and one or more event IDs", - kind: ORG_PROJECT_TARGET, + kind: "org/project", parse: parseEventViewInput, resolve: resolveEventViewInput, }), diff --git a/packages/cli/src/commands/feedback/positional.ts b/packages/cli/src/commands/feedback/positional.ts index 1ca7460d1..37999b97a 100644 --- a/packages/cli/src/commands/feedback/positional.ts +++ b/packages/cli/src/commands/feedback/positional.ts @@ -3,7 +3,6 @@ * identifier, parsed before the command runs. */ -import { ORG_PROJECT_TARGET } from "../../lib/completion-target.js"; import { ContextError } from "../../lib/errors.js"; import { type IssueArgument, @@ -40,7 +39,7 @@ export function feedbackPositional(command: string) { brief: "Feedback: @latest, numeric ID, short ID, <org>/SHORT-ID, or <org>/<project>/<suffix>", parse: boundPositional( - ORG_PROJECT_TARGET, + "org/project", (raw) => (raw === undefined ? undefined : parseIssueArgument(raw)), async (feedback, context: { readonly cwd: string }) => { if (!feedback) { diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index d01e450eb..400d11c2e 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -26,7 +26,6 @@ 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 { refreshToken } from "../lib/db/auth.js"; import { ContextError, ValidationError } from "../lib/errors.js"; import { warmOrgDetection } from "../lib/init/org-prefetch.js"; @@ -401,7 +400,7 @@ export const initCommand = buildCommand< parameters: { positional: boundArguments({ placeholder: "target/directory", - kind: ORG_PROJECT_OR_DIRECTORY_TARGET, + kind: "org-project-or-directory", brief: "Optional target (<org>/<project>, <org>/, or <project>) and directory (default: current directory)", parse: parseInitInput, diff --git a/packages/cli/src/commands/integration/delete.ts b/packages/cli/src/commands/integration/delete.ts index 975619f97..0ad4275f6 100644 --- a/packages/cli/src/commands/integration/delete.ts +++ b/packages/cli/src/commands/integration/delete.ts @@ -9,7 +9,6 @@ import type { SentryContext } from "../../context.js"; import { deleteIntegration, getIntegration } from "../../lib/api-client.js"; -import { ORG_TARGET } from "../../lib/completion-target.js"; import { ApiError, ValidationError } from "../../lib/errors.js"; import { renderMarkdown, safeCodeSpan } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -181,7 +180,7 @@ export const deleteCommand = buildDeleteCommand({ positional: boundArguments({ placeholder: "org/integration-id", brief: "[<org>/]<integration-id> - Integration to remove", - kind: ORG_TARGET, + kind: "org", parse: parseIntegrationArguments, resolve: async ( input, diff --git a/packages/cli/src/commands/issue/link-utils.ts b/packages/cli/src/commands/issue/link-utils.ts index b61631d70..5f096e2ea 100644 --- a/packages/cli/src/commands/issue/link-utils.ts +++ b/packages/cli/src/commands/issue/link-utils.ts @@ -1,6 +1,6 @@ /** Shared arguments for external issue association commands. */ -import { parseText } from "../../lib/completion-target.js"; +import { parseUrl } from "../../lib/completion-target.js"; import { ValidationError } from "../../lib/errors.js"; import { issueParameter } from "./positional.js"; @@ -12,7 +12,7 @@ export function externalIssuePositionals(command: string) { issueParameter({ command }), { placeholder: "url", - parse: parseText, + parse: parseUrl, brief: "URL of an existing tracker issue or GitHub pull request", }, ], diff --git a/packages/cli/src/commands/issue/positional.ts b/packages/cli/src/commands/issue/positional.ts index 1e123a131..86bd99b4e 100644 --- a/packages/cli/src/commands/issue/positional.ts +++ b/packages/cli/src/commands/issue/positional.ts @@ -8,7 +8,6 @@ * pasted issue URL is applied. See `positional-parse.ts`. */ -import { ISSUE_TARGET } from "../../lib/completion-target.js"; import { ContextError } from "../../lib/errors.js"; import { type IssueArgument, @@ -80,7 +79,7 @@ function issueTarget( ): (this: IssueTargetContext, input?: string) => IssueTarget { const { command, commandBase = "sentry issue" } = issueCommand; return boundPositional( - ISSUE_TARGET, + "issue", parseIssueTargetInput, async (issue, context: IssueTargetContext) => await resolveIssue({ @@ -142,7 +141,7 @@ export function issuesPositional(options: { return boundArguments({ placeholder: "issue", brief, - kind: ISSUE_TARGET, + kind: "issue", parse: (raw): readonly IssueArgument[] => { const issues = parseIssueArguments(raw, { usageHint, optional }); if (!distinct) { diff --git a/packages/cli/src/commands/log/list.ts b/packages/cli/src/commands/log/list.ts index 5a23fe479..73fc5ff0c 100644 --- a/packages/cli/src/commands/log/list.ts +++ b/packages/cli/src/commands/log/list.ts @@ -21,7 +21,6 @@ import { parseOrgProjectInput, validateLimit, } from "../../lib/arg-parsing.js"; -import { ORG_PROJECT_TARGET } from "../../lib/completion-target.js"; import { AuthError, stringifyUnknown, @@ -825,7 +824,7 @@ export const listCommand = buildListCommand( positional: boundArguments({ placeholder: "org/project-or-trace-id", brief: "[<org>/[<project>/]]<trace-id>, <org>/<project>, or <project>", - kind: ORG_PROJECT_TARGET, + kind: "org/project", parse: parseLogListInput, resolve: resolveLogListTarget, }), diff --git a/packages/cli/src/commands/log/view.ts b/packages/cli/src/commands/log/view.ts index f0a41e784..1fab3ef1a 100644 --- a/packages/cli/src/commands/log/view.ts +++ b/packages/cli/src/commands/log/view.ts @@ -18,7 +18,6 @@ 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 { ContextError, ResolutionError, @@ -490,7 +489,7 @@ export const viewCommand = buildCommand({ placeholder: "org/project/log-id", brief: "[<org>/<project>] <log-id> [<log-id>...] - Target (optional) and one or more log IDs", - kind: ORG_PROJECT_TARGET, + kind: "org/project", parse: parseLogViewInput, resolve: resolveLogViewInput, }), diff --git a/packages/cli/src/commands/project/create.ts b/packages/cli/src/commands/project/create.ts index 3bd128f1a..dd3256600 100644 --- a/packages/cli/src/commands/project/create.ts +++ b/packages/cli/src/commands/project/create.ts @@ -28,7 +28,6 @@ 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 { ApiError, CliError, @@ -720,7 +719,7 @@ export const createCommand = buildCommand({ parameters: { positional: boundArguments({ placeholder: "name:platform", - kind: PROJECT_SPEC_TARGET, + kind: "project-spec", brief: "One or more project name and platform pairs", parse: parseProjectSpecs, resolve: (input: ProjectSpecsInput, context: SentryContext) => diff --git a/packages/cli/src/commands/release/parse.ts b/packages/cli/src/commands/release/parse.ts index 5788ad2d9..c30a21fdd 100644 --- a/packages/cli/src/commands/release/parse.ts +++ b/packages/cli/src/commands/release/parse.ts @@ -6,7 +6,6 @@ * slug separators. This module provides version-aware parsing. */ -import { ORG_TARGET } from "../../lib/completion-target.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { type BoundPositional, @@ -113,7 +112,7 @@ export function releaseParameter(usageHint: string, brief: string) { placeholder: "org/version", brief, parse: boundPositional( - ORG_TARGET, + "org", (raw) => parseReleaseInput(raw, usageHint), async ( { version, orgSlug }, diff --git a/packages/cli/src/commands/replay/view.ts b/packages/cli/src/commands/replay/view.ts index 8111fb690..9077a1cec 100644 --- a/packages/cli/src/commands/replay/view.ts +++ b/packages/cli/src/commands/replay/view.ts @@ -18,7 +18,6 @@ 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 { ApiError, ResolutionError } from "../../lib/errors.js"; import { filterFields } from "../../lib/formatters/json.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -127,7 +126,7 @@ export function replayArguments( return boundArguments({ placeholder: "replay-id-or-url", brief: "[<org>/<project>] <replay-id or trace-id> or <replay-url>", - kind: ORG_PROJECT_TARGET, + kind: "org/project", parse: (raw) => parseReplayArguments(raw, usageHint), resolve: async ( input, diff --git a/packages/cli/src/commands/span/list.ts b/packages/cli/src/commands/span/list.ts index 28c83633e..65edb6664 100644 --- a/packages/cli/src/commands/span/list.ts +++ b/packages/cli/src/commands/span/list.ts @@ -19,7 +19,6 @@ import { parseOrgProjectInput, validateLimit, } from "../../lib/arg-parsing.js"; -import { ORG_PROJECT_TARGET } from "../../lib/completion-target.js"; import { advancePaginationState, buildPaginationContextKey, @@ -659,7 +658,7 @@ export const listCommand = buildListCommand("span", { positional: boundArguments({ placeholder: "org/project/trace-id", brief: "[<org>/<project>] or [<org>/<project>/]<trace-id>", - kind: ORG_PROJECT_TARGET, + kind: "org/project", parse: parseSpanListInput, resolve: resolveSpanListTarget, }), diff --git a/packages/cli/src/commands/span/view.ts b/packages/cli/src/commands/span/view.ts index 4b2f29c84..094c29be6 100644 --- a/packages/cli/src/commands/span/view.ts +++ b/packages/cli/src/commands/span/view.ts @@ -13,7 +13,6 @@ import { } from "../../lib/api-client.js"; import { spansFlag } from "../../lib/arg-parsing.js"; import { buildCommand } from "../../lib/command.js"; -import { ORG_PROJECT_TARGET } from "../../lib/completion-target.js"; import { ContextError, ResolutionError, @@ -380,7 +379,7 @@ export const viewCommand = buildCommand({ placeholder: "trace-id/span-id", brief: "[<org>/<project>/]<trace-id> <span-id> [<span-id>...] - Trace ID and one or more span IDs", - kind: ORG_PROJECT_TARGET, + kind: "org/project", parse: parseSpanViewInput, resolve: resolveSpanViewInput, }), diff --git a/packages/cli/src/commands/trace/logs.ts b/packages/cli/src/commands/trace/logs.ts index afe1e97b4..6bf7b418f 100644 --- a/packages/cli/src/commands/trace/logs.ts +++ b/packages/cli/src/commands/trace/logs.ts @@ -13,7 +13,6 @@ 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 { toSearchQueryError } from "../../lib/errors.js"; import { filterFields } from "../../lib/formatters/json.js"; import { formatLogTable } from "../../lib/formatters/log.js"; @@ -198,7 +197,7 @@ export const logsCommand = buildCommand({ placeholder: "org/project/trace-id", brief: "[<org>/[<project>/]]<trace-id> - Optional org/project and required trace ID", - kind: ORG_PROJECT_TARGET, + kind: "org/project", parse: parseTraceLogsInput, resolve: resolveTraceLogsTarget, }), diff --git a/packages/cli/src/commands/trace/view.ts b/packages/cli/src/commands/trace/view.ts index 90f70254d..0d9891cad 100644 --- a/packages/cli/src/commands/trace/view.ts +++ b/packages/cli/src/commands/trace/view.ts @@ -17,7 +17,6 @@ import { import { looksLikeIssueShortId, spansFlag } 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 { ContextError, ResolutionError, @@ -501,7 +500,7 @@ export const viewCommand = buildCommand({ placeholder: "org/project/trace-id", brief: "[<org>/<project>/]<trace-id> - Target (optional) and trace ID (required)", - kind: ORG_PROJECT_TARGET, + kind: "org/project", parse: parseTraceViewInput, resolve: resolveTraceViewInput, }), diff --git a/packages/cli/src/commands/trial/start.ts b/packages/cli/src/commands/trial/start.ts index f336dd8bd..e0cdc1158 100644 --- a/packages/cli/src/commands/trial/start.ts +++ b/packages/cli/src/commands/trial/start.ts @@ -20,7 +20,6 @@ import { import { detectSwappedTrialArgs } from "../../lib/arg-parsing.js"; import { openBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; -import { ORG_TARGET } from "../../lib/completion-target.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { success } from "../../lib/formatters/colors.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -162,7 +161,7 @@ export const startCommand = buildCommand({ positional: boundArguments({ placeholder: "name", brief: `Trial name (${NAMES_LIST}) and organization slug (auto-detected if omitted)`, - kind: ORG_TARGET, + kind: "org", parse: parseTrialStartArguments, resolve: async ( input, diff --git a/packages/cli/src/lib/complete.ts b/packages/cli/src/lib/complete.ts index 17deda067..517f1c65a 100644 --- a/packages/cli/src/lib/complete.ts +++ b/packages/cli/src/lib/complete.ts @@ -19,10 +19,7 @@ import { readdirSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import generatedTargets from "../generated/completion-targets.json"; -import type { - CompletionTarget, - CompletionValues, -} from "./completion-target.js"; +import type { CompletionValues } from "./completion-target.js"; import { getConfiguredSentryUrl } from "./constants.js"; import { type AccountSummary, @@ -131,15 +128,17 @@ const GENERATED = generatedTargets as { values: CompletionValues; }; -/** What the word being completed is, and the positionals typed before it. */ -type CompletionSlot = { - kind: CompletionTarget | null; +/** What a completer reads besides the partial. */ +type CompleterContext = { /** Positionals before the word being completed, flags removed. */ positionals: string[]; /** The values a `choice` argument offers. */ choices?: readonly string[]; }; +/** What the word being completed is, and the positionals typed before it. */ +type CompletionSlot = CompleterContext & { kind: CompletionTarget | null }; + /** * Find the argument the word being completed fills. * @@ -197,7 +196,7 @@ function findSlot(words: string[]): CompletionSlot | undefined { */ type Completer = ( partial: string, - slot: CompletionSlot, + slot: CompleterContext, ) => Completion[] | Promise<Completion[]>; /** @@ -211,16 +210,25 @@ const completeDashboards = entityCompleter( { qualify: true }, ); -/** The completer of every kind a parser can declare. */ -const COMPLETERS: Record<CompletionTarget, Completer> = { +/** + * The completer of every kind a parser can declare. Its keys are the + * kinds: {@link CompletionTarget} is derived from them. + */ +const COMPLETERS = { + /** Organization slugs only (e.g. `org`, `org/version`, `org/id`). */ org: (partial) => completeOrgSlugs(partial), + /** `org/` prefixes, `org/project` pairs, and project aliases. */ "org/project": (partial) => completeOrgSlashProject(partial), + /** What `org/project` does plus cached issue short IDs. */ issue: entityCompleter("issue", completeOrgSlashProject), - // Only the first word: in `<org/project> <id-or-title>`, the org of the - // second word is for the dashboard grammar to decide, and this - // fast path cannot load it + /** + * `org/` prefixes and cached dashboards (`org/<id>`), in the first word + * only: in `<org/project> <id-or-title>`, the org of the second word is + * for the dashboard grammar to decide, and this fast path cannot load it. + */ dashboard: (partial, slot) => slot.positionals.length > 0 ? [] : completeDashboards(partial), + /** The term at the end of an issue search query (`is:`, `level:`). */ "issue-query": async (partial) => { const { completeSearchQuery } = await import("@sentry/toolkit-core/search-query"); @@ -228,13 +236,22 @@ const COMPLETERS: Record<CompletionTarget, Completer> = { (value) => ({ value }), ); }, + /** Relative `--period` values (`24h`, `7d`, a number plus a unit). */ period: (partial) => completePeriods(partial), + /** `project create` `name:platform` specs: `org/` prefixes, then platforms. */ "project-spec": (partial) => completeProjectCreateSpec(partial), + /** Stored accounts (`sentry auth list`) by email, username, or ID. */ account: (partial) => completeAccounts(partial), + /** File and directory paths. */ file: (partial) => completePaths(partial, { directoriesOnly: false }), + /** Directory paths only. */ directory: (partial) => completePaths(partial, { directoriesOnly: true }), + /** + * `sentry init`'s `[<target>] [<directory>]`: what `org/project` does, or + * directories once the word looks like a path (`./`, `../`, `/`, `~/`). + * At most one of each, in either order. + */ "org-project-or-directory": async (partial, slot) => { - // At most one target and one directory, in either order const { looksLikePath } = await import("./arg-parsing.js"); const [previous, ...rest] = slot.positionals; if (rest.length > 0) { @@ -257,14 +274,23 @@ const COMPLETERS: Record<CompletionTarget, Completer> = { : `./${partial}`; return completePaths(path, { directoriesOnly: true }); }, + /** A CLI command path, one route segment per word (`help issue list`). */ "command-path": (partial, slot) => completeSubcommands(slot.positionals.join(" "), partial), + /** The values its parser was made with by `choiceParser`. */ choice: (partial, slot) => (slot.choices ?? []) .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. + */ text: () => [], -}; +} satisfies Record<string, Completer>; + +/** What shell completion suggests for an argument: a key of {@link COMPLETERS}. */ +export type CompletionTarget = keyof typeof COMPLETERS; /** * Subcommands of the route group `group` (`""` for the top level) whose diff --git a/packages/cli/src/lib/completion-target.ts b/packages/cli/src/lib/completion-target.ts index 5a9bc40f2..4c13d830b 100644 --- a/packages/cli/src/lib/completion-target.ts +++ b/packages/cli/src/lib/completion-target.ts @@ -6,7 +6,8 @@ * in {@link PARSER_TARGETS}. `script/generate-completion-targets.ts` * collects the kind of every argument at build time, because the * `__complete` fast path cannot boot Stricli to walk the route tree, and - * `src/lib/complete.ts` maps each kind to its completer. + * `src/lib/complete.ts` maps each kind to its completer; the kinds are the + * keys of that map. * * Commands get the tag from the bound parsers in `target-positional.ts` * and `positional-parse.ts`, which parse the value too, or, for arguments @@ -15,80 +16,14 @@ */ import { ISSUE_IS_VALUES } from "@sentry/toolkit-core/search-query"; +import type { CompletionTarget } from "./complete.js"; import { ISSUE_LEVELS } from "../types/sentry.js"; import { parseLoginEmail } from "./db/accounts.js"; import { sanitizeIssueQuery } from "./search-query.js"; import { parsePeriod, UNIT_SECONDS } from "./time-range.js"; -/** Completes organization slugs only (e.g. `org`, `org/version`, `org/id`). */ -export const ORG_TARGET = "org"; - -/** Completes `org/` prefixes, `org/project` pairs, and project aliases. */ -export const ORG_PROJECT_TARGET = "org/project"; - -/** Completes everything {@link ORG_PROJECT_TARGET} does plus cached issue short IDs. */ -export const ISSUE_TARGET = "issue"; - -/** Completes `org/` prefixes and cached dashboards (`org/<id>`) in the first positional. */ -export const DASHBOARD_TARGET = "dashboard"; - -/** Completes the term at the end of an issue search query (`is:`, `level:`). */ -export const ISSUE_QUERY_TARGET = "issue-query"; - -/** Completes relative `--period` values (`24h`, `7d`, a number plus a unit). */ -export const PERIOD_TARGET = "period"; - -/** Completes `project create` `name:platform` specs: `org/` prefixes, then platforms. */ -export const PROJECT_SPEC_TARGET = "project-spec"; - -/** Completes stored accounts (`sentry auth list`) by email, username, or ID. */ -export const ACCOUNT_TARGET = "account"; - -/** Completes file and directory paths. */ -export const FILE_TARGET = "file"; - -/** Completes directory paths only. */ -export const DIRECTORY_TARGET = "directory"; - -/** - * Completes what {@link ORG_PROJECT_TARGET} does, or directories once the - * word looks like a path (`./`, `../`, `/`, `~/`), as `sentry init` reads - * its `[<target>] [<directory>]` arguments. - */ -export const ORG_PROJECT_OR_DIRECTORY_TARGET = "org-project-or-directory"; - -/** Completes a CLI command path, one route segment per word (`help issue list`). */ -export const COMMAND_PATH_TARGET = "command-path"; - -/** Completes the values its parser was made with by {@link choiceParser}. */ -export const CHOICE_TARGET = "choice"; - -/** - * Free text with nothing to suggest (messages, queries, names of new - * things). Declared, so that every positional says how it completes. - */ -export const TEXT_TARGET = "text"; - -/** Every completion target kind, in a stable order. */ -export const COMPLETION_TARGETS = [ - ORG_TARGET, - ORG_PROJECT_TARGET, - ISSUE_TARGET, - DASHBOARD_TARGET, - ISSUE_QUERY_TARGET, - PERIOD_TARGET, - PROJECT_SPEC_TARGET, - ACCOUNT_TARGET, - FILE_TARGET, - DIRECTORY_TARGET, - ORG_PROJECT_OR_DIRECTORY_TARGET, - COMMAND_PATH_TARGET, - CHOICE_TARGET, - TEXT_TARGET, -] as const; - -/** What shell completion suggests for an argument. */ -export type CompletionTarget = (typeof COMPLETION_TARGETS)[number]; +/** What shell completion suggests for an argument: a key of the completer map. */ +export type { CompletionTarget }; const COMPLETION_TARGET_KEY = Symbol("completionTarget"); @@ -113,19 +48,25 @@ function identityParser(kind: CompletionTarget): (input: string) => string { } /** Parses a file or directory path; completes paths. */ -export const parseFilePath = identityParser(FILE_TARGET); +export const parseFilePath = identityParser("file"); /** Parses a directory path; completes directories. */ -export const parseDirectoryPath = identityParser(DIRECTORY_TARGET); +export const parseDirectoryPath = identityParser("directory"); /** Parses free text; completes nothing. */ -export const parseText = identityParser(TEXT_TARGET); +export const parseText = identityParser("text"); + +/** + * Parses a URL; completes nothing. The command validates it, so each one + * keeps its own error for a URL it cannot use. + */ +export const parseUrl = identityParser("text"); /** Parses one segment of a CLI command path; completes route names. */ -export const parseCommandPath = identityParser(COMMAND_PATH_TARGET); +export const parseCommandPath = identityParser("command-path"); /** Parses a stored-account selector; completes stored accounts. */ -export const parseAccountSelector = identityParser(ACCOUNT_TARGET); +export const parseAccountSelector = identityParser("account"); /** * Build a parser that completes `values`. It returns its input unchanged, @@ -135,7 +76,7 @@ export const parseAccountSelector = identityParser(ACCOUNT_TARGET); export function choiceParser( values: readonly string[], ): (input: string) => string { - const parse = identityParser(CHOICE_TARGET); + const parse = identityParser("choice"); Object.defineProperty(parse, COMPLETION_CHOICES_KEY, { value: [...values] }); return parse; } @@ -149,9 +90,9 @@ const PARSER_TARGETS: ReadonlyMap<unknown, CompletionTarget> = new Map< unknown, CompletionTarget >([ - [sanitizeIssueQuery, ISSUE_QUERY_TARGET], - [parsePeriod, PERIOD_TARGET], - [parseLoginEmail, ACCOUNT_TARGET], + [sanitizeIssueQuery, "issue-query"], + [parsePeriod, "period"], + [parseLoginEmail, "account"], ]); /** @@ -191,11 +132,11 @@ export function getCompletionChoices( /** Fixed values the completers offer (see {@link COMPLETION_VALUES}). */ export type CompletionValues = { - /** Issue search keys with a fixed set of values, for {@link ISSUE_QUERY_TARGET} */ + /** Issue search keys with a fixed set of values, for `issue-query` */ issueSearch: Readonly<Record<string, readonly string[]>>; - /** Units of a relative period, for {@link PERIOD_TARGET} (`7` → `7d`) */ + /** Units of a relative period, for `period` (`7` → `7d`) */ periodUnits: readonly string[]; - /** Relative periods offered for {@link PERIOD_TARGET}, with descriptions */ + /** Relative periods offered for `period`, with descriptions */ periodPresets: Readonly<Record<string, string>>; }; diff --git a/packages/cli/src/lib/db/completion-cache.ts b/packages/cli/src/lib/db/completion-cache.ts index c4d87f27a..68b76d225 100644 --- a/packages/cli/src/lib/db/completion-cache.ts +++ b/packages/cli/src/lib/db/completion-cache.ts @@ -16,7 +16,6 @@ * other caches in `maybeCleanupCaches()`, and logout clears the table. */ -import type { CompletionTarget } from "../completion-target.js"; import { getDatabase, maybeCleanupCaches } from "./index.js"; import { runUpsert } from "./utils.js"; @@ -25,10 +24,7 @@ import { runUpsert } from "./utils.js"; * kind of the arguments that take them: issues by short ID, dashboards by * numeric ID, both with the title as description. */ -export type CompletionEntityKind = Extract< - CompletionTarget, - "issue" | "dashboard" ->; +export type CompletionEntityKind = "issue" | "dashboard"; /** Most recently seen entries kept per kind and org. */ export const MAX_ENTITIES_PER_ORG = 500; diff --git a/packages/cli/src/lib/introspect.ts b/packages/cli/src/lib/introspect.ts index 822b16d1e..5a077e8a7 100644 --- a/packages/cli/src/lib/introspect.ts +++ b/packages/cli/src/lib/introspect.ts @@ -13,7 +13,6 @@ import generatedCommandApi from "../generated/command-api.json"; import { type CommandExample, exampleSteps } from "./command-example.js"; import { - CHOICE_TARGET, type CompletionTarget, getCompletionChoices, getCompletionTarget, @@ -441,7 +440,7 @@ function argumentCompletion( ): { kind: CompletionTarget | null; values?: readonly string[] } { const values = enumValues ?? getCompletionChoices(parse); return values - ? { kind: CHOICE_TARGET, values } + ? { kind: "choice", values } : { kind: getCompletionTarget(parse) ?? null }; } diff --git a/packages/cli/src/lib/list-command.ts b/packages/cli/src/lib/list-command.ts index 388b6bc01..6fb729fc6 100644 --- a/packages/cli/src/lib/list-command.ts +++ b/packages/cli/src/lib/list-command.ts @@ -25,7 +25,6 @@ import { type CommandDocumentation, numberParser, } from "./command.js"; -import { ORG_PROJECT_TARGET, ORG_TARGET } from "./completion-target.js"; import { disableOrgCache } from "./db/regions.js"; import { logger } from "./logger.js"; @@ -119,10 +118,7 @@ export function listTargetPositional( brief: parseOptions.names ? NAMED_LIST_TARGET_BRIEF : ORG_ALL_TARGET_BRIEF, - parse: listTarget( - orgScoped ? ORG_TARGET : ORG_PROJECT_TARGET, - parseOptions, - ), + parse: listTarget(orgScoped ? "org" : "org/project", parseOptions), optional: true as const, }, ], diff --git a/packages/cli/src/lib/target-positional.ts b/packages/cli/src/lib/target-positional.ts index c59f9bd96..784b56c78 100644 --- a/packages/cli/src/lib/target-positional.ts +++ b/packages/cli/src/lib/target-positional.ts @@ -31,7 +31,6 @@ import { parseListTargetInput, parseOrgProjectInput, } from "./arg-parsing.js"; -import { ORG_PROJECT_TARGET, ORG_TARGET } from "./completion-target.js"; import { ContextError, ValidationError } from "./errors.js"; import { validateResourceId } from "./input-validation.js"; import { requireExplicitTarget } from "./mutate-command.js"; @@ -80,7 +79,7 @@ function commandNameOf(context: TargetContext): string { /** * Positional parser for an `[<org>/][<project>]` target with its own - * resolver. Completes `ORG_PROJECT_TARGET`. In a destructive command + * resolver. Completes `org/project`. In a destructive command * an auto-detected target fails `requireExplicitTarget` before `func` runs. * * The parse has no side effects; the server of a pasted Sentry URL is @@ -96,7 +95,7 @@ export function orgProjectTarget<R>( ) => Promise<R>, ): (this: TargetContext, input?: string) => OrgProjectTarget<R> { return boundPositional( - ORG_PROJECT_TARGET, + "org/project", (raw) => parseOrgProjectInput(raw).parsed, async (parsed, context: TargetContext, raw) => { const commandName = commandNameOf(context); @@ -210,7 +209,7 @@ export type OrgTarget = BoundPositional<OrgInput, ResolvedOrg>; * where it was detected from. Completes org slugs. */ export const orgTarget = boundPositional( - ORG_TARGET, + "org", (raw) => parseOrgInput(raw), (input, context: TargetContext) => resolveOrgInput( @@ -235,7 +234,7 @@ export type ListCommandTarget = BoundPositional<ListTarget, ListTarget>; * org-scoped resources, else orgs, projects, and aliases */ export function listTarget( - kind: typeof ORG_TARGET | typeof ORG_PROJECT_TARGET, + kind: "org" | "org/project", options: ListTargetOptions = {}, ): (this: unknown, input?: string) => ListCommandTarget { return boundPositional( diff --git a/packages/cli/test/lib/completions.property.test.ts b/packages/cli/test/lib/completions.property.test.ts index fbb5a7ec6..69b28bd07 100644 --- a/packages/cli/test/lib/completions.property.test.ts +++ b/packages/cli/test/lib/completions.property.test.ts @@ -18,10 +18,7 @@ import { constantFrom, assert as fcAssert, property } from "fast-check"; import { describe, expect, test } from "vitest"; import { app, routes } from "../../src/app.js"; import generatedTargets from "../../src/generated/completion-targets.json"; -import { - COMPLETION_VALUES, - PROJECT_SPEC_TARGET, -} from "../../src/lib/completion-target.js"; +import { COMPLETION_VALUES } from "../../src/lib/completion-target.js"; import { parsePeriod } from "../../src/lib/time-range.js"; import { extractCommandTree, @@ -430,7 +427,7 @@ describe("complete.ts: generated completion targets", () => { test("project create completes name:platform specs", () => { expect(generatedTargets.commands["project create"]?.positionals).toEqual([ - PROJECT_SPEC_TARGET, + "project-spec", ]); }); }); diff --git a/packages/cli/test/lib/introspect.test.ts b/packages/cli/test/lib/introspect.test.ts index 9d9c416d6..6d3e25195 100644 --- a/packages/cli/test/lib/introspect.test.ts +++ b/packages/cli/test/lib/introspect.test.ts @@ -7,15 +7,8 @@ import { describe, expect, test } from "vitest"; import { - CHOICE_TARGET, type CompletionTarget, choiceParser, - DASHBOARD_TARGET, - FILE_TARGET, - ISSUE_QUERY_TARGET, - ISSUE_TARGET, - ORG_PROJECT_TARGET, - ORG_TARGET, parseFilePath, withCompletionTarget, } from "../../src/lib/completion-target.js"; @@ -420,9 +413,9 @@ describe("extractCommandCompletions", () => { /** A fresh parser tagged with `kind`, as the bound parsers are. */ const tagged = (kind: CompletionTarget) => withCompletionTarget(kind, (input: string) => input); - const orgParser = tagged(ORG_TARGET); - const orgProjectParser = tagged(ORG_PROJECT_TARGET); - const issueParser = tagged(ISSUE_TARGET); + const orgParser = tagged("org"); + const orgProjectParser = tagged("org/project"); + const issueParser = tagged("issue"); /** Command whose positionals use the given parsers. */ function commandWith(...parsers: unknown[]): Command { @@ -467,17 +460,17 @@ describe("extractCommandCompletions", () => { expect(extractCommandCompletions(topLevel)).toEqual({ "issue link": { - positionals: [ISSUE_TARGET, null], + positionals: ["issue", null], variadic: false, flags: {}, }, - "issue merge": { positionals: [ISSUE_TARGET], variadic: true, flags: {} }, + "issue merge": { positionals: ["issue"], variadic: true, flags: {} }, "issue rules view": { - positionals: [ORG_PROJECT_TARGET], + positionals: ["org/project"], variadic: false, flags: {}, }, - trial: { positionals: [null, ORG_TARGET], variadic: false, flags: {} }, + trial: { positionals: [null, "org"], variadic: false, flags: {} }, }); }); @@ -489,7 +482,7 @@ describe("extractCommandCompletions", () => { parameters: [ { placeholder: "target", - parse: tagged(DASHBOARD_TARGET), + parse: tagged("dashboard"), }, ], }, @@ -513,19 +506,19 @@ describe("extractCommandCompletions", () => { expect(extractCommandCompletions(topLevel)).toEqual({ list: { - positionals: [DASHBOARD_TARGET], + positionals: ["dashboard"], variadic: false, flags: { "--period": null, - "--query": ISSUE_QUERY_TARGET, - "-q": ISSUE_QUERY_TARGET, + "--query": "issue-query", + "-q": "issue-query", "-t": null, }, }, search: { positionals: [], variadic: false, - flags: { "--query": ISSUE_QUERY_TARGET }, + flags: { "--query": "issue-query" }, }, }); }); @@ -567,12 +560,12 @@ describe("extractCommandCompletions", () => { extractCommandCompletions(makeRouteMap([makeEntry("shell", shell)])), ).toEqual({ shell: { - positionals: [CHOICE_TARGET, FILE_TARGET], + positionals: ["choice", "file"], variadic: false, flags: { - "--format": CHOICE_TARGET, - "--mode": CHOICE_TARGET, - "-f": CHOICE_TARGET, + "--format": "choice", + "--mode": "choice", + "-f": "choice", }, choices: { "--format": ["json", "text"], diff --git a/packages/cli/test/lib/positional-parse.test.ts b/packages/cli/test/lib/positional-parse.test.ts index d9ee1ec10..11ac5d860 100644 --- a/packages/cli/test/lib/positional-parse.test.ts +++ b/packages/cli/test/lib/positional-parse.test.ts @@ -16,11 +16,7 @@ import { import { describe, expect, test, vi } from "vitest"; import type { ParsedOrgProject } from "../../src/lib/arg-parsing.js"; import { buildCommand } from "../../src/lib/command.js"; -import { - getCompletionTarget, - ORG_PROJECT_TARGET, - ORG_TARGET, -} from "../../src/lib/completion-target.js"; +import { getCompletionTarget } from "../../src/lib/completion-target.js"; import { ContextError, EXIT, @@ -187,7 +183,7 @@ describe("orgProjectTarget", () => { }); test("keeps completing org/project slugs", () => { - expect(getCompletionTarget(echoTarget)).toBe(ORG_PROJECT_TARGET); + expect(getCompletionTarget(echoTarget)).toBe("org/project"); }); }); @@ -258,7 +254,7 @@ describe("parsing and resolution", () => { describe("listTarget", () => { const context = { cwd: "/work" }; - const namedTarget = listTarget(ORG_PROJECT_TARGET, { names: true }); + const namedTarget = listTarget("org/project", { names: true }); test("parses what to list and the name filter", () => { expect(namedTarget.call(context, "acme/web-*").parsed).toEqual({ @@ -286,8 +282,8 @@ describe("listTarget", () => { }); test("completes org slugs only for org-scoped resources", () => { - expect(getCompletionTarget(listTarget(ORG_TARGET))).toBe(ORG_TARGET); - expect(getCompletionTarget(namedTarget)).toBe(ORG_PROJECT_TARGET); + expect(getCompletionTarget(listTarget("org"))).toBe("org"); + expect(getCompletionTarget(namedTarget)).toBe("org/project"); }); test("invalid input fails as an argument parse error", async () => { @@ -349,7 +345,7 @@ const idResolver = vi.fn( const idArguments = boundArguments({ placeholder: "target-or-id", brief: "[<target>] <id>", - kind: ORG_PROJECT_TARGET, + kind: "org/project", parse: parseIdArguments, resolve: idResolver, }); @@ -493,7 +489,7 @@ describe("boundArguments", () => { test("each argument completes as the given kind", () => { expect(getCompletionTarget(idArguments.parameter.parse)).toBe( - ORG_PROJECT_TARGET, + "org/project", ); }); }); From 5384964000a690eb0986f3b11c7e06b1f675bb7b Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:52:32 +0000 Subject: [PATCH 6/6] ref(cli): Restore the *_TARGET names for completion kinds Commands, helpers, and tests name kinds with the ORG_TARGET-style constants again instead of string literals. Each constant is checked with `satisfies CompletionTarget`, so the keys of COMPLETERS stay the only list of kinds and a name with no completer is a type error. COMPLETERS is frozen, so no code can add or swap a completer at runtime. --- packages/cli/AGENTS.md | 8 +-- packages/cli/script/check-doc-examples.ts | 5 +- .../src/commands/agent-conversation/view.ts | 3 +- packages/cli/src/commands/alert/arguments.ts | 6 +- .../src/commands/alert/issues/rule-resolve.ts | 3 +- .../cli/src/commands/alert/metrics/list.ts | 3 +- .../commands/alert/metrics/rule-resolve.ts | 3 +- .../cli/src/commands/dashboard/arguments.ts | 8 ++- packages/cli/src/commands/dashboard/create.ts | 3 +- .../cli/src/commands/dashboard/widget/add.ts | 3 +- packages/cli/src/commands/event/view.ts | 3 +- .../cli/src/commands/feedback/positional.ts | 3 +- packages/cli/src/commands/init.ts | 3 +- .../cli/src/commands/integration/delete.ts | 3 +- packages/cli/src/commands/issue/positional.ts | 5 +- packages/cli/src/commands/log/list.ts | 3 +- packages/cli/src/commands/log/view.ts | 3 +- packages/cli/src/commands/project/create.ts | 3 +- packages/cli/src/commands/release/parse.ts | 3 +- packages/cli/src/commands/replay/view.ts | 3 +- packages/cli/src/commands/span/list.ts | 3 +- packages/cli/src/commands/span/view.ts | 3 +- packages/cli/src/commands/trace/logs.ts | 3 +- packages/cli/src/commands/trace/view.ts | 3 +- packages/cli/src/commands/trial/start.ts | 3 +- packages/cli/src/lib/complete.ts | 4 +- packages/cli/src/lib/completion-target.ts | 72 +++++++++++++++---- packages/cli/src/lib/introspect.ts | 3 +- packages/cli/src/lib/list-command.ts | 6 +- packages/cli/src/lib/target-positional.ts | 9 +-- .../cli/test/lib/completions.property.test.ts | 7 +- packages/cli/test/lib/introspect.test.ts | 39 +++++----- .../cli/test/lib/positional-parse.test.ts | 18 +++-- 33 files changed, 173 insertions(+), 77 deletions(-) diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 54b03e1b6..e6cc7bb6c 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -389,10 +389,10 @@ positional without one. 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"` org slugs; `"org/project"` `org/`, `org/project`, aliases; `"issue"` plus cached issue short IDs; `"dashboard"` cached dashboards | +| 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 `/` | | Directory | `parseDirectoryPath` | directories only | -| Target or directory (`init`) | `boundArguments({ kind: "org-project-or-directory", … })` | `org/project` targets, or directories once the word looks like a path (`./`, `/`, `~/`), so a suggestion is never read as a target | +| 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 <key> <value>`). The parser returns its input, so the command still validates it | | Stored account | `parseAccountSelector` | stored accounts (`auth switch`, `auth logout`) | | CLI command path | `parseCommandPath` | route names, one segment per word (`help`) | @@ -419,8 +419,8 @@ 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 kind that is not an entity, add its entry to `COMPLETERS` -(`src/lib/complete.ts`), whose keys are the `CompletionTarget` type, and a -pre-tagged parser. +(`src/lib/complete.ts`), whose keys are the `CompletionTarget` type, a +`*_TARGET` name for it in `completion-target.ts`, and a pre-tagged parser. `pnpm run generate:completions` collects every command's kinds and choice values, and every route group's subcommands, into the gitignored `src/generated/completion-targets.json` that the `__complete` fast path diff --git a/packages/cli/script/check-doc-examples.ts b/packages/cli/script/check-doc-examples.ts index b31d90fc7..ad6689cbd 100644 --- a/packages/cli/script/check-doc-examples.ts +++ b/packages/cli/script/check-doc-examples.ts @@ -62,7 +62,8 @@ if (!skillContentExists) { const { routes } = await import("../src/app.js"); const { isCommand, isRouteMap } = await import("../src/lib/introspect.js"); -const { getCompletionTarget } = await import("../src/lib/completion-target.js"); +const { getCompletionTarget, ORG_PROJECT_TARGET } = + await import("../src/lib/completion-target.js"); /** Generated from the route tree, so not worth re-checking. */ const GENERATED_DOCS_DIR = `${DOCS_CONTENT}/commands`; @@ -194,7 +195,7 @@ function isTargetParam(param: PositionalParam | undefined): boolean { return false; } return ( - getCompletionTarget(param.parse) === "org/project" || + getCompletionTarget(param.parse) === ORG_PROJECT_TARGET || TARGET_PLACEHOLDER_RE.test(param.placeholder ?? "") ); } diff --git a/packages/cli/src/commands/agent-conversation/view.ts b/packages/cli/src/commands/agent-conversation/view.ts index b122f0b4a..a0af5f939 100644 --- a/packages/cli/src/commands/agent-conversation/view.ts +++ b/packages/cli/src/commands/agent-conversation/view.ts @@ -7,6 +7,7 @@ import type { SentryContext } from "../../context.js"; import { getConversationSpans } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; +import { ORG_TARGET } from "../../lib/completion-target.js"; import { buildTranscriptResult, formatTranscriptResult, @@ -133,7 +134,7 @@ export const viewCommand = buildCommand({ positional: boundArguments({ placeholder: "org/conversation-id", brief: "Organization slug (optional) and conversation ID", - kind: "org", + kind: ORG_TARGET, parse: parseConversationArguments, resolve: async ( input, diff --git a/packages/cli/src/commands/alert/arguments.ts b/packages/cli/src/commands/alert/arguments.ts index ce3016f08..927d76ca6 100644 --- a/packages/cli/src/commands/alert/arguments.ts +++ b/packages/cli/src/commands/alert/arguments.ts @@ -15,6 +15,10 @@ import { type ParsedOrgProject, parseOrgProjectInput, } from "../../lib/arg-parsing.js"; +import type { + ORG_PROJECT_TARGET, + ORG_TARGET, +} from "../../lib/completion-target.js"; import { type BoundPositional, boundPositional, @@ -52,7 +56,7 @@ type RuleArgSplitter = ( export function alertRuleParameter(options: { readonly placeholder: string; readonly brief: string; - readonly kind: "org" | "org/project"; + readonly kind: typeof ORG_TARGET | typeof ORG_PROJECT_TARGET; readonly split: RuleArgSplitter; readonly usageHint: string; }) { diff --git a/packages/cli/src/commands/alert/issues/rule-resolve.ts b/packages/cli/src/commands/alert/issues/rule-resolve.ts index 1e371fd20..e7da9789b 100644 --- a/packages/cli/src/commands/alert/issues/rule-resolve.ts +++ b/packages/cli/src/commands/alert/issues/rule-resolve.ts @@ -10,6 +10,7 @@ import { isNotFoundApiError, listIssueAlertsPaginated, } from "../../../lib/api-client.js"; +import { ORG_PROJECT_TARGET } from "../../../lib/completion-target.js"; import { ResolutionError, ValidationError } from "../../../lib/errors.js"; import { fuzzyMatch } from "../../../lib/fuzzy.js"; import { logger } from "../../../lib/logger.js"; @@ -76,7 +77,7 @@ export function issueRuleParameter(usageHint: string, brief: string) { return alertRuleParameter({ placeholder: "org/project/rule-id-or-name", brief, - kind: "org/project", + kind: ORG_PROJECT_TARGET, split: parseIssueRuleArg, usageHint, }); diff --git a/packages/cli/src/commands/alert/metrics/list.ts b/packages/cli/src/commands/alert/metrics/list.ts index a8c19b006..c0aa78df9 100644 --- a/packages/cli/src/commands/alert/metrics/list.ts +++ b/packages/cli/src/commands/alert/metrics/list.ts @@ -16,6 +16,7 @@ import { paginate } from "../../../lib/api/infrastructure.js"; import { listMetricAlertsPaginated } from "../../../lib/api-client.js"; import type { ParsedOrgProject } from "../../../lib/arg-parsing.js"; import { openInBrowser } from "../../../lib/browser.js"; +import { ORG_PROJECT_TARGET } from "../../../lib/completion-target.js"; import { advancePaginationState, buildMultiOrgContextKey, @@ -86,7 +87,7 @@ const METRIC_ALERT_TARGET_POSITIONAL = { { placeholder: "target", brief: "<org>/, <org>/<project> (project ignored), or <project> (search)", - parse: listTarget("org/project"), + parse: listTarget(ORG_PROJECT_TARGET), optional: true as const, }, ], diff --git a/packages/cli/src/commands/alert/metrics/rule-resolve.ts b/packages/cli/src/commands/alert/metrics/rule-resolve.ts index fbf124bcb..60714ff62 100644 --- a/packages/cli/src/commands/alert/metrics/rule-resolve.ts +++ b/packages/cli/src/commands/alert/metrics/rule-resolve.ts @@ -10,6 +10,7 @@ import { listMetricAlertsPaginated, type MetricAlertRule, } from "../../../lib/api-client.js"; +import { ORG_TARGET } from "../../../lib/completion-target.js"; import { ResolutionError, ValidationError } from "../../../lib/errors.js"; import { fuzzyMatch } from "../../../lib/fuzzy.js"; import { logger } from "../../../lib/logger.js"; @@ -74,7 +75,7 @@ export function metricRuleParameter(usageHint: string, brief: string) { return alertRuleParameter({ placeholder: "org/rule-id-or-name", brief, - kind: "org", + kind: ORG_TARGET, split: parseMetricRuleArg, usageHint, }); diff --git a/packages/cli/src/commands/dashboard/arguments.ts b/packages/cli/src/commands/dashboard/arguments.ts index 76d18ad2f..8fa6a302e 100644 --- a/packages/cli/src/commands/dashboard/arguments.ts +++ b/packages/cli/src/commands/dashboard/arguments.ts @@ -10,6 +10,10 @@ import { type ParsedOrgProject, parseOrgProjectInput, } from "../../lib/arg-parsing.js"; +import { + DASHBOARD_TARGET, + ORG_PROJECT_TARGET, +} from "../../lib/completion-target.js"; import { parseDashboardArgs, parseDashboardListArgs, @@ -154,7 +158,7 @@ export function dashboardArguments(options: { return boundArguments({ placeholder, brief: "[<org/project>] <dashboard-id-or-title>", - kind: "dashboard", + kind: DASHBOARD_TARGET, parse: (args) => parseDashboardInput(args, usageHint), resolve: (input: DashboardInput, context: SentryContext) => resolveDashboardInput(input, context.cwd, usageHint), @@ -199,7 +203,7 @@ export function dashboardListArguments(usageHint: string) { return boundArguments({ placeholder: "org/title-filter", brief: "[<org/project>] [<name-glob>]", - kind: "org/project", + kind: ORG_PROJECT_TARGET, parse: parseDashboardListInput, resolve: (input: DashboardListInput, context: SentryContext) => { applyInputContext(input); diff --git a/packages/cli/src/commands/dashboard/create.ts b/packages/cli/src/commands/dashboard/create.ts index b092ad4b8..4b657ccfd 100644 --- a/packages/cli/src/commands/dashboard/create.ts +++ b/packages/cli/src/commands/dashboard/create.ts @@ -11,6 +11,7 @@ import { parseOrgProjectInput, } from "../../lib/arg-parsing.js"; import { buildCommand } from "../../lib/command.js"; +import { ORG_PROJECT_TARGET } from "../../lib/completion-target.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { formatDashboardCreated } from "../../lib/formatters/human.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -185,7 +186,7 @@ export const createCommand = buildCommand({ positional: boundArguments({ placeholder: "org/project/title", brief: "[<org/project>] <title>", - kind: "org/project", + kind: ORG_PROJECT_TARGET, parse: parseCreateInput, resolve: async (input: CreateInput, context: SentryContext) => { if (input.baseUrl) { diff --git a/packages/cli/src/commands/dashboard/widget/add.ts b/packages/cli/src/commands/dashboard/widget/add.ts index 7b868ce4b..ab1c960f3 100644 --- a/packages/cli/src/commands/dashboard/widget/add.ts +++ b/packages/cli/src/commands/dashboard/widget/add.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 { DASHBOARD_TARGET } 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"; @@ -156,7 +157,7 @@ export const addCommand = buildCommand({ positional: boundArguments({ placeholder: "org/project/dashboard/title", brief: "[<org/project>] <dashboard> <title>", - kind: "dashboard", + kind: DASHBOARD_TARGET, parse: (args): WidgetAddInput => { const { dashboardArgs, title } = parseAddPositionalArgs(args); return { ...parseDashboardInput(dashboardArgs, USAGE_HINT), title }; diff --git a/packages/cli/src/commands/event/view.ts b/packages/cli/src/commands/event/view.ts index 10512a798..410fbb616 100644 --- a/packages/cli/src/commands/event/view.ts +++ b/packages/cli/src/commands/event/view.ts @@ -26,6 +26,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 { ApiError, AuthError, @@ -1185,7 +1186,7 @@ export const viewCommand = buildCommand({ placeholder: "org/project/event-id", brief: "[<org>/<project>] <event-id> [<event-id>...] - Target (optional) and one or more event IDs", - kind: "org/project", + kind: ORG_PROJECT_TARGET, parse: parseEventViewInput, resolve: resolveEventViewInput, }), diff --git a/packages/cli/src/commands/feedback/positional.ts b/packages/cli/src/commands/feedback/positional.ts index 37999b97a..1ca7460d1 100644 --- a/packages/cli/src/commands/feedback/positional.ts +++ b/packages/cli/src/commands/feedback/positional.ts @@ -3,6 +3,7 @@ * identifier, parsed before the command runs. */ +import { ORG_PROJECT_TARGET } from "../../lib/completion-target.js"; import { ContextError } from "../../lib/errors.js"; import { type IssueArgument, @@ -39,7 +40,7 @@ export function feedbackPositional(command: string) { brief: "Feedback: @latest, numeric ID, short ID, <org>/SHORT-ID, or <org>/<project>/<suffix>", parse: boundPositional( - "org/project", + ORG_PROJECT_TARGET, (raw) => (raw === undefined ? undefined : parseIssueArgument(raw)), async (feedback, context: { readonly cwd: string }) => { if (!feedback) { diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 400d11c2e..d01e450eb 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -26,6 +26,7 @@ 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 { refreshToken } from "../lib/db/auth.js"; import { ContextError, ValidationError } from "../lib/errors.js"; import { warmOrgDetection } from "../lib/init/org-prefetch.js"; @@ -400,7 +401,7 @@ export const initCommand = buildCommand< parameters: { positional: boundArguments({ placeholder: "target/directory", - kind: "org-project-or-directory", + kind: ORG_PROJECT_OR_DIRECTORY_TARGET, brief: "Optional target (<org>/<project>, <org>/, or <project>) and directory (default: current directory)", parse: parseInitInput, diff --git a/packages/cli/src/commands/integration/delete.ts b/packages/cli/src/commands/integration/delete.ts index 0ad4275f6..975619f97 100644 --- a/packages/cli/src/commands/integration/delete.ts +++ b/packages/cli/src/commands/integration/delete.ts @@ -9,6 +9,7 @@ import type { SentryContext } from "../../context.js"; import { deleteIntegration, getIntegration } from "../../lib/api-client.js"; +import { ORG_TARGET } from "../../lib/completion-target.js"; import { ApiError, ValidationError } from "../../lib/errors.js"; import { renderMarkdown, safeCodeSpan } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -180,7 +181,7 @@ export const deleteCommand = buildDeleteCommand({ positional: boundArguments({ placeholder: "org/integration-id", brief: "[<org>/]<integration-id> - Integration to remove", - kind: "org", + kind: ORG_TARGET, parse: parseIntegrationArguments, resolve: async ( input, diff --git a/packages/cli/src/commands/issue/positional.ts b/packages/cli/src/commands/issue/positional.ts index 86bd99b4e..1e123a131 100644 --- a/packages/cli/src/commands/issue/positional.ts +++ b/packages/cli/src/commands/issue/positional.ts @@ -8,6 +8,7 @@ * pasted issue URL is applied. See `positional-parse.ts`. */ +import { ISSUE_TARGET } from "../../lib/completion-target.js"; import { ContextError } from "../../lib/errors.js"; import { type IssueArgument, @@ -79,7 +80,7 @@ function issueTarget( ): (this: IssueTargetContext, input?: string) => IssueTarget { const { command, commandBase = "sentry issue" } = issueCommand; return boundPositional( - "issue", + ISSUE_TARGET, parseIssueTargetInput, async (issue, context: IssueTargetContext) => await resolveIssue({ @@ -141,7 +142,7 @@ export function issuesPositional(options: { return boundArguments({ placeholder: "issue", brief, - kind: "issue", + kind: ISSUE_TARGET, parse: (raw): readonly IssueArgument[] => { const issues = parseIssueArguments(raw, { usageHint, optional }); if (!distinct) { diff --git a/packages/cli/src/commands/log/list.ts b/packages/cli/src/commands/log/list.ts index 73fc5ff0c..5a23fe479 100644 --- a/packages/cli/src/commands/log/list.ts +++ b/packages/cli/src/commands/log/list.ts @@ -21,6 +21,7 @@ import { parseOrgProjectInput, validateLimit, } from "../../lib/arg-parsing.js"; +import { ORG_PROJECT_TARGET } from "../../lib/completion-target.js"; import { AuthError, stringifyUnknown, @@ -824,7 +825,7 @@ export const listCommand = buildListCommand( positional: boundArguments({ placeholder: "org/project-or-trace-id", brief: "[<org>/[<project>/]]<trace-id>, <org>/<project>, or <project>", - kind: "org/project", + kind: ORG_PROJECT_TARGET, parse: parseLogListInput, resolve: resolveLogListTarget, }), diff --git a/packages/cli/src/commands/log/view.ts b/packages/cli/src/commands/log/view.ts index 1fab3ef1a..f0a41e784 100644 --- a/packages/cli/src/commands/log/view.ts +++ b/packages/cli/src/commands/log/view.ts @@ -18,6 +18,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 { ContextError, ResolutionError, @@ -489,7 +490,7 @@ export const viewCommand = buildCommand({ placeholder: "org/project/log-id", brief: "[<org>/<project>] <log-id> [<log-id>...] - Target (optional) and one or more log IDs", - kind: "org/project", + kind: ORG_PROJECT_TARGET, parse: parseLogViewInput, resolve: resolveLogViewInput, }), diff --git a/packages/cli/src/commands/project/create.ts b/packages/cli/src/commands/project/create.ts index dd3256600..3bd128f1a 100644 --- a/packages/cli/src/commands/project/create.ts +++ b/packages/cli/src/commands/project/create.ts @@ -28,6 +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 { ApiError, CliError, @@ -719,7 +720,7 @@ export const createCommand = buildCommand({ parameters: { positional: boundArguments({ placeholder: "name:platform", - kind: "project-spec", + kind: PROJECT_SPEC_TARGET, brief: "One or more project name and platform pairs", parse: parseProjectSpecs, resolve: (input: ProjectSpecsInput, context: SentryContext) => diff --git a/packages/cli/src/commands/release/parse.ts b/packages/cli/src/commands/release/parse.ts index c30a21fdd..5788ad2d9 100644 --- a/packages/cli/src/commands/release/parse.ts +++ b/packages/cli/src/commands/release/parse.ts @@ -6,6 +6,7 @@ * slug separators. This module provides version-aware parsing. */ +import { ORG_TARGET } from "../../lib/completion-target.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { type BoundPositional, @@ -112,7 +113,7 @@ export function releaseParameter(usageHint: string, brief: string) { placeholder: "org/version", brief, parse: boundPositional( - "org", + ORG_TARGET, (raw) => parseReleaseInput(raw, usageHint), async ( { version, orgSlug }, diff --git a/packages/cli/src/commands/replay/view.ts b/packages/cli/src/commands/replay/view.ts index 9077a1cec..8111fb690 100644 --- a/packages/cli/src/commands/replay/view.ts +++ b/packages/cli/src/commands/replay/view.ts @@ -18,6 +18,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 { ApiError, ResolutionError } from "../../lib/errors.js"; import { filterFields } from "../../lib/formatters/json.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -126,7 +127,7 @@ export function replayArguments( return boundArguments({ placeholder: "replay-id-or-url", brief: "[<org>/<project>] <replay-id or trace-id> or <replay-url>", - kind: "org/project", + kind: ORG_PROJECT_TARGET, parse: (raw) => parseReplayArguments(raw, usageHint), resolve: async ( input, diff --git a/packages/cli/src/commands/span/list.ts b/packages/cli/src/commands/span/list.ts index 65edb6664..28c83633e 100644 --- a/packages/cli/src/commands/span/list.ts +++ b/packages/cli/src/commands/span/list.ts @@ -19,6 +19,7 @@ import { parseOrgProjectInput, validateLimit, } from "../../lib/arg-parsing.js"; +import { ORG_PROJECT_TARGET } from "../../lib/completion-target.js"; import { advancePaginationState, buildPaginationContextKey, @@ -658,7 +659,7 @@ export const listCommand = buildListCommand("span", { positional: boundArguments({ placeholder: "org/project/trace-id", brief: "[<org>/<project>] or [<org>/<project>/]<trace-id>", - kind: "org/project", + kind: ORG_PROJECT_TARGET, parse: parseSpanListInput, resolve: resolveSpanListTarget, }), diff --git a/packages/cli/src/commands/span/view.ts b/packages/cli/src/commands/span/view.ts index 094c29be6..4b2f29c84 100644 --- a/packages/cli/src/commands/span/view.ts +++ b/packages/cli/src/commands/span/view.ts @@ -13,6 +13,7 @@ import { } from "../../lib/api-client.js"; import { spansFlag } from "../../lib/arg-parsing.js"; import { buildCommand } from "../../lib/command.js"; +import { ORG_PROJECT_TARGET } from "../../lib/completion-target.js"; import { ContextError, ResolutionError, @@ -379,7 +380,7 @@ export const viewCommand = buildCommand({ placeholder: "trace-id/span-id", brief: "[<org>/<project>/]<trace-id> <span-id> [<span-id>...] - Trace ID and one or more span IDs", - kind: "org/project", + kind: ORG_PROJECT_TARGET, parse: parseSpanViewInput, resolve: resolveSpanViewInput, }), diff --git a/packages/cli/src/commands/trace/logs.ts b/packages/cli/src/commands/trace/logs.ts index 6bf7b418f..afe1e97b4 100644 --- a/packages/cli/src/commands/trace/logs.ts +++ b/packages/cli/src/commands/trace/logs.ts @@ -13,6 +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 { toSearchQueryError } from "../../lib/errors.js"; import { filterFields } from "../../lib/formatters/json.js"; import { formatLogTable } from "../../lib/formatters/log.js"; @@ -197,7 +198,7 @@ export const logsCommand = buildCommand({ placeholder: "org/project/trace-id", brief: "[<org>/[<project>/]]<trace-id> - Optional org/project and required trace ID", - kind: "org/project", + kind: ORG_PROJECT_TARGET, parse: parseTraceLogsInput, resolve: resolveTraceLogsTarget, }), diff --git a/packages/cli/src/commands/trace/view.ts b/packages/cli/src/commands/trace/view.ts index 0d9891cad..90f70254d 100644 --- a/packages/cli/src/commands/trace/view.ts +++ b/packages/cli/src/commands/trace/view.ts @@ -17,6 +17,7 @@ import { import { looksLikeIssueShortId, spansFlag } 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 { ContextError, ResolutionError, @@ -500,7 +501,7 @@ export const viewCommand = buildCommand({ placeholder: "org/project/trace-id", brief: "[<org>/<project>/]<trace-id> - Target (optional) and trace ID (required)", - kind: "org/project", + kind: ORG_PROJECT_TARGET, parse: parseTraceViewInput, resolve: resolveTraceViewInput, }), diff --git a/packages/cli/src/commands/trial/start.ts b/packages/cli/src/commands/trial/start.ts index e0cdc1158..f336dd8bd 100644 --- a/packages/cli/src/commands/trial/start.ts +++ b/packages/cli/src/commands/trial/start.ts @@ -20,6 +20,7 @@ import { import { detectSwappedTrialArgs } from "../../lib/arg-parsing.js"; import { openBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; +import { ORG_TARGET } from "../../lib/completion-target.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { success } from "../../lib/formatters/colors.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -161,7 +162,7 @@ export const startCommand = buildCommand({ positional: boundArguments({ placeholder: "name", brief: `Trial name (${NAMES_LIST}) and organization slug (auto-detected if omitted)`, - kind: "org", + kind: ORG_TARGET, parse: parseTrialStartArguments, resolve: async ( input, diff --git a/packages/cli/src/lib/complete.ts b/packages/cli/src/lib/complete.ts index 517f1c65a..35a07bb43 100644 --- a/packages/cli/src/lib/complete.ts +++ b/packages/cli/src/lib/complete.ts @@ -214,7 +214,7 @@ const completeDashboards = entityCompleter( * The completer of every kind a parser can declare. Its keys are the * kinds: {@link CompletionTarget} is derived from them. */ -const COMPLETERS = { +const COMPLETERS = Object.freeze({ /** Organization slugs only (e.g. `org`, `org/version`, `org/id`). */ org: (partial) => completeOrgSlugs(partial), /** `org/` prefixes, `org/project` pairs, and project aliases. */ @@ -287,7 +287,7 @@ const COMPLETERS = { * new things). Declared, so that every positional says how it completes. */ text: () => [], -} satisfies Record<string, Completer>; +} satisfies Record<string, Completer>); /** What shell completion suggests for an argument: a key of {@link COMPLETERS}. */ export type CompletionTarget = keyof typeof COMPLETERS; diff --git a/packages/cli/src/lib/completion-target.ts b/packages/cli/src/lib/completion-target.ts index 4c13d830b..2bc61bcc8 100644 --- a/packages/cli/src/lib/completion-target.ts +++ b/packages/cli/src/lib/completion-target.ts @@ -25,6 +25,52 @@ import { parsePeriod, UNIT_SECONDS } from "./time-range.js"; /** What shell completion suggests for an argument: a key of the completer map. */ export type { CompletionTarget }; +// Names for the kinds, each checked against the keys of `COMPLETERS` in +// `complete.ts`, which documents what each one completes. + +/** Organization slugs. */ +export const ORG_TARGET = "org" satisfies CompletionTarget; + +/** `org/` prefixes, `org/project` pairs, and project aliases. */ +export const ORG_PROJECT_TARGET = "org/project" satisfies CompletionTarget; + +/** {@link ORG_PROJECT_TARGET} plus cached issue short IDs. */ +export const ISSUE_TARGET = "issue" satisfies CompletionTarget; + +/** `org/` prefixes and cached dashboards, in the first positional. */ +export const DASHBOARD_TARGET = "dashboard" satisfies CompletionTarget; + +/** The term at the end of an issue search query. */ +export const ISSUE_QUERY_TARGET = "issue-query" satisfies CompletionTarget; + +/** Relative `--period` values. */ +export const PERIOD_TARGET = "period" satisfies CompletionTarget; + +/** `project create` `name:platform` specs. */ +export const PROJECT_SPEC_TARGET = "project-spec" satisfies CompletionTarget; + +/** Stored accounts. */ +export const ACCOUNT_TARGET = "account" satisfies CompletionTarget; + +/** File and directory paths. */ +export const FILE_TARGET = "file" satisfies CompletionTarget; + +/** Directory paths only. */ +export const DIRECTORY_TARGET = "directory" satisfies CompletionTarget; + +/** `sentry init`'s `[<target>] [<directory>]`. */ +export const ORG_PROJECT_OR_DIRECTORY_TARGET = + "org-project-or-directory" satisfies CompletionTarget; + +/** A CLI command path, one route segment per word. */ +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; + +/** Free text with nothing to suggest. */ +export const TEXT_TARGET = "text" satisfies CompletionTarget; + const COMPLETION_TARGET_KEY = Symbol("completionTarget"); const COMPLETION_CHOICES_KEY = Symbol("completionChoices"); @@ -48,25 +94,25 @@ function identityParser(kind: CompletionTarget): (input: string) => string { } /** Parses a file or directory path; completes paths. */ -export const parseFilePath = identityParser("file"); +export const parseFilePath = identityParser(FILE_TARGET); /** Parses a directory path; completes directories. */ -export const parseDirectoryPath = identityParser("directory"); +export const parseDirectoryPath = identityParser(DIRECTORY_TARGET); /** Parses free text; completes nothing. */ -export const parseText = identityParser("text"); +export const parseText = identityParser(TEXT_TARGET); /** * Parses a URL; completes nothing. The command validates it, so each one * keeps its own error for a URL it cannot use. */ -export const parseUrl = identityParser("text"); +export const parseUrl = identityParser(TEXT_TARGET); /** Parses one segment of a CLI command path; completes route names. */ -export const parseCommandPath = identityParser("command-path"); +export const parseCommandPath = identityParser(COMMAND_PATH_TARGET); /** Parses a stored-account selector; completes stored accounts. */ -export const parseAccountSelector = identityParser("account"); +export const parseAccountSelector = identityParser(ACCOUNT_TARGET); /** * Build a parser that completes `values`. It returns its input unchanged, @@ -76,7 +122,7 @@ export const parseAccountSelector = identityParser("account"); export function choiceParser( values: readonly string[], ): (input: string) => string { - const parse = identityParser("choice"); + const parse = identityParser(CHOICE_TARGET); Object.defineProperty(parse, COMPLETION_CHOICES_KEY, { value: [...values] }); return parse; } @@ -90,9 +136,9 @@ const PARSER_TARGETS: ReadonlyMap<unknown, CompletionTarget> = new Map< unknown, CompletionTarget >([ - [sanitizeIssueQuery, "issue-query"], - [parsePeriod, "period"], - [parseLoginEmail, "account"], + [sanitizeIssueQuery, ISSUE_QUERY_TARGET], + [parsePeriod, PERIOD_TARGET], + [parseLoginEmail, ACCOUNT_TARGET], ]); /** @@ -132,11 +178,11 @@ export function getCompletionChoices( /** Fixed values the completers offer (see {@link COMPLETION_VALUES}). */ export type CompletionValues = { - /** Issue search keys with a fixed set of values, for `issue-query` */ + /** Issue search keys with a fixed set of values, for {@link ISSUE_QUERY_TARGET} */ issueSearch: Readonly<Record<string, readonly string[]>>; - /** Units of a relative period, for `period` (`7` → `7d`) */ + /** Units of a relative period, for {@link PERIOD_TARGET} (`7` → `7d`) */ periodUnits: readonly string[]; - /** Relative periods offered for `period`, with descriptions */ + /** Relative periods offered for {@link PERIOD_TARGET}, with descriptions */ periodPresets: Readonly<Record<string, string>>; }; diff --git a/packages/cli/src/lib/introspect.ts b/packages/cli/src/lib/introspect.ts index 5a077e8a7..822b16d1e 100644 --- a/packages/cli/src/lib/introspect.ts +++ b/packages/cli/src/lib/introspect.ts @@ -13,6 +13,7 @@ import generatedCommandApi from "../generated/command-api.json"; import { type CommandExample, exampleSteps } from "./command-example.js"; import { + CHOICE_TARGET, type CompletionTarget, getCompletionChoices, getCompletionTarget, @@ -440,7 +441,7 @@ function argumentCompletion( ): { kind: CompletionTarget | null; values?: readonly string[] } { const values = enumValues ?? getCompletionChoices(parse); return values - ? { kind: "choice", values } + ? { kind: CHOICE_TARGET, values } : { kind: getCompletionTarget(parse) ?? null }; } diff --git a/packages/cli/src/lib/list-command.ts b/packages/cli/src/lib/list-command.ts index 6fb729fc6..388b6bc01 100644 --- a/packages/cli/src/lib/list-command.ts +++ b/packages/cli/src/lib/list-command.ts @@ -25,6 +25,7 @@ import { type CommandDocumentation, numberParser, } from "./command.js"; +import { ORG_PROJECT_TARGET, ORG_TARGET } from "./completion-target.js"; import { disableOrgCache } from "./db/regions.js"; import { logger } from "./logger.js"; @@ -118,7 +119,10 @@ export function listTargetPositional( brief: parseOptions.names ? NAMED_LIST_TARGET_BRIEF : ORG_ALL_TARGET_BRIEF, - parse: listTarget(orgScoped ? "org" : "org/project", parseOptions), + parse: listTarget( + orgScoped ? ORG_TARGET : ORG_PROJECT_TARGET, + parseOptions, + ), optional: true as const, }, ], diff --git a/packages/cli/src/lib/target-positional.ts b/packages/cli/src/lib/target-positional.ts index 784b56c78..c59f9bd96 100644 --- a/packages/cli/src/lib/target-positional.ts +++ b/packages/cli/src/lib/target-positional.ts @@ -31,6 +31,7 @@ import { parseListTargetInput, parseOrgProjectInput, } from "./arg-parsing.js"; +import { ORG_PROJECT_TARGET, ORG_TARGET } from "./completion-target.js"; import { ContextError, ValidationError } from "./errors.js"; import { validateResourceId } from "./input-validation.js"; import { requireExplicitTarget } from "./mutate-command.js"; @@ -79,7 +80,7 @@ function commandNameOf(context: TargetContext): string { /** * Positional parser for an `[<org>/][<project>]` target with its own - * resolver. Completes `org/project`. In a destructive command + * resolver. Completes `ORG_PROJECT_TARGET`. In a destructive command * an auto-detected target fails `requireExplicitTarget` before `func` runs. * * The parse has no side effects; the server of a pasted Sentry URL is @@ -95,7 +96,7 @@ export function orgProjectTarget<R>( ) => Promise<R>, ): (this: TargetContext, input?: string) => OrgProjectTarget<R> { return boundPositional( - "org/project", + ORG_PROJECT_TARGET, (raw) => parseOrgProjectInput(raw).parsed, async (parsed, context: TargetContext, raw) => { const commandName = commandNameOf(context); @@ -209,7 +210,7 @@ export type OrgTarget = BoundPositional<OrgInput, ResolvedOrg>; * where it was detected from. Completes org slugs. */ export const orgTarget = boundPositional( - "org", + ORG_TARGET, (raw) => parseOrgInput(raw), (input, context: TargetContext) => resolveOrgInput( @@ -234,7 +235,7 @@ export type ListCommandTarget = BoundPositional<ListTarget, ListTarget>; * org-scoped resources, else orgs, projects, and aliases */ export function listTarget( - kind: "org" | "org/project", + kind: typeof ORG_TARGET | typeof ORG_PROJECT_TARGET, options: ListTargetOptions = {}, ): (this: unknown, input?: string) => ListCommandTarget { return boundPositional( diff --git a/packages/cli/test/lib/completions.property.test.ts b/packages/cli/test/lib/completions.property.test.ts index 69b28bd07..fbb5a7ec6 100644 --- a/packages/cli/test/lib/completions.property.test.ts +++ b/packages/cli/test/lib/completions.property.test.ts @@ -18,7 +18,10 @@ import { constantFrom, assert as fcAssert, property } from "fast-check"; import { describe, expect, test } from "vitest"; import { app, routes } from "../../src/app.js"; import generatedTargets from "../../src/generated/completion-targets.json"; -import { COMPLETION_VALUES } from "../../src/lib/completion-target.js"; +import { + COMPLETION_VALUES, + PROJECT_SPEC_TARGET, +} from "../../src/lib/completion-target.js"; import { parsePeriod } from "../../src/lib/time-range.js"; import { extractCommandTree, @@ -427,7 +430,7 @@ describe("complete.ts: generated completion targets", () => { test("project create completes name:platform specs", () => { expect(generatedTargets.commands["project create"]?.positionals).toEqual([ - "project-spec", + PROJECT_SPEC_TARGET, ]); }); }); diff --git a/packages/cli/test/lib/introspect.test.ts b/packages/cli/test/lib/introspect.test.ts index 6d3e25195..9d9c416d6 100644 --- a/packages/cli/test/lib/introspect.test.ts +++ b/packages/cli/test/lib/introspect.test.ts @@ -7,8 +7,15 @@ import { describe, expect, test } from "vitest"; import { + CHOICE_TARGET, type CompletionTarget, choiceParser, + DASHBOARD_TARGET, + FILE_TARGET, + ISSUE_QUERY_TARGET, + ISSUE_TARGET, + ORG_PROJECT_TARGET, + ORG_TARGET, parseFilePath, withCompletionTarget, } from "../../src/lib/completion-target.js"; @@ -413,9 +420,9 @@ describe("extractCommandCompletions", () => { /** A fresh parser tagged with `kind`, as the bound parsers are. */ const tagged = (kind: CompletionTarget) => withCompletionTarget(kind, (input: string) => input); - const orgParser = tagged("org"); - const orgProjectParser = tagged("org/project"); - const issueParser = tagged("issue"); + const orgParser = tagged(ORG_TARGET); + const orgProjectParser = tagged(ORG_PROJECT_TARGET); + const issueParser = tagged(ISSUE_TARGET); /** Command whose positionals use the given parsers. */ function commandWith(...parsers: unknown[]): Command { @@ -460,17 +467,17 @@ describe("extractCommandCompletions", () => { expect(extractCommandCompletions(topLevel)).toEqual({ "issue link": { - positionals: ["issue", null], + positionals: [ISSUE_TARGET, null], variadic: false, flags: {}, }, - "issue merge": { positionals: ["issue"], variadic: true, flags: {} }, + "issue merge": { positionals: [ISSUE_TARGET], variadic: true, flags: {} }, "issue rules view": { - positionals: ["org/project"], + positionals: [ORG_PROJECT_TARGET], variadic: false, flags: {}, }, - trial: { positionals: [null, "org"], variadic: false, flags: {} }, + trial: { positionals: [null, ORG_TARGET], variadic: false, flags: {} }, }); }); @@ -482,7 +489,7 @@ describe("extractCommandCompletions", () => { parameters: [ { placeholder: "target", - parse: tagged("dashboard"), + parse: tagged(DASHBOARD_TARGET), }, ], }, @@ -506,19 +513,19 @@ describe("extractCommandCompletions", () => { expect(extractCommandCompletions(topLevel)).toEqual({ list: { - positionals: ["dashboard"], + positionals: [DASHBOARD_TARGET], variadic: false, flags: { "--period": null, - "--query": "issue-query", - "-q": "issue-query", + "--query": ISSUE_QUERY_TARGET, + "-q": ISSUE_QUERY_TARGET, "-t": null, }, }, search: { positionals: [], variadic: false, - flags: { "--query": "issue-query" }, + flags: { "--query": ISSUE_QUERY_TARGET }, }, }); }); @@ -560,12 +567,12 @@ describe("extractCommandCompletions", () => { extractCommandCompletions(makeRouteMap([makeEntry("shell", shell)])), ).toEqual({ shell: { - positionals: ["choice", "file"], + positionals: [CHOICE_TARGET, FILE_TARGET], variadic: false, flags: { - "--format": "choice", - "--mode": "choice", - "-f": "choice", + "--format": CHOICE_TARGET, + "--mode": CHOICE_TARGET, + "-f": CHOICE_TARGET, }, choices: { "--format": ["json", "text"], diff --git a/packages/cli/test/lib/positional-parse.test.ts b/packages/cli/test/lib/positional-parse.test.ts index 11ac5d860..d9ee1ec10 100644 --- a/packages/cli/test/lib/positional-parse.test.ts +++ b/packages/cli/test/lib/positional-parse.test.ts @@ -16,7 +16,11 @@ import { import { describe, expect, test, vi } from "vitest"; import type { ParsedOrgProject } from "../../src/lib/arg-parsing.js"; import { buildCommand } from "../../src/lib/command.js"; -import { getCompletionTarget } from "../../src/lib/completion-target.js"; +import { + getCompletionTarget, + ORG_PROJECT_TARGET, + ORG_TARGET, +} from "../../src/lib/completion-target.js"; import { ContextError, EXIT, @@ -183,7 +187,7 @@ describe("orgProjectTarget", () => { }); test("keeps completing org/project slugs", () => { - expect(getCompletionTarget(echoTarget)).toBe("org/project"); + expect(getCompletionTarget(echoTarget)).toBe(ORG_PROJECT_TARGET); }); }); @@ -254,7 +258,7 @@ describe("parsing and resolution", () => { describe("listTarget", () => { const context = { cwd: "/work" }; - const namedTarget = listTarget("org/project", { names: true }); + const namedTarget = listTarget(ORG_PROJECT_TARGET, { names: true }); test("parses what to list and the name filter", () => { expect(namedTarget.call(context, "acme/web-*").parsed).toEqual({ @@ -282,8 +286,8 @@ describe("listTarget", () => { }); test("completes org slugs only for org-scoped resources", () => { - expect(getCompletionTarget(listTarget("org"))).toBe("org"); - expect(getCompletionTarget(namedTarget)).toBe("org/project"); + expect(getCompletionTarget(listTarget(ORG_TARGET))).toBe(ORG_TARGET); + expect(getCompletionTarget(namedTarget)).toBe(ORG_PROJECT_TARGET); }); test("invalid input fails as an argument parse error", async () => { @@ -345,7 +349,7 @@ const idResolver = vi.fn( const idArguments = boundArguments({ placeholder: "target-or-id", brief: "[<target>] <id>", - kind: "org/project", + kind: ORG_PROJECT_TARGET, parse: parseIdArguments, resolve: idResolver, }); @@ -489,7 +493,7 @@ describe("boundArguments", () => { test("each argument completes as the given kind", () => { expect(getCompletionTarget(idArguments.parameter.parse)).toBe( - "org/project", + ORG_PROJECT_TARGET, ); }); });