diff --git a/.oxlintrc.json b/.oxlintrc.json index 233e8d2e0..4848b6473 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -69,6 +69,7 @@ "sentry-cli/no-inline-touch-cache": "error", "sentry-cli/no-args-join-in-release": "error", "sentry-cli/no-direct-target-resolution": "error", + "sentry-cli/require-target-resolution": "error", "sentry-cli/no-generic-is-record": "error", "sentry-cli/prefer-paginate-helper": "error", "sentry-cli/no-namespace-import": "error", diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 2e8d0c163..6f9b892d3 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -239,11 +239,41 @@ const parsed = parseOrgProjectArg(targetArg); Reference: `span/list.ts`, `trace/view.ts`, `event/view.ts` +**Target positionals:** A single `[/][]` positional uses a +parser from `src/lib/target-positional.ts`, which binds the resolver: +`orgOptionalTarget` (org, optional project), `projectBoundTarget` (one org and +project), or `orgProjectTarget(resolve)` for a command-specific resolver (see +`project view`). `func` takes the matching type (`OrgOptionalTarget`, +`ProjectBoundTarget`, `OrgProjectTarget`), reads the parsed form as +`target.parsed` (e.g. `target.parsed.type`), and gets the org and project +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`. + +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 `buildDeleteCommand` command, the target refuses to auto-detect, so +delete commands don't call `requireExplicitTarget` for it. + +Both steps are lazy (`lazyPositional` in `src/lib/positional-parse.ts`): the +parse runs on the first `parsed` read and the resolver on the first +`resolved` read, both inside `func` after the auth guard. Invalid input still +throws the parser's `CliError` with its exit code. `target.raw` is what the +user typed; shared code that handles any positional uses +`rawPositional(value)`. `buildCommand` runs these parsers on +positionals that arrive as strings or are omitted, so the SDK invoke path and +tests that call `command.loader()` keep passing raw strings. Positionals whose +meaning depends on how many arguments were given, such as +`[//]`, keep parsing in `func`. + **Shell completion:** Target positionals that take an org or project 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. `LIST_TARGET_POSITIONAL` -and `LIST_ORG_TARGET_POSITIONAL` already use them. +and `LIST_ORG_TARGET_POSITIONAL` already use them. A parser tagged with +`withCompletionTarget`, such as the `target-positional.ts` parsers, completes +the same way. `pnpm run generate:completions` collects these into the gitignored `src/generated/completion-targets.json` that the `__complete` fast path reads. `project create` keeps its dedicated `name:platform` completer in diff --git a/packages/cli/lint-rules/cli-oxlint-plugin.js b/packages/cli/lint-rules/cli-oxlint-plugin.js index d1035dd58..69f58b882 100644 --- a/packages/cli/lint-rules/cli-oxlint-plugin.js +++ b/packages/cli/lint-rules/cli-oxlint-plugin.js @@ -25,20 +25,53 @@ function rule(message, visitors) { }; } -function containsIdentifier(node, identifier) { +function someNode(node, predicate) { if (!node || typeof node !== "object") return false; - if (node.type === "Identifier" && node.name === identifier) return true; + if (predicate(node)) return true; return Object.entries(node).some( ([key, value]) => key !== "parent" && (Array.isArray(value) - ? value.some((child) => containsIdentifier(child, identifier)) - : value && - typeof value === "object" && - containsIdentifier(value, identifier)), + ? value.some((child) => someNode(child, predicate)) + : value && typeof value === "object" && someNode(value, predicate)), ); } +function containsIdentifier(node, identifier) { + return someNode( + node, + (n) => n.type === "Identifier" && n.name === identifier, + ); +} + +function readsProperty(node, object, property) { + return someNode( + node, + (n) => + member(n, property) && + n.object.type === "Identifier" && + n.object.name === object, + ); +} + +/** Target positional types from `src/lib/target-positional.ts`. */ +const BOUND_TARGET_TYPES = new Set([ + "OrgOptionalTarget", + "OrgProjectTarget", + "ProjectBoundTarget", +]); + +function boundTargetParams(fn) { + return fn.params.filter((param) => { + const type = param.typeAnnotation?.typeAnnotation; + return ( + param.type === "Identifier" && + type?.type === "TSTypeReference" && + BOUND_TARGET_TYPES.has(type.typeName.name) + ); + }); +} + function silentBody(block, parameter) { if (block?.type !== "BlockStatement" || block.body.length > 1) return false; if (block.body.length === 0) return true; @@ -203,6 +236,22 @@ const rules = { } : {}, ), + "require-target-resolution": rule( + "Await `.resolved`: it runs the target's resolver and destructive-command guard.", + (_context, file, report) => { + if (!file.includes("/src/commands/")) return {}; + const check = (node) => { + for (const param of boundTargetParams(node)) { + if (!readsProperty(node.body, param.name, "resolved")) report(param); + } + }; + return { + FunctionDeclaration: check, + FunctionExpression: check, + ArrowFunctionExpression: check, + }; + }, + ), "no-generic-is-record": rule( "Use a shape-specific guard instead of isRecord().", (_context, file, report) => diff --git a/packages/cli/src/commands/feedback/list.ts b/packages/cli/src/commands/feedback/list.ts index d146db42e..6502e2fba 100644 --- a/packages/cli/src/commands/feedback/list.ts +++ b/packages/cli/src/commands/feedback/list.ts @@ -11,7 +11,6 @@ import { listFeedback, } from "../../lib/api-client.js"; import { validateLimit } from "../../lib/arg-parsing.js"; -import { parseOrgProjectTarget } from "../../lib/completion-target.js"; import { advancePaginationState, buildPaginationContextKey, @@ -35,10 +34,13 @@ import { jsonTransformListResult } from "../../lib/org-list.js"; import { withProgress } from "../../lib/polling.js"; import { type ResolvedOrgOptionalTarget, - resolveOrgOptionalFromArg, toNumericId, } from "../../lib/resolve-target.js"; import { sanitizeQuery } from "../../lib/search-query.js"; +import { + type OrgOptionalTarget, + orgOptionalTarget, +} from "../../lib/target-positional.js"; import { appendPeriodHint, PERIOD_BRIEF, @@ -55,7 +57,6 @@ import { const DEFAULT_PERIOD = "14d"; const DEFAULT_STATUS: FeedbackStatus = "unresolved"; const PAGINATION_KEY = "feedback-list"; -const COMMAND_NAME = "feedback list"; type ListFlags = { readonly status: FeedbackStatus; @@ -159,7 +160,7 @@ export const listCommand = buildListCommand("feedback", { { placeholder: "org/project", brief: "/, /, or (search)", - parse: parseOrgProjectTarget, + parse: orgOptionalTarget, optional: true, }, ], @@ -196,12 +197,12 @@ export const listCommand = buildListCommand("feedback", { q: "query", }, }, - async *func(this: SentryContext, flags: ListFlags, target?: string) { - const resolved = await resolveOrgOptionalFromArg( - target, - this.cwd, - COMMAND_NAME, - ); + async *func( + this: SentryContext, + flags: ListFlags, + target: OrgOptionalTarget, + ) { + const resolved = await target.resolved; const projectId = await projectIdFor(resolved); const contextKey = buildPaginationContextKey( "feedback", diff --git a/packages/cli/src/commands/project/delete.ts b/packages/cli/src/commands/project/delete.ts index 3598d0554..fda33c9d4 100644 --- a/packages/cli/src/commands/project/delete.ts +++ b/packages/cli/src/commands/project/delete.ts @@ -14,7 +14,8 @@ * Safety measures: * - Uses `buildDeleteCommand` — auto-injects `--yes`/`--force`/`--dry-run` * flags and enforces the non-interactive guard before `func()` runs - * - No auto-detect mode: `requireExplicitTarget` blocks accidental deletion + * - No auto-detect mode: `buildDeleteCommand` marks the target destructive, so + * `target.resolved` rejects an omitted target * - Type-out confirmation via `confirmByTyping` (unless --yes/--force) */ @@ -24,8 +25,6 @@ import { getOrganization, getProject, } from "../../lib/api-client.js"; -import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; -import { parseOrgProjectTarget } from "../../lib/completion-target.js"; import { getCachedOrgRole } from "../../lib/db/regions.js"; import { ApiError } from "../../lib/errors.js"; import { @@ -38,16 +37,15 @@ import { buildDeleteCommand, confirmByTyping, isConfirmationBypassed, - requireExplicitTarget, } from "../../lib/mutate-command.js"; -import { resolveProjectBoundTarget } from "../../lib/resolve-target.js"; import { buildProjectUrl } from "../../lib/sentry-web-urls.js"; +import { + type ProjectBoundTarget, + projectBoundTarget, +} from "../../lib/target-positional.js"; const log = logger.withTag("project.delete"); -/** Command name used in error messages and resolution hints */ -const COMMAND_NAME = "project delete"; - /** * Build an actionable 403 error by checking the user's org role. * @@ -169,23 +167,17 @@ export const deleteCommand = buildDeleteCommand({ { placeholder: "org/project", brief: "/ or (search across orgs)", - parse: parseOrgProjectTarget, + parse: projectBoundTarget, }, ], }, }, - async *func(this: SentryContext, flags: DeleteFlags, target: string) { - const { cwd } = this; - - // Block auto-detect for safety — destructive commands require explicit targets - const parsed = parseOrgProjectArg(target); - requireExplicitTarget( - parsed, - "Project target", - `sentry ${COMMAND_NAME} /`, - ); - - const resolved = await resolveProjectBoundTarget(parsed, cwd, COMMAND_NAME); + async *func( + this: SentryContext, + flags: DeleteFlags, + target: ProjectBoundTarget, + ) { + const resolved = await target.resolved; const { org: orgSlug, project: projectSlug } = resolved; // Use already-fetched project data from project-search, or fetch for diff --git a/packages/cli/src/commands/project/view.ts b/packages/cli/src/commands/project/view.ts index aa4e2bb5f..7d509a1ef 100644 --- a/packages/cli/src/commands/project/view.ts +++ b/packages/cli/src/commands/project/view.ts @@ -12,12 +12,11 @@ import { tryGetPrimaryDsn, } from "../../lib/api-client.js"; import { + type ParsedOrgProject, ProjectSpecificationType, - parseOrgProjectArg, } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; -import { parseOrgProjectTarget } from "../../lib/completion-target.js"; import { AuthError, ContextError, withAuthGuard } from "../../lib/errors.js"; import { divider, formatProjectDetails } from "../../lib/formatters/index.js"; import { filterFields } from "../../lib/formatters/json.js"; @@ -34,6 +33,11 @@ import { resolveProjectBoundSlug, } from "../../lib/resolve-target.js"; import { buildProjectUrl } from "../../lib/sentry-web-urls.js"; +import { + type OrgProjectTarget, + orgProjectTarget, + type TargetContext, +} from "../../lib/target-positional.js"; import type { SentryProject } from "../../types/index.js"; type ViewFlags = { @@ -257,6 +261,72 @@ function formatProjectViewHuman(entries: ProjectViewEntry[]): string { return parts.join("\n"); } +/** Project view targets: one for an explicit or searched project, all detected ones for auto-detect. */ +type ViewTargets = { targets: ResolvedTarget[]; footer?: string }; + +/** Resolve the `project view` positional to the projects to show. */ +async function resolveViewTargets( + parsed: ParsedOrgProject, + { cwd }: TargetContext, +): Promise { + switch (parsed.type) { + case ProjectSpecificationType.Explicit: + // Direct org/project - single target, no multi-target resolution + return { + targets: [ + { + org: parsed.org, + project: parsed.project, + orgDisplay: parsed.org, + projectDisplay: parsed.project, + }, + ], + }; + + case ProjectSpecificationType.ProjectSearch: { + // Search for project across all orgs - single target + const resolved = await resolveProjectBoundSlug( + parsed.projectSlug, + USAGE_HINT, + `sentry project view /${parsed.projectSlug}`, + parsed.originalSlug, + ); + return { + targets: [ + { + org: resolved.org, + project: resolved.project, + orgDisplay: resolved.org, + projectDisplay: resolved.project, + projectData: resolved.projectData, + }, + ], + }; + } + + case ProjectSpecificationType.OrgAll: + throw new ContextError( + "Specific project", + `sentry project view ${parsed.org}/`, + ["Specify the full org/project target, not just the organization"], + ); + + case ProjectSpecificationType.AutoDetect: { + // Auto-detect supports monorepo multi-target resolution + const result = await resolveAllTargets({ cwd }); + + if (result.targets.length === 0) { + throw buildContextError(result.skippedSelfHosted); + } + + return { targets: result.targets, footer: result.footer }; + } + + default: + throw new ContextError("Organization and project", USAGE_HINT); + } +} + export const viewCommand = buildCommand({ docs: { brief: "View details of a project", @@ -280,7 +350,7 @@ export const viewCommand = buildCommand({ { placeholder: "org/project", brief: "/, (search), or omit for auto-detect", - parse: parseOrgProjectTarget, + parse: orgProjectTarget(resolveViewTargets), optional: true, }, ], @@ -295,71 +365,14 @@ export const viewCommand = buildCommand({ }, aliases: { ...FRESH_ALIASES, w: "web" }, }, - async *func(this: SentryContext, flags: ViewFlags, targetArg?: string) { + async *func( + this: SentryContext, + flags: ViewFlags, + target: OrgProjectTarget, + ) { applyFreshFlag(flags); - const { cwd } = this; - - const parsed = parseOrgProjectArg(targetArg); - - let resolvedTargets: ResolvedTarget[]; - let footer: string | undefined; - - switch (parsed.type) { - case ProjectSpecificationType.Explicit: - // Direct org/project - single target, no multi-target resolution - resolvedTargets = [ - { - org: parsed.org, - project: parsed.project, - orgDisplay: parsed.org, - projectDisplay: parsed.project, - }, - ]; - break; - - case ProjectSpecificationType.ProjectSearch: { - // Search for project across all orgs - single target - const resolved = await resolveProjectBoundSlug( - parsed.projectSlug, - USAGE_HINT, - `sentry project view /${parsed.projectSlug}`, - parsed.originalSlug, - ); - resolvedTargets = [ - { - org: resolved.org, - project: resolved.project, - orgDisplay: resolved.org, - projectDisplay: resolved.project, - projectData: resolved.projectData, - }, - ]; - break; - } - case ProjectSpecificationType.OrgAll: - throw new ContextError( - "Specific project", - `sentry project view ${parsed.org}/`, - ["Specify the full org/project target, not just the organization"], - ); - - case ProjectSpecificationType.AutoDetect: { - // Auto-detect supports monorepo multi-target resolution - const result = await resolveAllTargets({ cwd }); - - if (result.targets.length === 0) { - throw buildContextError(result.skippedSelfHosted); - } - - resolvedTargets = result.targets; - footer = result.footer; - break; - } - - default: - throw new ContextError("Organization and project", USAGE_HINT); - } + const { targets: resolvedTargets, footer } = await target.resolved; if (flags.web) { await handleWebView(resolvedTargets); @@ -373,7 +386,7 @@ export const viewCommand = buildCommand({ let dsns: (string | null)[]; let targets: ResolvedTarget[]; - if (parsed.type === ProjectSpecificationType.AutoDetect) { + if (target.parsed.type === ProjectSpecificationType.AutoDetect) { const fetched = await fetchAllProjectDetails(resolvedTargets); projects = fetched.projects; dsns = fetched.dsns; diff --git a/packages/cli/src/commands/replay/list.ts b/packages/cli/src/commands/replay/list.ts index 47eeed5aa..073e39fbd 100644 --- a/packages/cli/src/commands/replay/list.ts +++ b/packages/cli/src/commands/replay/list.ts @@ -11,7 +11,6 @@ import { type ReplaySortValue, } from "../../lib/api-client.js"; import { validateLimit } from "../../lib/arg-parsing.js"; -import { parseOrgProjectTarget } from "../../lib/completion-target.js"; import { advancePaginationState, buildPaginationContextKey, @@ -45,8 +44,11 @@ import { getReplayUserLabel, parseReplayEnvironmentFilter, } from "../../lib/replay-search.js"; -import { resolveOrgOptionalFromArg } from "../../lib/resolve-target.js"; import { sanitizeQuery } from "../../lib/search-query.js"; +import { + type OrgOptionalTarget, + orgOptionalTarget, +} from "../../lib/target-positional.js"; import { appendPeriodHint, serializeTimeRange, @@ -93,7 +95,6 @@ const SORT_MAP: Record = { const DEFAULT_PERIOD = LIST_PERIOD_FLAG.default; const DEFAULT_SORT: ReplaySortValue = SORT_MAP.date; const PAGINATION_KEY = "replay-list"; -const COMMAND_NAME = "replay list"; function parseLimit(value: string): number { return validateLimit(value, LIST_MIN_LIMIT, LIST_MAX_LIMIT); @@ -281,7 +282,7 @@ export const listCommand = buildListCommand("replay", { { placeholder: "org/project", brief: "/, /, or (search)", - parse: parseOrgProjectTarget, + parse: orgOptionalTarget, optional: true, }, ], @@ -323,13 +324,16 @@ export const listCommand = buildListCommand("replay", { s: "sort", }, }, - async *func(this: SentryContext, flags: ListFlags, target?: string) { - const { cwd } = this; + async *func( + this: SentryContext, + flags: ListFlags, + target: OrgOptionalTarget, + ) { const timeRange = flags.period; const environment = parseReplayEnvironmentFilter(flags.environment); const { query } = flags; - const resolved = await resolveOrgOptionalFromArg(target, cwd, COMMAND_NAME); + const resolved = await target.resolved; const contextKey = buildPaginationContextKey( "replay", diff --git a/packages/cli/src/commands/trace/list.ts b/packages/cli/src/commands/trace/list.ts index cc55d4b0a..1cefeb846 100644 --- a/packages/cli/src/commands/trace/list.ts +++ b/packages/cli/src/commands/trace/list.ts @@ -7,7 +7,6 @@ import type { SentryContext } from "../../context.js"; import { listTransactions } from "../../lib/api-client.js"; import { validateLimit } from "../../lib/arg-parsing.js"; -import { parseOrgProjectTarget } from "../../lib/completion-target.js"; import { advancePaginationState, buildPaginationContextKey, @@ -30,11 +29,12 @@ import { TARGET_PATTERN_NOTE, } from "../../lib/list-command.js"; import { withProgress } from "../../lib/polling.js"; -import { - resolveLogProjectId, - resolveProjectBoundFromArg, -} from "../../lib/resolve-target.js"; +import { resolveLogProjectId } from "../../lib/resolve-target.js"; import { sanitizeQuery } from "../../lib/search-query.js"; +import { + type ProjectBoundTarget, + projectBoundTarget, +} from "../../lib/target-positional.js"; import { appendPeriodHint, serializeTimeRange, @@ -83,9 +83,6 @@ type TraceListResult = { /** Accepted values for the --sort flag */ const VALID_SORT_VALUES: SortValue[] = ["date", "duration"]; -/** Command name used in resolver error messages */ -const COMMAND_NAME = "trace list"; - /** Command key for pagination cursor storage */ export const PAGINATION_KEY = "trace-list"; @@ -225,7 +222,7 @@ export const listCommand = buildListCommand("trace", { { placeholder: "org/project", brief: "/ or (search)", - parse: parseOrgProjectTarget, + parse: projectBoundTarget, optional: true, }, ], @@ -258,17 +255,16 @@ export const listCommand = buildListCommand("trace", { s: "sort", }, }, - async *func(this: SentryContext, flags: ListFlags, target?: string) { - const { cwd } = this; + async *func( + this: SentryContext, + flags: ListFlags, + target: ProjectBoundTarget, + ) { const timeRange = flags.period; const { query } = flags; // Resolve org/project from positional arg, config, or DSN auto-detection - const { org, project } = await resolveProjectBoundFromArg( - target, - cwd, - COMMAND_NAME, - ); + const { org, project } = await target.resolved; // Resolve slug → numeric ID so the Events query scopes via the `project` // param. `project:` only matches actively-selected projects and can // otherwise 400 with "not actively selected" (#1317). diff --git a/packages/cli/src/context.ts b/packages/cli/src/context.ts index 59aaad3ff..fe0e4f85c 100644 --- a/packages/cli/src/context.ts +++ b/packages/cli/src/context.ts @@ -32,6 +32,12 @@ export interface SentryContext extends CommandContext { * help when a user passes `help` as a positional argument. */ readonly commandPrefix?: readonly string[]; + /** + * Set by the typed SDK invoker, which calls a command's func directly. + * Turns off CLI-only `help` recovery, so a failing call with a `help` + * positional throws its error instead of returning help text. + */ + readonly directInvoke?: boolean; } /** diff --git a/packages/cli/src/lib/command.ts b/packages/cli/src/lib/command.ts index 33d6de8f7..9e6835651 100644 --- a/packages/cli/src/lib/command.ts +++ b/packages/cli/src/lib/command.ts @@ -65,6 +65,7 @@ import { parseLogLevel, setLogLevel, } from "./logger.js"; +import { applyLazyPositionals, rawPositional } from "./positional-parse.js"; import { setArgsContext, setFlagContext, withTracing } from "./telemetry.js"; import { applyTipsPreference } from "./tips.js"; @@ -666,7 +667,11 @@ export function buildCommand< async function maybeRecoverWithHelp( err: unknown, stdout: Writer, - ctx: { commandPrefix?: readonly string[]; stderr: Writer }, + ctx: { + commandPrefix?: readonly string[]; + directInvoke?: boolean; + stderr: Writer; + }, args: unknown[], ): Promise { if (!(err instanceof CliError) || err instanceof OutputError) { @@ -674,11 +679,14 @@ export function buildCommand< } if ( args.length === 0 || - !args.some((a) => a === "help" || a === "--h" || a === "-help") + !args.some((a) => { + const raw = rawPositional(a); + return raw === "help" || raw === "--h" || raw === "-help"; + }) ) { return false; } - if (!ctx.commandPrefix) { + if (!ctx.commandPrefix || ctx.directInvoke) { return false; } const pathSegments = ctx.commandPrefix.slice(1); // strip "sentry" prefix @@ -703,8 +711,17 @@ export function buildCommand< const wrappedFunc = async function ( this: CONTEXT, flags: Record, - ...args: unknown[] + ...positionalArgs: unknown[] ) { + // Callers that skip Stricli's scanner (SDK invoke, tests) pass raw + // strings, and omitted optional positionals are undefined; give lazily + // parsed positionals the value Stricli would. + const args = applyLazyPositionals( + positionalArgs, + existingParams.positional, + this, + ); + applyLoggingFlags( flags[LOG_LEVEL_KEY] as LogLevelName | undefined, flags.verbose as boolean, @@ -722,7 +739,7 @@ export function buildCommand< const cleanFlags = cleanRawFlags(flags as Record); setFlagContext(cleanFlags); if (args.length > 0) { - setArgsContext(args); + setArgsContext(args.map(rawPositional)); } // Environment-based JSON mode (used by library entry point) diff --git a/packages/cli/src/lib/completion-target.ts b/packages/cli/src/lib/completion-target.ts index b29ed62cb..9992f8dcd 100644 --- a/packages/cli/src/lib/completion-target.ts +++ b/packages/cli/src/lib/completion-target.ts @@ -1,12 +1,15 @@ /** * Completion-aware positional parsers. * - * Target positionals use these instead of `String` so shell completion can - * read the target kind straight from the command's `parameters`. Both parsers - * return their input unchanged; the kind is identified by which function a - * positional uses. `script/generate-completion-targets.ts` collects them at - * build time because the `__complete` fast path cannot boot Stricli to walk - * the route tree. + * Target positionals use a parser tagged with {@link withCompletionTarget} + * instead of `String` so shell completion can read the target kind straight + * from the command's `parameters`. `script/generate-completion-targets.ts` + * collects them at build time because the `__complete` fast path cannot boot + * Stricli to walk the route tree. + * + * {@link parseOrgTarget} and {@link parseOrgProjectTarget} 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. */ /** Completes organization slugs only (e.g. `org`, `org/version`, `org/id`). */ @@ -21,30 +24,46 @@ export const COMPLETION_TARGETS = [ORG_TARGET, ORG_PROJECT_TARGET] as const; /** Which cached slugs shell completion suggests for a target positional. */ export type CompletionTarget = (typeof COMPLETION_TARGETS)[number]; -/** Parser for positionals whose leading segment is an organization slug. */ -export function parseOrgTarget(input: string): string { - return input; +const COMPLETION_TARGET_KEY = Symbol("completionTarget"); + +/** + * Tag a positional parser with the completion kind it accepts. + * + * @returns `parse` itself, so the tag travels with the function + */ +export function withCompletionTarget

unknown>( + kind: CompletionTarget, + parse: P, +): P { + Object.defineProperty(parse, COMPLETION_TARGET_KEY, { value: kind }); + 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 function parseOrgProjectTarget(input: string): string { - return input; -} +export const parseOrgProjectTarget = withCompletionTarget( + ORG_PROJECT_TARGET, + (input: string): string => input, +); /** * Read the completion kind from a positional's `parse` function. * - * @returns The kind for {@link parseOrgTarget} / {@link parseOrgProjectTarget}, + * @returns The kind a parser was tagged with by {@link withCompletionTarget}, * or `undefined` for any other parser */ export function getCompletionTarget( parse: unknown, ): CompletionTarget | undefined { - if (parse === parseOrgTarget) { - return ORG_TARGET; - } - if (parse === parseOrgProjectTarget) { - return ORG_PROJECT_TARGET; + if (typeof parse !== "function") { + return; } - return; + return (parse as { [COMPLETION_TARGET_KEY]?: CompletionTarget })[ + COMPLETION_TARGET_KEY + ]; } diff --git a/packages/cli/src/lib/list-command.ts b/packages/cli/src/lib/list-command.ts index 8092e01a4..71a264461 100644 --- a/packages/cli/src/lib/list-command.ts +++ b/packages/cli/src/lib/list-command.ts @@ -44,6 +44,7 @@ import { type ListResult, type OrgListConfig, } from "./org-list.js"; +import { applyLazyPositionals, rawPositional } from "./positional-parse.js"; import { disableResponseCache } from "./response-cache.js"; import { PERIOD_BRIEF, parsePeriod } from "./time-range.js"; import { shellQuote } from "./utils.js"; @@ -617,21 +618,20 @@ export function buildListCommand< // Auto-apply fresh flag before command runs applyFreshFlag(flags as unknown as { readonly fresh: boolean }); - // The first positional arg is always the target (org/project pattern). - // Intercept it to handle plural alias confusion. - if ( - args.length > 0 && - (typeof args[0] === "string" || args[0] === undefined) - ) { + // The first positional arg is always the target (org/project pattern), + // either a raw string or a parsed value. Intercept it to handle plural + // alias confusion. + const rawTarget = rawPositional(args[0]); + if (typeof rawTarget === "string") { // All list commands use SentryContext which has stderr at top level const ctx = this as unknown as { stderr: { write(s: string): void } }; - args[0] = interceptSubcommand( - args[0] as string | undefined, - ctx.stderr, - routeName, - ); + if (interceptSubcommand(rawTarget, ctx.stderr, routeName) === undefined) { + args[0] = undefined; + } } - return originalFunc.call(this, flags, ...(args as unknown as ARGS)); + // A dropped lazy target becomes an omitted one (auto-detect) + const finalArgs = applyLazyPositionals(args, params.positional, this); + return originalFunc.call(this, flags, ...(finalArgs as unknown as ARGS)); } as typeof originalFunc; return buildCommand({ diff --git a/packages/cli/src/lib/mutate-command.ts b/packages/cli/src/lib/mutate-command.ts index f79a24ba6..53912b838 100644 --- a/packages/cli/src/lib/mutate-command.ts +++ b/packages/cli/src/lib/mutate-command.ts @@ -23,6 +23,7 @@ import { buildCommand, type CommandDocumentation } from "./command.js"; import { CliError, ContextError } from "./errors.js"; import type { CommandReturn } from "./formatters/output.js"; import { logger } from "./logger.js"; +import { markDestructive } from "./positional-parse.js"; // --------------------------------------------------------------------------- // Level A: shared flag / alias definitions @@ -399,6 +400,9 @@ export function buildDeleteCommand< ); } + // Lazy target positionals refuse to auto-detect when they resolve + markDestructive(args); + return originalFunc.call(this, flags, ...(args as unknown as ARGS)); } as typeof originalFunc; diff --git a/packages/cli/src/lib/positional-parse.ts b/packages/cli/src/lib/positional-parse.ts new file mode 100644 index 000000000..b91c5fe52 --- /dev/null +++ b/packages/cli/src/lib/positional-parse.ts @@ -0,0 +1,185 @@ +/** + * Positionals whose value parses and resolves lazily. + * + * A parser made with {@link lazyPositional} gives the command a + * {@link LazyPositional}: + * + * - `target.parsed` runs the parse on first access, inside `func`. + * - `target.resolved` starts the positional's own resolver on first access + * and returns its promise, so `func` awaits it instead of picking and + * calling a resolver itself. A parse error rejects it. + * - `target.raw` is what the user typed (`undefined` if omitted). + * + * Until `func` reads it, the value does nothing, so nothing can fail and + * nothing runs early: + * + * - Stricli's argument scanner never sees an error, so invalid input still + * throws the parser's `CliError` from the command, with its exit code, + * telemetry, and `--json` error output, instead of Stricli's generic + * "Failed to parse" (exit -4). + * - Parser side effects, such as `parseOrgProjectArg` applying the host of a + * pasted Sentry URL, run after the auth guard and rc URL check, as they did + * when `func` called the parser itself. + * + * `buildCommand` also runs these parsers on positionals that reach the + * command as plain strings (the SDK invoke path and tests calling the loaded + * func) and on omitted optional ones (parsed from `undefined`), so `func` + * always gets a {@link LazyPositional} and every caller keeps passing what + * the user typed. + * + * Shared code that handles any positional (plural alias interception, `help` + * recovery, telemetry) reads the raw input with {@link rawPositional}. + * `buildDeleteCommand` calls {@link markDestructive} on its positionals, so + * resolvers see `destructive: true` and can refuse to auto-detect. + */ + +import { + type CompletionTarget, + withCompletionTarget, +} from "./completion-target.js"; + +/** What the running command tells a positional's resolver. */ +export type ResolveOptions = { + /** The command is destructive (see {@link markDestructive}). */ + readonly destructive: boolean; +}; + +/** Values whose command is destructive (see {@link markDestructive}). */ +const destructiveValues = new WeakSet(); + +/** Parsers made by {@link lazyPositional}. */ +const lazyParsers = new WeakSet(); + +/** + * A positional value that parses on first `parsed` read and resolves on + * first `resolved` read. Each step runs once. + */ +export class LazyPositional { + #parsed: { value: T } | undefined; + #resolved: Promise | undefined; + readonly #parse: (raw: string | undefined) => T; + readonly #resolve: (value: T, options: ResolveOptions) => Promise; + + /** What the user typed; `undefined` for an omitted positional. */ + readonly raw: string | undefined; + + constructor( + raw: string | undefined, + parse: (raw: string | undefined) => T, + resolve: (value: T, options: ResolveOptions) => Promise, + ) { + this.raw = raw; + this.#parse = parse; + this.#resolve = resolve; + } + + /** The parsed value. Throws the parser's error. */ + get parsed(): T { + this.#parsed ??= { value: this.#parse(this.raw) }; + return this.#parsed.value; + } + + /** The resolved value; rejects on a parse or resolve error. */ + get resolved(): Promise { + this.#resolved ??= (async () => + await this.#resolve(this.parsed, { + destructive: destructiveValues.has(this), + }))(); + return this.#resolved; + } +} + +/** + * Build a positional parser that hands the command a {@link LazyPositional}. + * + * @param kind - Shell completion kind for the positional + * @param parse - Parses the raw input (`undefined` when omitted); may throw a + * `CliError` + * @param resolve - Resolves the parsed value; gets the command context that + * Stricli passes to parsers as `this`, and {@link ResolveOptions} + */ +export function lazyPositional( + kind: CompletionTarget, + parse: (raw: string | undefined) => T, + resolve: (value: T, context: C, options: ResolveOptions) => Promise, +): (this: C, input?: string) => LazyPositional { + const parser = withCompletionTarget(kind, function (this: C, input?: string) { + return new LazyPositional(input, parse, (value: T, options) => + resolve(value, this, options), + ); + }); + lazyParsers.add(parser); + return parser; +} + +/** A Stricli positional definition — the subset that carries the parsers. */ +type PositionalDef = + | { kind: "tuple"; parameters: readonly { parse?: unknown }[] } + | { kind: "array"; parameter: { parse?: unknown } }; + +/** The {@link lazyPositional} parser at a positional index, if any. */ +function lazyParserAt( + def: PositionalDef, + index: number, +): ((this: unknown, input?: string) => unknown) | undefined { + const parse = + def.kind === "array" ? def.parameter.parse : def.parameters[index]?.parse; + return typeof parse === "function" && lazyParsers.has(parse) + ? (parse as (this: unknown, input?: string) => unknown) + : undefined; +} + +/** + * Give every {@link lazyPositional} positional its stand-in. + * + * Stricli already ran the parsers for typed input. This covers callers that + * skip its scanner (the SDK invoke path, tests calling the loaded func), + * which pass raw strings, and omitted optional positionals, which Stricli + * leaves `undefined`. Lazy parsers never throw and have no side effects, so + * this is safe to run up front. Other positionals are left as they are. + * + * @param context - Passed to each parser as `this`, as Stricli does + */ +export function applyLazyPositionals( + args: readonly unknown[], + positional: unknown, + context: unknown, +): unknown[] { + const def = positional as PositionalDef | undefined; + if (!def) { + return [...args]; + } + const count = + def.kind === "tuple" + ? Math.max(args.length, def.parameters.length) + : args.length; + return Array.from({ length: count }, (_, index) => { + const arg = args[index]; + const parse = lazyParserAt(def, index); + const missing = arg === undefined && def.kind === "tuple"; + return parse && (typeof arg === "string" || missing) + ? parse.call(context, arg) + : arg; + }); +} + +/** + * Tell the resolvers of these positionals that the command is destructive. + * Must run before `func` reads `resolved`. Other values are ignored. + */ +export function markDestructive(args: readonly unknown[]): void { + for (const arg of args) { + if (arg instanceof LazyPositional) { + destructiveValues.add(arg); + } + } +} + +/** + * What the user typed for a positional value. Never triggers a parse. + * + * @returns `value.raw` for a {@link LazyPositional}, otherwise `value` + */ +export function rawPositional(value: unknown): unknown { + return value instanceof LazyPositional ? value.raw : value; +} diff --git a/packages/cli/src/lib/sdk-invoke.ts b/packages/cli/src/lib/sdk-invoke.ts index bd44c4356..898b6ec01 100644 --- a/packages/cli/src/lib/sdk-invoke.ts +++ b/packages/cli/src/lib/sdk-invoke.ts @@ -564,6 +564,11 @@ export function buildInvoker(options?: SentryOptions) { positionalArgs: string[], meta?: { streaming?: boolean }, ): Promise | AsyncIterable { + // Usage hints read the command path Stricli's forCommand would set + const direct = { + commandPrefix: ["sentry", ...commandPath], + directInvoke: true, + }; if (meta?.streaming) { return executeWithStream(options, async (ctx, span) => { const { handler, flagDefs } = await resolveCommand(commandPath); @@ -573,7 +578,7 @@ export function buildInvoker(options?: SentryOptions) { } const resolvedFlags = applyFlagDefaults(flags, flagDefs); await handler.call( - ctx.context, + { ...ctx.context, ...direct }, { ...resolvedFlags, json: true }, ...positionalArgs, ); @@ -588,7 +593,7 @@ export function buildInvoker(options?: SentryOptions) { } const resolvedFlags = applyFlagDefaults(flags, flagDefs); await handler.call( - ctx.context, + { ...ctx.context, ...direct }, { ...resolvedFlags, json: true }, ...positionalArgs, ); diff --git a/packages/cli/src/lib/target-positional.ts b/packages/cli/src/lib/target-positional.ts new file mode 100644 index 000000000..c73f85c0a --- /dev/null +++ b/packages/cli/src/lib/target-positional.ts @@ -0,0 +1,107 @@ +/** + * `[/][]` target positionals. + * + * Each parser here hands `func` an {@link OrgProjectTarget}: the + * {@link ParsedOrgProject} as `target.parsed` (parsed on first read) plus a + * `resolved` promise bound to the resolver the positional declares. A + * command reads `target.parsed.type` when it needs the parsed form and awaits + * `target.resolved` for the org and project, without choosing or calling a + * parser or resolver itself. An omitted target arrives as auto-detect. See + * `positional-parse.ts` for when parsing and resolution run. + * + * 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 (`buildDeleteCommand`) + * refuse an auto-detected target. + */ + +import { type ParsedOrgProject, parseOrgProjectArg } from "./arg-parsing.js"; +import { ORG_PROJECT_TARGET } from "./completion-target.js"; +import { requireExplicitTarget } from "./mutate-command.js"; +import { type LazyPositional, lazyPositional } from "./positional-parse.js"; +import { + type ResolvedOrgOptionalTarget, + type ResolvedProjectBoundTarget, + resolveOrgOptionalTarget, + resolveProjectBoundTarget, +} from "./resolve-target.js"; + +/** A parsed `[/][]` target that resolves to `R`. */ +export type OrgProjectTarget = LazyPositional; + +/** What a target resolver gets from the command context. */ +export type TargetContext = { + readonly cwd: string; + /** Command path with the program name, e.g. `["sentry", "trace", "list"]` */ + readonly commandPrefix?: readonly string[]; +}; + +/** What a target resolver gets: the context plus the running command's name. */ +export type TargetResolveContext = TargetContext & { + /** Command path for usage hints, e.g. `"trace list"` */ + readonly commandName: string; +}; + +/** + * The running command's path without the program name. + * + * @throws {Error} When the context has no `commandPrefix`; every caller that + * runs a command (Stricli, the SDK invoker) sets it + */ +function commandNameOf(context: TargetContext): string { + const prefix = context.commandPrefix; + if (!prefix || prefix.length < 2) { + throw new Error( + "Target resolution needs context.commandPrefix (set by Stricli's forCommand)", + ); + } + return prefix.slice(1).join(" "); +} + +/** + * Positional parser for an `[/][]` target with its own + * resolver. Completes like `parseOrgProjectTarget`. In a destructive command + * an auto-detected target fails `requireExplicitTarget` before `resolve` + * runs. + * + * @param resolve - Turns the parsed target into what `target.resolved` + * yields + */ +export function orgProjectTarget( + resolve: ( + parsed: ParsedOrgProject, + context: TargetResolveContext, + ) => Promise, +): (this: TargetContext, input?: string) => OrgProjectTarget { + return lazyPositional( + ORG_PROJECT_TARGET, + (raw) => parseOrgProjectArg(raw), + async (parsed, context: TargetContext, { destructive }) => { + const commandName = commandNameOf(context); + if (destructive) { + requireExplicitTarget( + parsed, + "Project target", + `sentry ${commandName} /`, + ); + } + return await resolve(parsed, { ...context, commandName }); + }, + ); +} + +/** A target resolved with {@link resolveOrgOptionalTarget}. */ +export type OrgOptionalTarget = OrgProjectTarget; + +/** Target for commands that need an org and take an optional project. */ +export const orgOptionalTarget = orgProjectTarget((parsed, context) => + resolveOrgOptionalTarget(parsed, context.cwd, context.commandName), +); + +/** A target resolved with {@link resolveProjectBoundTarget}. */ +export type ProjectBoundTarget = OrgProjectTarget; + +/** Target for commands that need a single org and project. */ +export const projectBoundTarget = orgProjectTarget((parsed, context) => + resolveProjectBoundTarget(parsed, context.cwd, context.commandName), +); diff --git a/packages/cli/test/commands/feedback/list.test.ts b/packages/cli/test/commands/feedback/list.test.ts index 6773a19e9..2b17f4eb0 100644 --- a/packages/cli/test/commands/feedback/list.test.ts +++ b/packages/cli/test/commands/feedback/list.test.ts @@ -81,6 +81,7 @@ function createMockContext() { stdout: { write: stdoutWrite }, stderr: { write: vi.fn(() => true) }, cwd: "/tmp", + commandPrefix: ["sentry", "feedback", "list"], }, stdoutWrite, }; @@ -101,7 +102,7 @@ describe("feedback list", () => { slug: "test-project", name: "Test Project", }); - resolveTargetSpy = vi.spyOn(resolveTarget, "resolveOrgOptionalFromArg"); + resolveTargetSpy = vi.spyOn(resolveTarget, "resolveOrgOptionalTarget"); resolveCursorSpy = vi.spyOn(paginationDb, "resolveCursor").mockReturnValue({ cursor: undefined, direction: "next", @@ -230,7 +231,7 @@ describe("feedback list", () => { const output = stdoutWrite.mock.calls.map((call) => call[0]).join(""); expect(JSON.parse(output).data).toEqual([{ shortId: "TEST-PROJECT-2SDJ" }]); expect(resolveTargetSpy).toHaveBeenCalledWith( - undefined, + { type: "auto-detect" }, "/tmp", "feedback list", ); @@ -259,7 +260,7 @@ describe("feedback list", () => { ); expect(resolveTargetSpy).toHaveBeenCalledWith( - "test-project", + { type: "project-search", projectSlug: "test-project" }, "/tmp", "feedback list", ); diff --git a/packages/cli/test/commands/project/delete.test.ts b/packages/cli/test/commands/project/delete.test.ts index dcd839625..151469704 100644 --- a/packages/cli/test/commands/project/delete.test.ts +++ b/packages/cli/test/commands/project/delete.test.ts @@ -133,6 +133,7 @@ function createMockContext() { stdout: { write: stdoutWrite }, stderr: { write: stderrWrite }, cwd: "/tmp", + commandPrefix: ["sentry", "project", "delete"], }, stdoutWrite, stderrWrite, @@ -425,6 +426,7 @@ describe("project delete — interactive confirmation", () => { stdout: { write: stdoutWrite }, stderr: { write: vi.fn(() => true) }, cwd: "/tmp", + commandPrefix: ["sentry", "project", "delete"], }, stdoutWrite, }; diff --git a/packages/cli/test/commands/project/view.func.test.ts b/packages/cli/test/commands/project/view.func.test.ts index 5d385b588..ed8b98480 100644 --- a/packages/cli/test/commands/project/view.func.test.ts +++ b/packages/cli/test/commands/project/view.func.test.ts @@ -78,6 +78,7 @@ function createMockContext() { stdout: { write: stdoutWrite }, stderr: { write: vi.fn(() => true) }, cwd: "/tmp", + commandPrefix: ["sentry", "project", "view"], }, stdoutWrite, }; diff --git a/packages/cli/test/commands/replay/list.test.ts b/packages/cli/test/commands/replay/list.test.ts index 05f8b5103..e4f05bad6 100644 --- a/packages/cli/test/commands/replay/list.test.ts +++ b/packages/cli/test/commands/replay/list.test.ts @@ -99,6 +99,7 @@ describe("listCommand.func", () => { stdout: { write: stdoutWrite }, stderr: { write: vi.fn(() => true) }, cwd: "/tmp", + commandPrefix: ["sentry", "replay", "list"], }, stdoutWrite, }; @@ -106,7 +107,7 @@ describe("listCommand.func", () => { beforeEach(() => { listReplaysSpy = vi.spyOn(apiClient, "listReplays"); - resolveTargetSpy = vi.spyOn(resolveTarget, "resolveOrgOptionalFromArg"); + resolveTargetSpy = vi.spyOn(resolveTarget, "resolveOrgOptionalTarget"); resolveCursorSpy = vi.spyOn(paginationDb, "resolveCursor").mockReturnValue({ cursor: undefined, direction: "next" as const, diff --git a/packages/cli/test/commands/trace/list.test.ts b/packages/cli/test/commands/trace/list.test.ts index fb89ef87e..ed16064e9 100644 --- a/packages/cli/test/commands/trace/list.test.ts +++ b/packages/cli/test/commands/trace/list.test.ts @@ -294,6 +294,7 @@ describe("listCommand.func", () => { stdout: { write: stdoutWrite }, stderr: { write: vi.fn(() => true) }, cwd: "/tmp", + commandPrefix: ["sentry", "trace", "list"], }, stdoutWrite, }; diff --git a/packages/cli/test/lib/help-positional.test.ts b/packages/cli/test/lib/help-positional.test.ts index 598c92a67..43ff47584 100644 --- a/packages/cli/test/lib/help-positional.test.ts +++ b/packages/cli/test/lib/help-positional.test.ts @@ -52,10 +52,13 @@ type CapturedOutput = { * We must provide it so `commandPrefix` is set on the context, enabling * the help recovery logic in `buildCommand`. */ -function buildMockContext(captured: { - stdout: string; - stderr: string; -}): SentryContext & { +function buildMockContext( + captured: { + stdout: string; + stderr: string; + }, + extra: Partial = {}, +): SentryContext & { forCommand: (opts: { prefix: readonly string[] }) => SentryContext; } { const stdoutWriter = { @@ -89,6 +92,7 @@ function buildMockContext(captured: { forCommand: ({ prefix }: { prefix: readonly string[] }): SentryContext => ({ ...baseContext, commandPrefix: prefix, + ...extra, }), }; } @@ -100,9 +104,12 @@ function buildMockContext(captured: { * will be recovered by the buildCommand wrapper, which shows help output * instead of the error. */ -async function runCommand(args: string[]): Promise { +async function runCommand( + args: string[], + extra?: Partial, +): Promise { const captured = { stdout: "", stderr: "" }; - const mockContext = buildMockContext(captured); + const mockContext = buildMockContext(captured, extra); try { await run(app, args, mockContext); @@ -114,6 +121,17 @@ async function runCommand(args: string[]): Promise { } describe("help recovery on ResolutionError", () => { + test("is off for direct (typed SDK) invocations", async () => { + // The typed SDK sets directInvoke; its callers get the error, not help + const args = ["issue", "list", "help"]; + const recovered = await runCommand(args); + const direct = await runCommand(args, { directInvoke: true }); + + expect(recovered.stdout).toContain("sentry issue list"); + expect(direct.stdout).not.toContain("sentry issue list"); + expect(direct.stderr).not.toContain("Tip"); + }); + test("sentry issue list help → shows help for issue list", async () => { // "help" is treated as a project slug, fails resolution → recovery shows help const { stdout, stderr } = await runCommand(["issue", "list", "help"]); @@ -142,6 +160,14 @@ describe("help recovery on ResolutionError", () => { expect(stderr).toContain("sentry issue list --help"); }); + + test("sentry project view help → recovers through a parsed target positional", async () => { + // project view's positional reaches func parsed, not as a string + const { stdout, stderr } = await runCommand(["project", "view", "help"]); + + expect(stdout).toContain("sentry project view"); + expect(stderr).toContain("sentry project view --help"); + }); }); describe("help recovery on ValidationError", () => { diff --git a/packages/cli/test/lib/positional-parse.test.ts b/packages/cli/test/lib/positional-parse.test.ts new file mode 100644 index 000000000..0103e0536 --- /dev/null +++ b/packages/cli/test/lib/positional-parse.test.ts @@ -0,0 +1,218 @@ +/** + * Lazily parsed and resolved target positionals (src/lib/positional-parse.ts, + * src/lib/target-positional.ts). + * + * Runs a test command through Stricli's `run()` so the positional parser, + * the command wrapper, and exit-code handling are exercised together. + */ + +import { buildApplication, type CommandContext, run } from "@stricli/core"; +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, +} from "../../src/lib/completion-target.js"; +import { + ContextError, + EXIT, + getExitCode, + ValidationError, +} from "../../src/lib/errors.js"; +import { + markDestructive, + rawPositional, +} from "../../src/lib/positional-parse.js"; +import { buildRouteMap } from "../../src/lib/route-map.js"; +import { + type OrgProjectTarget, + orgProjectTarget, +} from "../../src/lib/target-positional.js"; + +type TestContext = CommandContext & { + cwd: string; + process: { exitCode?: number }; + stdout: { write: (s: string) => boolean }; +}; + +/** Resolver that echoes the parsed target, cwd, and command name. */ +const echoResolver = vi.fn( + async ( + parsed: ParsedOrgProject, + { cwd, commandName }: { cwd: string; commandName: string }, + ) => ({ parsed: { ...parsed }, cwd, commandName }), +); + +const echoTarget = orgProjectTarget(echoResolver); + +type EchoTarget = OrgProjectTarget>>; + +/** Run `test cmd ...args`, recording what `target.resolved` yields. */ +async function runWithTarget(args: string[]): Promise<{ + resolved: unknown[]; + stderr: string; + exitCode: number | undefined; +}> { + const resolved: unknown[] = []; + const command = buildCommand< + Record, + [EchoTarget], + TestContext + >({ + docs: { brief: "Takes a target" }, + auth: false, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + placeholder: "org/project", + brief: "Target", + parse: echoTarget, + optional: true, + }, + ], + }, + }, + // oxlint-disable-next-line require-yield -- test command only records the resolved target + async *func(_flags, target) { + resolved.push(await target.resolved); + }, + }); + 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 }; +} + +describe("orgProjectTarget", () => { + test("resolves the parsed target with the command context and name", async () => { + const { resolved } = await runWithTarget(["acme/web"]); + expect(resolved).toEqual([ + { + parsed: { type: "explicit", org: "acme", project: "web" }, + cwd: "/work", + commandName: "cmd", + }, + ]); + }); + + test("an omitted target resolves as auto-detect", async () => { + const { resolved } = await runWithTarget([]); + expect(resolved).toEqual([ + { parsed: { type: "auto-detect" }, cwd: "/work", commandName: "cmd" }, + ]); + }); + + test("invalid input fails inside the command with the CliError exit code", async () => { + const { resolved, stderr, exitCode } = await runWithTarget(["@latest"]); + expect(resolved).toEqual([]); + expect(exitCode).toBe(EXIT.VALIDATION); + expect(stderr).toContain("is an issue selector"); + // Not Stricli's argument-parse error, which would exit with -4. + expect(stderr).not.toContain("Failed to parse"); + }); + + test("keeps completing org/project slugs", () => { + expect(getCompletionTarget(echoTarget)).toBe(ORG_PROJECT_TARGET); + }); +}); + +describe("lazy parsing and resolution", () => { + const context = { cwd: "/work", commandPrefix: ["sentry", "thing", "list"] }; + + test("parses on first read of parsed", () => { + const target = echoTarget.call(context, "@latest"); + expect(() => target.parsed).toThrow(ValidationError); + }); + + test("rejects resolved with the parse error", async () => { + const target = echoTarget.call(context, "@latest"); + await expect(target.resolved).rejects.toThrow(ValidationError); + }); + + test("keeps the raw input and the parsed value", () => { + const target = echoTarget.call(context, "acme/web"); + expect(target.raw).toBe("acme/web"); + expect(target.parsed).toEqual({ + type: "explicit", + org: "acme", + project: "web", + }); + expect(target.parsed).toBe(target.parsed); + }); + + test("resolves once, on first access of resolved", async () => { + echoResolver.mockClear(); + const target = echoTarget.call(context, "acme/web"); + expect(echoResolver).not.toHaveBeenCalled(); + const first = target.resolved; + expect(target.resolved).toBe(first); + await first; + expect(echoResolver).toHaveBeenCalledTimes(1); + }); + + test("a destructive command refuses an auto-detected target", async () => { + echoResolver.mockClear(); + const omitted = echoTarget.call(context, undefined); + markDestructive([omitted]); + const error = await omitted.resolved.catch((e: unknown) => e); + expect(error).toBeInstanceOf(ContextError); + expect((error as Error).message).toContain( + "sentry thing list /", + ); + expect(echoResolver).not.toHaveBeenCalled(); + + const explicit = echoTarget.call(context, "acme/web"); + markDestructive([explicit]); + await expect(explicit.resolved).resolves.toMatchObject({ + commandName: "thing list", + }); + }); + + test("needs the command path on the context", async () => { + const target = echoTarget.call({ cwd: "/work" }, "acme/web"); + await expect(target.resolved).rejects.toThrow("commandPrefix"); + }); + + test("is not a thenable, so awaiting it does not parse", async () => { + // Stricli awaits parser results; a parse here would fail in its scanner. + const target = echoTarget.call(context, "@latest"); + await expect(Promise.resolve(target)).resolves.toBe(target); + }); +}); + +describe("rawPositional", () => { + const context = { cwd: "/work" }; + + test("returns what the user typed without parsing", () => { + expect(rawPositional(echoTarget.call(context, "acme/"))).toBe("acme/"); + expect(rawPositional(echoTarget.call(context, "@latest"))).toBe("@latest"); + expect(rawPositional(echoTarget.call(context, undefined))).toBeUndefined(); + expect(rawPositional("plain")).toBe("plain"); + expect(rawPositional(undefined)).toBeUndefined(); + }); +}); diff --git a/packages/cli/test/lib/sdk-invoke.test.ts b/packages/cli/test/lib/sdk-invoke.test.ts index c477d6230..39c54d1bc 100644 --- a/packages/cli/test/lib/sdk-invoke.test.ts +++ b/packages/cli/test/lib/sdk-invoke.test.ts @@ -8,9 +8,13 @@ import { describe, expect, test } from "vitest"; import { applyFlagDefaults, + buildInvoker, type FlagDef, parseOutput, } from "../../src/lib/sdk-invoke.js"; +import { useTestConfigDir } from "../helpers.js"; + +useTestConfigDir("sdk-invoke-"); // --------------------------------------------------------------------------- // applyFlagDefaults — parsed flags with defaults @@ -256,3 +260,23 @@ describe("parseOutput: binary output", () => { expect(parseOutput(obj, ["ignored"])).toBe(obj); }); }); + +describe("buildInvoker: lazily parsed positionals", () => { + test("a command's target positional is parsed and validated", async () => { + // The SDK passes raw strings; the command wrapper parses the target, so + // project view rejects it instead of falling back to auto-detection. + const invoke = buildInvoker({ token: "sntrys_test" }); + await expect( + invoke(["project", "view"], {}, ["@latest"]) as Promise, + ).rejects.toThrow("is an issue selector"); + }); + + test("resolution sees the command path and the destructive guard", async () => { + // The usage hint names the command from the SDK's command path, and + // project delete refuses to auto-detect an omitted target. + const invoke = buildInvoker({ token: "sntrys_test" }); + await expect( + invoke(["project", "delete"], { yes: true }, []) as Promise, + ).rejects.toThrow("sentry project delete /"); + }); +});