diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 245c7d2ab..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 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. 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/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/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 11a76c7a9..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"; @@ -57,6 +58,7 @@ import { ClearScreen, CommandOutput, type CommandReturn, + type CommandYield, extractSchemaFieldPaths, extractSchemaFields, formatSchemaForHelp, @@ -136,17 +138,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 +162,16 @@ type LocalCommandBuilderArguments< FLAGS extends BaseFlags, ARGS extends BaseArgs, CONTEXT extends CommandContext, + OUTPUT, + SCHEMA extends GenericSchema, > = { 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 +187,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 +561,22 @@ export function buildCommand< const FLAGS extends BaseFlags = NonNullable, 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; - 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/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 64fce195b..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"; @@ -91,15 +91,35 @@ export function resolveRenderer(human: HumanOutput): HumanRenderer { export type HumanOutput = ((data: T) => string) | (() => HumanRenderer); /** - * 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) + * 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; + +/** + * The JSON value `--fields` operates on: the data, or each item when the + * data is an array. */ -export type OutputConfig = { +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. + */ +type SchemaMatches = [JsonItem] extends [ + InferOutput, +] + ? unknown + : { "~outputMismatch": InferOutput }; + +/** Fields shared by both forms of {@link OutputConfig}. */ +type OutputConfigBase = { /** * Human-readable renderer. * @@ -115,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. - */ - 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. + * Ignored when {@link OutputConfig.jsonTransform} is set — the transform + * is responsible for shaping the final JSON output. */ - 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; + jsonExclude?: ReadonlyArray>; }; +/** + * 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}. * @@ -184,6 +222,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..a5b1e5c82 100644 --- a/packages/cli/src/lib/list-command.ts +++ b/packages/cli/src/lib/list-command.ts @@ -14,22 +14,20 @@ */ 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 { 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 +494,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 +529,15 @@ export function buildListCommand< const FLAGS extends BaseFlags = NonNullable, 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: 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..753f9ee74 100644 --- a/packages/cli/src/lib/mutate-command.ts +++ b/packages/cli/src/lib/mutate-command.ts @@ -18,10 +18,15 @@ 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, 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 +248,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 +313,14 @@ export function buildDeleteCommand< const FLAGS extends BaseFlags = NonNullable, 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: 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, 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" }); + }); });