diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index a440e1b17..d3f30e479 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -184,7 +184,7 @@ export const myCommand = buildCommand({ }, output: { human: formatMyData, // (data: T) => string - jsonTransform: jsonTransformMyData, // optional: (data: T, fields?) => unknown + jsonTransform: jsonTransformMyData, // optional: (data: T, fields?) => typed JSON jsonExclude: ["humanOnlyField"], // optional: strip keys from JSON }, parameters: { @@ -227,7 +227,9 @@ 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. 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. +- 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. 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. +- With `jsonTransform`, the schema describes the transform's item: each item of a `{ data: [...] }` envelope or an array, otherwise the result itself. Every field the schema documents must exist on that item with a matching type; optionality isn't compared, since `--fields` makes items `Partial`. So the transform must return a typed result, not `unknown` or `Record`: return `ListJson` from `jsonTransformListResult`, type a hand-built envelope as `ListEnvelope` (`lib/org-list.ts`), and let a single object's type come from its spread with `filterFieldsIfSet`. The error names the mismatched fields under `~outputMismatch`. +- An `OrgListConfig` checks its entity against `schema` when you pass the schema's type: `OrgListConfig`. Don't annotate an output config as `OutputConfig` or check it with `satisfies OutputConfig`: that erases the schema's type, and the check passes without comparing anything. - 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/span.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/span.md index a13fa3684..ceb1011d1 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/span.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/span.md @@ -33,15 +33,15 @@ List spans in a project or trace | Field | Type | Description | |-------|------|-------------| -| `id` | string | Span ID | -| `parent_span` | string \| null | Parent span ID | -| `span.op` | string \| null | Span operation (e.g. http.client, db) | +| `span_id` | string | Span ID | +| `trace_id` | string | Trace ID | +| `parent_span_id` | string \| null | Parent span ID | +| `op` | string | Span operation (e.g. http.client, db) | | `description` | string \| null | Span description | -| `span.duration` | number \| null | Duration (ms) | -| `timestamp` | string | Timestamp (ISO 8601) | -| `project` | string | Project slug | -| `transaction` | string \| null | Transaction name | -| `trace` | string | Trace ID | +| `duration_ms` | number | Duration (ms) | +| `start_timestamp` | number | Start time (Unix seconds) | +| `project_slug` | string | Project slug | +| `transaction` | string | Transaction name | **Examples:** diff --git a/packages/cli/src/commands/agent-conversation/list.ts b/packages/cli/src/commands/agent-conversation/list.ts index 68c111936..68061548b 100644 --- a/packages/cli/src/commands/agent-conversation/list.ts +++ b/packages/cli/src/commands/agent-conversation/list.ts @@ -30,6 +30,7 @@ import { paginationHint, targetPatternExplanation, } from "../../lib/list-command.js"; +import type { ListEnvelope } from "../../lib/org-list.js"; import { withProgress } from "../../lib/polling.js"; import { toNumericId } from "../../lib/resolve-target.js"; import { @@ -109,13 +110,13 @@ function formatListHuman(result: ConversationListResult): string { function jsonTransform( result: ConversationListResult, fields?: string[], -): unknown { +): ListEnvelope { const items = fields && fields.length > 0 ? result.conversations.map((c) => filterFields(c, fields)) : result.conversations; - const envelope: Record = { + const envelope: ListEnvelope = { data: items, hasMore: result.hasMore, hasPrev: !!result.hasPrev, diff --git a/packages/cli/src/commands/dashboard/list.ts b/packages/cli/src/commands/dashboard/list.ts index 5cf6570d0..1aa0396fb 100644 --- a/packages/cli/src/commands/dashboard/list.ts +++ b/packages/cli/src/commands/dashboard/list.ts @@ -30,6 +30,7 @@ import { buildListLimitFlag, paginationHint, } from "../../lib/list-command.js"; +import type { ListEnvelope } from "../../lib/org-list.js"; import { withProgress } from "../../lib/polling.js"; import { buildDashboardsListUrl, @@ -186,13 +187,13 @@ function formatDashboardListHuman(result: DashboardListResult): string { function jsonTransformDashboardList( result: DashboardListResult, fields?: string[], -): unknown { +): ListEnvelope { const items = fields && fields.length > 0 ? result.dashboards.map((d) => filterFields(d, fields)) : result.dashboards; - const envelope: Record = { + const envelope: ListEnvelope = { data: items, hasMore: result.hasMore, hasPrev: !!result.hasPrev, diff --git a/packages/cli/src/commands/dashboard/revisions.ts b/packages/cli/src/commands/dashboard/revisions.ts index 4e3ff0fd4..d8e3eb1b0 100644 --- a/packages/cli/src/commands/dashboard/revisions.ts +++ b/packages/cli/src/commands/dashboard/revisions.ts @@ -25,6 +25,7 @@ import { LIST_CURSOR_FLAG, paginationHint, } from "../../lib/list-command.js"; +import type { ListEnvelope } from "../../lib/org-list.js"; import { withProgress } from "../../lib/polling.js"; import { buildDashboardUrl } from "../../lib/sentry-web-urls.js"; import { @@ -91,13 +92,13 @@ function formatRevisionsHuman(result: RevisionsResult): string { function jsonTransformRevisions( result: RevisionsResult, fields?: string[], -): unknown { +): ListEnvelope { const items = fields && fields.length > 0 ? result.revisions.map((r) => filterFields(r, fields)) : result.revisions; - const envelope: Record = { + const envelope: ListEnvelope = { data: items, hasMore: result.hasMore, hasPrev: !!result.hasPrev, diff --git a/packages/cli/src/commands/event/shared-events.ts b/packages/cli/src/commands/event/shared-events.ts index a7f3490f5..e396e38a9 100644 --- a/packages/cli/src/commands/event/shared-events.ts +++ b/packages/cli/src/commands/event/shared-events.ts @@ -21,6 +21,7 @@ import { LIST_PERIOD_FLAG, PERIOD_ALIASES, } from "../../lib/list-command.js"; +import type { ListEnvelope } from "../../lib/org-list.js"; import { sanitizeQuery } from "../../lib/search-query.js"; import { appendPeriodHint, type TimeRange } from "../../lib/time-range.js"; import type { IssueEvent } from "../../types/index.js"; @@ -154,13 +155,13 @@ export function formatEventsHuman(result: EventsResult): string { export function jsonTransformEvents( result: EventsResult, fields?: string[], -): unknown { +): ListEnvelope { const items = fields && fields.length > 0 ? result.events.map((e) => filterFields(e, fields)) : result.events; - const envelope: Record = { + const envelope: ListEnvelope = { data: items, hasMore: result.hasMore, hasPrev: result.hasPrev, diff --git a/packages/cli/src/commands/event/view.ts b/packages/cli/src/commands/event/view.ts index b50a5772e..ecaa5ad5a 100644 --- a/packages/cli/src/commands/event/view.ts +++ b/packages/cli/src/commands/event/view.ts @@ -44,7 +44,7 @@ import { loadEventAttachments, } from "../../lib/event-attachments.js"; import { formatEventDetails } from "../../lib/formatters/index.js"; -import { filterFields } from "../../lib/formatters/json.js"; +import { filterFieldsIfSet } from "../../lib/formatters/json.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { validateHexId } from "../../lib/hex-id.js"; import { @@ -162,20 +162,13 @@ export function formatEventView(data: EventViewData): string { * This preserves backward compatibility: single-event callers still get * a flat object, while multi-event callers get an array. */ -export function jsonTransformEventView( - data: EventViewData, - fields?: string[], -): unknown { - const transform = (entry: SingleEventViewData): Record => { +export function jsonTransformEventView(data: EventViewData, fields?: string[]) { + const transform = (entry: SingleEventViewData) => { const fieldsOut: EventViewFields = { trace: entry.trace, attachments: entry.attachments ?? [], }; - const result: Record = { ...entry.event, ...fieldsOut }; - if (fields && fields.length > 0) { - return filterFields(result, fields) as Record; - } - return result; + return filterFieldsIfSet({ ...entry.event, ...fieldsOut }, fields); }; // Use requestedCount (not events.length) to decide the shape so that diff --git a/packages/cli/src/commands/feedback/list.ts b/packages/cli/src/commands/feedback/list.ts index 6ef037995..445351a37 100644 --- a/packages/cli/src/commands/feedback/list.ts +++ b/packages/cli/src/commands/feedback/list.ts @@ -32,7 +32,7 @@ import { paginationHint, targetPatternExplanation, } from "../../lib/list-command.js"; -import { jsonTransformListResult } from "../../lib/org-list.js"; +import { jsonTransformListResult, type ListJson } from "../../lib/org-list.js"; import { withProgress } from "../../lib/polling.js"; import { type ResolvedOrgOptionalTarget, @@ -53,6 +53,7 @@ import { } from "../../lib/time-range.js"; import { type FeedbackListResult, + type SentryFeedback, SentryFeedbackSchema, } from "../../types/index.js"; @@ -121,7 +122,7 @@ function pageHint( function jsonTransformFeedbackList( result: FeedbackListResult, fields?: string[], -): unknown { +): ListJson { return jsonTransformListResult( { items: result.feedback, diff --git a/packages/cli/src/commands/feedback/view.ts b/packages/cli/src/commands/feedback/view.ts index 92be7a08f..ebb08bf6b 100644 --- a/packages/cli/src/commands/feedback/view.ts +++ b/packages/cli/src/commands/feedback/view.ts @@ -82,8 +82,8 @@ async function tryListAttachments( function jsonTransformFeedbackView( data: FeedbackViewResult, fields?: string[], -): unknown { - const result: Record = { +) { + const result = { ...data.feedback, org: data.org, event: data.event, diff --git a/packages/cli/src/commands/integration/list.ts b/packages/cli/src/commands/integration/list.ts index fdda6386b..17a440406 100644 --- a/packages/cli/src/commands/integration/list.ts +++ b/packages/cli/src/commands/integration/list.ts @@ -40,7 +40,8 @@ const INTEGRATION_COLUMNS: Column[] = [ /** Shared config that plugs into the org-list framework. */ const integrationListConfig: OrgListConfig< SentryIntegration, - IntegrationWithOrg + IntegrationWithOrg, + typeof SentryIntegrationSchema > = { paginationKey: PAGINATION_KEY, entityPlural: "integrations", diff --git a/packages/cli/src/commands/issue/list.ts b/packages/cli/src/commands/issue/list.ts index 7994a96ae..4a63a7e64 100644 --- a/packages/cli/src/commands/issue/list.ts +++ b/packages/cli/src/commands/issue/list.ts @@ -53,10 +53,7 @@ import { shouldAutoCompact, writeIssueTable, } from "../../lib/formatters/index.js"; -import { - CommandOutput, - type OutputConfig, -} from "../../lib/formatters/output.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; import { appendQueryHint, buildListCommand, @@ -74,6 +71,7 @@ import { type FetchResult as FetchResultOf, jsonTransformListResult, type ListCommandMeta, + type ListJson, type ListResult, type ModeHandler, trimWithGroupGuarantee, @@ -1604,7 +1602,7 @@ function formatIssueListHuman(result: IssueListResult): string { function jsonTransformIssueList( result: IssueListResult, fields?: string[], -): unknown { +): ListJson { const transformed = jsonTransformListResult(result, fields); // Only inject into empty paginated envelopes — helps agents discover // query syntax when their search returned nothing. @@ -1623,7 +1621,7 @@ function jsonTransformIssueList( } /** Output configuration for the issue list command. */ -const issueListOutput: OutputConfig = { +const issueListOutput = { human: formatIssueListHuman, jsonTransform: jsonTransformIssueList, schema: SentryIssueSchema, diff --git a/packages/cli/src/commands/log/list.ts b/packages/cli/src/commands/log/list.ts index d5685735c..5600ed70f 100644 --- a/packages/cli/src/commands/log/list.ts +++ b/packages/cli/src/commands/log/list.ts @@ -51,6 +51,7 @@ import { TARGET_PATTERN_NOTE, } from "../../lib/list-command.js"; import { logger } from "../../lib/logger.js"; +import type { ListEnvelope } from "../../lib/org-list.js"; import { withProgress } from "../../lib/polling.js"; import { type BoundArguments, @@ -737,7 +738,10 @@ function createLogRenderer(): HumanRenderer { * {@link LogLike} items (follow mode). Single-fetch yields a JSON envelope * with `data` and `hasMore`; follow mode yields one JSON object per line (JSONL). */ -function jsonTransformLogOutput(data: LogOutput, fields?: string[]): unknown { +function jsonTransformLogOutput( + data: LogOutput, + fields?: string[], +): ListEnvelope | Partial { if (isLogListResult(data)) { // Batch (single-fetch): return envelope with data + hasMore const logList = data; diff --git a/packages/cli/src/commands/monitor/list.ts b/packages/cli/src/commands/monitor/list.ts index eed9d9386..a167b9733 100644 --- a/packages/cli/src/commands/monitor/list.ts +++ b/packages/cli/src/commands/monitor/list.ts @@ -59,7 +59,11 @@ const MONITOR_COLUMNS: Column[] = [ ]; /** Shared config that plugs into the org-list framework. */ -const monitorListConfig: OrgListConfig = { +const monitorListConfig: OrgListConfig< + SentryMonitor, + MonitorWithOrg, + typeof SentryMonitorSchema +> = { paginationKey: PAGINATION_KEY, entityPlural: "monitors", commandPrefix: "sentry monitor list", diff --git a/packages/cli/src/commands/project/list.ts b/packages/cli/src/commands/project/list.ts index fc6003e91..9009e8551 100644 --- a/packages/cli/src/commands/project/list.ts +++ b/packages/cli/src/commands/project/list.ts @@ -36,10 +36,7 @@ import { resolveCursor, } from "../../lib/db/pagination.js"; import { ResolutionError, withAuthGuard } from "../../lib/errors.js"; -import { - CommandOutput, - type OutputConfig, -} from "../../lib/formatters/output.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; import { type Column, formatTable } from "../../lib/formatters/table.js"; import { buildListCommand, @@ -922,9 +919,10 @@ export const listCommand = buildListCommand("project", { } return parts.join(""); }, - jsonTransform: jsonTransformListResult, + jsonTransform: (result: ListResult, fields?: string[]) => + jsonTransformListResult(result, fields), schema: orgListItemSchema(SentryProjectSchema), - } satisfies OutputConfig>, + }, parameters: { positional: listTargetPositional({ names: true, multi: true }), flags: { diff --git a/packages/cli/src/commands/project/view.ts b/packages/cli/src/commands/project/view.ts index 8e11951e3..3a3e2e9b8 100644 --- a/packages/cli/src/commands/project/view.ts +++ b/packages/cli/src/commands/project/view.ts @@ -240,7 +240,7 @@ function hydrateOrganizationName(entry: ProjectViewEntry): ProjectViewEntry { function jsonTransformProjectView( entries: ProjectViewEntry[], fields?: string[], -): unknown { +) { const hydrated = entries.map((entry) => { const { detectedFrom: _detectedFrom, ...rest } = hydrateOrganizationName(entry); diff --git a/packages/cli/src/commands/replay/list.ts b/packages/cli/src/commands/replay/list.ts index 045fb96c0..c25df646a 100644 --- a/packages/cli/src/commands/replay/list.ts +++ b/packages/cli/src/commands/replay/list.ts @@ -42,6 +42,7 @@ import { paginationHint, targetPatternExplanation, } from "../../lib/list-command.js"; +import type { ListEnvelope } from "../../lib/org-list.js"; import { withProgress } from "../../lib/polling.js"; import { getReplayUserLabel, @@ -231,13 +232,13 @@ function formatReplayListHuman(result: ReplayListResult): string { function jsonTransformReplayList( result: ReplayListResult, fields?: string[], -): unknown { +): ListEnvelope { const items = fields && fields.length > 0 ? result.replays.map((replay) => filterFields(replay, fields)) : result.replays; - const envelope: Record = { + const envelope: ListEnvelope = { data: items, hasMore: result.hasMore, hasPrev: result.hasPrev, diff --git a/packages/cli/src/commands/replay/view.ts b/packages/cli/src/commands/replay/view.ts index 88a50c630..f5ab631f0 100644 --- a/packages/cli/src/commands/replay/view.ts +++ b/packages/cli/src/commands/replay/view.ts @@ -355,7 +355,7 @@ export const viewCommand = buildCommand({ human: formatReplayDetails, jsonTransform: (data: ReplayViewData, fields?: string[]) => { const { replay, ...outputFields } = data; - const result: Record = { ...replay, ...outputFields }; + const result = { ...replay, ...outputFields }; return fields && fields.length > 0 ? filterFields(result, fields) : result; diff --git a/packages/cli/src/commands/repo/list.ts b/packages/cli/src/commands/repo/list.ts index 5bde0442d..377fbef2c 100644 --- a/packages/cli/src/commands/repo/list.ts +++ b/packages/cli/src/commands/repo/list.ts @@ -42,7 +42,11 @@ const REPO_COLUMNS: Column[] = [ ]; /** Shared config that plugs into the org-list framework. */ -const repoListConfig: OrgListConfig = { +const repoListConfig: OrgListConfig< + SentryRepository, + RepositoryWithOrg, + typeof SentryRepositorySchema +> = { paginationKey: PAGINATION_KEY, entityPlural: "repositories", commandPrefix: "sentry repo list", diff --git a/packages/cli/src/commands/span/list.ts b/packages/cli/src/commands/span/list.ts index cf9c076f4..0e5a72b91 100644 --- a/packages/cli/src/commands/span/list.ts +++ b/packages/cli/src/commands/span/list.ts @@ -50,6 +50,7 @@ import { paginationHint, TARGET_PATTERN_NOTE, } from "../../lib/list-command.js"; +import type { ListEnvelope } from "../../lib/org-list.js"; import { withProgress } from "../../lib/polling.js"; import { type BoundArguments, @@ -75,7 +76,7 @@ import { type TraceTargetInput, warnIfNormalized, } from "../../lib/trace-target.js"; -import { SpanListItemSchema } from "../../types/index.js"; +import { FlatSpanSchema } from "../../types/index.js"; type ListFlags = { readonly limit: number; @@ -388,12 +389,15 @@ function formatSpanListHuman(data: SpanListData): string { * Produces a `{ data: [...], hasMore, nextCursor? }` envelope matching the * standard paginated list format. Applies `--fields` filtering per element. */ -function jsonTransformSpanList(data: SpanListData, fields?: string[]): unknown { +function jsonTransformSpanList( + data: SpanListData, + fields?: string[], +): ListEnvelope { const items = fields && fields.length > 0 ? data.flatSpans.map((item) => filterFields(item, fields)) : data.flatSpans; - const envelope: Record = { + const envelope: ListEnvelope = { data: items, hasMore: data.hasMore, hasPrev: !!data.hasPrev, @@ -657,7 +661,7 @@ export const listCommand = buildListCommand("span", { output: { human: formatSpanListHuman, jsonTransform: jsonTransformSpanList, - schema: SpanListItemSchema, + schema: FlatSpanSchema, }, parameters: { positional: boundArguments({ diff --git a/packages/cli/src/commands/team/list.ts b/packages/cli/src/commands/team/list.ts index 93727c88b..b593c5fd0 100644 --- a/packages/cli/src/commands/team/list.ts +++ b/packages/cli/src/commands/team/list.ts @@ -43,7 +43,11 @@ const TEAM_COLUMNS: Column[] = [ ]; /** Shared config that plugs into the org-list framework. */ -const teamListConfig: OrgListConfig = { +const teamListConfig: OrgListConfig< + SentryTeam, + TeamWithOrg, + typeof SentryTeamSchema +> = { paginationKey: PAGINATION_KEY, entityPlural: "teams", commandPrefix: "sentry team list", diff --git a/packages/cli/src/commands/trace/list.ts b/packages/cli/src/commands/trace/list.ts index ef826c769..ae0bba6d8 100644 --- a/packages/cli/src/commands/trace/list.ts +++ b/packages/cli/src/commands/trace/list.ts @@ -30,6 +30,7 @@ import { paginationHint, TARGET_PATTERN_NOTE, } from "../../lib/list-command.js"; +import type { ListEnvelope } from "../../lib/org-list.js"; import { withProgress } from "../../lib/polling.js"; import { resolveLogProjectId } from "../../lib/resolve-target.js"; import { sanitizeQuery } from "../../lib/search-query.js"; @@ -173,13 +174,13 @@ function formatTraceListHuman(result: TraceListResult): string { function jsonTransformTraceList( result: TraceListResult, fields?: string[], -): unknown { +): ListEnvelope { const items = fields && fields.length > 0 ? result.traces.map((t) => filterFields(t, fields)) : result.traces; - const envelope: Record = { + const envelope: ListEnvelope = { data: items, hasMore: result.hasMore, hasPrev: !!result.hasPrev, diff --git a/packages/cli/src/lib/api/replays.ts b/packages/cli/src/lib/api/replays.ts index cedb8fa14..9b3801e87 100644 --- a/packages/cli/src/lib/api/replays.ts +++ b/packages/cli/src/lib/api/replays.ts @@ -19,6 +19,7 @@ import { type ReplayListItem, type ReplayListResponse, ReplayListResponseSchema, + type ReplayStringProjectId, } from "../../types/index.js"; import { ApiError } from "../errors.js"; @@ -165,13 +166,14 @@ export type GetReplayRecordingSegmentsOptions = { */ function normalizeReplayProjectId< T extends { project_id?: string | number | null }, ->(replay: T): T { +>(replay: T): T & ReplayStringProjectId { if ( replay.project_id === null || replay.project_id === undefined || typeof replay.project_id === "string" ) { - return replay; + // Narrowing `project_id` doesn't narrow `T`. + return replay as T & ReplayStringProjectId; } return { diff --git a/packages/cli/src/lib/command.ts b/packages/cli/src/lib/command.ts index cd88511e0..03f295630 100644 --- a/packages/cli/src/lib/command.ts +++ b/packages/cli/src/lib/command.ts @@ -164,6 +164,7 @@ type LocalCommandBuilderArguments< CONTEXT extends CommandContext, OUTPUT, SCHEMA extends GenericSchema, + TRANSFORMED, > = { readonly parameters?: Record; readonly docs: CommandDocumentation; @@ -187,7 +188,7 @@ type LocalCommandBuilderArguments< * }) * ``` */ - readonly output?: OutputConfig; + readonly output?: OutputConfig; /** * Whether the command requires authentication. Defaults to `true`. * @@ -563,13 +564,15 @@ export function buildCommand< const CONTEXT extends CommandContext = CommandContext, OUTPUT = never, SCHEMA extends GenericSchema = GenericSchema, + TRANSFORMED = unknown, >( builderArgs: LocalCommandBuilderArguments< FLAGS, ARGS, CONTEXT, OUTPUT, - SCHEMA + SCHEMA, + TRANSFORMED >, ): Command { const originalFunc = builderArgs.func; diff --git a/packages/cli/src/lib/formatters/issue.ts b/packages/cli/src/lib/formatters/issue.ts index c3e6e2c9e..bd2943218 100644 --- a/packages/cli/src/lib/formatters/issue.ts +++ b/packages/cli/src/lib/formatters/issue.ts @@ -6,7 +6,7 @@ import type { InferOutput } from "valibot"; import type { IssueViewOutputFields, SentryIssue } from "../../types/index.js"; import { getReplayIdFromEvent } from "../replay-search.js"; import { formatEventDetails, formatIssueDetails } from "./human.js"; -import { filterFields } from "./json.js"; +import { filterFieldsIfSet } from "./json.js"; import { renderMarkdown } from "./markdown.js"; /** @@ -97,20 +97,17 @@ export function formatIssueView(data: IssueViewData): string { return data.issues.map(formatSingleIssueView).join("\n\n---\n\n"); } -function flattenIssueView( - entry: SingleIssueViewData, - fields?: string[], -): Record { - const result: Record = { - ...entry.issue, - event: entry.event, - org: entry.org, - replayIds: entry.replayIds, - trace: entry.trace, - }; - return fields && fields.length > 0 - ? (filterFields(result, fields) as Record) - : result; +function flattenIssueView(entry: SingleIssueViewData, fields?: string[]) { + return filterFieldsIfSet( + { + ...entry.issue, + event: entry.event, + org: entry.org, + replayIds: entry.replayIds, + trace: entry.trace, + }, + fields, + ); } /** @@ -124,10 +121,7 @@ function flattenIssueView( * @param fields - Optional issue fields to retain * @returns A flat issue object for one request, otherwise an array */ -export function jsonTransformIssueView( - data: IssueViewData, - fields?: string[], -): unknown { +export function jsonTransformIssueView(data: IssueViewData, fields?: string[]) { if (data.requestedCount <= 1) { const [first] = data.issues; if (first) { diff --git a/packages/cli/src/lib/formatters/output.ts b/packages/cli/src/lib/formatters/output.ts index f7c9056bd..a347933d2 100644 --- a/packages/cli/src/lib/formatters/output.ts +++ b/packages/cli/src/lib/formatters/output.ts @@ -112,12 +112,74 @@ type JsonItem = T extends readonly (infer Item)[] ? Item : T; * 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 [ +export type SchemaMatches = [JsonItem] extends [ InferOutput, ] ? unknown : { "~outputMismatch": InferOutput }; +/** + * The value `--fields` operates on in a `jsonTransform` result: each item of + * a `{ data: [...] }` envelope or an array, otherwise the result itself. + * Distributes over unions, so every shape the transform returns counts. + * `filterFields` on an array types its items as possibly `undefined`, so + * `null` and `undefined` items are dropped. + */ +type TransformItem = NonNullable< + R extends { readonly data: readonly (infer Item)[] } + ? Item + : R extends readonly (infer Item)[] + ? Item + : R +>; + +/** + * Every key of `T` as a required, defined field. A transform's items are + * `Partial` when `--fields` filters them, so the transform check compares + * which fields exist and their types, not whether they are optional. + */ +type PresentFields = { [K in keyof T]-?: Exclude }; + +/** Flattens `T` so errors print its fields instead of the alias. */ +type Simplify = { [K in keyof T]: T[K] } & {}; + +/** + * The fields of `Schema` that `Data` lacks or types differently, with the + * type the schema documents. Index signatures (loose objects) are skipped. + */ +type MismatchedFields = { + [ + K in keyof Schema as string extends K + ? never + : number extends K + ? never + : K extends keyof Data + ? [Data[K]] extends [Schema[K]] + ? never + : K + : K + ]: Schema[K]; +}; + +/** + * Holds when every field `S` documents exists on the transform's item with + * a matching type. Otherwise it requires a property no schema has, and the + * error lists the mismatched fields. A transform that returns `unknown` + * never matches, so a transform with a schema must return a typed result. + */ +type TransformSchemaMatches = [ + PresentFields>, +] extends [PresentFields>] + ? unknown + : { + "~outputMismatch": Simplify< + MismatchedFields< + PresentFields>, + PresentFields> + > + >; + }; + /** Fields shared by both forms of {@link OutputConfig}. */ type OutputConfigBase = { /** @@ -155,16 +217,18 @@ type OutputConfigBase = { * 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. + * (what `--fields` operates on), and every field it documents must exist on + * that item with a matching type (see {@link TransformItem}). * * @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 + * @typeParam R - Type `jsonTransform` returns, inferred by the builders */ export type OutputConfig< T, S extends GenericSchema = GenericSchema, + R = unknown, > = OutputConfigBase & ( | { jsonTransform?: undefined; schema?: S & SchemaMatches } @@ -182,8 +246,8 @@ export type OutputConfig< * * When `jsonTransform` is set, `jsonExclude` is ignored. */ - jsonTransform: (data: T, fields?: string[]) => unknown; - schema?: GenericSchema; + jsonTransform: (data: T, fields?: string[]) => R; + schema?: S & TransformSchemaMatches; } ); diff --git a/packages/cli/src/lib/formatters/trace.ts b/packages/cli/src/lib/formatters/trace.ts index ca9760cce..56d6d62e2 100644 --- a/packages/cli/src/lib/formatters/trace.ts +++ b/packages/cli/src/lib/formatters/trace.ts @@ -6,7 +6,9 @@ */ import { tableCell, text } from "@sentry/toolkit-core/markdown"; +import type { InferOutput } from "valibot"; import type { + FlatSpanSchema, SpanListItem, TraceSpan, TransactionListItem, @@ -385,19 +387,7 @@ export function formatTraceSummary(summary: TraceSummary): string { // --------------------------------------------------------------------------- /** Flat span for list output — no nested children */ -export type FlatSpan = { - span_id: string; - trace_id?: string; - parent_span_id?: string | null; - op?: string; - description?: string | null; - duration_ms?: number; - start_timestamp: number; - project_slug?: string; - transaction?: string; - /** Custom attributes from --fields (index signature) */ - [key: string]: unknown; -}; +export type FlatSpan = InferOutput; /** Result of finding a span by ID in the tree */ export type FoundSpan = { diff --git a/packages/cli/src/lib/list-command.ts b/packages/cli/src/lib/list-command.ts index a5b1e5c82..37a5d06fc 100644 --- a/packages/cli/src/lib/list-command.ts +++ b/packages/cli/src/lib/list-command.ts @@ -531,13 +531,14 @@ export function buildListCommand< const CONTEXT extends CommandContext = CommandContext, OUTPUT = never, SCHEMA extends GenericSchema = GenericSchema, + TRANSFORMED = unknown, >( routeName: string, builderArgs: { readonly parameters?: Record; readonly docs: CommandDocumentation; readonly func: SentryCommandFunction>; - readonly output?: OutputConfig; + readonly output?: OutputConfig; readonly auth?: boolean; }, options?: ListCommandOptions, @@ -611,7 +612,7 @@ export function buildListCommand< return originalFunc.call(this, flags, ...(finalArgs as unknown as ARGS)); } as typeof originalFunc; - return buildCommand({ + return buildCommand({ ...builderArgs, parameters: mergedParams, func: wrappedFunc, @@ -684,14 +685,17 @@ export function buildOrgListCommand( docs: OrgListCommandDocs, routeName: string, ): Command { + // `OrgListConfig.schema` checks the entity against the schema; the generic + // `TWithOrg` can't be checked here, so the schema's type is erased. + const output: OutputConfig> = { + human: (result: ListResult) => formatListHuman(result, config), + jsonTransform: (result: ListResult, fields?: string[]) => + jsonTransformListResult(result, fields), + schema: config.schema && orgListItemSchema(config.schema), + }; return buildListCommand(routeName, { docs, - output: { - human: (result: ListResult) => formatListHuman(result, config), - jsonTransform: (result: ListResult, fields?: string[]) => - jsonTransformListResult(result, fields), - schema: config.schema && orgListItemSchema(config.schema), - } satisfies OutputConfig>, + output, parameters: { positional: config.namesOf ? NAMED_LIST_ORG_TARGET_POSITIONAL diff --git a/packages/cli/src/lib/mutate-command.ts b/packages/cli/src/lib/mutate-command.ts index 753f9ee74..37034c32b 100644 --- a/packages/cli/src/lib/mutate-command.ts +++ b/packages/cli/src/lib/mutate-command.ts @@ -315,12 +315,13 @@ export function buildDeleteCommand< const CONTEXT extends CommandContext = CommandContext, OUTPUT = never, SCHEMA extends GenericSchema = GenericSchema, + TRANSFORMED = unknown, >( builderArgs: { readonly parameters?: Record; readonly docs: CommandDocumentation; readonly func: SentryCommandFunction>; - readonly output?: OutputConfig; + readonly output?: OutputConfig; readonly auth?: boolean; }, options?: DeleteCommandOptions, @@ -387,7 +388,7 @@ export function buildDeleteCommand< return originalFunc.call(this, flags, ...(args as unknown as ARGS)); } as typeof originalFunc; - return buildCommand({ + return buildCommand({ ...builderArgs, parameters: mergedParams, func: wrappedFunc, diff --git a/packages/cli/src/lib/org-list.ts b/packages/cli/src/lib/org-list.ts index 63ddd533c..c6787f9e7 100644 --- a/packages/cli/src/lib/org-list.ts +++ b/packages/cli/src/lib/org-list.ts @@ -67,6 +67,7 @@ import { withAuthGuard, } from "./errors.js"; import { filterFields } from "./formatters/json.js"; +import type { SchemaMatches } from "./formatters/output.js"; import { paginationHint } from "./list-command.js"; import { fetchByName, @@ -115,6 +116,26 @@ export type ListResult = { jsonExtra?: Record; }; +/** + * JSON envelope of a paginated list. `data` holds the items, which + * `--fields` may have filtered. + */ +export type ListEnvelope = { + data: Partial[]; + hasMore: boolean; + hasPrev?: boolean; + nextCursor?: string; + errors?: unknown[]; + /** Command-specific metadata ({@link ListResult.jsonExtra}) */ + [extra: string]: unknown; +}; + +/** + * JSON output of {@link jsonTransformListResult}: the envelope when + * paginated, a flat array of items otherwise. + */ +export type ListJson = ListEnvelope | Partial[]; + /** * Transform a {@link ListResult} into the standard JSON output format. * @@ -128,7 +149,7 @@ export type ListResult = { export function jsonTransformListResult( result: ListResult, fields?: string[], -): unknown { +): ListJson { const items = fields && fields.length > 0 ? result.items.map((item) => filterFields(item, fields)) @@ -136,7 +157,7 @@ export function jsonTransformListResult( // Paginated mode: wrap in envelope if (result.hasMore !== undefined) { - const envelope: Record = { + const envelope: ListEnvelope = { data: items, hasMore: result.hasMore, }; @@ -432,8 +453,14 @@ export type BaseListFlags = { * * @template TEntity Raw entity type from the API (e.g., SentryTeam) * @template TWithOrg Entity with orgSlug attached for display + * @template TSchema Type of `schema`; pass it so the entity is checked + * against the schema */ -export type OrgListConfig = ListCommandMeta & { +export type OrgListConfig< + TEntity, + TWithOrg, + TSchema extends OrgListEntitySchema = OrgListEntitySchema, +> = ListCommandMeta & { /** * Fetch all entities for one org (non-paginated). * @returns Raw entities from the API @@ -482,8 +509,10 @@ export type OrgListConfig = ListCommandMeta & { * Valibot object schema of the entity. `buildOrgListCommand` adds * {@link OrgListItemOutputFields} and forwards it to `OutputConfig.schema` * so `--help`, `sentry help`, and SKILL.md can document available fields. + * `TEntity` must satisfy it, like the data of a command without + * `jsonTransform`. */ - schema?: OrgListEntitySchema; + schema?: TSchema & SchemaMatches; /** * The names a name filter matches against (e.g. slug and display name). diff --git a/packages/cli/src/types/index.ts b/packages/cli/src/types/index.ts index 2508d3a34..746af2401 100644 --- a/packages/cli/src/types/index.ts +++ b/packages/cli/src/types/index.ts @@ -86,6 +86,7 @@ export type { ReplayRelatedIssue, ReplayRelatedTrace, ReplaySdk, + ReplayStringProjectId, ReplayUser, } from "./replay.js"; export { @@ -183,6 +184,7 @@ export { type EventViewAttachment, EventViewOutputFields, EventViewOutputSchema, + FlatSpanSchema, ISSUE_LEVELS, ISSUE_STATUSES, IssueEventSchema, @@ -206,7 +208,6 @@ export { SentryRepositorySchema, SentryTeamSchema, SentryUserSchema, - SpanListItemSchema, SpansResponseSchema, TraceItemAttributeSchema, TraceItemDetailSchema, diff --git a/packages/cli/src/types/replay.ts b/packages/cli/src/types/replay.ts index c3bd5a96d..4d312da5d 100644 --- a/packages/cli/src/types/replay.ts +++ b/packages/cli/src/types/replay.ts @@ -444,8 +444,15 @@ export type ReplayOs = InferOutput; export type ReplaySdk = InferOutput; export type ReplayDevice = InferOutput; export type ReplayOtaUpdates = InferOutput; -export type ReplayListItem = InferOutput; -export type ReplayDetails = InferOutput; +/** + * `project_id` as the API layer returns it: the replay API sends a number, + * which `lib/api/replays.ts` normalizes to a string. + */ +export type ReplayStringProjectId = { project_id?: string | null }; +export type ReplayListItem = InferOutput & + ReplayStringProjectId; +export type ReplayDetails = InferOutput & + ReplayStringProjectId; export type ReplayListResponse = InferOutput; export type ReplayDetailsResponse = InferOutput< typeof ReplayDetailsResponseSchema diff --git a/packages/cli/src/types/sentry.ts b/packages/cli/src/types/sentry.ts index f1d156655..f38d5b13c 100644 --- a/packages/cli/src/types/sentry.ts +++ b/packages/cli/src/types/sentry.ts @@ -15,6 +15,7 @@ import type { EventAttachmentDetailsResponse, + GroupEventsResponseDict as SdkGroupEventsList, IssueEventDetailsResponse, DeployResponse as SdkDeployResponse, GetOrganizationIssueResponse as SdkIssueDetail, @@ -595,44 +596,7 @@ export const EventViewOutputSchema = pipe( * minimal event metadata without stacktraces, breadcrumbs, or contexts. * Use {@link SentryEvent} for full event details from the detail endpoint. */ -export type IssueEvent = { - /** Internal event ID (numeric string) */ - id: string; - /** Event type (e.g., "error", "default", "transaction") */ - "event.type": string; - /** The group (issue) ID this event belongs to */ - groupID: string | null; - /** UUID-format event ID */ - eventID: string; - /** Project ID (numeric string) */ - projectID: string; - /** Event message */ - message: string; - /** Event title (typically the error type + message) */ - title: string; - /** Source location (file:line) where the event originated */ - location: string | null; - /** The culprit (function/module that caused the error) */ - culprit: string | null; - /** User context if available */ - user: { - id?: string | null; - email?: string | null; - username?: string | null; - ip_address?: string | null; - name?: string | null; - } | null; - /** Event tags */ - tags: Array<{ key: string; value: string }>; - /** Platform (e.g., "python", "javascript") */ - platform: string | null; - /** ISO 8601 timestamp when the event was created */ - dateCreated: string; - /** Crash file URL if available */ - crashFile: string | null; - /** Event metadata */ - metadata: Record | null; -}; +export type IssueEvent = SdkGroupEventsList[number]; /** * Valibot schema for {@link IssueEvent} — used for `--fields` documentation in `--help`. @@ -1445,6 +1409,32 @@ export const SpanListItemSchema = pipe( export type SpanListItem = InferOutput; +/** + * A span as `span list` outputs it: a {@link SpanListItem} with the fields + * `spanListItemToFlatSpan` renames, plus any custom attributes `--fields` + * requested. + */ +export const FlatSpanSchema = pipe( + looseObject({ + span_id: pipe(string(), description("Span ID")), + trace_id: optional(pipe(string(), description("Trace ID"))), + parent_span_id: optional( + nullable(pipe(string(), description("Parent span ID"))), + ), + op: optional( + pipe(string(), description("Span operation (e.g. http.client, db)")), + ), + description: optional( + nullable(pipe(string(), description("Span description"))), + ), + duration_ms: optional(pipe(number(), description("Duration (ms)"))), + start_timestamp: pipe(number(), description("Start time (Unix seconds)")), + project_slug: optional(pipe(string(), description("Project slug"))), + transaction: optional(pipe(string(), description("Transaction name"))), + }), + description("Span list item"), +); + /** Response from the spans events endpoint */ export const SpansResponseSchema = object({ data: array(SpanListItemSchema), diff --git a/packages/cli/test/commands/span/list.test.ts b/packages/cli/test/commands/span/list.test.ts index 5f84c175e..f67104907 100644 --- a/packages/cli/test/commands/span/list.test.ts +++ b/packages/cli/test/commands/span/list.test.ts @@ -55,9 +55,11 @@ vi.mock("../../../src/lib/resolve-target.js", async (importOriginal) => { }); import { ApiError, ValidationError } from "../../../src/lib/errors.js"; +import { extractSchemaFields } from "../../../src/lib/formatters/output.js"; // oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for spyOn mocking import * as resolveTarget from "../../../src/lib/resolve-target.js"; import { parsePeriod } from "../../../src/lib/time-range.js"; +import { FlatSpanSchema } from "../../../src/types/index.js"; const VALID_TRACE_ID = "aaaa1111bbbb2222cccc3333dddd4444"; @@ -588,6 +590,43 @@ describe("listCommand.func (trace mode)", () => { expect(parsed.data[0].trace_id).toBe(VALID_TRACE_ID); }); + test("JSON output documents the fields it emits", async () => { + listSpansSpy.mockResolvedValue({ + data: [ + { + id: "a1b2c3d4e5f67890", + parent_span: "0987654321fedcba", + "span.op": "http.client", + description: "GET /api", + "span.duration": 12, + timestamp: "2024-01-15T10:30:00+00:00", + project: "test-project", + transaction: "/checkout", + trace: VALID_TRACE_ID, + }, + ], + nextCursor: undefined, + }); + + const { context, getStdout } = createContext(); + + await func.call( + context, + { + limit: 25, + sort: "date", + period: parsePeriod("7d"), + json: true, + fresh: false, + }, + VALID_TRACE_ID, + ); + + const documented = extractSchemaFields(FlatSpanSchema).map((f) => f.name); + const [span] = JSON.parse(getStdout()).data; + expect(documented).toEqual(expect.arrayContaining(Object.keys(span))); + }); + test("trace mode passes allProjects: true to listSpans", async () => { listSpansSpy.mockResolvedValue({ data: [], nextCursor: undefined });