From 077c025ee52cc6f27558a8dc0416ec43dc867f33 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:37:26 +0000 Subject: [PATCH 1/2] ref(cli): Type-check command yields against their output config buildCommand, buildListCommand and buildDeleteCommand now infer the output type from `output` and require every CommandOutput that `func` yields to carry it. `NoInfer` keeps the yields from widening the type, so an untyped `yield new CommandOutput(data)` that doesn't match the formatter is a compile error. Commands without an output config may only yield ClearScreen. Fixes #1852 Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- packages/cli/AGENTS.md | 2 +- packages/cli/src/commands/trial/start.ts | 33 ++++++++++++++--------- packages/cli/src/lib/command.ts | 27 ++++++++++++++----- packages/cli/src/lib/formatters/output.ts | 23 +++++++++++++++- packages/cli/src/lib/list-command.ts | 33 ++++------------------- packages/cli/src/lib/mutate-command.ts | 33 +++++++---------------- 6 files changed, 78 insertions(+), 73 deletions(-) diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 245c7d2ab..c07cc2716 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -226,7 +226,7 @@ Lint enforcement: `stderr.write()` is banned in command files (GritQL rule). Use - **API fields** keep their `@sentry/api` type and come from the generated `@sentry/api/valibot` schema (`vXxxResponse.entries`, wrapped in `pipe(field, description(…))` when it needs a description). - **Fields the CLI adds** (target context such as `org`/`orgSlug`, flags such as `dryRun`/`deleted`, computed values such as `url`, `trace`, `period`) are written once as a valibot `object` of output fields. Spread its `entries` into the schema, and get the type with `InferOutput`, or `WithOutputFields` (`src/types/output.ts`) when the fields sit on (or override) an API object. -- Type the command's data with the derived type: the `human` formatter's parameter, and `yield new CommandOutput(…)` where the yielded value is not already typed. A field added to the schema without the code that sets it, or removed while code still sets it, is then a compile error. +- Type the `human` formatter's parameter (and `jsonTransform`'s, when set) with the derived type. `buildCommand`, `buildListCommand`, and `buildDeleteCommand` infer the output type from `output` and check every `CommandOutput` that `func` yields against it, so an annotation on the yield isn't needed. A field added to the schema without the code that sets it, or removed while code still sets it, is then a compile error. `output.schema` itself is not checked against that type. - Reuse the shared fields: `DryRunOutputField`, `orgListItemSchema`/`WithOrg` (`lib/org-list.ts`) for org-scoped lists, `opaque()` for a field the docs leave opaque but the code types (a full event payload). - Data only the human formatter needs (pre-rendered lines, a resolved target) stays out of the schema; keep it outside the derived type or strip it with `jsonExclude`. diff --git a/packages/cli/src/commands/trial/start.ts b/packages/cli/src/commands/trial/start.ts index 0845e0b97..951032158 100644 --- a/packages/cli/src/commands/trial/start.ts +++ b/packages/cli/src/commands/trial/start.ts @@ -23,7 +23,10 @@ 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"; +import { + CommandOutput, + type CommandYield, +} from "../../lib/formatters/output.js"; import { logger as log } from "../../lib/logger.js"; import { prompt } from "../../lib/prompt.js"; import { @@ -77,6 +80,21 @@ export type ResolvedTrialStart = { readonly org: string; }; +/** + * Data `trial start` yields: the plan trial's URL and QR code first, then + * the started trial. + */ +type TrialStartOutput = { + name?: string; + category?: string; + organization?: string; + lengthDays?: number | null; + started?: boolean; + url?: string; + qr?: string; + opened?: boolean; +}; + /** The arguments of `trial start`. */ export type TrialStartArguments = BoundArguments< TrialStartInput, @@ -265,7 +283,7 @@ async function promptOpenBrowser(url: string): Promise { async function* handlePlanTrial( orgSlug: string, json: boolean, -): AsyncGenerator { +): AsyncGenerator, void, undefined> { const logger = log.withTag("trial"); // Check if plan trial is actually available @@ -314,16 +332,7 @@ async function* handlePlanTrial( } /** Format start result as human-readable output */ -function formatStartResult(data: { - name?: string; - category?: string; - organization?: string; - lengthDays?: number | null; - started?: boolean; - url?: string; - qr?: string; - opened?: boolean; -}): string { +function formatStartResult(data: TrialStartOutput): string { // Intermediate URL + QR code yield for plan trials if (data.url && data.qr && !data.category) { return `\n ${data.url}\n\n${data.qr}`; diff --git a/packages/cli/src/lib/command.ts b/packages/cli/src/lib/command.ts index 11a76c7a9..a60c9d554 100644 --- a/packages/cli/src/lib/command.ts +++ b/packages/cli/src/lib/command.ts @@ -57,6 +57,7 @@ import { ClearScreen, CommandOutput, type CommandReturn, + type CommandYield, extractSchemaFieldPaths, extractSchemaFields, formatSchemaForHelp, @@ -136,17 +137,21 @@ export type CommandDocumentation = * The return value (`CommandReturn`) is captured by the wrapper and * rendered after all yields are consumed. Hints live exclusively on * the return value — never on individual yields. + * + * `OUTPUT` is the data type of the command's {@link OutputConfig}; each + * yielded {@link CommandOutput} must carry it. */ -type SentryCommandFunction< +export type SentryCommandFunction< FLAGS extends BaseFlags, ARGS extends BaseArgs, CONTEXT extends CommandContext, + OUTPUT, > = ( this: CONTEXT, flags: FLAGS, ...args: ARGS // void is required here — generators that don't return a value have implicit void return, which is distinct from undefined in TypeScript's type system -) => AsyncGenerator; +) => AsyncGenerator, CommandReturn | void, undefined>; /** * Arguments for building a command with a local function. @@ -156,10 +161,15 @@ type LocalCommandBuilderArguments< FLAGS extends BaseFlags, ARGS extends BaseArgs, CONTEXT extends CommandContext, + OUTPUT, > = { readonly parameters?: Record; readonly docs: CommandDocumentation; - readonly func: SentryCommandFunction; + /** + * `NoInfer` makes `OUTPUT` come from `output` alone, so a yield that + * doesn't match the output config is an error instead of widening it. + */ + readonly func: SentryCommandFunction>; /** * Output configuration — controls flag injection and auto-rendering. * @@ -175,8 +185,7 @@ type LocalCommandBuilderArguments< * }) * ``` */ - // oxlint-disable-next-line typescript/no-explicit-any -- Variance erasure — OutputConfig.human is contravariant in T, but the builder erases T because it doesn't know the output type. Using `any` allows commands to declare OutputConfig while the wrapper handles it generically. - readonly output?: OutputConfig; + readonly output?: OutputConfig; /** * Whether the command requires authentication. Defaults to `true`. * @@ -550,11 +559,15 @@ export function buildCommand< const FLAGS extends BaseFlags = NonNullable, const ARGS extends BaseArgs = [], const CONTEXT extends CommandContext = CommandContext, + OUTPUT = never, >( - builderArgs: LocalCommandBuilderArguments, + builderArgs: LocalCommandBuilderArguments, ): Command { const originalFunc = builderArgs.func; - const outputConfig = builderArgs.output; + // The wrapper renders whatever the generator yields; the builder + // signature already tied the yields to this config. + // oxlint-disable-next-line typescript/no-explicit-any -- OutputConfig.human is contravariant in T, so the erased config can't be OutputConfig + const outputConfig: OutputConfig | undefined = builderArgs.output; const requiresAuth = builderArgs.auth !== false && builderArgs.auth !== "dsn"; const skipRcUrlCheck = builderArgs.skipRcUrlCheck === true || builderArgs.auth === "dsn"; diff --git a/packages/cli/src/lib/formatters/output.ts b/packages/cli/src/lib/formatters/output.ts index 64fce195b..d61581847 100644 --- a/packages/cli/src/lib/formatters/output.ts +++ b/packages/cli/src/lib/formatters/output.ts @@ -90,6 +90,17 @@ export function resolveRenderer(human: HumanOutput): HumanRenderer { */ export type HumanOutput = ((data: T) => string) | (() => HumanRenderer); +/** + * Keys `jsonExclude` can strip: the data's top-level keys, or each item's + * keys when the data is an array (see `applyJsonExclude`). Distributes over + * unions, so a key of any member counts. + */ +type JsonExcludeKey = T extends readonly (infer Item)[] + ? Item extends unknown + ? keyof Item & string + : never + : keyof T & string; + /** * Output configuration declared on `buildCommand` for automatic rendering. * @@ -118,7 +129,7 @@ export type OutputConfig = { * Ignored when {@link jsonTransform} is set — the transform is * responsible for shaping the final JSON output. */ - jsonExclude?: ReadonlyArray; + jsonExclude?: ReadonlyArray>; /** * Custom JSON serialization transform. * @@ -184,6 +195,16 @@ export class ClearScreen { readonly _brand = "ClearScreen" as const; } +/** + * Values a command generator may yield. + * + * `buildCommand` infers `T` from the command's {@link OutputConfig}, so a + * yielded {@link CommandOutput} must match the type that `human`, + * `jsonTransform`, and `jsonExclude` take. Commands without an output + * config get `T = never` and may only yield {@link ClearScreen}. + */ +export type CommandYield = CommandOutput | ClearScreen; + /** * Return type for command generators. * diff --git a/packages/cli/src/lib/list-command.ts b/packages/cli/src/lib/list-command.ts index f20693ff6..655a0125a 100644 --- a/packages/cli/src/lib/list-command.ts +++ b/packages/cli/src/lib/list-command.ts @@ -20,16 +20,13 @@ import { buildCommand, type CommandDocumentation, numberParser, + type SentryCommandFunction, } from "./command.js"; import { ORG_PROJECT_TARGET, ORG_TARGET } from "./completion-target.js"; import { disableOrgCache } from "./db/regions.js"; import { disableDsnCache } from "./dsn/index.js"; import { warning } from "./formatters/colors.js"; -import { - CommandOutput, - type CommandReturn, - type OutputConfig, -} from "./formatters/output.js"; +import { CommandOutput, type OutputConfig } from "./formatters/output.js"; import { dispatchOrgScopedList, jsonTransformListResult, @@ -496,26 +493,6 @@ function isSubcommandName(arg: string, routeName: string): boolean { /** Base flags type (mirrors command.ts) */ type BaseFlags = Readonly>>; -/** Base args type (mirrors command.ts) */ -type BaseArgs = readonly unknown[]; - -/** - * Command function type that returns an async generator. - * - * Mirrors `SentryCommandFunction` from `command.ts`. All command functions - * are async generators — non-streaming commands yield once and return. - */ -type ListCommandFunction< - FLAGS extends BaseFlags, - ARGS extends BaseArgs, - CONTEXT extends CommandContext, -> = ( - this: CONTEXT, - flags: FLAGS, - ...args: ARGS - // void is required here — generators that don't return a value have implicit void return, which is distinct from undefined in TypeScript's type system -) => AsyncGenerator; - /** * Options for controlling which flags {@link buildListCommand} auto-injects. * @@ -551,14 +528,14 @@ export function buildListCommand< const FLAGS extends BaseFlags = NonNullable, const ARGS extends readonly unknown[] = [], const CONTEXT extends CommandContext = CommandContext, + OUTPUT = never, >( routeName: string, builderArgs: { readonly parameters?: Record; readonly docs: CommandDocumentation; - readonly func: ListCommandFunction; - // oxlint-disable-next-line typescript/no-explicit-any -- OutputConfig is generic but type is erased at the builder level - readonly output?: OutputConfig; + readonly func: SentryCommandFunction>; + readonly output?: OutputConfig; readonly auth?: boolean; }, options?: ListCommandOptions, diff --git a/packages/cli/src/lib/mutate-command.ts b/packages/cli/src/lib/mutate-command.ts index 584648756..5f7c9574d 100644 --- a/packages/cli/src/lib/mutate-command.ts +++ b/packages/cli/src/lib/mutate-command.ts @@ -19,9 +19,13 @@ import { isatty } from "node:tty"; import type { Command, CommandContext } from "@stricli/core"; import type { ParsedOrgProject } from "./arg-parsing.js"; -import { buildCommand, type CommandDocumentation } from "./command.js"; +import { + buildCommand, + type CommandDocumentation, + type SentryCommandFunction, +} from "./command.js"; import { CliError, ContextError } from "./errors.js"; -import type { CommandReturn } from "./formatters/output.js"; +import type { OutputConfig } from "./formatters/output.js"; import { prompt } from "./prompt.js"; // --------------------------------------------------------------------------- @@ -243,25 +247,6 @@ export function requireExplicitTarget( /** Base flags type (mirrors command.ts) */ type BaseFlags = Readonly>>; -/** Base args type (mirrors command.ts) */ -type BaseArgs = readonly unknown[]; - -/** - * Command function type that returns an async generator. - * - * Mirrors `SentryCommandFunction` from `command.ts`. - */ -type DeleteCommandFunction< - FLAGS extends BaseFlags, - ARGS extends BaseArgs, - CONTEXT extends CommandContext, -> = ( - this: CONTEXT, - flags: FLAGS, - ...args: ARGS - // void is required here — generators that don't return a value have implicit void return, which is distinct from undefined in TypeScript's type system -) => AsyncGenerator; - /** * Options for controlling which flags {@link buildDeleteCommand} auto-injects. * @@ -327,13 +312,13 @@ export function buildDeleteCommand< const FLAGS extends BaseFlags = NonNullable, const ARGS extends readonly unknown[] = [], const CONTEXT extends CommandContext = CommandContext, + OUTPUT = never, >( builderArgs: { readonly parameters?: Record; readonly docs: CommandDocumentation; - readonly func: DeleteCommandFunction; - // oxlint-disable-next-line typescript/no-explicit-any -- OutputConfig is generic but type is erased at the builder level - readonly output?: import("./formatters/output.js").OutputConfig; + readonly func: SentryCommandFunction>; + readonly output?: OutputConfig; readonly auth?: boolean; }, options?: DeleteCommandOptions, From da08ab6b70610769532a34e8b54a5a7032610465 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:48:30 +0000 Subject: [PATCH 2/2] ref(cli): Check output.schema against the command's output type Without jsonTransform, the yielded data (each item for array data) must now satisfy output.schema, so a field the schema documents but the command doesn't produce, or documents with another type, is a compile error. With jsonTransform the schema describes the transform's item, which the types can't see, so it stays unchecked. The check found these mismatches, now fixed: - alert issues/metrics edit: type the update responses with their @sentry/api types. The endpoints return `enabled`, not `status`, so `alert metrics edit` always reported "active"; derive status from `enabled` in both commands. - feedback resolve/spam/unresolve: return SentryFeedback from a shared updateFeedbackStatus helper. - release create: document `projects` as either full projects or the dry run's `{ slug }` objects. - trial list: document the fields the command emits (name, status, daysRemaining) instead of the raw API trial, which listed reasonCode. Also drop the CommandOutput yield annotations the builders made redundant, keeping the ones that stop discriminant literals widening to string. Co-Authored-By: BYK --- packages/cli/AGENTS.md | 3 +- .../skills/sentry-cli/references/release.md | 2 +- .../skills/sentry-cli/references/trial.md | 8 +- .../src/commands/agent-conversation/list.ts | 2 +- .../src/commands/agent-conversation/view.ts | 2 +- .../cli/src/commands/alert/issues/edit.ts | 4 +- .../cli/src/commands/alert/metrics/edit.ts | 4 +- .../cli/src/commands/alert/output-schemas.ts | 27 +++-- packages/cli/src/commands/auth/list.ts | 2 +- packages/cli/src/commands/auth/logout.ts | 6 +- packages/cli/src/commands/auth/switch.ts | 2 +- packages/cli/src/commands/bash-hook.ts | 4 +- packages/cli/src/commands/build/download.ts | 2 +- packages/cli/src/commands/build/upload.ts | 2 +- packages/cli/src/commands/cli/completion.ts | 2 +- packages/cli/src/commands/cli/setup.ts | 2 +- .../cli/src/commands/code-mappings/upload.ts | 2 +- .../src/commands/dart-symbol-map/upload.ts | 4 +- packages/cli/src/commands/dashboard/create.ts | 2 +- .../src/commands/dashboard/widget/delete.ts | 4 +- .../src/commands/debug-files/bundle-jvm.ts | 2 +- .../commands/debug-files/bundle-sources.ts | 4 +- .../cli/src/commands/debug-files/check.ts | 2 +- packages/cli/src/commands/debug-files/find.ts | 2 +- .../src/commands/debug-files/print-sources.ts | 2 +- .../cli/src/commands/debug-files/upload.ts | 6 +- packages/cli/src/commands/docs/query.ts | 2 +- packages/cli/src/commands/event/send.ts | 4 +- packages/cli/src/commands/feedback/resolve.ts | 11 +- packages/cli/src/commands/feedback/spam.ts | 11 +- .../cli/src/commands/feedback/unresolve.ts | 11 +- packages/cli/src/commands/feedback/utils.ts | 16 ++- packages/cli/src/commands/issue/archive.ts | 2 +- packages/cli/src/commands/issue/merge.ts | 2 +- packages/cli/src/commands/issue/resolve.ts | 2 +- packages/cli/src/commands/issue/unresolve.ts | 2 +- packages/cli/src/commands/log/list.ts | 8 +- packages/cli/src/commands/platform/list.ts | 4 +- packages/cli/src/commands/proguard/upload.ts | 4 +- packages/cli/src/commands/proguard/uuid.ts | 2 +- .../cli/src/commands/react-native/gradle.ts | 2 +- .../cli/src/commands/react-native/xcode.ts | 4 +- packages/cli/src/commands/release/archive.ts | 4 +- packages/cli/src/commands/release/create.ts | 39 +++++-- packages/cli/src/commands/release/delete.ts | 6 +- packages/cli/src/commands/release/deploy.ts | 4 +- packages/cli/src/commands/release/finalize.ts | 4 +- packages/cli/src/commands/release/restore.ts | 4 +- packages/cli/src/commands/replay/download.ts | 2 +- packages/cli/src/commands/snapshots/diff.ts | 2 +- .../cli/src/commands/snapshots/download.ts | 2 +- packages/cli/src/commands/snapshots/upload.ts | 4 +- packages/cli/src/commands/sourcemap/inject.ts | 2 +- .../cli/src/commands/sourcemap/resolve.ts | 2 +- packages/cli/src/commands/sourcemap/upload.ts | 4 +- packages/cli/src/commands/trial/list.ts | 58 ++++++---- packages/cli/src/commands/wasm-split.ts | 2 +- packages/cli/src/lib/api/alerts.ts | 10 +- packages/cli/src/lib/command.ts | 13 ++- packages/cli/src/lib/formatters/feedback.ts | 7 +- packages/cli/src/lib/formatters/output.ts | 105 +++++++++++------- packages/cli/src/lib/list-command.ts | 4 +- packages/cli/src/lib/mutate-command.ts | 4 +- .../test/commands/alert/metrics/edit.test.ts | 28 +++++ 64 files changed, 309 insertions(+), 192 deletions(-) diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index c07cc2716..a440e1b17 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -226,7 +226,8 @@ Lint enforcement: `stderr.write()` is banned in command files (GritQL rule). Use - **API fields** keep their `@sentry/api` type and come from the generated `@sentry/api/valibot` schema (`vXxxResponse.entries`, wrapped in `pipe(field, description(…))` when it needs a description). - **Fields the CLI adds** (target context such as `org`/`orgSlug`, flags such as `dryRun`/`deleted`, computed values such as `url`, `trace`, `period`) are written once as a valibot `object` of output fields. Spread its `entries` into the schema, and get the type with `InferOutput`, or `WithOutputFields` (`src/types/output.ts`) when the fields sit on (or override) an API object. -- Type the `human` formatter's parameter (and `jsonTransform`'s, when set) with the derived type. `buildCommand`, `buildListCommand`, and `buildDeleteCommand` infer the output type from `output` and check every `CommandOutput` that `func` yields against it, so an annotation on the yield isn't needed. A field added to the schema without the code that sets it, or removed while code still sets it, is then a compile error. `output.schema` itself is not checked against that type. +- Type the `human` formatter's parameter (and `jsonTransform`'s, when set) with the derived type. `buildCommand`, `buildListCommand`, and `buildDeleteCommand` infer the output type from `output` and check every `CommandOutput` that `func` yields against it. Annotate a yield (`new CommandOutput(…)`) only when it builds a discriminated-union literal (`kind: "endpoints"`) that would otherwise widen to `string`. +- Without `jsonTransform`, the data (each item, for array data) must also satisfy `output.schema`: a field the schema documents but the command doesn't produce, or documents with another type, is a compile error. With `jsonTransform`, the schema describes the transform's item, which the types can't see, so it isn't checked. Keep the data typed: an API response typed `Record` can't satisfy a schema built from `@sentry/api/valibot`, so type it with its `@sentry/api` response type. - Reuse the shared fields: `DryRunOutputField`, `orgListItemSchema`/`WithOrg` (`lib/org-list.ts`) for org-scoped lists, `opaque()` for a field the docs leave opaque but the code types (a full event payload). - Data only the human formatter needs (pre-rendered lines, a resolved target) stays out of the schema; keep it outside the derived type or strip it with `jsonExclude`. diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/release.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/release.md index 06136b5ee..e3c532b24 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/release.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/release.md @@ -178,7 +178,7 @@ Create a release | `lastDeploy` | object \| null | Most recent deploy | | `authors` | array | Commit authors | | `owner` | object \| null | Release owner | -| `projects` | array | Projects in the release | +| `projects` | array | Projects in the release (only slug with --dry-run) | | `adoptionStages` | object \| null | Adoption stage per project | | `dryRun` | boolean | True when --dry-run made no change | diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/trial.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/trial.md index 52b1feff7..3895149d8 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/trial.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/trial.md @@ -25,12 +25,14 @@ List product trials | Field | Type | Description | |-------|------|-------------| +| `name` | string | CLI trial name (e.g. seer), or "plan" for the plan trial | | `category` | string | Trial category (e.g. seerUsers, seerAutofix) | -| `startDate` | string \| null | Start date (ISO 8601) | -| `endDate` | string \| null | End date (ISO 8601) | -| `reasonCode` | number | Reason code | +| `status` | string | Trial status | +| `daysRemaining` | number \| null | Days left on an active trial | | `isStarted` | boolean | Whether the trial has started | | `lengthDays` | number \| null | Trial duration in days | +| `startDate` | string \| null | Start date (ISO 8601) | +| `endDate` | string \| null | End date (ISO 8601) | **Examples:** diff --git a/packages/cli/src/commands/agent-conversation/list.ts b/packages/cli/src/commands/agent-conversation/list.ts index 168c8f118..68c111936 100644 --- a/packages/cli/src/commands/agent-conversation/list.ts +++ b/packages/cli/src/commands/agent-conversation/list.ts @@ -284,7 +284,7 @@ export const listCommand = buildListCommand("agent-conversation", { const hasPrev = hasPreviousPage(PAGINATION_KEY, contextKey); const hasMore = !!nextCursor; - yield new CommandOutput({ + yield new CommandOutput({ conversations, hasMore, hasPrev, diff --git a/packages/cli/src/commands/agent-conversation/view.ts b/packages/cli/src/commands/agent-conversation/view.ts index a0af5f939..1d0317091 100644 --- a/packages/cli/src/commands/agent-conversation/view.ts +++ b/packages/cli/src/commands/agent-conversation/view.ts @@ -177,6 +177,6 @@ export const viewCommand = buildCommand({ title, }); result.truncated = truncated; - yield new CommandOutput(result); + yield new CommandOutput(result); }, }); diff --git a/packages/cli/src/commands/alert/issues/edit.ts b/packages/cli/src/commands/alert/issues/edit.ts index 2989ee01c..dc2dba7b3 100644 --- a/packages/cli/src/commands/alert/issues/edit.ts +++ b/packages/cli/src/commands/alert/issues/edit.ts @@ -339,9 +339,7 @@ export const editCommand = buildCommand({ id: stringifyUnknown(updated.id ?? rule.id), // The workflows endpoint returns `enabled` rather than `status`; map it // back to a status label so human/JSON output matches the create path. - status: stringifyUnknown( - updated.status ?? (updated.enabled === false ? "disabled" : "active"), - ), + status: updated.enabled === false ? "disabled" : "active", } satisfies IssueAlertEditOutput); }, }); diff --git a/packages/cli/src/commands/alert/metrics/edit.ts b/packages/cli/src/commands/alert/metrics/edit.ts index ac4e90bb8..ec470a757 100644 --- a/packages/cli/src/commands/alert/metrics/edit.ts +++ b/packages/cli/src/commands/alert/metrics/edit.ts @@ -44,7 +44,6 @@ import { } from "../mutation-utils.js"; import type { AlertRuleArgument } from "../arguments.js"; import { metricRuleParameter, resolveMetricAlertRule } from "./rule-resolve.js"; -import { metricAlertStatusLabel } from "./status.js"; import { type MetricAlertEditOutput, MetricAlertEditOutputSchema, @@ -329,7 +328,8 @@ export const editCommand = buildCommand({ ...updated, org: orgSlug, id: stringifyUnknown(updated.id ?? rule.id), - status: metricAlertStatusLabel(updated.status), + // The detectors endpoint returns `enabled` rather than `status`. + status: updated.enabled === false ? "disabled" : "active", } satisfies MetricAlertEditOutput); }, }); diff --git a/packages/cli/src/commands/alert/output-schemas.ts b/packages/cli/src/commands/alert/output-schemas.ts index a4ed9a904..4f45b5884 100644 --- a/packages/cli/src/commands/alert/output-schemas.ts +++ b/packages/cli/src/commands/alert/output-schemas.ts @@ -15,6 +15,10 @@ * `project`, the `status` label, `dryRun`, `deleted`) are written here. */ +import type { + UpdateOrganizationDetectorResponse, + UpdateOrganizationWorkflowResponse, +} from "@sentry/api"; import { vCreateOrganizationProjectDetectorResponse, vCreateOrganizationWorkflowResponse, @@ -40,6 +44,7 @@ import { unknown, } from "valibot"; import { DryRunOutputField } from "../../types/index.js"; +import type { WithOutputFields } from "../../types/output.js"; // Fields the CLI adds to every alert mutation's output. const org = pipe(string(), description("Organization slug")); @@ -193,12 +198,11 @@ export const IssueAlertEditOutputSchema = looseObject({ ...IssueAlertEditFields.entries, }); -/** - * JSON output of `alert issues edit`. The API layer hands the updated - * workflow over untyped, so only the CLI's fields are typed. - */ -export type IssueAlertEditOutput = Record & - InferOutput; +/** JSON output of `alert issues edit`. */ +export type IssueAlertEditOutput = WithOutputFields< + UpdateOrganizationWorkflowResponse, + typeof IssueAlertEditFields +>; /** Fields `alert metrics edit` sets on the updated detector. */ const MetricAlertEditFields = object({ org, id: editedId, status }); @@ -210,12 +214,11 @@ export const MetricAlertEditOutputSchema = looseObject({ ...MetricAlertEditFields.entries, }); -/** - * JSON output of `alert metrics edit`. The API layer hands the updated - * detector over untyped, so only the CLI's fields are typed. - */ -export type MetricAlertEditOutput = Record & - InferOutput; +/** JSON output of `alert metrics edit`. */ +export type MetricAlertEditOutput = WithOutputFields< + UpdateOrganizationDetectorResponse, + typeof MetricAlertEditFields +>; /** `delete` output: the deleted (or, with `--dry-run`, matched) rule. */ function deleteEntries(rule: R) { diff --git a/packages/cli/src/commands/auth/list.ts b/packages/cli/src/commands/auth/list.ts index cd38a6f45..c26ec12d0 100644 --- a/packages/cli/src/commands/auth/list.ts +++ b/packages/cli/src/commands/auth/list.ts @@ -56,7 +56,7 @@ export const listCommand = buildCommand({ const accounts = envAccount ? [...stored, await describeEnvAccount(envAccount)] : stored; - yield new CommandOutput({ accounts }); + yield new CommandOutput({ accounts }); if (getAuthConfig()?.source.startsWith("env:")) { return { diff --git a/packages/cli/src/commands/auth/logout.ts b/packages/cli/src/commands/auth/logout.ts index a99a8c4d0..82f98dbab 100644 --- a/packages/cli/src/commands/auth/logout.ts +++ b/packages/cli/src/commands/auth/logout.ts @@ -152,7 +152,7 @@ export const logoutCommand = buildCommand({ const accounts = listAccounts(); if (accounts.length === 0) { assertNotEnvOnly(); - return yield new CommandOutput({ + return yield new CommandOutput({ loggedOut: false, message: "Not currently authenticated.", }); @@ -186,7 +186,7 @@ export const logoutCommand = buildCommand({ targets = active ? [active] : []; } if (targets.length === 0) { - return yield new CommandOutput({ + return yield new CommandOutput({ loggedOut: false, message: "No account is active. Run 'sentry auth list' and pass an account to log out of, or 'all'.", @@ -232,7 +232,7 @@ async function* finishLogout( if (removed.some((a) => a.active) && activeAccount) { pointDefaultUrlAtAccount(activeAccount.host); } - yield new CommandOutput({ + yield new CommandOutput({ loggedOut: removed.length > 0, ...(removed.length === 0 ? { message: "No stored account matched." } : {}), configPath, diff --git a/packages/cli/src/commands/auth/switch.ts b/packages/cli/src/commands/auth/switch.ts index 88a1a2858..1c37c7793 100644 --- a/packages/cli/src/commands/auth/switch.ts +++ b/packages/cli/src/commands/auth/switch.ts @@ -146,7 +146,7 @@ export const switchCommand = buildCommand({ const active = activeRow ? toAccountSummary(activeRow) : target; pointDefaultUrlAtAccount(active.host); const switched = !target.active; - yield new CommandOutput({ + yield new CommandOutput({ switched, account: { ...active, active: true }, }); diff --git a/packages/cli/src/commands/bash-hook.ts b/packages/cli/src/commands/bash-hook.ts index 0cb4c1df8..1c6ad6c6a 100644 --- a/packages/cli/src/commands/bash-hook.ts +++ b/packages/cli/src/commands/bash-hook.ts @@ -333,10 +333,10 @@ export const bashHookCommand = buildCommand({ }, async *func(this: SentryContext, flags: BashHookFlags) { if (flags["send-event"]) { - yield new CommandOutput(await handleSendEvent(flags)); + yield new CommandOutput(await handleSendEvent(flags)); return; } - yield new CommandOutput(handleScriptOutput(flags)); + yield new CommandOutput(handleScriptOutput(flags)); }, }); diff --git a/packages/cli/src/commands/build/download.ts b/packages/cli/src/commands/build/download.ts index 35ce12980..423594ff3 100644 --- a/packages/cli/src/commands/build/download.ts +++ b/packages/cli/src/commands/build/download.ts @@ -132,7 +132,7 @@ export const downloadCommand = buildCommand({ const regionUrl = await resolveOrgRegion(org); await downloadBuildArtifact(regionUrl, url, output); - yield new CommandOutput({ + yield new CommandOutput({ buildId, org, output, diff --git a/packages/cli/src/commands/build/upload.ts b/packages/cli/src/commands/build/upload.ts index 4e8c2fcc6..809bde156 100644 --- a/packages/cli/src/commands/build/upload.ts +++ b/packages/cli/src/commands/build/upload.ts @@ -392,7 +392,7 @@ export const uploadCommand = buildCommand({ } const uploadedCount = builds.filter((b) => b.error === null).length; - yield new CommandOutput({ builds, uploadedCount }); + yield new CommandOutput({ builds, uploadedCount }); // Deliberate deviation from the legacy CLI, which only exits non-zero when // *every* build fails: we fail loud on *any* failure so CI/agents never diff --git a/packages/cli/src/commands/cli/completion.ts b/packages/cli/src/commands/cli/completion.ts index 695a140de..b12fe8c60 100644 --- a/packages/cli/src/commands/cli/completion.ts +++ b/packages/cli/src/commands/cli/completion.ts @@ -85,6 +85,6 @@ export const completionCommand = buildCommand({ ); } - yield new CommandOutput(script); + yield new CommandOutput(script); }, }); diff --git a/packages/cli/src/commands/cli/setup.ts b/packages/cli/src/commands/cli/setup.ts index 99a4bde8c..48481b1c3 100644 --- a/packages/cli/src/commands/cli/setup.ts +++ b/packages/cli/src/commands/cli/setup.ts @@ -807,7 +807,7 @@ export const setupCommand = buildCommand({ printWelcomeMessage(emit, CLI_VERSION, binaryPath); } - yield new CommandOutput({ + yield new CommandOutput({ messages, warnings, freshInstall, diff --git a/packages/cli/src/commands/code-mappings/upload.ts b/packages/cli/src/commands/code-mappings/upload.ts index 4801936b6..292e783c7 100644 --- a/packages/cli/src/commands/code-mappings/upload.ts +++ b/packages/cli/src/commands/code-mappings/upload.ts @@ -294,7 +294,7 @@ export const uploadCommand = buildCommand({ })); // 7. Yield result - yield new CommandOutput({ + yield new CommandOutput({ org, project, repository, diff --git a/packages/cli/src/commands/dart-symbol-map/upload.ts b/packages/cli/src/commands/dart-symbol-map/upload.ts index 652a5540e..382c23665 100644 --- a/packages/cli/src/commands/dart-symbol-map/upload.ts +++ b/packages/cli/src/commands/dart-symbol-map/upload.ts @@ -241,7 +241,7 @@ export const uploadCommand = buildCommand({ // 3. --no-upload: validate only, no auth needed if (flags["no-upload"]) { - yield new CommandOutput({ + yield new CommandOutput({ path: mappingPath, debugId: flags["debug-id"], uploaded: false, @@ -276,7 +276,7 @@ export const uploadCommand = buildCommand({ }); // 6. Yield result - yield new CommandOutput({ + yield new CommandOutput({ org, project, path: mappingPath, diff --git a/packages/cli/src/commands/dashboard/create.ts b/packages/cli/src/commands/dashboard/create.ts index f91760f42..1cd6bd49e 100644 --- a/packages/cli/src/commands/dashboard/create.ts +++ b/packages/cli/src/commands/dashboard/create.ts @@ -230,7 +230,7 @@ export const createCommand = buildCommand({ ); const url = buildDashboardUrl(orgSlug, dashboard.id); - yield new CommandOutput({ ...dashboard, url }); + yield new CommandOutput({ ...dashboard, url }); return { hint: `Dashboard: ${url}` }; }, }); diff --git a/packages/cli/src/commands/dashboard/widget/delete.ts b/packages/cli/src/commands/dashboard/widget/delete.ts index 5cbba99a5..803fbb3e3 100644 --- a/packages/cli/src/commands/dashboard/widget/delete.ts +++ b/packages/cli/src/commands/dashboard/widget/delete.ts @@ -118,7 +118,7 @@ export const deleteCommand = buildDeleteCommand( // Dry-run mode: show what would be removed without removing it if (flags["dry-run"]) { - yield new CommandOutput({ + yield new CommandOutput({ dryRun: true, widgetTitle, widgetCount: widgets.length, @@ -140,7 +140,7 @@ export const deleteCommand = buildDeleteCommand( () => updateDashboard(orgSlug, dashboardId, updateBody), ); - yield new CommandOutput({ + yield new CommandOutput({ deleted: true, widgetTitle, widgetCount: updated.widgets?.length ?? 0, diff --git a/packages/cli/src/commands/debug-files/bundle-jvm.ts b/packages/cli/src/commands/debug-files/bundle-jvm.ts index 227afd46a..cff0cb069 100644 --- a/packages/cli/src/commands/debug-files/bundle-jvm.ts +++ b/packages/cli/src/commands/debug-files/bundle-jvm.ts @@ -166,7 +166,7 @@ export const bundleJvmCommand = buildCommand({ } // 5. Yield result - yield new CommandOutput({ + yield new CommandOutput({ outputPath: result.outputPath, debugId: flags["debug-id"], fileCount: result.fileCount, diff --git a/packages/cli/src/commands/debug-files/bundle-sources.ts b/packages/cli/src/commands/debug-files/bundle-sources.ts index 5fa067ea6..7e0d32cf4 100644 --- a/packages/cli/src/commands/debug-files/bundle-sources.ts +++ b/packages/cli/src/commands/debug-files/bundle-sources.ts @@ -153,7 +153,7 @@ export const bundleSourcesCommand = buildCommand({ // this.process === the global process in production (see buildContext); // using it here keeps the exit-code observable in tests. this.process.exitCode = 1; - yield new CommandOutput({ + yield new CommandOutput({ path, outputPath: null, debugId: result.debugId, @@ -174,7 +174,7 @@ export const bundleSourcesCommand = buildCommand({ await mkdir(dirname(outputPath), { recursive: true }); await writeFile(outputPath, result.bundle); - yield new CommandOutput({ + yield new CommandOutput({ path, outputPath, debugId: result.debugId, diff --git a/packages/cli/src/commands/debug-files/check.ts b/packages/cli/src/commands/debug-files/check.ts index 213be9eee..cc1c7b19a 100644 --- a/packages/cli/src/commands/debug-files/check.ts +++ b/packages/cli/src/commands/debug-files/check.ts @@ -174,7 +174,7 @@ export const checkCommand = buildCommand({ } const usable = isUsable(archive); - yield new CommandOutput({ + yield new CommandOutput({ path, fileFormat: archive.fileFormat, objects: archive.objects, diff --git a/packages/cli/src/commands/debug-files/find.ts b/packages/cli/src/commands/debug-files/find.ts index 035e44942..cf020f181 100644 --- a/packages/cli/src/commands/debug-files/find.ts +++ b/packages/cli/src/commands/debug-files/find.ts @@ -186,7 +186,7 @@ export const findCommand = buildCommand({ }, async *func(this: SentryContext, flags: FindFlags, ...ids: string[]) { if (ids.length === 0) { - yield new CommandOutput({ matches: [], missing: [] }); + yield new CommandOutput({ matches: [], missing: [] }); return { hint: "Provide one or more debug identifiers to search for." }; } diff --git a/packages/cli/src/commands/debug-files/print-sources.ts b/packages/cli/src/commands/debug-files/print-sources.ts index 06fe188a4..118e58e17 100644 --- a/packages/cli/src/commands/debug-files/print-sources.ts +++ b/packages/cli/src/commands/debug-files/print-sources.ts @@ -189,7 +189,7 @@ export const printSourcesCommand = buildCommand({ ); } - yield new CommandOutput({ path, objects }); + yield new CommandOutput({ path, objects }); if (objects.length === 0) { return { hint: `No objects found in '${path}'. Try: ${USAGE_HINT}` }; diff --git a/packages/cli/src/commands/debug-files/upload.ts b/packages/cli/src/commands/debug-files/upload.ts index 42cc83038..cce819e91 100644 --- a/packages/cli/src/commands/debug-files/upload.ts +++ b/packages/cli/src/commands/debug-files/upload.ts @@ -368,7 +368,7 @@ function* doDryRun( }, ) { const { difs, missingIds, requireAll, oversizedCount } = params; - yield new CommandOutput({ + yield new CommandOutput({ uploaded: false, files: difs.map((d) => ({ name: d.name, debugId: d.debugId })), filesUploaded: 0, @@ -408,7 +408,7 @@ function* doNothingToUpload( }, ) { const { missingIds, requireAll, oversizedCount, maxFileSize } = params; - yield new CommandOutput({ + yield new CommandOutput({ uploaded: false, files: [], filesUploaded: 0, @@ -464,7 +464,7 @@ async function* doUpload( (r) => r.state === "error" || r.state === "not_found", ); - yield new CommandOutput({ + yield new CommandOutput({ org: params.org, project: params.project, uploaded: true, diff --git a/packages/cli/src/commands/docs/query.ts b/packages/cli/src/commands/docs/query.ts index 213a3864e..9d555d62e 100644 --- a/packages/cli/src/commands/docs/query.ts +++ b/packages/cli/src/commands/docs/query.ts @@ -82,6 +82,6 @@ export const queryCommand = buildCommand({ }, async () => await queryDocs(query, detectedContext), ); - yield new CommandOutput({ ...result, detectedContext }); + yield new CommandOutput({ ...result, detectedContext }); }, }); diff --git a/packages/cli/src/commands/event/send.ts b/packages/cli/src/commands/event/send.ts index e8623d56b..3d683c406 100644 --- a/packages/cli/src/commands/event/send.ts +++ b/packages/cli/src/commands/event/send.ts @@ -370,7 +370,7 @@ built entirely from the file contents. dsnComponents, ); await sendEnvelopeRequest(dsn, body); - yield new CommandOutput({ eventId, file }); + yield new CommandOutput({ eventId, file }); } } else { if (flags.raw) { @@ -397,7 +397,7 @@ built entirely from the file contents. ); } await sendEnvelopeRequest(dsn, body); - yield new CommandOutput({ + yield new CommandOutput({ eventId: event.event_id ?? "", }); } diff --git a/packages/cli/src/commands/feedback/resolve.ts b/packages/cli/src/commands/feedback/resolve.ts index 5650f0315..d4f4fc294 100644 --- a/packages/cli/src/commands/feedback/resolve.ts +++ b/packages/cli/src/commands/feedback/resolve.ts @@ -3,13 +3,13 @@ */ import type { SentryContext } from "../../context.js"; -import { updateIssueStatus } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; import { formatResolvedFeedback } from "../../lib/formatters/feedback.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { disableResponseCache } from "../../lib/response-cache.js"; import { SentryFeedbackSchema } from "../../types/index.js"; import { type FeedbackTarget, feedbackPositional } from "./positional.js"; +import { updateFeedbackStatus } from "./utils.js"; export const resolveCommand = buildCommand({ docs: { @@ -48,11 +48,8 @@ export const resolveCommand = buildCommand({ async *func(this: SentryContext, _flags, target: FeedbackTarget) { // A cached inbox could select Feedback that has already been resolved elsewhere. disableResponseCache(); - const { org, feedback } = await target.resolved; - const updated = await updateIssueStatus(feedback.id, "resolved", { - orgSlug: org, - }); - - yield new CommandOutput(updated); + yield new CommandOutput( + await updateFeedbackStatus(await target.resolved, "resolved"), + ); }, }); diff --git a/packages/cli/src/commands/feedback/spam.ts b/packages/cli/src/commands/feedback/spam.ts index 8ed8fcdfe..5c6ec4c98 100644 --- a/packages/cli/src/commands/feedback/spam.ts +++ b/packages/cli/src/commands/feedback/spam.ts @@ -3,13 +3,13 @@ */ import type { SentryContext } from "../../context.js"; -import { updateIssueStatus } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; import { formatSpamFeedback } from "../../lib/formatters/feedback.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { disableResponseCache } from "../../lib/response-cache.js"; import { SentryFeedbackSchema } from "../../types/index.js"; import { type FeedbackTarget, feedbackPositional } from "./positional.js"; +import { updateFeedbackStatus } from "./utils.js"; export const spamCommand = buildCommand({ docs: { @@ -47,11 +47,8 @@ export const spamCommand = buildCommand({ }, async *func(this: SentryContext, _flags, target: FeedbackTarget) { disableResponseCache(); - const { org, feedback } = await target.resolved; - const updated = await updateIssueStatus(feedback.id, "ignored", { - orgSlug: org, - }); - - yield new CommandOutput(updated); + yield new CommandOutput( + await updateFeedbackStatus(await target.resolved, "ignored"), + ); }, }); diff --git a/packages/cli/src/commands/feedback/unresolve.ts b/packages/cli/src/commands/feedback/unresolve.ts index 2ed6826da..a17b77c4e 100644 --- a/packages/cli/src/commands/feedback/unresolve.ts +++ b/packages/cli/src/commands/feedback/unresolve.ts @@ -3,13 +3,13 @@ */ import type { SentryContext } from "../../context.js"; -import { updateIssueStatus } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; import { formatReopenedFeedback } from "../../lib/formatters/feedback.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { disableResponseCache } from "../../lib/response-cache.js"; import { SentryFeedbackSchema } from "../../types/index.js"; import { type FeedbackTarget, feedbackPositional } from "./positional.js"; +import { updateFeedbackStatus } from "./utils.js"; export const unresolveCommand = buildCommand({ docs: { @@ -52,11 +52,8 @@ export const unresolveCommand = buildCommand({ }, async *func(this: SentryContext, _flags, target: FeedbackTarget) { disableResponseCache(); - const { org, feedback } = await target.resolved; - const updated = await updateIssueStatus(feedback.id, "unresolved", { - orgSlug: org, - }); - - yield new CommandOutput(updated); + yield new CommandOutput( + await updateFeedbackStatus(await target.resolved, "unresolved"), + ); }, }); diff --git a/packages/cli/src/commands/feedback/utils.ts b/packages/cli/src/commands/feedback/utils.ts index 851b54af2..1fbf95d9f 100644 --- a/packages/cli/src/commands/feedback/utils.ts +++ b/packages/cli/src/commands/feedback/utils.ts @@ -2,7 +2,7 @@ * Shared resolution utilities for modern User Feedback commands. */ -import { listFeedback } from "../../lib/api-client.js"; +import { listFeedback, updateIssueStatus } from "../../lib/api-client.js"; import type { IssueSelector } from "../../lib/arg-parsing.js"; import { ApiError, @@ -128,3 +128,17 @@ export async function resolveFeedback( return { org, feedback: issue as SentryFeedback }; } + +/** + * Set the status of Feedback that {@link resolveFeedback} returned. The + * Issues API answers with the updated issue, which is still that Feedback. + */ +export async function updateFeedbackStatus( + { org, feedback }: ResolvedFeedback, + status: "resolved" | "unresolved" | "ignored", +): Promise { + const updated = await updateIssueStatus(feedback.id, status, { + orgSlug: org, + }); + return updated as SentryFeedback; +} diff --git a/packages/cli/src/commands/issue/archive.ts b/packages/cli/src/commands/issue/archive.ts index d6ca7c363..f6cabefc1 100644 --- a/packages/cli/src/commands/issue/archive.ts +++ b/packages/cli/src/commands/issue/archive.ts @@ -427,6 +427,6 @@ export const archiveCommand = buildCommand({ }); log.debug(`Archived ${updated.shortId}`); - yield new CommandOutput(updated); + yield new CommandOutput(updated); }, }); diff --git a/packages/cli/src/commands/issue/merge.ts b/packages/cli/src/commands/issue/merge.ts index 7e63e9139..358dcb624 100644 --- a/packages/cli/src/commands/issue/merge.ts +++ b/packages/cli/src/commands/issue/merge.ts @@ -372,7 +372,7 @@ export const mergeCommand = buildCommand({ ); } - yield new CommandOutput({ + yield new CommandOutput({ org, parentShortId, childShortIds, diff --git a/packages/cli/src/commands/issue/resolve.ts b/packages/cli/src/commands/issue/resolve.ts index aefd30b92..a5cebcb47 100644 --- a/packages/cli/src/commands/issue/resolve.ts +++ b/packages/cli/src/commands/issue/resolve.ts @@ -201,7 +201,7 @@ export const resolveCommand = buildCommand({ log.debug( `Resolved ${updated.shortId} ${describeSpec(statusDetails ?? null)}`, ); - yield new CommandOutput({ + yield new CommandOutput({ issue: updated, spec: statusDetails ?? null, }); diff --git a/packages/cli/src/commands/issue/unresolve.ts b/packages/cli/src/commands/issue/unresolve.ts index 35101cfba..7aad926e2 100644 --- a/packages/cli/src/commands/issue/unresolve.ts +++ b/packages/cli/src/commands/issue/unresolve.ts @@ -64,6 +64,6 @@ export const unresolveCommand = buildCommand({ }); log.debug(`Reopened ${updated.shortId}`); - yield new CommandOutput(updated); + yield new CommandOutput(updated); }, }); diff --git a/packages/cli/src/commands/log/list.ts b/packages/cli/src/commands/log/list.ts index 20f100df1..d5685735c 100644 --- a/packages/cli/src/commands/log/list.ts +++ b/packages/cli/src/commands/log/list.ts @@ -493,7 +493,7 @@ async function* yieldFollowItems( let contextSent = !extraFields?.length; for await (const batch of generator) { if (!contextSent && batch.length > 0) { - yield new CommandOutput({ + yield new CommandOutput({ logs: batch, hasMore: false, extraFields, @@ -501,7 +501,7 @@ async function* yieldFollowItems( contextSent = true; } else { for (const item of batch) { - yield new CommandOutput(item); + yield new CommandOutput(item); } } } @@ -522,7 +522,7 @@ async function* yieldTraceFollowItems( for await (const batch of generator) { if (!contextSent && batch.length > 0) { // First non-empty batch: yield as LogListResult to set trace context - yield new CommandOutput({ + yield new CommandOutput({ logs: batch, traceId, hasMore: false, @@ -530,7 +530,7 @@ async function* yieldTraceFollowItems( contextSent = true; } else { for (const item of batch) { - yield new CommandOutput(item); + yield new CommandOutput(item); } } } diff --git a/packages/cli/src/commands/platform/list.ts b/packages/cli/src/commands/platform/list.ts index bb36a759d..435253cbe 100644 --- a/packages/cli/src/commands/platform/list.ts +++ b/packages/cli/src/commands/platform/list.ts @@ -69,7 +69,7 @@ export const listCommand = buildCommand({ // Stricli requires AsyncGenerator but this is synchronous (in-memory data) async *func(this: SentryContext, flags: PlatformsFlags) { if (!flags.search) { - return yield new CommandOutput(VALID_PLATFORMS); + return yield new CommandOutput(VALID_PLATFORMS); } const term = flags.search.toLowerCase(); @@ -82,6 +82,6 @@ export const listCommand = buildCommand({ throw new OutputError([] satisfies readonly string[]); } - return yield new CommandOutput(matches); + return yield new CommandOutput(matches); }, }); diff --git a/packages/cli/src/commands/proguard/upload.ts b/packages/cli/src/commands/proguard/upload.ts index e67e0e7fc..cdf546248 100644 --- a/packages/cli/src/commands/proguard/upload.ts +++ b/packages/cli/src/commands/proguard/upload.ts @@ -264,7 +264,7 @@ export const uploadCommand = buildCommand({ // 6. --no-upload: just print UUIDs and return (no auth needed) if (flags["no-upload"]) { - yield new CommandOutput({ + yield new CommandOutput({ mappings: uniqueMappings.map((m) => ({ path: m.path, uuid: m.uuid })), filesUploaded: 0, }); @@ -294,7 +294,7 @@ export const uploadCommand = buildCommand({ }); // 9. Yield result - yield new CommandOutput({ + yield new CommandOutput({ org, project, mappings: uniqueMappings.map((m) => ({ path: m.path, uuid: m.uuid })), diff --git a/packages/cli/src/commands/proguard/uuid.ts b/packages/cli/src/commands/proguard/uuid.ts index 5f31665a1..c79f9e55b 100644 --- a/packages/cli/src/commands/proguard/uuid.ts +++ b/packages/cli/src/commands/proguard/uuid.ts @@ -100,7 +100,7 @@ export const uuidCommand = buildCommand({ } const uuid = computeProguardUuid(content); - yield new CommandOutput({ path, uuid }); + yield new CommandOutput({ path, uuid }); return {}; }, }); diff --git a/packages/cli/src/commands/react-native/gradle.ts b/packages/cli/src/commands/react-native/gradle.ts index a2bac075c..51d09e86e 100644 --- a/packages/cli/src/commands/react-native/gradle.ts +++ b/packages/cli/src/commands/react-native/gradle.ts @@ -241,7 +241,7 @@ export const gradleCommand = buildCommand({ uploads = 1; } - yield new CommandOutput({ + yield new CommandOutput({ bundle: bundlePath, sourcemap: sourcemapPath, debugId, diff --git a/packages/cli/src/commands/react-native/xcode.ts b/packages/cli/src/commands/react-native/xcode.ts index b5af2287e..fc980215a 100644 --- a/packages/cli/src/commands/react-native/xcode.ts +++ b/packages/cli/src/commands/react-native/xcode.ts @@ -538,7 +538,7 @@ export const xcodeCommand = buildCommand({ if (prep.exitCode !== undefined) { this.process.exitCode = prep.exitCode; } - yield new CommandOutput(prep.result); + yield new CommandOutput(prep.result); return { hint: prep.hint }; } @@ -550,7 +550,7 @@ export const xcodeCommand = buildCommand({ flags, ); - yield new CommandOutput({ + yield new CommandOutput({ mode, bundle: pair.bundle, sourcemap: pair.sourcemap, diff --git a/packages/cli/src/commands/release/archive.ts b/packages/cli/src/commands/release/archive.ts index bddc13e63..c41f472d3 100644 --- a/packages/cli/src/commands/release/archive.ts +++ b/packages/cli/src/commands/release/archive.ts @@ -100,7 +100,7 @@ export const archiveCommand = buildCommand({ const { version, org, detectedFrom } = await target.resolved; if (flags["dry-run"]) { - yield new CommandOutput({ + yield new CommandOutput({ dryRun: true, version, org, @@ -111,7 +111,7 @@ export const archiveCommand = buildCommand({ const release = await updateRelease(org, version, { status: "archived", }); - yield new CommandOutput(release); + yield new CommandOutput(release); const hint = detectedFrom ? `Detected from ${detectedFrom}` : undefined; return { hint }; }, diff --git a/packages/cli/src/commands/release/create.ts b/packages/cli/src/commands/release/create.ts index ce49981b6..a089e4985 100644 --- a/packages/cli/src/commands/release/create.ts +++ b/packages/cli/src/commands/release/create.ts @@ -5,7 +5,16 @@ */ import { inlineCode, text } from "@sentry/toolkit-core/markdown"; -import { looseObject, object } from "valibot"; +import { + array, + description, + looseObject, + object, + optional, + pipe, + string, + union, +} from "valibot"; import type { SentryContext } from "../../context.js"; import { createRelease } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; @@ -31,12 +40,26 @@ import { type ReleaseTarget, releaseParameter } from "./parse.js"; const USAGE_HINT = "sentry release create [/]"; -/** The field `--dry-run` adds to the release it reports. */ -const ReleaseCreateOutputFields = object({ dryRun: DryRunOutputField }); +/** + * The fields `--dry-run` changes on the release it reports: the `dryRun` + * flag, and `projects` as `{ slug }` objects instead of full projects. + */ +const ReleaseCreateOutputFields = object({ + dryRun: DryRunOutputField, + projects: optional( + pipe( + union([ + SentryReleaseSchema.entries.projects.wrapped, + array(object({ slug: pipe(string(), description("Project slug")) })), + ]), + description("Projects in the release (only slug with --dry-run)"), + ), + ), +}); /** * JSON output: the created release, or with `--dry-run` the release that - * would be created, in the same shape (`projects` as `{ slug }` objects). + * would be created, in the same shape. */ const ReleaseCreateOutputSchema = looseObject({ ...SentryReleaseSchema.entries, @@ -45,11 +68,9 @@ const ReleaseCreateOutputSchema = looseObject({ /** The release `--dry-run` reports: the request body, shaped like a release. */ type ReleaseCreateDryRun = WithOutputFields< - Pick & { - projects: { slug: string }[]; - }, + Pick, typeof ReleaseCreateOutputFields ->; +> & { projects: { slug: string }[] }; function formatReleaseCreated(release: SentryRelease): string { const lines: string[] = []; @@ -215,7 +236,7 @@ export const createCommand = buildCommand({ url: body.url, dateReleased: body.dateReleased, }; - yield new CommandOutput(preview); + yield new CommandOutput(preview); return { hint: "Dry run — no release was created." }; } diff --git a/packages/cli/src/commands/release/delete.ts b/packages/cli/src/commands/release/delete.ts index 90e1a35e7..5fa4b89a3 100644 --- a/packages/cli/src/commands/release/delete.ts +++ b/packages/cli/src/commands/release/delete.ts @@ -133,7 +133,7 @@ export const deleteCommand = buildDeleteCommand({ // Dry-run mode: show what would be deleted if (flags["dry-run"]) { - yield new CommandOutput({ + yield new CommandOutput({ deleted: false, org, version, @@ -153,7 +153,7 @@ export const deleteCommand = buildDeleteCommand({ `Type '${version}' to permanently delete this release${deployInfo}:`, ); if (!confirmed) { - yield new CommandOutput({ + yield new CommandOutput({ deleted: false, org, version, @@ -167,6 +167,6 @@ export const deleteCommand = buildDeleteCommand({ } catch (error) { throw enrichDeleteError(error, org, version); } - yield new CommandOutput({ deleted: true, org, version }); + yield new CommandOutput({ deleted: true, org, version }); }, }); diff --git a/packages/cli/src/commands/release/deploy.ts b/packages/cli/src/commands/release/deploy.ts index 045f728b8..3456c6f06 100644 --- a/packages/cli/src/commands/release/deploy.ts +++ b/packages/cli/src/commands/release/deploy.ts @@ -231,7 +231,7 @@ export const deployCommand = buildCommand({ } if (flags["dry-run"]) { - yield new CommandOutput({ + yield new CommandOutput({ dryRun: true, version, environment, @@ -244,6 +244,6 @@ export const deployCommand = buildCommand({ } const deploy = await createReleaseDeploy(org, version, body); - yield new CommandOutput(deploy); + yield new CommandOutput(deploy); }, }); diff --git a/packages/cli/src/commands/release/finalize.ts b/packages/cli/src/commands/release/finalize.ts index 15da841e2..6c28ceb9f 100644 --- a/packages/cli/src/commands/release/finalize.ts +++ b/packages/cli/src/commands/release/finalize.ts @@ -137,7 +137,7 @@ export const finalizeCommand = buildCommand({ const { version, org, detectedFrom } = await target.resolved; if (flags["dry-run"]) { - yield new CommandOutput({ + yield new CommandOutput({ dryRun: true, version, org, @@ -153,7 +153,7 @@ export const finalizeCommand = buildCommand({ body.url = flags.url; } const release = await updateRelease(org, version, body); - yield new CommandOutput(release); + yield new CommandOutput(release); const hint = detectedFrom ? `Detected from ${detectedFrom}` : undefined; return { hint }; }, diff --git a/packages/cli/src/commands/release/restore.ts b/packages/cli/src/commands/release/restore.ts index 2085f4d93..52c6f4d0a 100644 --- a/packages/cli/src/commands/release/restore.ts +++ b/packages/cli/src/commands/release/restore.ts @@ -97,7 +97,7 @@ export const restoreCommand = buildCommand({ const { version, org, detectedFrom } = await target.resolved; if (flags["dry-run"]) { - yield new CommandOutput({ + yield new CommandOutput({ dryRun: true, version, org, @@ -108,7 +108,7 @@ export const restoreCommand = buildCommand({ const release = await updateRelease(org, version, { status: "open", }); - yield new CommandOutput(release); + yield new CommandOutput(release); const hint = detectedFrom ? `Detected from ${detectedFrom}` : undefined; return { hint }; }, diff --git a/packages/cli/src/commands/replay/download.ts b/packages/cli/src/commands/replay/download.ts index dbfe1a2ce..a6228fb80 100644 --- a/packages/cli/src/commands/replay/download.ts +++ b/packages/cli/src/commands/replay/download.ts @@ -162,7 +162,7 @@ export const downloadCommand = buildCommand({ await mkdir(dirname(output), { recursive: true }); await writeFile(output, JSON.stringify(events)); - yield new CommandOutput({ + yield new CommandOutput({ org: resolved.org, replayId: replay.id, output, diff --git a/packages/cli/src/commands/snapshots/diff.ts b/packages/cli/src/commands/snapshots/diff.ts index 75c5cfa5f..a35b9a2a7 100644 --- a/packages/cli/src/commands/snapshots/diff.ts +++ b/packages/cli/src/commands/snapshots/diff.ts @@ -347,7 +347,7 @@ export const diffCommand = buildCommand({ } const summary = summarize(images); - yield new CommandOutput({ + yield new CommandOutput({ baseDir, headDir, outputDir, diff --git a/packages/cli/src/commands/snapshots/download.ts b/packages/cli/src/commands/snapshots/download.ts index 57acc02e8..88dd04191 100644 --- a/packages/cli/src/commands/snapshots/download.ts +++ b/packages/cli/src/commands/snapshots/download.ts @@ -251,7 +251,7 @@ export const downloadCommand = buildCommand({ ); } - yield new CommandOutput({ + yield new CommandOutput({ org, snapshotId, output, diff --git a/packages/cli/src/commands/snapshots/upload.ts b/packages/cli/src/commands/snapshots/upload.ts index 18e1b0aa6..f57fcfc89 100644 --- a/packages/cli/src/commands/snapshots/upload.ts +++ b/packages/cli/src/commands/snapshots/upload.ts @@ -517,7 +517,7 @@ export const uploadCommand = buildCommand({ const { selective, allImageNames } = await resolveSelective(flags, images); // A complete name list makes zero uploaded images a valid all-unchanged run. if (images.length === 0 && allImageNames === undefined) { - yield new CommandOutput({ + yield new CommandOutput({ imagesFound: 0, uploaded: 0, skipped: 0, @@ -547,7 +547,7 @@ export const uploadCommand = buildCommand({ }); const snapshot = await createPreprodSnapshot(org, project, manifest); - yield new CommandOutput({ + yield new CommandOutput({ imagesFound: images.length, uploaded, skipped, diff --git a/packages/cli/src/commands/sourcemap/inject.ts b/packages/cli/src/commands/sourcemap/inject.ts index e885ac781..9705e2492 100644 --- a/packages/cli/src/commands/sourcemap/inject.ts +++ b/packages/cli/src/commands/sourcemap/inject.ts @@ -188,7 +188,7 @@ export const injectCommand = buildCommand({ const modified = results.filter((r) => r.injected).length; const skipped = results.length - modified; - yield new CommandOutput({ + yield new CommandOutput({ modified, skipped, files: results, diff --git a/packages/cli/src/commands/sourcemap/resolve.ts b/packages/cli/src/commands/sourcemap/resolve.ts index b84503c3e..4f1599713 100644 --- a/packages/cli/src/commands/sourcemap/resolve.ts +++ b/packages/cli/src/commands/sourcemap/resolve.ts @@ -224,7 +224,7 @@ export const resolveCommand = buildCommand({ } } - yield new CommandOutput({ + yield new CommandOutput({ total: files.length, resolved, withDebugId, diff --git a/packages/cli/src/commands/sourcemap/upload.ts b/packages/cli/src/commands/sourcemap/upload.ts index 443661bbb..3739c5292 100644 --- a/packages/cli/src/commands/sourcemap/upload.ts +++ b/packages/cli/src/commands/sourcemap/upload.ts @@ -380,7 +380,7 @@ export const uploadCommand = buildCommand({ // --allow-empty: nothing to upload, so don't require Sentry // credentials. This makes the flag actually usable in the // library-only / conditional-release-skip cases the docs name. - yield new CommandOutput({ + yield new CommandOutput({ release: flags.release, dist: flags.dist, filesUploaded: 0, @@ -468,7 +468,7 @@ export const uploadCommand = buildCommand({ (f) => f.type === "minified_source", ).length; - yield new CommandOutput({ + yield new CommandOutput({ org, project, release: flags.release, diff --git a/packages/cli/src/commands/trial/list.ts b/packages/cli/src/commands/trial/list.ts index e6c212ba4..44f341ce5 100644 --- a/packages/cli/src/commands/trial/list.ts +++ b/packages/cli/src/commands/trial/list.ts @@ -6,6 +6,16 @@ * plan-level upgrade trials. */ +import { + description, + type InferOutput, + nullable, + number, + object, + picklist, + pipe, + string, +} from "valibot"; import type { SentryContext } from "../../context.js"; import { getCustomerTrialInfo } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; @@ -33,29 +43,37 @@ type ListFlags = { readonly fields?: string[]; }; +const { category, isStarted, lengthDays, startDate, endDate } = + ProductTrialSchema.pipe[0].entries; + /** - * Enriched trial entry for display. - * Adds derived fields (name, displayName, status, daysRemaining) to the raw API data. + * JSON output of one trial: the API trial's fields plus the ones the CLI + * derives (name, status, daysRemaining). */ -type TrialListEntry = { - /** CLI-friendly name (e.g., "seer") or "plan" for plan-level trials */ - name: string; +const TrialListEntrySchema = object({ + name: pipe( + string(), + description('CLI trial name (e.g. seer), or "plan" for the plan trial'), + ), + category, + status: pipe( + picklist(["available", "active", "expired"]), + description("Trial status"), + ), + daysRemaining: pipe( + nullable(number()), + description("Days left on an active trial"), + ), + isStarted, + lengthDays, + startDate, + endDate, +}); + +/** A trial for display: the JSON fields plus its human-only display name. */ +type TrialListEntry = InferOutput & { /** Human-readable product name (e.g., "Seer") or plan upgrade name */ displayName: string; - /** API category name (e.g., "seerUsers"), or "plan" for plan-level trials */ - category: string; - /** Derived status */ - status: TrialStatus; - /** Days remaining for active trials, null otherwise */ - daysRemaining: number | null; - /** Raw isStarted flag from API */ - isStarted: boolean; - /** Trial length in days, null if not set */ - lengthDays: number | null; - /** ISO date string when trial started, null if not started */ - startDate: string | null; - /** ISO date string when trial ends, null if not started */ - endDate: string | null; }; /** Status display labels with color indicators */ @@ -217,7 +235,7 @@ export const listCommand = buildCommand({ output: { human: formatTrialListHuman, jsonExclude: ["displayName"], - schema: ProductTrialSchema, + schema: TrialListEntrySchema, }, parameters: { positional: { diff --git a/packages/cli/src/commands/wasm-split.ts b/packages/cli/src/commands/wasm-split.ts index e8e52b7ca..df73b80bf 100644 --- a/packages/cli/src/commands/wasm-split.ts +++ b/packages/cli/src/commands/wasm-split.ts @@ -203,7 +203,7 @@ export const wasmSplitCommand = buildCommand({ return {}; } - yield new CommandOutput({ + yield new CommandOutput({ buildId: formatBuildId(result.buildId), input, ...(result.moduleChanged ? { output } : {}), diff --git a/packages/cli/src/lib/api/alerts.ts b/packages/cli/src/lib/api/alerts.ts index 26591fc4b..c47dff881 100644 --- a/packages/cli/src/lib/api/alerts.ts +++ b/packages/cli/src/lib/api/alerts.ts @@ -17,6 +17,8 @@ import { listOrganizationDetectors, updateOrganizationDetector, updateOrganizationWorkflow, + type UpdateOrganizationDetectorResponse, + type UpdateOrganizationWorkflowResponse, } from "@sentry/api"; import { METRIC_MONITOR_PRIORITY, @@ -529,7 +531,7 @@ export async function updateIssueAlertRule( orgSlug: string, workflowId: string, body: Record, -): Promise> { +): Promise { const config = await getOrgSdkConfig(orgSlug); const result = await updateOrganizationWorkflow({ ...config, @@ -543,7 +545,7 @@ export async function updateIssueAlertRule( typeof updateOrganizationWorkflow >[0]["body"], }); - return unwrapResult>( + return unwrapResult( result, "Failed to update issue alert rule", ); @@ -825,7 +827,7 @@ export async function putMetricAlertRule( orgSlug: string, ruleId: string, body: Record, -): Promise> { +): Promise { const config = await getOrgSdkConfig(orgSlug); const result = await updateOrganizationDetector({ ...config, @@ -843,7 +845,7 @@ export async function putMetricAlertRule( typeof updateOrganizationDetector >[0]["body"], }); - return unwrapResult>( + return unwrapResult( result, `Failed to update metric alert rule '${ruleId}'`, ); diff --git a/packages/cli/src/lib/command.ts b/packages/cli/src/lib/command.ts index a60c9d554..cd88511e0 100644 --- a/packages/cli/src/lib/command.ts +++ b/packages/cli/src/lib/command.ts @@ -37,6 +37,7 @@ import { buildCommand as stricliCommand, numberParser as stricliNumberParser, } from "@stricli/core"; +import type { GenericSchema } from "valibot"; import type { Writer } from "../types/index.js"; import { asRunningAppCommand } from "./app-identity.js"; import { appendCacheHint } from "./cache-hint.js"; @@ -162,6 +163,7 @@ type LocalCommandBuilderArguments< ARGS extends BaseArgs, CONTEXT extends CommandContext, OUTPUT, + SCHEMA extends GenericSchema, > = { readonly parameters?: Record; readonly docs: CommandDocumentation; @@ -185,7 +187,7 @@ type LocalCommandBuilderArguments< * }) * ``` */ - readonly output?: OutputConfig; + readonly output?: OutputConfig; /** * Whether the command requires authentication. Defaults to `true`. * @@ -560,8 +562,15 @@ export function buildCommand< const ARGS extends BaseArgs = [], const CONTEXT extends CommandContext = CommandContext, OUTPUT = never, + SCHEMA extends GenericSchema = GenericSchema, >( - builderArgs: LocalCommandBuilderArguments, + builderArgs: LocalCommandBuilderArguments< + FLAGS, + ARGS, + CONTEXT, + OUTPUT, + SCHEMA + >, ): Command { const originalFunc = builderArgs.func; // The wrapper renders whatever the generator yields; the builder diff --git a/packages/cli/src/lib/formatters/feedback.ts b/packages/cli/src/lib/formatters/feedback.ts index c19060e90..3636546a4 100644 --- a/packages/cli/src/lib/formatters/feedback.ts +++ b/packages/cli/src/lib/formatters/feedback.ts @@ -13,7 +13,6 @@ import type { FeedbackViewResult, SentryEvent, SentryFeedback, - SentryIssue, } from "../../types/index.js"; import { getReplayIdFromEvent } from "../replay-search.js"; import { formatEventDetails } from "./human.js"; @@ -33,21 +32,21 @@ const FEEDBACK_UNSAFE_TERMINAL_RE = /[\x00-\x09\x0b\x0c\x0e-\x1f\x7f-\x9f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g; /** Confirm resolution using the updated Feedback's terminal-safe identifier. */ -export function formatResolvedFeedback(feedback: SentryIssue): string { +export function formatResolvedFeedback(feedback: SentryFeedback): string { return renderMarkdown( `Resolved feedback ${feedbackCodeSpan(feedback.shortId)}.`, ); } /** Confirm reopening using the updated Feedback's terminal-safe identifier. */ -export function formatReopenedFeedback(feedback: SentryIssue): string { +export function formatReopenedFeedback(feedback: SentryFeedback): string { return renderMarkdown( `Reopened feedback ${feedbackCodeSpan(feedback.shortId)}.`, ); } /** Confirm spam using the updated Feedback's terminal-safe identifier. */ -export function formatSpamFeedback(feedback: SentryIssue): string { +export function formatSpamFeedback(feedback: SentryFeedback): string { return renderMarkdown( `Marked feedback ${feedbackCodeSpan(feedback.shortId)} as spam.`, ); diff --git a/packages/cli/src/lib/formatters/output.ts b/packages/cli/src/lib/formatters/output.ts index d61581847..f7c9056bd 100644 --- a/packages/cli/src/lib/formatters/output.ts +++ b/packages/cli/src/lib/formatters/output.ts @@ -19,7 +19,7 @@ * formatter — there is no divergent-data path. */ -import { type GenericSchema, getDescription } from "valibot"; +import { type GenericSchema, getDescription, type InferOutput } from "valibot"; import type { Writer } from "../../types/index.js"; import { plainSafeMuted } from "./human.js"; import { filterFields, formatJson } from "./json.js"; @@ -102,15 +102,24 @@ type JsonExcludeKey = T extends readonly (infer Item)[] : keyof T & string; /** - * Output configuration declared on `buildCommand` for automatic rendering. - * - * When present, `--json` and `--fields` flags are injected and the wrapper - * auto-renders yielded {@link CommandOutput} values. - * - * @typeParam T - Type of data the command yields (used by `human` formatter - * and serialized as-is to JSON) + * The JSON value `--fields` operates on: the data, or each item when the + * data is an array. + */ +type JsonItem = T extends readonly (infer Item)[] ? Item : T; + +/** + * Holds when the JSON data satisfies `S`, so every field the schema + * documents exists on the data with a matching type. Otherwise it requires + * a property no schema has, and the error shows the schema's output type. */ -export type OutputConfig = { +type SchemaMatches = [JsonItem] extends [ + InferOutput, +] + ? unknown + : { "~outputMismatch": InferOutput }; + +/** Fields shared by both forms of {@link OutputConfig}. */ +type OutputConfigBase = { /** * Human-readable renderer. * @@ -126,40 +135,58 @@ export type OutputConfig = { * (e.g. pre-formatted terminal strings) and should not appear * in the JSON contract. * - * Ignored when {@link jsonTransform} is set — the transform is - * responsible for shaping the final JSON output. + * Ignored when {@link OutputConfig.jsonTransform} is set — the transform + * is responsible for shaping the final JSON output. */ jsonExclude?: ReadonlyArray>; - /** - * Custom JSON serialization transform. - * - * When set, replaces the default JSON output path entirely. - * The function receives the raw command data and the parsed `--fields` - * list, and returns the final object to serialize. - * - * This is useful for list commands that wrap items in a - * `{ data, hasMore, nextCursor }` envelope and need per-element - * field filtering rather than top-level filtering. - * - * When `jsonTransform` is set, `jsonExclude` is ignored. - */ - jsonTransform?: (data: T, fields?: string[]) => unknown; - /** - * Valibot schema describing the shape of JSON output (after transform/exclude). - * Declare it once and derive the command's output type from it; see - * "Declaring Command Output" in `packages/cli/AGENTS.md`. - * - * For list commands with {@link jsonTransform}, this should describe the - * **item** type (what `--fields` operates on), not the envelope. - * - * Used for: - * - `--help` output: appends available JSON fields to the command description - * - `sentry help `: structured field documentation - * - `generate-skill.ts`: SKILL.md field tables for AI agents - */ - schema?: GenericSchema; }; +/** + * Output configuration declared on `buildCommand` for automatic rendering. + * + * When present, `--json` and `--fields` flags are injected and the wrapper + * auto-renders yielded {@link CommandOutput} values. + * + * `schema` is a valibot schema describing the JSON output. Declare it once + * and derive the command's output type from it; see "Declaring Command + * Output" in `packages/cli/AGENTS.md`. It drives `--help`, `sentry help + * `, `--fields` completion, and the SKILL.md field tables. + * + * Without `jsonTransform`, the data (each item for array data) must satisfy + * `schema`, so a documented field the command doesn't produce is a compile + * error. With `jsonTransform`, `schema` describes the transform's **item** + * (what `--fields` operates on), which the types can't see, so it isn't + * checked. + * + * @typeParam T - Type of data the command yields (used by `human` formatter + * and serialized as-is to JSON) + * @typeParam S - Type of `schema`, inferred by the command builders + */ +export type OutputConfig< + T, + S extends GenericSchema = GenericSchema, +> = OutputConfigBase & + ( + | { jsonTransform?: undefined; schema?: S & SchemaMatches } + | { + /** + * Custom JSON serialization transform. + * + * When set, replaces the default JSON output path entirely. + * The function receives the raw command data and the parsed + * `--fields` list, and returns the final object to serialize. + * + * This is useful for list commands that wrap items in a + * `{ data, hasMore, nextCursor }` envelope and need per-element + * field filtering rather than top-level filtering. + * + * When `jsonTransform` is set, `jsonExclude` is ignored. + */ + jsonTransform: (data: T, fields?: string[]) => unknown; + schema?: GenericSchema; + } + ); + /** * Yield type for commands with {@link OutputConfig}. * diff --git a/packages/cli/src/lib/list-command.ts b/packages/cli/src/lib/list-command.ts index 655a0125a..a5b1e5c82 100644 --- a/packages/cli/src/lib/list-command.ts +++ b/packages/cli/src/lib/list-command.ts @@ -14,6 +14,7 @@ */ import type { Aliases, Command, CommandContext } from "@stricli/core"; +import type { GenericSchema } from "valibot"; import type { SentryContext } from "../context.js"; import { type ListTargetOptions, parseCursorFlag } from "./arg-parsing.js"; import { @@ -529,13 +530,14 @@ export function buildListCommand< const ARGS extends readonly unknown[] = [], const CONTEXT extends CommandContext = CommandContext, OUTPUT = never, + SCHEMA extends GenericSchema = GenericSchema, >( routeName: string, builderArgs: { readonly parameters?: Record; readonly docs: CommandDocumentation; readonly func: SentryCommandFunction>; - readonly output?: OutputConfig; + readonly output?: OutputConfig; readonly auth?: boolean; }, options?: ListCommandOptions, diff --git a/packages/cli/src/lib/mutate-command.ts b/packages/cli/src/lib/mutate-command.ts index 5f7c9574d..753f9ee74 100644 --- a/packages/cli/src/lib/mutate-command.ts +++ b/packages/cli/src/lib/mutate-command.ts @@ -18,6 +18,7 @@ import { isatty } from "node:tty"; import type { Command, CommandContext } from "@stricli/core"; +import type { GenericSchema } from "valibot"; import type { ParsedOrgProject } from "./arg-parsing.js"; import { buildCommand, @@ -313,12 +314,13 @@ export function buildDeleteCommand< const ARGS extends readonly unknown[] = [], const CONTEXT extends CommandContext = CommandContext, OUTPUT = never, + SCHEMA extends GenericSchema = GenericSchema, >( builderArgs: { readonly parameters?: Record; readonly docs: CommandDocumentation; readonly func: SentryCommandFunction>; - readonly output?: OutputConfig; + readonly output?: OutputConfig; readonly auth?: boolean; }, options?: DeleteCommandOptions, diff --git a/packages/cli/test/commands/alert/metrics/edit.test.ts b/packages/cli/test/commands/alert/metrics/edit.test.ts index 3aed4abac..5a2a407cb 100644 --- a/packages/cli/test/commands/alert/metrics/edit.test.ts +++ b/packages/cli/test/commands/alert/metrics/edit.test.ts @@ -284,4 +284,32 @@ describe("alert metrics edit", () => { expect.stringContaining("is_transaction:true"), ); }); + test("reports the status from the updated detector's enabled flag", async () => { + const context = createContext(); + resolveSpy.mockResolvedValue("test-org"); + getRuleSpy.mockResolvedValue(sampleRule); + getDocSpy.mockResolvedValue({ + id: "9", + name: "Metric Rule", + status: 0, + query: "event.type:error", + aggregate: "count()", + dataset: "errors", + timeWindow: 5, + }); + // The detectors endpoint answers with `enabled`, not `status` + putSpy.mockResolvedValue({ id: "9", name: "Metric Rule", enabled: false }); + const func = (await editCommand.loader()) as unknown as ( + this: unknown, + flags: EditFlags, + arg: string, + ) => Promise; + + await func.call(context, { status: "disabled", json: true }, "test-org/9"); + + const output = JSON.parse( + context.stdout.write.mock.calls.map((c) => c[0]).join(""), + ); + expect(output).toMatchObject({ id: "9", status: "disabled" }); + }); });