diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 64b4d505c..75a245188 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -276,6 +276,15 @@ 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`. +List commands take their target from `listTargetPositional()` in +`src/lib/list-command.ts` (`LIST_TARGET_POSITIONAL`, +`LIST_ORG_TARGET_POSITIONAL`, or `listTargetPositional({ orgScoped, names, +multi })` for org-only completion, name globs, and comma-separated projects). +`dispatchOrgScopedList` resolves each target mode its own way, so `func` +takes a `ListCommandTarget` and passes `const { parsed, filter } = await +target.resolved` to it; resolving only applies a pasted URL's server. The +lint rule covers `ListCommandTarget` too. + Resolvers need no command name: they read it from `this.commandPrefix`, which Stricli's `forCommand` and the SDK invoker set (test mock contexts set it too). In a destructive command (`buildDeleteCommand`, or `buildCommand` with @@ -325,12 +334,12 @@ dashboard use a completion-aware parser from `src/lib/completion-target.ts` instead of `String`: `parseOrgProjectTarget` completes `org/`, `org/project`, and project aliases; `parseOrgTarget` completes org slugs only; `parseIssueTarget` adds cached issue short IDs; `parseDashboardTarget` -completes cached dashboards. `LIST_TARGET_POSITIONAL`, -`LIST_ORG_TARGET_POSITIONAL`, and `issueIdPositional` already use them. A +completes cached dashboards. `issueIdPositional` already uses them. A parser tagged with `withCompletionTarget`, such as the `target-positional.ts` -parsers, completes the same way. 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 +parsers behind `listTargetPositional`, completes the same way. 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`. @@ -840,7 +849,7 @@ boundary. Two abstraction levels exist for list commands: -1. **`src/lib/list-command.ts`** — `buildOrgListCommand` factory + shared Stricli parameter constants (`LIST_TARGET_POSITIONAL`, `LIST_JSON_FLAG`, `LIST_CURSOR_FLAG`, `buildListLimitFlag`). Use this for simple entity lists like `team list` and `repo list`. +1. **`src/lib/list-command.ts`** — `buildOrgListCommand` factory + shared Stricli parameter constants (`LIST_TARGET_POSITIONAL` and `listTargetPositional`, `LIST_JSON_FLAG`, `LIST_CURSOR_FLAG`, `buildListLimitFlag`). Use this for simple entity lists like `team list` and `repo list`. 2. **`src/lib/org-list.ts`** — `dispatchOrgScopedList` with `OrgListConfig` and a 4-mode handler map: `auto-detect`, `explicit`, `org-all`, `project-search`. Complex commands (`project list`, `issue list`) call `dispatchOrgScopedList` with an `overrides` map directly instead of using `buildOrgListCommand`. diff --git a/packages/cli/lint-rules/cli-oxlint-plugin.js b/packages/cli/lint-rules/cli-oxlint-plugin.js index d3cfcfecd..22b7c2cf0 100644 --- a/packages/cli/lint-rules/cli-oxlint-plugin.js +++ b/packages/cli/lint-rules/cli-oxlint-plugin.js @@ -61,6 +61,7 @@ function readsProperty(node, object, property) { * `boundArguments` positionals (`BoundArguments` and the commands' aliases). */ const BOUND_TARGET_TYPES = new Set([ + "ListCommandTarget", "OrgOptionalTarget", "OrgProjectTarget", "ProjectBoundTarget", diff --git a/packages/cli/src/commands/alert/issues/list.ts b/packages/cli/src/commands/alert/issues/list.ts index 7fc6c3d83..f9473ed16 100644 --- a/packages/cli/src/commands/alert/issues/list.ts +++ b/packages/cli/src/commands/alert/issues/list.ts @@ -15,7 +15,7 @@ import { buildProjectAliasMap } from "../../../lib/alias.js"; import type { IssueAlertRule } from "../../../lib/api/alerts.js"; import { paginate } from "../../../lib/api/infrastructure.js"; import { listIssueAlertsPaginated } from "../../../lib/api-client.js"; -import { parseOrgProjectArg } from "../../../lib/arg-parsing.js"; +import type { ParsedOrgProject } from "../../../lib/arg-parsing.js"; import { openInBrowser } from "../../../lib/browser.js"; import { advancePaginationState, @@ -69,6 +69,7 @@ import { resolveProjectBoundTargets, } from "../../../lib/resolve-target.js"; import { buildIssueAlertsUrl } from "../../../lib/sentry-web-urls.js"; +import type { ListCommandTarget } from "../../../lib/target-positional.js"; import type { ProjectAliasEntry, Writer } from "../../../types/index.js"; import { assertAlertListLimit, @@ -156,7 +157,7 @@ async function fetchRulesForTarget( } async function resolveWebUrl( - parsed: ReturnType, + parsed: ParsedOrgProject, cwd: string, ): Promise { if (parsed.type === "explicit") { @@ -210,7 +211,7 @@ async function resolveWebUrl( // Mode handler type ResolvedTargetsOptions = { - parsed: ReturnType; + parsed: ParsedOrgProject; flags: ListFlags; cwd: string; projectSearchResolution?: ProjectSearchTargetResolution; @@ -553,9 +554,13 @@ export const listCommand = buildListCommand("alert issues", { }, aliases: { ...LIST_BASE_ALIASES, w: "web", q: "query" }, }, - async *func(this: SentryContext, flags: ListFlags, target?: string) { + async *func( + this: SentryContext, + flags: ListFlags, + target: ListCommandTarget, + ) { const { cwd } = this; - const parsed = parseOrgProjectArg(target); + const { parsed } = await target.resolved; assertAlertListLimit(flags.limit); if (flags.web) { diff --git a/packages/cli/src/commands/dsn/list.ts b/packages/cli/src/commands/dsn/list.ts index 25d6cb847..883953382 100644 --- a/packages/cli/src/commands/dsn/list.ts +++ b/packages/cli/src/commands/dsn/list.ts @@ -10,7 +10,7 @@ import { listOrganizationDsns, listProjectDsns, } from "../../lib/api/projects.js"; -import { parseOrgProjectArg, validateLimit } from "../../lib/arg-parsing.js"; +import { validateLimit } from "../../lib/arg-parsing.js"; import { advancePaginationState, buildMultiTargetContextKey, @@ -52,6 +52,7 @@ import { resolveProjectBoundTargets, resolveTargetSlugs, } from "../../lib/resolve-target.js"; +import type { ListCommandTarget } from "../../lib/target-positional.js"; const PAGINATION_KEY = "dsn-list"; const USAGE_HINT = "sentry dsn list /"; @@ -349,10 +350,14 @@ export const listCommand = buildListCommand("dsn", { }, aliases: { n: "limit" }, }, - async *func(this: SentryContext, flags: BaseListFlags, target?: string) { + async *func( + this: SentryContext, + flags: BaseListFlags, + target: ListCommandTarget, + ) { const result: ListResult = await dispatchOrgScopedList({ config: listConfig, - parsed: parseOrgProjectArg(target), + parsed: (await target.resolved).parsed, cwd: this.cwd, flags, allowCursorInModes: ["auto-detect", "explicit", "project-search"], diff --git a/packages/cli/src/commands/issue/list.ts b/packages/cli/src/commands/issue/list.ts index c14871071..f75be5500 100644 --- a/packages/cli/src/commands/issue/list.ts +++ b/packages/cli/src/commands/issue/list.ts @@ -17,7 +17,7 @@ import { extractRequiredScopes } from "../../lib/api-scope.js"; import { formatProjectTarget, looksLikeIssueShortId, - parseOrgProjectArg, + type ParsedOrgProject, rejectIssueCommandTokenListTarget, } from "../../lib/arg-parsing.js"; @@ -61,7 +61,7 @@ import { CURSOR_KEYWORDS, LIST_BASE_ALIASES, LIST_MAX_LIMIT, - LIST_TARGET_POSITIONAL, + listTargetPositional, paginationHint, parseCursorFlag, targetPatternExplanation, @@ -90,6 +90,7 @@ import { unscopedCountFilterKeys, } from "../../lib/search-query.js"; import { isSaaS } from "../../lib/sentry-web-urls.js"; +import type { ListCommandTarget } from "../../lib/target-positional.js"; import { appendPeriodHint, formatTimeRangeFlag, @@ -1001,7 +1002,7 @@ async function handleOrganizationIssues( /** Options for {@link handleResolvedTargets}. */ type ResolvedTargetsOptions = { - parsed: ReturnType; + parsed: ParsedOrgProject; flags: ListFlags; cwd: string; timeRange: TimeRange; @@ -1710,7 +1711,7 @@ export const listCommand = buildListCommand("issue", { }, output: issueListOutput, parameters: { - positional: LIST_TARGET_POSITIONAL, + positional: listTargetPositional({ multi: true }), flags: { query: { kind: "parsed", @@ -1753,7 +1754,11 @@ export const listCommand = buildListCommand("issue", { t: "period", }, }, - async *func(this: SentryContext, rawFlags: ListFlagsInput, target?: string) { + async *func( + this: SentryContext, + rawFlags: ListFlagsInput, + target: ListCommandTarget, + ) { const { cwd } = this; const log = logger.withTag("issue.list"); @@ -1765,11 +1770,11 @@ export const listCommand = buildListCommand("issue", { sort: rawFlags.sort ?? defaultIssueSort(), }; - if (target) { - rejectIssueCommandTokenListTarget(target); + if (target.raw) { + rejectIssueCommandTokenListTarget(target.raw); } - const parsed = parseOrgProjectArg(target, { multi: true }); + const { parsed } = await target.resolved; // Auto-recover: user passed an issue short ID (e.g., "ARMAX-3E" or a // lowercase/multi-segment variant like "javascript-react-mr-1b") instead diff --git a/packages/cli/src/commands/project/list.ts b/packages/cli/src/commands/project/list.ts index 209b029ab..9393cbdeb 100644 --- a/packages/cli/src/commands/project/list.ts +++ b/packages/cli/src/commands/project/list.ts @@ -24,7 +24,6 @@ import { explicitProjectSlugs, formatProjectTarget, type ParsedOrgProject, - parseListTarget, } from "../../lib/arg-parsing.js"; import { getDefaultOrganization } from "../../lib/db/defaults.js"; import { @@ -45,7 +44,7 @@ import { buildListCommand, buildListLimitFlag, LIST_BASE_ALIASES, - NAMED_LIST_TARGET_POSITIONAL, + listTargetPositional, paginationHint, targetPatternExplanation, } from "../../lib/list-command.js"; @@ -71,6 +70,7 @@ import { resolveAllTargets, } from "../../lib/resolve-target.js"; import { getApiBaseUrl } from "../../lib/sentry-client.js"; +import type { ListCommandTarget } from "../../lib/target-positional.js"; import { shellQuote } from "../../lib/utils.js"; import type { SentryProject } from "../../types/index.js"; @@ -922,7 +922,7 @@ export const listCommand = buildListCommand("project", { jsonTransform: jsonTransformListResult, } satisfies OutputConfig>, parameters: { - positional: NAMED_LIST_TARGET_POSITIONAL, + positional: listTargetPositional({ names: true, multi: true }), flags: { limit: buildListLimitFlag("projects"), platform: { @@ -934,10 +934,14 @@ export const listCommand = buildListCommand("project", { }, aliases: { ...LIST_BASE_ALIASES, p: "platform" }, }, - async *func(this: SentryContext, cliFlags: ListFlags, rawTarget?: string) { + async *func( + this: SentryContext, + cliFlags: ListFlags, + target: ListCommandTarget, + ) { const { cwd } = this; - const { parsed, filter } = parseListTarget(rawTarget, { multi: true }); + const { parsed, filter } = await target.resolved; const flags: ProjectListFlags = { ...cliFlags, filter }; const result = await dispatchOrgScopedList({ diff --git a/packages/cli/src/commands/release/list.ts b/packages/cli/src/commands/release/list.ts index d2aa61780..200a9d946 100644 --- a/packages/cli/src/commands/release/list.ts +++ b/packages/cli/src/commands/release/list.ts @@ -13,10 +13,7 @@ import { listReleasesPaginated, type ReleaseSortValue, } from "../../lib/api-client.js"; -import { - type ParsedOrgProject, - parseOrgProjectArg, -} from "../../lib/arg-parsing.js"; +import type { ParsedOrgProject } from "../../lib/arg-parsing.js"; import { colorTag, escapeMarkdownInline, @@ -45,6 +42,7 @@ import { toNumericId, } from "../../lib/resolve-target.js"; import { buildReleaseUrl } from "../../lib/sentry-web-urls.js"; +import type { ListCommandTarget } from "../../lib/target-positional.js"; import type { SentryRelease } from "../../types/index.js"; import { fmtCrashFree } from "./view.js"; @@ -711,9 +709,13 @@ export const listCommand = buildListCommand("release", { }, aliases: { ...LIST_BASE_ALIASES, s: "sort", e: "environment", t: "period" }, }, - async *func(this: SentryContext, flags: ListFlags, target?: string) { + async *func( + this: SentryContext, + flags: ListFlags, + target: ListCommandTarget, + ) { const { cwd } = this; - const parsed = parseOrgProjectArg(target); + const { parsed } = await target.resolved; // Flatten: -e prod,dev -e staging → ["prod", "dev", "staging"] const envFilter = flags.environment ? [...flags.environment] diff --git a/packages/cli/src/lib/arg-parsing.ts b/packages/cli/src/lib/arg-parsing.ts index c5ba4d140..36595502a 100644 --- a/packages/cli/src/lib/arg-parsing.ts +++ b/packages/cli/src/lib/arg-parsing.ts @@ -624,7 +624,8 @@ export type ParsedOrgProject = /** * A list target that filters names (`/web-*`, `web-*`), from the - * target grammar when {@link parseListTarget} passes its `isNameFilter` hook. + * target grammar when {@link parseListTargetInput} passes its + * `isNameFilter` hook. */ export type ParsedNameFilter = { type: "name-filter"; @@ -803,37 +804,61 @@ export type ListTarget = { readonly filter?: string; }; +/** Which target forms a list command accepts beyond `[/][]`. */ +export type ListTargetOptions = { + /** Read a glob as a name filter (`/web-*`) */ + readonly names?: boolean; + /** Accept comma-separated projects (`/web,api`) */ + readonly multi?: boolean; +}; + +/** A parsed list target and the server of a pasted Sentry URL. */ +export type ListTargetInput = { + readonly target: ListTarget; + /** Base URL of a pasted Sentry web URL, for `applySentryUrlContext` */ + readonly baseUrl?: string; +}; + /** - * Parse the target of a list command that filters by name. A glob is a - * filter rather than a project (no slug contains glob characters; the - * grammar decides): `/web-*` lists that organization's matches, and - * `web-*` or `/web-*` the auto-detected ones. Every other form parses like - * {@link parseOrgProjectArg}, which also applies a pasted URL's server. + * Parse the target of a list command, without side effects: the server of a + * pasted Sentry URL is returned as `baseUrl` instead of applied (see + * {@link parseOrgProjectInput}). + * + * With `names`, a glob is a filter rather than a project (no slug contains + * glob characters; the grammar decides): `/web-*` lists that + * organization's matches, and `web-*` or `/web-*` the auto-detected ones. + * Every other form parses like {@link parseOrgProjectInput}. * * @example - * parseListTarget("acme/web-*") // { parsed: { type: "org-all", org: "acme" }, filter: "web-*" } - * parseListTarget("web-*") // { parsed: { type: "auto-detect" }, filter: "web-*" } - * parseListTarget("acme/web") // { parsed: { type: "explicit", org: "acme", project: "web" } } + * parseListTargetInput("acme/web-*", { names: true }).target + * // { parsed: { type: "org-all", org: "acme" }, filter: "web-*" } + * parseListTargetInput("web-*", { names: true }).target + * // { parsed: { type: "auto-detect" }, filter: "web-*" } + * parseListTargetInput("acme/web").target + * // { parsed: { type: "explicit", org: "acme", project: "web" } } */ -export function parseListTarget( +export function parseListTargetInput( arg: string | undefined, - options: { multi?: boolean } = {}, -): ListTarget { + options: ListTargetOptions = {}, +): ListTargetInput { + const hooks = targetHooks(options); const { parsed, baseUrl } = parseTargetInput(arg, (text) => - parseTarget(text, { ...targetHooks(options), isNameFilter: isGlobFilter }), + options.names + ? parseTarget(text, { ...hooks, isNameFilter: isGlobFilter }) + : parseTarget(text, hooks), ); - if (baseUrl) { - applySentryUrlContext(baseUrl); - } if (parsed.type !== "name-filter") { - return { parsed }; + return { target: { parsed }, baseUrl }; } return { - parsed: - parsed.org === undefined - ? { type: "auto-detect" } - : { type: "org-all", org: parsed.org }, - filter: parsed.filter, + target: { + parsed: + parsed.org === undefined + ? { type: "auto-detect" } + : { type: "org-all", org: parsed.org }, + filter: parsed.filter, + }, + baseUrl, }; } diff --git a/packages/cli/src/lib/list-command.ts b/packages/cli/src/lib/list-command.ts index 19ac91ace..388b6bc01 100644 --- a/packages/cli/src/lib/list-command.ts +++ b/packages/cli/src/lib/list-command.ts @@ -19,13 +19,13 @@ import type { SentryContext } from "../context.js"; const _require = createRequire(import.meta.url); -import { parseListTarget, parseOrgProjectArg } from "./arg-parsing.js"; +import type { ListTargetOptions } from "./arg-parsing.js"; import { buildCommand, type CommandDocumentation, numberParser, } from "./command.js"; -import { parseOrgProjectTarget, parseOrgTarget } from "./completion-target.js"; +import { ORG_PROJECT_TARGET, ORG_TARGET } from "./completion-target.js"; import { disableOrgCache } from "./db/regions.js"; import { logger } from "./logger.js"; @@ -51,6 +51,7 @@ import { skippedArgument, } from "./positional-parse.js"; import { disableResponseCache } from "./response-cache.js"; +import { type ListCommandTarget, listTarget } from "./target-positional.js"; import { PERIOD_BRIEF, parsePeriod } from "./time-range.js"; import { shellQuote } from "./utils.js"; @@ -84,8 +85,8 @@ export const ORG_ALL_TARGET_BRIEF = /** * {@link ORG_ALL_TARGET_BRIEF} for list commands that also match names - * (see {@link parseListTarget}): a glob filters names, and a name that is - * no project matches names instead. + * (see {@link parseListTargetInput}): a glob filters names, and a name that + * is no project matches names instead. */ export const NAMED_LIST_TARGET_BRIEF = "/ (all), / or (a project, else matching names), or a name glob (/web-*)"; @@ -94,63 +95,53 @@ export const NAMED_LIST_TARGET_BRIEF = export const PROJECT_TARGET_BRIEF = "/ or (search)"; /** - * Positional `org/project` parameter shared by all list commands. + * Positional `org/project` parameter of a list command, parsed with + * {@link listTarget}: `func` gets a `ListCommandTarget` and passes + * `(await target.resolved)` to `dispatchOrgScopedList`. * * Accepts `/`, `/`, or bare `` (search). * Marked optional so the command falls back to auto-detection when omitted. + * + * @param options.orgScoped - For org-scoped resources (teams, repos, + * releases, …): shell completion suggests org slugs only + * @param options.names - A glob filters names, and a name that is no + * project matches names instead */ -export const LIST_TARGET_POSITIONAL = { - kind: "tuple" as const, - parameters: [ - { - placeholder: "org/project", - brief: ORG_ALL_TARGET_BRIEF, - parse: parseOrgProjectTarget, - optional: true as const, - }, - ], -}; +export function listTargetPositional( + options: ListTargetOptions & { readonly orgScoped?: boolean } = {}, +) { + const { orgScoped, ...parseOptions } = options; + return { + kind: "tuple" as const, + parameters: [ + { + placeholder: "org/project", + brief: parseOptions.names + ? NAMED_LIST_TARGET_BRIEF + : ORG_ALL_TARGET_BRIEF, + parse: listTarget( + orgScoped ? ORG_TARGET : ORG_PROJECT_TARGET, + parseOptions, + ), + optional: true as const, + }, + ], + }; +} -/** - * {@link LIST_TARGET_POSITIONAL} for org-scoped resources (teams, repos, - * releases, …): same syntax, but shell completion suggests org slugs only. - */ -export const LIST_ORG_TARGET_POSITIONAL = { - kind: "tuple" as const, - parameters: [ - { - placeholder: "org/project", - brief: ORG_ALL_TARGET_BRIEF, - parse: parseOrgTarget, - optional: true as const, - }, - ], -}; +/** {@link listTargetPositional} for most list commands. */ +export const LIST_TARGET_POSITIONAL = listTargetPositional(); -/** - * {@link LIST_TARGET_POSITIONAL} for list commands whose target also - * matches names; parse it with {@link parseListTarget}. - */ -export const NAMED_LIST_TARGET_POSITIONAL = { - kind: "tuple" as const, - parameters: [ - { ...LIST_TARGET_POSITIONAL.parameters[0], brief: NAMED_LIST_TARGET_BRIEF }, - ], -}; +/** {@link listTargetPositional} for org-scoped resources. */ +export const LIST_ORG_TARGET_POSITIONAL = listTargetPositional({ + orgScoped: true, +}); -/** - * {@link LIST_ORG_TARGET_POSITIONAL} for org-scoped resources whose target - * also matches names; parse it with {@link parseListTarget}. - */ -export const NAMED_LIST_ORG_TARGET_POSITIONAL = { - kind: "tuple" as const, - parameters: [ - { - ...LIST_ORG_TARGET_POSITIONAL.parameters[0], - brief: NAMED_LIST_TARGET_BRIEF, - }, - ], -}; +/** {@link listTargetPositional} for org-scoped resources matched by name. */ +export const NAMED_LIST_ORG_TARGET_POSITIONAL = listTargetPositional({ + orgScoped: true, + names: true, +}); /** * Short note for commands that accept a bare project name but do not support @@ -756,7 +747,7 @@ function formatListHuman( * encapsulated in the shared org-list framework. The resulting command has: * - An optional positional `target` argument * - `--limit` / `-n`, `--json`, `--fields`, `--cursor` / `-c` flags - * - A `func` that calls `parseOrgProjectArg` then `dispatchOrgScopedList` + * - A `func` that passes the resolved target to `dispatchOrgScopedList` * * Rendering is handled automatically via `OutputConfig`: * - JSON mode produces paginated envelopes or flat arrays @@ -797,12 +788,10 @@ export function buildOrgListCommand( readonly fresh: boolean; readonly fields?: string[]; }, - rawTarget?: string, + target: ListCommandTarget, ) { const { cwd } = this; - const { parsed, filter } = config.namesOf - ? parseListTarget(rawTarget) - : { parsed: parseOrgProjectArg(rawTarget), filter: undefined }; + const { parsed, filter } = await target.resolved; const result = await dispatchOrgScopedList({ config, cwd, diff --git a/packages/cli/src/lib/list-filter.ts b/packages/cli/src/lib/list-filter.ts index 0a1247f14..bd97f1b9d 100644 --- a/packages/cli/src/lib/list-filter.ts +++ b/packages/cli/src/lib/list-filter.ts @@ -12,7 +12,8 @@ const GLOB_CHARS_RE = /[*?[\]{}]/; /** * Whether `filter` is a glob (`acme-*`) rather than plain text. The target - * grammar uses it to tell a name filter from a project (`parseListTarget`). + * grammar uses it to tell a name filter from a project + * (`parseListTargetInput`). */ export function isGlobFilter(filter: string): boolean { return GLOB_CHARS_RE.test(filter); diff --git a/packages/cli/src/lib/target-positional.ts b/packages/cli/src/lib/target-positional.ts index 1b34c4d25..6158145de 100644 --- a/packages/cli/src/lib/target-positional.ts +++ b/packages/cli/src/lib/target-positional.ts @@ -9,6 +9,10 @@ * parser or resolver itself. An omitted target arrives as auto-detect. See * `positional-parse.ts` for when parsing and resolution run. * + * List commands use {@link listTarget} instead: `dispatchOrgScopedList` + * resolves each target mode its own way, so `target.resolved` only applies a + * pasted URL's server and yields the parsed target. + * * Resolution gets the command path from `commandPrefix`, which Stricli's * `forCommand` (and the SDK invoker) set on the context, so usage hints need * no hand-written command name. Destructive commands (`destructive: true`, @@ -16,8 +20,14 @@ * `func` runs. */ -import { type ParsedOrgProject, parseOrgProjectInput } from "./arg-parsing.js"; -import { ORG_PROJECT_TARGET } from "./completion-target.js"; +import { + type ListTarget, + type ListTargetOptions, + type ParsedOrgProject, + parseListTargetInput, + parseOrgProjectInput, +} from "./arg-parsing.js"; +import { ORG_PROJECT_TARGET, type ORG_TARGET } from "./completion-target.js"; import { requireExplicitTarget } from "./mutate-command.js"; import { type BoundPositional, boundPositional } from "./positional-parse.js"; import { @@ -113,3 +123,35 @@ export type ProjectBoundTarget = OrgProjectTarget; export const projectBoundTarget = orgProjectTarget((parsed, context) => resolveProjectBoundTarget(parsed, context.cwd, context.commandName), ); + +/** + * The target of a list command. `target.parsed` is what to list and the name + * filter, if any; `await target.resolved` yields the same once a pasted + * Sentry URL's server is applied. List commands hand it to + * `dispatchOrgScopedList`, which resolves each target mode its own way. + */ +export type ListCommandTarget = BoundPositional; + +/** + * Positional parser for a list command's `[/][]` target. + * + * @param kind - What shell completion suggests: org slugs only for + * org-scoped resources, else orgs, projects, and aliases + */ +export function listTarget( + kind: typeof ORG_TARGET | typeof ORG_PROJECT_TARGET, + options: ListTargetOptions = {}, +): (this: unknown, input?: string) => ListCommandTarget { + return boundPositional( + kind, + (raw) => parseListTargetInput(raw, options).target, + async (target, _context: unknown, raw) => { + // Parsing again is cheap and pure; it recovers the URL's server + const { baseUrl } = parseListTargetInput(raw, options); + if (baseUrl) { + applySentryUrlContext(baseUrl); + } + return target; + }, + ); +} diff --git a/packages/cli/test/lib/arg-parsing.test.ts b/packages/cli/test/lib/arg-parsing.test.ts index c3bbaf794..45ee21df3 100644 --- a/packages/cli/test/lib/arg-parsing.test.ts +++ b/packages/cli/test/lib/arg-parsing.test.ts @@ -13,7 +13,7 @@ import { looksLikeIssueShortId, normalizeSlug, parseIssueArg, - parseListTarget, + parseListTargetInput, parseOrgProjectArg, parseSlashSeparatedArg, rejectIssueCommandTokenListTarget, @@ -1350,7 +1350,7 @@ describe("detectSwappedTrialArgs", () => { }); }); -describe("parseListTarget", () => { +describe("parseListTargetInput", () => { test.each([ [undefined, { parsed: { type: "auto-detect" } }], ["my-org/", { parsed: { type: "org-all", org: "my-org" } }], @@ -1378,11 +1378,16 @@ describe("parseListTarget", () => { { parsed: { type: "org-all", org: "my-org" }, filter: "web-*" }, ], ] as const)("%j → %j", (target, expected) => { - expect(parseListTarget(target, { multi: true })).toEqual(expected); + expect( + parseListTargetInput(target, { names: true, multi: true }).target, + ).toEqual(expected); }); test("a project list is still a list", () => { - expect(parseListTarget("my-org/web,api", { multi: true }).parsed).toEqual({ + expect( + parseListTargetInput("my-org/web,api", { names: true, multi: true }) + .target.parsed, + ).toEqual({ type: "explicit", org: "my-org", project: "web", @@ -1391,11 +1396,30 @@ describe("parseListTarget", () => { }); test("a glob after a second slash is not a filter", () => { - expect(parseListTarget("my-org/proj/x*").filter).toBeUndefined(); + expect( + parseListTargetInput("my-org/proj/x*", { names: true }).target.filter, + ).toBeUndefined(); }); test("the org of a filter is validated like any org slug", () => { - expect(() => parseListTarget("@latest/web-*")).toThrow(ValidationError); + expect(() => + parseListTargetInput("@latest/web-*", { names: true }), + ).toThrow(ValidationError); + }); + + test("without names, a glob names a project", () => { + expect(parseListTargetInput("my-org/web-*").target).toEqual({ + parsed: { type: "explicit", org: "my-org", project: "web-*" }, + }); + }); + + test("returns a pasted URL's server instead of applying it", () => { + expect( + parseListTargetInput("https://sentry.example.com/organizations/acme/"), + ).toEqual({ + target: { parsed: { type: "org-all", org: "acme" } }, + baseUrl: "https://sentry.example.com", + }); }); test("targets of other commands never parse as filters", () => { diff --git a/packages/cli/test/lib/positional-parse.test.ts b/packages/cli/test/lib/positional-parse.test.ts index 099fe70af..d9ee1ec10 100644 --- a/packages/cli/test/lib/positional-parse.test.ts +++ b/packages/cli/test/lib/positional-parse.test.ts @@ -1,6 +1,6 @@ /** * Bound target positionals (src/lib/positional-parse.ts, - * src/lib/target-positional.ts). + * src/lib/target-positional.ts, the list targets in src/lib/list-command.ts). * * Runs a test command through Stricli's `run()` so the positional parser, * the command wrapper, and exit-code handling are exercised together. @@ -19,6 +19,7 @@ import { buildCommand } from "../../src/lib/command.js"; import { getCompletionTarget, ORG_PROJECT_TARGET, + ORG_TARGET, } from "../../src/lib/completion-target.js"; import { ContextError, @@ -27,6 +28,7 @@ import { HostScopeError, ValidationError, } from "../../src/lib/errors.js"; +import { LIST_TARGET_POSITIONAL } from "../../src/lib/list-command.js"; import { applyBoundPositionals, type BoundArguments, @@ -39,6 +41,8 @@ import { } from "../../src/lib/positional-parse.js"; import { buildRouteMap } from "../../src/lib/route-map.js"; import { + type ListCommandTarget, + listTarget, type OrgProjectTarget, orgProjectTarget, } from "../../src/lib/target-positional.js"; @@ -64,6 +68,39 @@ const echoTarget = orgProjectTarget(echoResolver); type EchoTarget = OrgProjectTarget>>; +/** Run `test cmd ...args` through Stricli, as the CLI does. */ +async function runCommand( + command: ReturnType, + args: string[], +): Promise<{ stderr: string; exitCode: number | undefined }> { + const app = buildApplication( + buildRouteMap({ routes: { cmd: command }, docs: { brief: "Test" } }), + { name: "test", determineExitCode: getExitCode }, + ); + let stderr = ""; + const stdout = { write: () => true }; + const context = { + cwd: "/work", + // Like buildContext: Stricli passes the command path to forCommand + forCommand({ prefix }: { prefix: readonly string[] }) { + return { ...this, commandPrefix: prefix }; + }, + process: { + stdout, + stderr: { + write: (s: string) => { + stderr += s; + return true; + }, + }, + exitCode: undefined, + }, + stdout, + } as unknown as TestContext; + await run(app, ["cmd", ...args], context); + return { stderr, exitCode: context.process.exitCode }; +} + /** Run `test cmd ...args`, recording what `target.resolved` yields. */ async function runWithTarget( args: string[], @@ -101,32 +138,7 @@ async function runWithTarget( resolved.push(readResolved ? await target.resolved : target.parsed); }, }); - const app = buildApplication( - buildRouteMap({ routes: { cmd: command }, docs: { brief: "Test" } }), - { name: "test", determineExitCode: getExitCode }, - ); - let stderr = ""; - const stdout = { write: () => true }; - const context = { - cwd: "/work", - // Like buildContext: Stricli passes the command path to forCommand - forCommand({ prefix }: { prefix: readonly string[] }) { - return { ...this, commandPrefix: prefix }; - }, - process: { - stdout, - stderr: { - write: (s: string) => { - stderr += s; - return true; - }, - }, - exitCode: undefined, - }, - stdout, - } as unknown as TestContext; - await run(app, ["cmd", ...args], context); - return { resolved, stderr, exitCode: context.process.exitCode }; + return { resolved, ...(await runCommand(command, args)) }; } describe("orgProjectTarget", () => { @@ -244,6 +256,62 @@ describe("parsing and resolution", () => { }); }); +describe("listTarget", () => { + const context = { cwd: "/work" }; + 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({ + parsed: { type: "org-all", org: "acme" }, + filter: "web-*", + }); + expect(namedTarget.call(context, undefined).parsed).toEqual({ + parsed: { type: "auto-detect" }, + }); + }); + + test("resolves to the parsed target", async () => { + const target = namedTarget.call(context, "acme/web"); + await expect(target.resolved).resolves.toBe(target.parsed); + }); + + test("applies a pasted URL's server when resolving, not when parsing", async () => { + // No account for this server: applying it throws HostScopeError + const target = namedTarget.call( + context, + "https://sentry.example.com/organizations/acme/", + ); + expect(target.parsed).toEqual({ parsed: { type: "org-all", org: "acme" } }); + await expect(target.resolved).rejects.toThrow(HostScopeError); + }); + + test("completes org slugs only for org-scoped resources", () => { + 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 () => { + const resolved: unknown[] = []; + const command = buildCommand< + Record, + [ListCommandTarget], + TestContext + >({ + docs: { brief: "Lists things" }, + auth: false, + parameters: { positional: LIST_TARGET_POSITIONAL }, + // oxlint-disable-next-line require-yield -- test command only records the resolved target + async *func(_flags, target) { + resolved.push(await target.resolved); + }, + }); + const { stderr, exitCode } = await runCommand(command, ["@latest"]); + expect(resolved).toEqual([]); + expect(exitCode).toBe(ExitCode.InvalidArgument); + expect(stderr).toContain('Failed to parse "@latest"'); + }); +}); + describe("rawPositional", () => { const context = { cwd: "/work" };