From 616c8c415178e40b4f4f8851fc49c464e71e4f99 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 23:15:15 +0000 Subject: [PATCH 1/9] ref(cli): Read output fields from generated data, not schemas Closes #1914 (parts 2 and 3; part 1, the manifest, was #1918). Command output schemas move to build-only `src/output-schemas/.ts`, one module per top-level command. `script/generate-output-schemas.ts` (part of `generate:docs`, listed in `ensure-generated.mjs`) imports each module through tsx, reads every exported valibot `...Schema` with the field extraction that used to run at startup (now `script/lib/output-schema-fields.ts`), and writes its fields and `--fields` paths to the gitignored `src/generated/output-schemas/.ts` under the same export name. - Commands import that data as `output.schema` and only types from `src/output-schemas/`, so a run loads its own route's field lists and none of the schemas. The data is typed `OutputSchemaFields`, so `SchemaMatches` still checks a command's data against its schema. - `buildFieldsFlag`, `enrichDocsWithSchema` and `buildCommand` read `schema.fields` / `schema.paths`; commands carry `__jsonFields` instead of `__jsonSchema`, read by `introspect.ts` and `generate-sdk.ts`. - Schemas that also validate API responses (`SentryLogSchema`, `TransactionListItemSchema`, `ConversationListItemSchema`, `DashboardRevisionSchema`) stay put and are re-exported. - Org-scoped lists pass the item schema (`orgListItemSchema(Entity)`), built in `src/output-schemas/`. - New lint rule `sentry-cli/no-runtime-output-schema-imports` rejects value imports of `src/output-schemas/` from runtime code, including `import { type X }`, which `verbatimModuleSyntax` keeps. Help, `help --json`, `--fields` parsing and completion, the skill files and the docs pages are byte-identical to main; the SDK files differ only in a comment naming `__jsonFields`. --- .oxlintrc.json | 1 + packages/cli/AGENTS.md | 31 +- packages/cli/lint-rules/cli-oxlint-plugin.js | 30 + packages/cli/package.json | 3 +- packages/cli/script/ensure-generated.mjs | 1 + .../cli/script/generate-output-schemas.ts | 102 +++ packages/cli/script/generate-sdk.ts | 20 +- .../cli/script/lib/output-schema-fields.ts | 228 ++++++ .../src/commands/agent-conversation/list.ts | 6 +- .../cli/src/commands/alert/issues/create.ts | 10 +- .../cli/src/commands/alert/issues/delete.ts | 6 +- .../cli/src/commands/alert/issues/edit.ts | 6 +- .../cli/src/commands/alert/metrics/create.ts | 10 +- .../cli/src/commands/alert/metrics/delete.ts | 6 +- .../cli/src/commands/alert/metrics/edit.ts | 6 +- packages/cli/src/commands/auth/list.ts | 2 +- packages/cli/src/commands/dashboard/create.ts | 20 +- packages/cli/src/commands/dashboard/list.ts | 6 +- .../cli/src/commands/dashboard/restore.ts | 15 +- .../cli/src/commands/dashboard/revisions.ts | 6 +- packages/cli/src/commands/dashboard/view.ts | 8 +- .../cli/src/commands/dashboard/widget/add.ts | 2 +- .../src/commands/dashboard/widget/delete.ts | 2 +- .../cli/src/commands/dashboard/widget/edit.ts | 2 +- packages/cli/src/commands/event/list.ts | 2 +- packages/cli/src/commands/event/view.ts | 8 +- packages/cli/src/commands/feedback/list.ts | 7 +- packages/cli/src/commands/feedback/resolve.ts | 2 +- packages/cli/src/commands/feedback/spam.ts | 2 +- .../cli/src/commands/feedback/unresolve.ts | 2 +- packages/cli/src/commands/feedback/view.ts | 2 +- packages/cli/src/commands/integration/list.ts | 10 +- packages/cli/src/commands/issue/events.ts | 2 +- packages/cli/src/commands/issue/list.ts | 7 +- packages/cli/src/commands/issue/tags.ts | 19 +- packages/cli/src/commands/issue/view.ts | 2 +- packages/cli/src/commands/log/list.ts | 2 +- packages/cli/src/commands/monitor/list.ts | 7 +- packages/cli/src/commands/org/list.ts | 80 +- packages/cli/src/commands/org/view.ts | 2 +- packages/cli/src/commands/project/list.ts | 6 +- packages/cli/src/commands/project/view.ts | 26 +- packages/cli/src/commands/release/archive.ts | 6 +- packages/cli/src/commands/release/create.ts | 45 +- packages/cli/src/commands/release/delete.ts | 23 +- packages/cli/src/commands/release/deploy.ts | 37 +- packages/cli/src/commands/release/deploys.ts | 3 +- packages/cli/src/commands/release/finalize.ts | 32 +- packages/cli/src/commands/release/list.ts | 30 +- packages/cli/src/commands/release/restore.ts | 6 +- .../cli/src/commands/release/set-commits.ts | 3 +- packages/cli/src/commands/release/view.ts | 3 +- packages/cli/src/commands/replay/list.ts | 7 +- packages/cli/src/commands/replay/view.ts | 2 +- packages/cli/src/commands/repo/list.ts | 10 +- packages/cli/src/commands/span/list.ts | 2 +- packages/cli/src/commands/team/list.ts | 7 +- packages/cli/src/commands/trace/list.ts | 6 +- packages/cli/src/commands/trial/list.ts | 47 +- packages/cli/src/lib/command.ts | 26 +- packages/cli/src/lib/db/accounts.ts | 2 +- packages/cli/src/lib/formatters/dashboard.ts | 2 +- packages/cli/src/lib/formatters/issue.ts | 3 +- packages/cli/src/lib/formatters/output.ts | 261 +----- packages/cli/src/lib/formatters/replay.ts | 2 +- packages/cli/src/lib/formatters/trace.ts | 2 +- packages/cli/src/lib/introspect.ts | 18 +- packages/cli/src/lib/list-command.ts | 3 +- packages/cli/src/lib/org-list.ts | 56 +- .../src/{types => output-schemas}/account.ts | 0 .../src/output-schemas/agent-conversation.ts | 7 + .../alert.ts} | 3 +- packages/cli/src/output-schemas/dashboard.ts | 138 ++++ packages/cli/src/output-schemas/event.ts | 143 ++++ packages/cli/src/output-schemas/feedback.ts | 113 +++ .../cli/src/output-schemas/integration.ts | 52 ++ packages/cli/src/output-schemas/issue.ts | 211 +++++ packages/cli/src/output-schemas/log.ts | 7 + packages/cli/src/output-schemas/monitor.ts | 120 +++ packages/cli/src/output-schemas/org-list.ts | 29 + packages/cli/src/output-schemas/org.ts | 117 +++ .../src/{types => output-schemas}/output.ts | 5 +- packages/cli/src/output-schemas/project.ts | 84 ++ packages/cli/src/output-schemas/release.ts | 195 +++++ packages/cli/src/output-schemas/replay.ts | 148 ++++ packages/cli/src/output-schemas/repo.ts | 50 ++ packages/cli/src/output-schemas/span.ts | 39 + packages/cli/src/output-schemas/team.ts | 57 ++ packages/cli/src/output-schemas/trace.ts | 7 + packages/cli/src/output-schemas/trial.ts | 45 ++ packages/cli/src/types/dashboard.ts | 90 +-- packages/cli/src/types/feedback.ts | 116 +-- packages/cli/src/types/index.ts | 34 +- packages/cli/src/types/replay.ts | 135 +--- packages/cli/src/types/sentry.ts | 745 +----------------- packages/cli/test/commands/span/list.test.ts | 5 +- packages/cli/test/lib/api/monitors.test.ts | 2 +- .../cli/test/lib/formatters/output.test.ts | 25 - .../test/script/output-schema-fields.test.ts | 26 + 99 files changed, 2236 insertions(+), 1873 deletions(-) create mode 100644 packages/cli/script/generate-output-schemas.ts create mode 100644 packages/cli/script/lib/output-schema-fields.ts rename packages/cli/src/{types => output-schemas}/account.ts (100%) create mode 100644 packages/cli/src/output-schemas/agent-conversation.ts rename packages/cli/src/{commands/alert/output-schemas.ts => output-schemas/alert.ts} (98%) create mode 100644 packages/cli/src/output-schemas/dashboard.ts create mode 100644 packages/cli/src/output-schemas/event.ts create mode 100644 packages/cli/src/output-schemas/feedback.ts create mode 100644 packages/cli/src/output-schemas/integration.ts create mode 100644 packages/cli/src/output-schemas/issue.ts create mode 100644 packages/cli/src/output-schemas/log.ts create mode 100644 packages/cli/src/output-schemas/monitor.ts create mode 100644 packages/cli/src/output-schemas/org-list.ts create mode 100644 packages/cli/src/output-schemas/org.ts rename packages/cli/src/{types => output-schemas}/output.ts (86%) create mode 100644 packages/cli/src/output-schemas/project.ts create mode 100644 packages/cli/src/output-schemas/release.ts create mode 100644 packages/cli/src/output-schemas/replay.ts create mode 100644 packages/cli/src/output-schemas/repo.ts create mode 100644 packages/cli/src/output-schemas/span.ts create mode 100644 packages/cli/src/output-schemas/team.ts create mode 100644 packages/cli/src/output-schemas/trace.ts create mode 100644 packages/cli/src/output-schemas/trial.ts create mode 100644 packages/cli/test/script/output-schema-fields.test.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index 7bc2fbad1..18ac71c5a 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -100,6 +100,7 @@ "sentry-cli/no-examples-in-full-description": "error", "sentry-cli/no-regex-markdown-parsing": "error", "sentry-cli/json-transform-uses-fields": "error", + "sentry-cli/no-runtime-output-schema-imports": "error", "no-restricted-imports": [ "error", { diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index ea2829268..ce438b02d 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -232,29 +232,46 @@ Lint enforcement: `stderr.write()` is banned in command files (GritQL rule). Use `output.schema` drives `--fields` completion, the JSON field table in `--help`, and the skill references. Nothing validates output against it, so declare each field once and derive the TypeScript type from the schema; the compiler then keeps the two in sync ([#1824](https://github.com/getsentry/toolkit/issues/1824)). +The schemas live in `src/output-schemas/.ts` (one module per top-level command), which only the build loads ([#1914](https://github.com/getsentry/toolkit/issues/1914)). `script/generate-output-schemas.ts` (part of `generate:docs`) reads each exported `…Schema` and writes its field list and `--fields` paths to the gitignored `src/generated/output-schemas/.ts`, under the same export name. A command imports that data as its `output.schema`, so a run loads only its own route's field lists, and none of the schemas: + +- Import the schema data from `src/generated/output-schemas/`, and everything else from `src/output-schemas/` with `import type` only. Lint (`sentry-cli/no-runtime-output-schema-imports`) rejects a value import, or `import { type X }`, which `verbatimModuleSyntax` keeps as a runtime import. +- Export the type the command needs next to its schema (`export type ReleaseDeleteOutput = InferOutput`), or `import type` the schema and write `typeof`. +- A schema that also validates an API response at runtime (`SentryLogSchema`, `TransactionListItemSchema`, `ConversationListItemSchema`, `DashboardRevisionSchema`) stays where it is and is re-exported from its area's module. +- `src/output-schemas/` must not import `src/generated/output-schemas/`: the generator loads the former to write the latter. + - **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. +- **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/output-schemas/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. 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). +- An `OrgListConfig` takes the item's schema data (`orgListItemSchema(Entity)`, exported from the area's module) and checks its entity against it when you pass its 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` and `opaque()` (`src/output-schemas/output.ts`; `opaque` is for a field the docs leave opaque but the code types, such as a full event payload), and `orgListItemSchema` (`src/output-schemas/org-list.ts`) with `WithOrg` (`lib/org-list.ts`) for org-scoped lists. - 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`. ```typescript -const DeployOutputFields = object({ +// src/output-schemas/release.ts +export const ReleaseDeployOutputFields = object({ dryRun: DryRunOutputField, version: optional(pipe(string(), description("Release version (--dry-run)"))), }); -type DeployOutput = WithOutputFields; +export const ReleaseDeployOutputSchema = looseObject({ + ...SentryDeploySchema.entries, + ...ReleaseDeployOutputFields.entries, +}); + +// src/commands/release/deploy.ts +import { ReleaseDeployOutputSchema } from "../../generated/output-schemas/release.js"; +import type { ReleaseDeployOutputFields } from "../../output-schemas/release.js"; + +type DeployOutput = WithOutputFields; output: { human: (deploy: DeployOutput) => …, - schema: looseObject({ ...SentryDeploySchema.entries, ...DeployOutputFields.entries }), + schema: ReleaseDeployOutputSchema, }, ``` -Reference: `src/commands/release/deploy.ts`, `src/commands/alert/output-schemas.ts`, `ReplayViewOutputFields` in `src/types/replay.ts`. +Reference: `src/commands/release/deploy.ts`, `src/output-schemas/alert.ts`, `ReplayViewOutputFields` in `src/output-schemas/replay.ts`. ### Route Maps (Stricli) diff --git a/packages/cli/lint-rules/cli-oxlint-plugin.js b/packages/cli/lint-rules/cli-oxlint-plugin.js index 06df99150..064357e65 100644 --- a/packages/cli/lint-rules/cli-oxlint-plugin.js +++ b/packages/cli/lint-rules/cli-oxlint-plugin.js @@ -731,6 +731,36 @@ const rules = { } : {}, ), + "no-runtime-output-schema-imports": rule( + "src/output-schemas/ is build-only: use `import type` from it, and import the field data from src/generated/output-schemas/.", + (_context, file, report) => { + if (!file.includes("/src/") || file.includes("/src/output-schemas/")) + return {}; + const buildOnly = (source) => + typeof source === "string" && + /(?:^|\/)output-schemas\//.test(source) && + !/(?:^|\/)generated\/output-schemas\//.test(source); + // Only `import type` / `export type` is erased whole. Under + // `verbatimModuleSyntax`, `import { type X }` still loads the module. + return { + ImportDeclaration(node) { + if (buildOnly(node.source.value) && node.importKind !== "type") + report(node); + }, + ExportNamedDeclaration(node) { + if (buildOnly(node.source?.value) && node.exportKind !== "type") + report(node); + }, + ExportAllDeclaration(node) { + if (buildOnly(node.source.value) && node.exportKind !== "type") + report(node); + }, + ImportExpression(node) { + if (buildOnly(node.source.value)) report(node); + }, + }; + }, + ), "no-skipped-tests": rule( "Do not leave disabled tests in the suite.", (_context, file, report) => diff --git a/packages/cli/package.json b/packages/cli/package.json index 26c842c5c..148325e8f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -71,8 +71,9 @@ "generate:sdk": "pnpm tsx script/generate-sdk.ts", "generate:skill": "pnpm tsx script/generate-skill.ts", "generate:banner": "pnpm tsx script/generate-banner-image.ts", - "generate:docs": "node script/ensure-generated.mjs schema && pnpm run generate:grammar && pnpm run generate:command-api && pnpm run generate:banner && pnpm run generate:completions && pnpm run generate:command-docs && pnpm run generate:docs-sections && pnpm run generate:skill", + "generate:docs": "node script/ensure-generated.mjs schema && pnpm run generate:grammar && pnpm run generate:output-schemas && pnpm run generate:command-api && pnpm run generate:banner && pnpm run generate:completions && pnpm run generate:command-docs && pnpm run generate:docs-sections && pnpm run generate:skill", "generate:grammar": "node ../../scripts/generate-parsers.mjs .", + "generate:output-schemas": "pnpm tsx script/generate-output-schemas.ts", "generate:completions": "pnpm tsx script/generate-completion-targets.ts", "generate:docs-sections": "pnpm tsx script/generate-docs-sections.ts", "generate:schema": "pnpm tsx script/generate-api-schema.ts", diff --git a/packages/cli/script/ensure-generated.mjs b/packages/cli/script/ensure-generated.mjs index 83aa80ef1..3c22275d5 100644 --- a/packages/cli/script/ensure-generated.mjs +++ b/packages/cli/script/ensure-generated.mjs @@ -33,6 +33,7 @@ const GRAMMAR_OUTPUTS = [ ]; /** Produced by `generate:docs`; imported at runtime by `src/`. */ const DOCS_OUTPUTS = [ + "src/generated/output-schemas", "src/generated/command-api.json", "src/generated/skill-content.ts", "src/generated/completion-targets.json", diff --git a/packages/cli/script/generate-output-schemas.ts b/packages/cli/script/generate-output-schemas.ts new file mode 100644 index 000000000..126b5fb04 --- /dev/null +++ b/packages/cli/script/generate-output-schemas.ts @@ -0,0 +1,102 @@ +#!/usr/bin/env tsx +/** + * Generate Output Schema Fields + * + * Command output schemas (`src/output-schemas/`) are valibot schemas the CLI + * only needs for their field list: `--help`, `sentry help `, `--fields` + * parsing and completion, the skill references, and the SDK's return types. + * Reading that list out of a schema needs the schema, valibot, and + * everything the schema spreads in from `@sentry/api/valibot`, so this + * script reads it at build time instead. It loads each module in + * `src/output-schemas/`, and for each exported valibot schema whose name + * ends in `Schema` writes its fields and field paths + * ({@link OutputSchemaFields}) to `src/generated/output-schemas/`, in a + * module of the same name and under the same export name. Commands import + * those, so a run loads the field data of its own route's commands and no + * schema. + * + * `src/output-schemas/` must not import from `src/generated/output-schemas/`: + * this script loads it to write that directory. + * + * The output is gitignored and regenerated by `generate:docs`, which runs as + * part of `dev`, `build`, `bundle`, `typecheck`, and `test`. + * + * Usage: tsx script/generate-output-schemas.ts + * Output: src/generated/output-schemas/*.ts + */ + +import { mkdir, readdir, rm, writeFile } from "node:fs/promises"; +import { basename, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import type { GenericSchema } from "valibot"; +import type { OutputSchemaFields } from "../src/lib/formatters/output.js"; +import { + extractSchemaFieldPaths, + extractSchemaFields, +} from "./lib/output-schema-fields.js"; + +const SOURCE_DIR = "src/output-schemas"; +const OUTPUT_DIR = "src/generated/output-schemas"; + +/** Whether `value` is a valibot schema (not an action or a plain value). */ +function isSchema(value: unknown): value is GenericSchema { + return ( + typeof value === "object" && + value !== null && + (value as { kind?: unknown }).kind === "schema" + ); +} + +/** The generated module for one `src/output-schemas/` module. */ +function renderModule( + name: string, + schemas: [string, GenericSchema][], +): string { + const source = `../../output-schemas/${name}.js`; + const lines = [ + `// Generated by script/generate-output-schemas.ts from ${SOURCE_DIR}/${name}.ts.`, + "// Do not edit; change the schemas there instead.", + "", + 'import type { OutputSchemaFields } from "../../lib/formatters/output.js";', + "import type {", + ...schemas.map(([exportName]) => ` ${exportName} as ${exportName}Source,`), + `} from ${JSON.stringify(source)};`, + ]; + for (const [exportName, schema] of schemas) { + const fields: OutputSchemaFields = { + fields: extractSchemaFields(schema), + paths: extractSchemaFieldPaths(schema), + }; + lines.push( + "", + `export const ${exportName}: OutputSchemaFields = ${JSON.stringify(fields)};`, + ); + } + return `${lines.join("\n")}\n`; +} + +const files = (await readdir(SOURCE_DIR)) + .filter((file) => file.endsWith(".ts")) + .sort(); + +await rm(OUTPUT_DIR, { recursive: true, force: true }); +await mkdir(OUTPUT_DIR, { recursive: true }); + +let count = 0; +for (const file of files) { + const name = basename(file, ".ts"); + const exports: Record = await import( + pathToFileURL(resolve(SOURCE_DIR, file)).href + ); + const schemas = Object.entries(exports).filter( + (entry): entry is [string, GenericSchema] => + entry[0].endsWith("Schema") && isSchema(entry[1]), + ); + if (schemas.length === 0) { + continue; + } + count += schemas.length; + await writeFile(`${OUTPUT_DIR}/${name}.ts`, renderModule(name, schemas)); +} + +console.log(`Generated ${OUTPUT_DIR}/ (${count} output schemas)`); diff --git a/packages/cli/script/generate-sdk.ts b/packages/cli/script/generate-sdk.ts index e26db15be..c91c1bc6a 100644 --- a/packages/cli/script/generate-sdk.ts +++ b/packages/cli/script/generate-sdk.ts @@ -20,7 +20,7 @@ import { writeFile } from "node:fs/promises"; import ts from "typescript"; import { routes } from "../src/app.js"; -import { extractSchemaFields } from "../src/lib/formatters/output.js"; +import type { SchemaFieldInfo } from "../src/lib/formatters/output.js"; import { type Command, type FlagDef, @@ -238,7 +238,7 @@ function derivePositional(command: Command): PositionalInfo { } // --------------------------------------------------------------------------- -// Return Type Generation from __jsonSchema +// Return Type Generation from __jsonFields // --------------------------------------------------------------------------- /** Map a single schema field type name to a TypeScript type node. */ @@ -306,20 +306,16 @@ function propertySignature( } /** - * Build the result type from a command's __jsonSchema. - * Returns null if no schema is attached. + * Build the result type from a command's __jsonFields. + * Returns null if no output schema is attached. */ function buildReturnType( command: Command, typeName: string, ): ts.TypeAliasDeclaration | null { - const schema = (command as any).__jsonSchema; - if (!schema) { - return null; - } - - const fields = extractSchemaFields(schema); - if (fields.length === 0) { + const fields: readonly SchemaFieldInfo[] | undefined = (command as any) + .__jsonFields; + if (!fields?.length) { return null; } @@ -801,7 +797,7 @@ const sdkType = exportedObjectType(SDK_TYPE_NAME, [ /** Sections shared verbatim by the implementation file and the published declarations. */ const sharedTypes = [ - "// --- Return types (derived from __jsonSchema) ---", + "// --- Return types (derived from __jsonFields) ---", "", returnTypes.length > 0 ? printNodes(returnTypes) diff --git a/packages/cli/script/lib/output-schema-fields.ts b/packages/cli/script/lib/output-schema-fields.ts new file mode 100644 index 000000000..c5249c2b5 --- /dev/null +++ b/packages/cli/script/lib/output-schema-fields.ts @@ -0,0 +1,228 @@ +/** + * Read the field metadata of a valibot output schema: the fields `--help`, + * `sentry help ` and the skill references list, and the paths + * `--fields` completes. + * + * Build time only. `script/generate-output-schemas.ts` runs it over the + * schemas in `src/output-schemas/` and writes the result to + * `src/generated/output-schemas/`, which is what the CLI reads at runtime. + */ + +import { type GenericSchema, getDescription } from "valibot"; +import type { SchemaFieldInfo } from "../../src/lib/formatters/output.js"; + +/** Minimal structural view of a valibot schema's runtime shape. */ +type ValibotSchemaNode = { + type: string; + /** Inner schema for wrapper types (optional / nullable / nullish). */ + wrapped?: ValibotSchemaNode; + /** Member schemas for union / variant / picklist. */ + options?: unknown[]; + /** Item schema for array. */ + item?: ValibotSchemaNode; + /** Field schemas for object / loose_object. */ + entries?: Record; + /** + * Pipe steps for a `SchemaWithPipe` (e.g. `pipe(string(), description(...))` + * or the `pipe(unknown(), transform(Number), number())` coercion pattern). + * Each step carries a `kind` of `"schema"`, `"validation"`, `"transformation"`, + * or `"metadata"`. + */ + pipe?: Array<{ type: string; kind: string }>; +}; + +/** + * Resolve a piped schema (`pipe(...)`) to the node whose `type` reflects its + * output — the last step with `kind === "schema"`. For the coercion pattern + * `pipe(unknown(), transform(Number), number())` this is `number()`, not the + * leading `unknown()`. Non-piped schemas are returned unchanged. + */ +function unwrapPipe(schema: ValibotSchemaNode): ValibotSchemaNode { + if (!schema.pipe?.length) { + return schema; + } + const schemaSteps = schema.pipe.filter((step) => step.kind === "schema"); + const last = schemaSteps.at(-1); + return (last as ValibotSchemaNode | undefined) ?? schema; +} + +/** Leaf-type name mapping for {@link valibotTypeToString}. */ +const VALIBOT_TYPE_MAP: Record = { + string: "string", + number: "number", + boolean: "boolean", + object: "object", + loose_object: "object", + array: "array", + record: "object", + null: "null", + unknown: "unknown", + any: "any", + picklist: "string", + enum: "string", + literal: "string", +}; + +/** + * Resolve a valibot `union` into a deduplicated `" | "`-joined type string. + * + * Used by auto-generated `@sentry/api/valibot` schemas that represent nullable + * fields as `union([string(), null_()])` instead of `nullable(string())`. + */ +function resolveValibotUnion(options: ValibotSchemaNode[]): { + type: string; + optional: boolean; +} { + let optional = false; + const parts: string[] = []; + for (const opt of options) { + const resolved = valibotTypeToString(opt); + if (resolved.optional) { + optional = true; + } + if (!parts.includes(resolved.type)) { + parts.push(resolved.type); + } + } + return { type: parts.join(" | "), optional }; +} + +/** + * Map a valibot schema's runtime `type` to a human-readable string. + * + * Unwraps wrapper types (optional, nullable, nullish) and builds a composite + * type string (e.g. "string | null" for `nullable(string())`). Delegates + * union handling to {@link resolveValibotUnion}. + */ +function valibotTypeToString(input: ValibotSchemaNode): { + type: string; + optional: boolean; +} { + // Resolve `pipe(...)` schemas (incl. the coercion pattern) to their output + // schema before inspecting the type. + const schema = input ? unwrapPipe(input) : input; + if (!schema?.type) { + return { type: "unknown", optional: false }; + } + + // Unwrap wrapper types recursively. + if (schema.type === "optional" && schema.wrapped) { + const inner = valibotTypeToString(schema.wrapped); + return { type: inner.type, optional: true }; + } + if (schema.type === "nullable" && schema.wrapped) { + const inner = valibotTypeToString(schema.wrapped); + const nullableType = inner.type.includes(" | null") + ? inner.type + : `${inner.type} | null`; + return { type: nullableType, optional: inner.optional }; + } + if (schema.type === "nullish" && schema.wrapped) { + const inner = valibotTypeToString(schema.wrapped); + const nullableType = inner.type.includes(" | null") + ? inner.type + : `${inner.type} | null`; + return { type: nullableType, optional: true }; + } + if ( + (schema.type === "union" || schema.type === "variant") && + schema.options?.length + ) { + return resolveValibotUnion(schema.options as ValibotSchemaNode[]); + } + + return { type: VALIBOT_TYPE_MAP[schema.type] ?? "unknown", optional: false }; +} + +/** + * Extract field metadata from a valibot object schema. + * + * Returns an array of {@link SchemaFieldInfo} describing each top-level + * field's name, type, description, and optionality. Returns an empty + * array for non-object schemas. + * + * @param schema - A valibot schema (typically `object({...})`) + */ +export function extractSchemaFields(schema: GenericSchema): SchemaFieldInfo[] { + const node = schema as unknown as ValibotSchemaNode; + + if (node.type !== "object" && node.type !== "loose_object") { + return []; + } + + const entries = node.entries; + if (!entries) { + return []; + } + + return Object.entries(entries).map(([name, fieldSchema]) => { + const { type, optional } = valibotTypeToString(fieldSchema); + return { + name, + type, + description: findDescription(fieldSchema), + optional, + }; + }); +} + +/** Wrapper types {@link objectsOf} looks through to the schema they hold. */ +const WRAPPER_TYPES = new Set(["optional", "nullable", "nullish", "array"]); + +/** + * The object schemas a value of `schema` can be: through pipes, wrappers, + * array items (`--fields` paths reach into arrays), and union and variant + * members. + */ +function objectsOf(schema: ValibotSchemaNode): ValibotSchemaNode[] { + const node = unwrapPipe(schema); + if (WRAPPER_TYPES.has(node.type)) { + const inner = node.wrapped ?? node.item; + return inner ? objectsOf(inner) : []; + } + if (node.type === "union" || node.type === "variant") { + return (node.options as ValibotSchemaNode[]).flatMap(objectsOf); + } + return node.entries ? [node] : []; +} + +/** + * Every field path a valibot schema describes, in `--fields` dot notation: + * each top-level field, then the fields of nested objects + * (`metadata.title`), parents before children. + * + * @param schema - A valibot schema (typically `object({...})`) + */ +export function extractSchemaFieldPaths(schema: GenericSchema): string[] { + const paths = new Set(); + const visit = (node: ValibotSchemaNode, prefix: string) => { + for (const { entries = {} } of objectsOf(node)) { + for (const [name, field] of Object.entries(entries)) { + paths.add(`${prefix}${name}`); + visit(field, `${prefix}${name}.`); + } + } + }; + visit(schema as unknown as ValibotSchemaNode, ""); + return [...paths]; +} + +/** + * Find a `description()` pipe action for a field schema. + * + * `getDescription` does not recurse, and the description may sit under one or + * more wrapper schemas — e.g. `optional(nullable(pipe(string(), description(...))))` + * places it two `.wrapped` levels down. Walk the wrapper chain until a + * description is found. + */ +function findDescription(schema: ValibotSchemaNode): string | undefined { + let node: ValibotSchemaNode | undefined = schema; + while (node) { + const description = getDescription(node as unknown as GenericSchema); + if (description !== undefined) { + return description; + } + node = node.wrapped; + } + return; +} diff --git a/packages/cli/src/commands/agent-conversation/list.ts b/packages/cli/src/commands/agent-conversation/list.ts index 68061548b..a77ac7aca 100644 --- a/packages/cli/src/commands/agent-conversation/list.ts +++ b/packages/cli/src/commands/agent-conversation/list.ts @@ -43,10 +43,8 @@ import { type TimeRange, timeRangeToApiParams, } from "../../lib/time-range.js"; -import { - type ConversationListItem, - ConversationListItemSchema, -} from "../../types/conversation.js"; +import type { ConversationListItem } from "../../types/conversation.js"; +import { ConversationListItemSchema } from "../../generated/output-schemas/agent-conversation.js"; const CONVERSATION_SORT_FIELDS = [ "conversation.age", diff --git a/packages/cli/src/commands/alert/issues/create.ts b/packages/cli/src/commands/alert/issues/create.ts index 295b37d68..d6f33c4a4 100644 --- a/packages/cli/src/commands/alert/issues/create.ts +++ b/packages/cli/src/commands/alert/issues/create.ts @@ -44,11 +44,11 @@ import { validateIssueActions, validateIssueTriggers, } from "../mutation-utils.js"; -import { - type IssueAlertCreateBody, - type IssueAlertCreateOutput, - IssueAlertCreateOutputSchema, -} from "../output-schemas.js"; +import type { + IssueAlertCreateBody, + IssueAlertCreateOutput, +} from "../../../output-schemas/alert.js"; +import { IssueAlertCreateOutputSchema } from "../../../generated/output-schemas/alert.js"; const USAGE_HINT = "sentry alert issues create --name --condition --action "; diff --git a/packages/cli/src/commands/alert/issues/delete.ts b/packages/cli/src/commands/alert/issues/delete.ts index 963d47c5e..1e91e4906 100644 --- a/packages/cli/src/commands/alert/issues/delete.ts +++ b/packages/cli/src/commands/alert/issues/delete.ts @@ -18,10 +18,8 @@ import { import { resolveProjectBoundTargets } from "../../../lib/resolve-target.js"; import type { AlertRuleArgument } from "../arguments.js"; import { issueRuleParameter, resolveIssueAlertRule } from "./rule-resolve.js"; -import { - type IssueAlertDeleteOutput, - IssueAlertDeleteOutputSchema, -} from "../output-schemas.js"; +import type { IssueAlertDeleteOutput } from "../../../output-schemas/alert.js"; +import { IssueAlertDeleteOutputSchema } from "../../../generated/output-schemas/alert.js"; const USAGE_HINT = "sentry alert issues delete //"; diff --git a/packages/cli/src/commands/alert/issues/edit.ts b/packages/cli/src/commands/alert/issues/edit.ts index dc2dba7b3..5766ea3df 100644 --- a/packages/cli/src/commands/alert/issues/edit.ts +++ b/packages/cli/src/commands/alert/issues/edit.ts @@ -40,10 +40,8 @@ import { } from "../mutation-utils.js"; import type { AlertRuleArgument } from "../arguments.js"; import { issueRuleParameter, resolveIssueAlertRule } from "./rule-resolve.js"; -import { - type IssueAlertEditOutput, - IssueAlertEditOutputSchema, -} from "../output-schemas.js"; +import type { IssueAlertEditOutput } from "../../../output-schemas/alert.js"; +import { IssueAlertEditOutputSchema } from "../../../generated/output-schemas/alert.js"; const USAGE_HINT = "sentry alert issues edit // --name | --status active|disabled"; diff --git a/packages/cli/src/commands/alert/metrics/create.ts b/packages/cli/src/commands/alert/metrics/create.ts index e114fc1d4..15aa155e9 100644 --- a/packages/cli/src/commands/alert/metrics/create.ts +++ b/packages/cli/src/commands/alert/metrics/create.ts @@ -42,11 +42,11 @@ import { validateMetricTimeWindow, } from "../mutation-utils.js"; import { metricAlertStatusLabel } from "./status.js"; -import { - type MetricAlertCreateBody, - type MetricAlertCreateOutput, - MetricAlertCreateOutputSchema, -} from "../output-schemas.js"; +import type { + MetricAlertCreateBody, + MetricAlertCreateOutput, +} from "../../../output-schemas/alert.js"; +import { MetricAlertCreateOutputSchema } from "../../../generated/output-schemas/alert.js"; type CreateFlags = { readonly name: string; diff --git a/packages/cli/src/commands/alert/metrics/delete.ts b/packages/cli/src/commands/alert/metrics/delete.ts index 30a803c10..6b39ca3f7 100644 --- a/packages/cli/src/commands/alert/metrics/delete.ts +++ b/packages/cli/src/commands/alert/metrics/delete.ts @@ -17,10 +17,8 @@ import { import { resolveOrgOnlyTarget } from "../../../lib/resolve-target.js"; import type { AlertRuleArgument } from "../arguments.js"; import { metricRuleParameter, resolveMetricAlertRule } from "./rule-resolve.js"; -import { - type MetricAlertDeleteOutput, - MetricAlertDeleteOutputSchema, -} from "../output-schemas.js"; +import type { MetricAlertDeleteOutput } from "../../../output-schemas/alert.js"; +import { MetricAlertDeleteOutputSchema } from "../../../generated/output-schemas/alert.js"; const USAGE_HINT = "sentry alert metrics delete /"; diff --git a/packages/cli/src/commands/alert/metrics/edit.ts b/packages/cli/src/commands/alert/metrics/edit.ts index ec470a757..0c43ba548 100644 --- a/packages/cli/src/commands/alert/metrics/edit.ts +++ b/packages/cli/src/commands/alert/metrics/edit.ts @@ -44,10 +44,8 @@ import { } from "../mutation-utils.js"; import type { AlertRuleArgument } from "../arguments.js"; import { metricRuleParameter, resolveMetricAlertRule } from "./rule-resolve.js"; -import { - type MetricAlertEditOutput, - MetricAlertEditOutputSchema, -} from "../output-schemas.js"; +import type { MetricAlertEditOutput } from "../../../output-schemas/alert.js"; +import { MetricAlertEditOutputSchema } from "../../../generated/output-schemas/alert.js"; const USAGE_HINT = "sentry alert metrics edit / --name | --status active|disabled"; diff --git a/packages/cli/src/commands/auth/list.ts b/packages/cli/src/commands/auth/list.ts index c26ec12d0..77df081e4 100644 --- a/packages/cli/src/commands/auth/list.ts +++ b/packages/cli/src/commands/auth/list.ts @@ -19,7 +19,7 @@ import { } from "../../lib/formatters/accounts.js"; import { filterFieldsIfSet } from "../../lib/formatters/json.js"; import { CommandOutput } from "../../lib/formatters/output.js"; -import { AccountSummarySchema } from "../../types/account.js"; +import { AccountSummarySchema } from "../../generated/output-schemas/account.js"; export const listCommand = buildCommand({ auth: false, diff --git a/packages/cli/src/commands/dashboard/create.ts b/packages/cli/src/commands/dashboard/create.ts index 1cd6bd49e..a8a96a244 100644 --- a/packages/cli/src/commands/dashboard/create.ts +++ b/packages/cli/src/commands/dashboard/create.ts @@ -4,13 +4,6 @@ * Create a new dashboard in a Sentry organization. */ -import { - description, - type InferOutput, - looseObject, - pipe, - string, -} from "valibot"; import type { SentryContext } from "../../context.js"; import { createDashboard, getProject } from "../../lib/api-client.js"; import { @@ -36,7 +29,8 @@ import { import { applySentryUrlContext } from "../../lib/sentry-url-parser.js"; import { buildDashboardUrl } from "../../lib/sentry-web-urls.js"; import { setOrgProjectContext } from "../../lib/telemetry.js"; -import { DashboardDetailSchema } from "../../types/dashboard.js"; +import { DashboardCreateOutputSchema } from "../../generated/output-schemas/dashboard.js"; +import type { DashboardCreateOutput } from "../../output-schemas/dashboard.js"; import { withDashboardErrors } from "./resolve.js"; type CreateFlags = { @@ -44,13 +38,7 @@ type CreateFlags = { readonly fields?: string[]; }; -/** JSON output: the created dashboard and its URL. */ -const CreateOutputSchema = looseObject({ - ...DashboardDetailSchema.entries, - url: pipe(string(), description("Dashboard URL")), -}); - -type CreateResult = InferOutput; +type CreateResult = DashboardCreateOutput; /** `dashboard create`'s positionals, parsed: the title and the target. */ type CreateInput = { @@ -194,7 +182,7 @@ export const createCommand = buildCommand({ }, output: { human: formatDashboardCreated, - schema: CreateOutputSchema, + schema: DashboardCreateOutputSchema, }, parameters: { positional: boundArguments({ diff --git a/packages/cli/src/commands/dashboard/list.ts b/packages/cli/src/commands/dashboard/list.ts index 1aa0396fb..71dd3f79f 100644 --- a/packages/cli/src/commands/dashboard/list.ts +++ b/packages/cli/src/commands/dashboard/list.ts @@ -36,16 +36,14 @@ import { buildDashboardsListUrl, buildDashboardUrl, } from "../../lib/sentry-web-urls.js"; -import { - type DashboardListItem, - DashboardListItemSchema, -} from "../../types/dashboard.js"; +import type { DashboardListItem } from "../../types/dashboard.js"; import type { Writer } from "../../types/index.js"; import { type DashboardListArguments, dashboardListArguments, } from "./arguments.js"; import { withDashboardErrors } from "./resolve.js"; +import { DashboardListItemSchema } from "../../generated/output-schemas/dashboard.js"; /** Command key for pagination cursor storage */ export const PAGINATION_KEY = "dashboard-list"; diff --git a/packages/cli/src/commands/dashboard/restore.ts b/packages/cli/src/commands/dashboard/restore.ts index c2a172ab9..34f68d73a 100644 --- a/packages/cli/src/commands/dashboard/restore.ts +++ b/packages/cli/src/commands/dashboard/restore.ts @@ -5,7 +5,6 @@ */ import { tableCell } from "@sentry/toolkit-core/markdown"; -import { description, type InferOutput, object, pipe, string } from "valibot"; import type { SentryContext } from "../../context.js"; import { restoreDashboardRevision } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; @@ -16,7 +15,8 @@ import { CommandOutput } from "../../lib/formatters/output.js"; import { formatRelativeTime } from "../../lib/formatters/time-utils.js"; import { withProgress } from "../../lib/polling.js"; import { buildDashboardUrl } from "../../lib/sentry-web-urls.js"; -import { DashboardDetailSchema } from "../../types/dashboard.js"; +import { DashboardRestoreOutputSchema } from "../../generated/output-schemas/dashboard.js"; +import type { DashboardRestoreOutput } from "../../output-schemas/dashboard.js"; import { type DashboardArguments, dashboardArguments } from "./arguments.js"; import { withDashboardErrors } from "./resolve.js"; @@ -26,14 +26,7 @@ type RestoreFlags = { readonly fields?: string[]; }; -/** JSON output: the restored dashboard and the revision it was restored to. */ -const RestoreOutputSchema = object({ - dashboard: pipe(DashboardDetailSchema, description("Restored dashboard")), - orgSlug: pipe(string(), description("Organization slug")), - revisionId: pipe(string(), description("Restored revision ID")), -}); - -type RestoreResult = InferOutput; +type RestoreResult = DashboardRestoreOutput; function formatRestoreHuman(result: RestoreResult): string { const d = result.dashboard; @@ -80,7 +73,7 @@ export const restoreCommand = buildCommand({ }, output: { human: formatRestoreHuman, - schema: RestoreOutputSchema, + schema: DashboardRestoreOutputSchema, }, parameters: { positional: dashboardArguments({ diff --git a/packages/cli/src/commands/dashboard/revisions.ts b/packages/cli/src/commands/dashboard/revisions.ts index d8e3eb1b0..dd54647c9 100644 --- a/packages/cli/src/commands/dashboard/revisions.ts +++ b/packages/cli/src/commands/dashboard/revisions.ts @@ -28,13 +28,11 @@ import { import type { ListEnvelope } from "../../lib/org-list.js"; import { withProgress } from "../../lib/polling.js"; import { buildDashboardUrl } from "../../lib/sentry-web-urls.js"; -import { - type DashboardRevision, - DashboardRevisionSchema, -} from "../../types/dashboard.js"; +import type { DashboardRevision } from "../../types/dashboard.js"; import type { Writer } from "../../types/index.js"; import { type DashboardArguments, dashboardArguments } from "./arguments.js"; import { withDashboardErrors } from "./resolve.js"; +import { DashboardRevisionSchema } from "../../generated/output-schemas/dashboard.js"; const PAGINATION_KEY = "dashboard-revisions"; diff --git a/packages/cli/src/commands/dashboard/view.ts b/packages/cli/src/commands/dashboard/view.ts index 8c6b9192b..7acc0b65a 100644 --- a/packages/cli/src/commands/dashboard/view.ts +++ b/packages/cli/src/commands/dashboard/view.ts @@ -31,13 +31,13 @@ import { type TimeRange, timeRangeToSeconds, } from "../../lib/time-range.js"; -import { - DashboardViewOutputSchema, - type DashboardWidget, - type WidgetDataResult, +import type { + DashboardWidget, + WidgetDataResult, } from "../../types/dashboard.js"; import { type DashboardArguments, dashboardArguments } from "./arguments.js"; import { withDashboardErrors } from "./resolve.js"; +import { DashboardViewOutputSchema } from "../../generated/output-schemas/dashboard.js"; /** Default auto-refresh interval in seconds */ const DEFAULT_REFRESH_INTERVAL = 60; diff --git a/packages/cli/src/commands/dashboard/widget/add.ts b/packages/cli/src/commands/dashboard/widget/add.ts index c1331dfd9..dc7784ab9 100644 --- a/packages/cli/src/commands/dashboard/widget/add.ts +++ b/packages/cli/src/commands/dashboard/widget/add.ts @@ -25,7 +25,6 @@ import { import { buildDashboardUrl } from "../../../lib/sentry-web-urls.js"; import { assignDefaultLayout, - DashboardWidgetChangeOutputSchema, DEFAULT_WIDGET_TYPE, DISPLAY_TYPES, FALLBACK_LAYOUT, @@ -53,6 +52,7 @@ import { WIDGET_FIELD_COMPLETION, type WidgetQueryFlags, } from "../resolve.js"; +import { DashboardWidgetChangeOutputSchema } from "../../../generated/output-schemas/dashboard.js"; /** What `--query`, `--sort`, and `--group-by` complete: a spans widget's by default. */ const FIELD_COMPLETION: DatasetCompletion = { diff --git a/packages/cli/src/commands/dashboard/widget/delete.ts b/packages/cli/src/commands/dashboard/widget/delete.ts index 803fbb3e3..41c61dff4 100644 --- a/packages/cli/src/commands/dashboard/widget/delete.ts +++ b/packages/cli/src/commands/dashboard/widget/delete.ts @@ -21,11 +21,11 @@ import { buildDeleteCommand } from "../../../lib/mutate-command.js"; import { buildDashboardUrl } from "../../../lib/sentry-web-urls.js"; import { type DashboardWidgetDeleteOutput, - DashboardWidgetDeleteOutputSchema, prepareDashboardForUpdate, } from "../../../types/dashboard.js"; import { type DashboardArguments, dashboardArguments } from "../arguments.js"; import { withDashboardErrors, resolveWidgetIndex } from "../resolve.js"; +import { DashboardWidgetDeleteOutputSchema } from "../../../generated/output-schemas/dashboard.js"; type DeleteFlags = { readonly index?: number; diff --git a/packages/cli/src/commands/dashboard/widget/edit.ts b/packages/cli/src/commands/dashboard/widget/edit.ts index 158f67cda..3b5cbe2dc 100644 --- a/packages/cli/src/commands/dashboard/widget/edit.ts +++ b/packages/cli/src/commands/dashboard/widget/edit.ts @@ -19,7 +19,6 @@ import { CommandOutput } from "../../../lib/formatters/output.js"; import { buildDashboardUrl } from "../../../lib/sentry-web-urls.js"; import { type DashboardWidget, - DashboardWidgetChangeOutputSchema, type DashboardWidgetQuery, DISPLAY_TYPES, FALLBACK_LAYOUT, @@ -47,6 +46,7 @@ import { WIDGET_FIELD_COMPLETION, type WidgetQueryFlags, } from "../resolve.js"; +import { DashboardWidgetChangeOutputSchema } from "../../../generated/output-schemas/dashboard.js"; type EditFlags = WidgetQueryFlags & WidgetLayoutFlags & { diff --git a/packages/cli/src/commands/event/list.ts b/packages/cli/src/commands/event/list.ts index de2b9b9c5..6205e5eb2 100644 --- a/packages/cli/src/commands/event/list.ts +++ b/packages/cli/src/commands/event/list.ts @@ -24,7 +24,6 @@ import { serializeTimeRange, timeRangeToApiParams, } from "../../lib/time-range.js"; -import { IssueEventSchema } from "../../types/index.js"; import { type IssueTarget, issuePositional } from "../issue/positional.js"; import { buildCommandHint } from "../issue/utils.js"; import { @@ -35,6 +34,7 @@ import { formatEventsHuman, jsonTransformEvents, } from "./shared-events.js"; +import { IssueEventSchema } from "../../generated/output-schemas/event.js"; // --------------------------------------------------------------------------- // Constants diff --git a/packages/cli/src/commands/event/view.ts b/packages/cli/src/commands/event/view.ts index ecaa5ad5a..48ca7e7e3 100644 --- a/packages/cli/src/commands/event/view.ts +++ b/packages/cli/src/commands/event/view.ts @@ -79,12 +79,10 @@ import { buildEventSearchUrl } from "../../lib/sentry-web-urls.js"; import { getSpanTreeLines } from "../../lib/span-tree.js"; import { setOrgProjectContext } from "../../lib/telemetry.js"; import type { InferOutput } from "valibot"; -import { - type EventViewOutputFields, - EventViewOutputSchema, - type SentryEvent, -} from "../../types/index.js"; +import type { SentryEvent } from "../../types/index.js"; import { findSelectorIssue } from "../issue/utils.js"; +import { EventViewOutputSchema } from "../../generated/output-schemas/event.js"; +import type { EventViewOutputFields } from "../../output-schemas/event.js"; type ViewFlags = { readonly json: boolean; diff --git a/packages/cli/src/commands/feedback/list.ts b/packages/cli/src/commands/feedback/list.ts index 445351a37..6f634a9b0 100644 --- a/packages/cli/src/commands/feedback/list.ts +++ b/packages/cli/src/commands/feedback/list.ts @@ -51,11 +51,8 @@ import { type TimeRange, timeRangeToApiParams, } from "../../lib/time-range.js"; -import { - type FeedbackListResult, - type SentryFeedback, - SentryFeedbackSchema, -} from "../../types/index.js"; +import type { FeedbackListResult, SentryFeedback } from "../../types/index.js"; +import { SentryFeedbackSchema } from "../../generated/output-schemas/feedback.js"; const DEFAULT_PERIOD = "14d"; const DEFAULT_STATUS: FeedbackStatus = "unresolved"; diff --git a/packages/cli/src/commands/feedback/resolve.ts b/packages/cli/src/commands/feedback/resolve.ts index d4f4fc294..c163d728d 100644 --- a/packages/cli/src/commands/feedback/resolve.ts +++ b/packages/cli/src/commands/feedback/resolve.ts @@ -7,9 +7,9 @@ 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"; +import { SentryFeedbackSchema } from "../../generated/output-schemas/feedback.js"; export const resolveCommand = buildCommand({ docs: { diff --git a/packages/cli/src/commands/feedback/spam.ts b/packages/cli/src/commands/feedback/spam.ts index 5c6ec4c98..061fd861f 100644 --- a/packages/cli/src/commands/feedback/spam.ts +++ b/packages/cli/src/commands/feedback/spam.ts @@ -7,9 +7,9 @@ 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"; +import { SentryFeedbackSchema } from "../../generated/output-schemas/feedback.js"; export const spamCommand = buildCommand({ docs: { diff --git a/packages/cli/src/commands/feedback/unresolve.ts b/packages/cli/src/commands/feedback/unresolve.ts index a17b77c4e..8e0666234 100644 --- a/packages/cli/src/commands/feedback/unresolve.ts +++ b/packages/cli/src/commands/feedback/unresolve.ts @@ -7,9 +7,9 @@ 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"; +import { SentryFeedbackSchema } from "../../generated/output-schemas/feedback.js"; export const unresolveCommand = buildCommand({ docs: { diff --git a/packages/cli/src/commands/feedback/view.ts b/packages/cli/src/commands/feedback/view.ts index ebb08bf6b..dd3fca134 100644 --- a/packages/cli/src/commands/feedback/view.ts +++ b/packages/cli/src/commands/feedback/view.ts @@ -27,8 +27,8 @@ import { getReplayIdFromEvent, } from "../../lib/replay-search.js"; import type { FeedbackViewResult, SentryEvent } from "../../types/index.js"; -import { FeedbackViewOutputSchema } from "../../types/index.js"; import { type FeedbackTarget, feedbackPositional } from "./positional.js"; +import { FeedbackViewOutputSchema } from "../../generated/output-schemas/feedback.js"; const log = logger.withTag("feedback.view"); diff --git a/packages/cli/src/commands/integration/list.ts b/packages/cli/src/commands/integration/list.ts index 17a440406..b26a7c0be 100644 --- a/packages/cli/src/commands/integration/list.ts +++ b/packages/cli/src/commands/integration/list.ts @@ -16,10 +16,8 @@ import { type OrgListCommandDocs, } from "../../lib/list-command.js"; import type { OrgListConfig, WithOrg } from "../../lib/org-list.js"; -import { - type SentryIntegration, - SentryIntegrationSchema, -} from "../../types/index.js"; +import type { SentryIntegration } from "../../types/index.js"; +import { IntegrationListItemSchema } from "../../generated/output-schemas/integration.js"; /** Command key for pagination cursor storage */ export const PAGINATION_KEY = "integration-list"; @@ -41,7 +39,7 @@ const INTEGRATION_COLUMNS: Column[] = [ const integrationListConfig: OrgListConfig< SentryIntegration, IntegrationWithOrg, - typeof SentryIntegrationSchema + typeof IntegrationListItemSchema > = { paginationKey: PAGINATION_KEY, entityPlural: "integrations", @@ -54,7 +52,7 @@ const integrationListConfig: OrgListConfig< namesOf: (integration) => [integration.name, integration.provider.name], displayTable: (integrations: IntegrationWithOrg[]) => formatTable(integrations, INTEGRATION_COLUMNS), - schema: SentryIntegrationSchema, + schema: IntegrationListItemSchema, }; const docs: OrgListCommandDocs = { diff --git a/packages/cli/src/commands/issue/events.ts b/packages/cli/src/commands/issue/events.ts index 390fa9ee1..09abcba89 100644 --- a/packages/cli/src/commands/issue/events.ts +++ b/packages/cli/src/commands/issue/events.ts @@ -21,7 +21,6 @@ import { serializeTimeRange, timeRangeToApiParams, } from "../../lib/time-range.js"; -import { IssueEventSchema } from "../../types/index.js"; import { appendEventsFlags, EVENTS_ALIASES, @@ -32,6 +31,7 @@ import { } from "../event/shared-events.js"; import { type IssueTarget, issuePositional } from "./positional.js"; import { buildCommandHint } from "./utils.js"; +import { IssueEventSchema } from "../../generated/output-schemas/event.js"; // --------------------------------------------------------------------------- // Constants diff --git a/packages/cli/src/commands/issue/list.ts b/packages/cli/src/commands/issue/list.ts index 4a63a7e64..576d176ca 100644 --- a/packages/cli/src/commands/issue/list.ts +++ b/packages/cli/src/commands/issue/list.ts @@ -101,12 +101,9 @@ import { } from "../../lib/time-range.js"; import { tipsSuppressed } from "../../lib/tips.js"; import { shellQuote } from "../../lib/utils.js"; -import { - type SentryIssue, - SentryIssueSchema, - type Writer, -} from "../../types/index.js"; +import type { SentryIssue, Writer } from "../../types/index.js"; import { resolveIssue } from "./utils.js"; +import { SentryIssueSchema } from "../../generated/output-schemas/issue.js"; /** Command key for pagination cursor storage */ export const PAGINATION_KEY = "issue-list"; diff --git a/packages/cli/src/commands/issue/tags.ts b/packages/cli/src/commands/issue/tags.ts index 414a518cf..8554181c7 100644 --- a/packages/cli/src/commands/issue/tags.ts +++ b/packages/cli/src/commands/issue/tags.ts @@ -5,8 +5,6 @@ * because that endpoint ignores them. */ -import { vGetOrganizationIssueTagResponse } from "@sentry/api/valibot"; -import { description, object, pipe } from "valibot"; import type { SentryContext } from "../../context.js"; import { getIssueTag } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; @@ -26,6 +24,7 @@ import { requireIssueArgument, } from "./positional.js"; import { buildCommandHint, resolveIssue } from "./utils.js"; +import { IssueTagsOutputSchema } from "../../generated/output-schemas/issue.js"; type TagsFlags = { /** Emit the API response as JSON instead of a human table; defaults to false. */ @@ -73,21 +72,7 @@ export const tagsCommand = buildCommand({ }, output: { human: formatIssueTags, - schema: object({ - ...vGetOrganizationIssueTagResponse.entries, - totalValues: pipe( - vGetOrganizationIssueTagResponse.entries.totalValues, - description( - "Total retained events, including missing tag values; not a lifetime count", - ), - ), - topValues: pipe( - vGetOrganizationIssueTagResponse.entries.topValues, - description( - "Up to 9 values ordered by count, with nullable counts and first/last seen timestamps", - ), - ), - }), + schema: IssueTagsOutputSchema, }, parameters: { positional: { diff --git a/packages/cli/src/commands/issue/view.ts b/packages/cli/src/commands/issue/view.ts index 4af606301..a4865448c 100644 --- a/packages/cli/src/commands/issue/view.ts +++ b/packages/cli/src/commands/issue/view.ts @@ -30,9 +30,9 @@ import { } from "../../lib/replay-search.js"; import { getSpanTreeLines } from "../../lib/span-tree.js"; import type { SentryEvent } from "../../types/index.js"; -import { IssueViewOutputSchema } from "../../types/index.js"; import { type IssuesArguments, issuesPositional } from "./positional.js"; import { mapIssueArgsConcurrently, resolveIssue } from "./utils.js"; +import { IssueViewOutputSchema } from "../../generated/output-schemas/issue.js"; const log = logger.withTag("issue.view"); diff --git a/packages/cli/src/commands/log/list.ts b/packages/cli/src/commands/log/list.ts index 5600ed70f..75efa0d6f 100644 --- a/packages/cli/src/commands/log/list.ts +++ b/packages/cli/src/commands/log/list.ts @@ -79,7 +79,7 @@ import { warnIfNormalized, } from "../../lib/trace-target.js"; import { getUpdateNotification } from "../../lib/version-check.js"; -import { SentryLogSchema } from "../../types/index.js"; +import { SentryLogSchema } from "../../generated/output-schemas/log.js"; type ListFlags = { readonly limit: number; diff --git a/packages/cli/src/commands/monitor/list.ts b/packages/cli/src/commands/monitor/list.ts index a167b9733..6dfcf73b2 100644 --- a/packages/cli/src/commands/monitor/list.ts +++ b/packages/cli/src/commands/monitor/list.ts @@ -20,7 +20,8 @@ import { type OrgListCommandDocs, } from "../../lib/list-command.js"; import type { OrgListConfig, WithOrg } from "../../lib/org-list.js"; -import { type SentryMonitor, SentryMonitorSchema } from "../../types/index.js"; +import type { SentryMonitor } from "../../types/index.js"; +import { MonitorListItemSchema } from "../../generated/output-schemas/monitor.js"; /** Command key for pagination cursor storage */ export const PAGINATION_KEY = "monitor-list"; @@ -62,7 +63,7 @@ const MONITOR_COLUMNS: Column[] = [ const monitorListConfig: OrgListConfig< SentryMonitor, MonitorWithOrg, - typeof SentryMonitorSchema + typeof MonitorListItemSchema > = { paginationKey: PAGINATION_KEY, entityPlural: "monitors", @@ -74,7 +75,7 @@ const monitorListConfig: OrgListConfig< searchesNames: true, displayTable: (monitors: MonitorWithOrg[]) => formatTable(monitors, MONITOR_COLUMNS), - schema: SentryMonitorSchema, + schema: MonitorListItemSchema, }; const docs: OrgListCommandDocs = { diff --git a/packages/cli/src/commands/org/list.ts b/packages/cli/src/commands/org/list.ts index 64a047de8..e60013678 100644 --- a/packages/cli/src/commands/org/list.ts +++ b/packages/cli/src/commands/org/list.ts @@ -6,15 +6,7 @@ */ import { tableCell } from "@sentry/toolkit-core/markdown"; -import { - description, - type InferOutput, - looseObject, - object, - optional, - pipe, - string, -} from "valibot"; +import type { InferOutput } from "valibot"; import type { SentryContext } from "../../context.js"; import { resolveAccountSelector } from "../../lib/account-selection.js"; import { listOrganizationsUncached } from "../../lib/api-client.js"; @@ -55,12 +47,16 @@ import { } from "../../lib/list-command.js"; import { logger } from "../../lib/logger.js"; import { withProgress } from "../../lib/polling.js"; -import { - type SentryOrganization, - SentryOrganizationSchema, - type WithOutputFields, - type Writer, +import type { + SentryOrganization, + WithOutputFields, + Writer, } from "../../types/index.js"; +import { OrgListEntrySchema } from "../../generated/output-schemas/org.js"; +import type { + OrgListAccountSchema, + OrgListEntryFields, +} from "../../output-schemas/org.js"; type ListFlags = { readonly limit: number; @@ -70,70 +66,14 @@ type ListFlags = { readonly fields?: string[]; }; -/** - * The account an organization was listed under (no secrets): a stored - * account, or the env token's virtual account. - */ -const OrgListAccountSchema = object({ - /** - * What `--account` accepts: `:`, e.g. - * `jane@acme.com:sentry.io` (just `` when no server is recorded), as - * `sentry auth login` does too; `env:SENTRY_AUTH_TOKEN` for the env token. - */ - name: pipe(string(), description("What --account accepts")), - id: pipe(string(), description("Account ID")), - email: optional(pipe(string(), description("Account email"))), - username: optional(pipe(string(), description("Account username"))), - /** - * The server part of `name`: `host[:port]`, or the full origin such as - * `http://localhost:8000` for a non-HTTPS server. - */ - server: optional(pipe(string(), description("Account server"))), - /** The server's full origin, e.g. `https://sentry.io`. */ - host: optional(pipe(string(), description("Server origin"))), - /** Human label, e.g. `Jane on sentry.io`. */ - label: pipe(string(), description("Human-readable account label")), - /** The variable providing the token, for the env token's account. */ - envVar: optional( - pipe(string(), description("Variable providing the env token")), - ), -}); - type OrgListAccount = InferOutput; -/** - * Fields the list adds to each organization: a region display name and the - * stored account it was listed under. - * - * The `region` field is only present when the user's orgs span multiple - * regions (e.g. both US and EU). `account` is only present when orgs from - * several stored accounts are listed. Both are included in JSON output as - * well, providing context to machine consumers. - */ -const OrgListEntryFields = object({ - region: optional( - pipe(string(), description("Region, when orgs span several (e.g. US, EU)")), - ), - account: optional( - pipe( - OrgListAccountSchema, - description("Account the org was listed under, across accounts"), - ), - ), -}); - /** An organization with the fields of {@link OrgListEntryFields}. */ type OrgListEntry = WithOutputFields< SentryOrganization, typeof OrgListEntryFields >; -/** JSON fields of an {@link OrgListEntry}, for `--fields`. */ -const OrgListEntrySchema = looseObject({ - ...SentryOrganizationSchema.entries, - ...OrgListEntryFields.entries, -}); - /** One account's fetched orgs plus its slug → region URL cache. */ type AccountOrgs = { account?: AccountSummary; diff --git a/packages/cli/src/commands/org/view.ts b/packages/cli/src/commands/org/view.ts index 95a165eca..7a422983c 100644 --- a/packages/cli/src/commands/org/view.ts +++ b/packages/cli/src/commands/org/view.ts @@ -17,7 +17,7 @@ import { } from "../../lib/list-command.js"; import { buildOrgUrl } from "../../lib/sentry-web-urls.js"; import { type OrgTarget, orgTarget } from "../../lib/target-positional.js"; -import { SentryOrganizationSchema } from "../../types/index.js"; +import { SentryOrganizationSchema } from "../../generated/output-schemas/org.js"; type ViewFlags = { readonly json: boolean; diff --git a/packages/cli/src/commands/project/list.ts b/packages/cli/src/commands/project/list.ts index 9009e8551..30d76adf5 100644 --- a/packages/cli/src/commands/project/list.ts +++ b/packages/cli/src/commands/project/list.ts @@ -57,7 +57,6 @@ import { jsonTransformListResult, type ListCommandMeta, type ListResult, - orgListItemSchema, type WithOrg, } from "../../lib/org-list.js"; import { withProgress } from "../../lib/polling.js"; @@ -72,7 +71,8 @@ import { import { getApiBaseUrl } from "../../lib/sentry-client.js"; import type { ListCommandTarget } from "../../lib/target-positional.js"; import { shellQuote } from "../../lib/utils.js"; -import { type SentryProject, SentryProjectSchema } from "../../types/index.js"; +import type { SentryProject } from "../../types/index.js"; +import { ProjectListItemSchema } from "../../generated/output-schemas/project.js"; /** Extended result type with optional title shown above the table. */ type ProjectListResult = ListResult & { title?: string }; @@ -921,7 +921,7 @@ export const listCommand = buildListCommand("project", { }, jsonTransform: (result: ListResult, fields?: string[]) => jsonTransformListResult(result, fields), - schema: orgListItemSchema(SentryProjectSchema), + schema: ProjectListItemSchema, }, parameters: { positional: listTargetPositional({ names: true, multi: true }), diff --git a/packages/cli/src/commands/project/view.ts b/packages/cli/src/commands/project/view.ts index 3a3e2e9b8..66f8ae85f 100644 --- a/packages/cli/src/commands/project/view.ts +++ b/packages/cli/src/commands/project/view.ts @@ -5,14 +5,6 @@ * Supports monorepos with multiple detected projects. */ -import { - description, - looseObject, - nullable, - object, - pipe, - string, -} from "valibot"; import type { SentryContext } from "../../context.js"; import { getProject, @@ -47,11 +39,9 @@ import { orgProjectTarget, type TargetContext, } from "../../lib/target-positional.js"; -import { - type SentryProject, - SentryProjectSchema, - type WithOutputFields, -} from "../../types/index.js"; +import type { SentryProject, WithOutputFields } from "../../types/index.js"; +import { ProjectViewOutputSchema } from "../../generated/output-schemas/project.js"; +import type { ProjectViewOutputFields } from "../../output-schemas/project.js"; type ViewFlags = { readonly json: boolean; @@ -97,11 +87,6 @@ async function handleWebView(resolvedTargets: ResolvedTarget[]): Promise { ); } -/** The field the command adds to each project in its JSON output. */ -const ProjectViewOutputFields = object({ - dsn: pipe(nullable(string()), description("Primary DSN")), -}); - /** * A project entry enriched with its DSN and optional detection source. * This is the data shape returned by the command for both JSON and human output. @@ -375,10 +360,7 @@ export const viewCommand = buildCommand({ output: { human: formatProjectViewHuman, jsonTransform: jsonTransformProjectView, - schema: looseObject({ - ...SentryProjectSchema.entries, - ...ProjectViewOutputFields.entries, - }), + schema: ProjectViewOutputSchema, }, parameters: { positional: { diff --git a/packages/cli/src/commands/release/archive.ts b/packages/cli/src/commands/release/archive.ts index c41f472d3..d9ac6da89 100644 --- a/packages/cli/src/commands/release/archive.ts +++ b/packages/cli/src/commands/release/archive.ts @@ -17,11 +17,9 @@ import { } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { DRY_RUN_ALIASES, DRY_RUN_FLAG } from "../../lib/mutate-command.js"; -import { - type ReleaseUpdateOutput, - ReleaseUpdateOutputSchema, -} from "../../types/index.js"; +import type { ReleaseUpdateOutput } from "../../types/index.js"; import { type ReleaseTarget, releaseParameter } from "./parse.js"; +import { ReleaseUpdateOutputSchema } from "../../generated/output-schemas/release.js"; const USAGE_HINT = "sentry release archive [/]"; diff --git a/packages/cli/src/commands/release/create.ts b/packages/cli/src/commands/release/create.ts index a089e4985..7e0ff224b 100644 --- a/packages/cli/src/commands/release/create.ts +++ b/packages/cli/src/commands/release/create.ts @@ -5,16 +5,6 @@ */ import { inlineCode, text } from "@sentry/toolkit-core/markdown"; -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"; @@ -30,42 +20,13 @@ import { } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { DRY_RUN_ALIASES, DRY_RUN_FLAG } from "../../lib/mutate-command.js"; -import { - DryRunOutputField, - type SentryRelease, - SentryReleaseSchema, - type WithOutputFields, -} from "../../types/index.js"; +import type { SentryRelease, WithOutputFields } from "../../types/index.js"; import { type ReleaseTarget, releaseParameter } from "./parse.js"; +import { ReleaseCreateOutputSchema } from "../../generated/output-schemas/release.js"; +import type { ReleaseCreateOutputFields } from "../../output-schemas/release.js"; const USAGE_HINT = "sentry release create [/]"; -/** - * 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. - */ -const ReleaseCreateOutputSchema = looseObject({ - ...SentryReleaseSchema.entries, - ...ReleaseCreateOutputFields.entries, -}); - /** The release `--dry-run` reports: the request body, shaped like a release. */ type ReleaseCreateDryRun = WithOutputFields< Pick, diff --git a/packages/cli/src/commands/release/delete.ts b/packages/cli/src/commands/release/delete.ts index 5fa4b89a3..d318769a1 100644 --- a/packages/cli/src/commands/release/delete.ts +++ b/packages/cli/src/commands/release/delete.ts @@ -8,14 +8,6 @@ */ import { inlineCode } from "@sentry/toolkit-core/markdown"; -import { - boolean, - description, - type InferOutput, - object, - pipe, - string, -} from "valibot"; import type { SentryContext } from "../../context.js"; import { deleteRelease, getRelease } from "../../lib/api-client.js"; import { ApiError } from "../../lib/errors.js"; @@ -27,20 +19,13 @@ import { isConfirmationBypassed, } from "../../lib/mutate-command.js"; import { buildReleaseUrl } from "../../lib/sentry-web-urls.js"; -import { DryRunOutputField } from "../../types/index.js"; import { type ReleaseTarget, releaseParameter } from "./parse.js"; +import { ReleaseDeleteOutputSchema } from "../../generated/output-schemas/release.js"; +import type { ReleaseDeleteOutput } from "../../output-schemas/release.js"; const USAGE_HINT = "sentry release delete [/]"; -/** JSON output: the release that was (or, with `--dry-run`, would be) deleted. */ -const DeleteOutputSchema = object({ - deleted: pipe(boolean(), description("Whether the release was deleted")), - org: pipe(string(), description("Organization slug")), - version: pipe(string(), description("Release version")), - dryRun: DryRunOutputField, -}); - -type DeleteResult = InferOutput; +type DeleteResult = ReleaseDeleteOutput; function formatReleaseDeleted(result: DeleteResult): string { if (result.dryRun) { @@ -112,7 +97,7 @@ export const deleteCommand = buildDeleteCommand({ }, output: { human: formatReleaseDeleted, - schema: DeleteOutputSchema, + schema: ReleaseDeleteOutputSchema, }, parameters: { positional: { diff --git a/packages/cli/src/commands/release/deploy.ts b/packages/cli/src/commands/release/deploy.ts index 3456c6f06..e1d509594 100644 --- a/packages/cli/src/commands/release/deploy.ts +++ b/packages/cli/src/commands/release/deploy.ts @@ -6,14 +6,6 @@ */ import { inlineCode } from "@sentry/toolkit-core/markdown"; -import { - description, - looseObject, - object, - optional, - pipe, - string, -} from "valibot"; import type { SentryContext } from "../../context.js"; import { createReleaseDeploy } from "../../lib/api-client.js"; import { buildCommand, numberParser } from "../../lib/command.js"; @@ -27,29 +19,19 @@ import { mdKvTable, renderMarkdown } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { formatRelativeTime } from "../../lib/formatters/time-utils.js"; import { DRY_RUN_ALIASES, DRY_RUN_FLAG } from "../../lib/mutate-command.js"; -import { - DryRunOutputField, - type SentryDeploy, - SentryDeploySchema, - type WithOutputFields, -} from "../../types/index.js"; +import type { SentryDeploy, WithOutputFields } from "../../types/index.js"; import { type ReleaseTarget, releaseParameter } from "./parse.js"; +import { ReleaseDeployOutputSchema } from "../../generated/output-schemas/release.js"; +import type { ReleaseDeployOutputFields } from "../../output-schemas/release.js"; const USAGE_HINT = "sentry release deploy [/] [name]"; -/** - * Fields `--dry-run` changes: it prints the deploy that would be created, - * which has no `id` yet, with its release `version`. - */ -const DeployOutputFields = object({ - id: optional(SentryDeploySchema.entries.id), - dryRun: DryRunOutputField, - version: optional(pipe(string(), description("Release version (--dry-run)"))), -}); - /** JSON output: the created deploy, or the `--dry-run` preview. */ -type DeployOutput = WithOutputFields; +type DeployOutput = WithOutputFields< + SentryDeploy, + typeof ReleaseDeployOutputFields +>; function formatDeployCreated(deploy: DeployOutput): string { if (deploy.dryRun) { @@ -129,10 +111,7 @@ export const deployCommand = buildCommand({ }, output: { human: formatDeployCreated, - schema: looseObject({ - ...SentryDeploySchema.entries, - ...DeployOutputFields.entries, - }), + schema: ReleaseDeployOutputSchema, }, parameters: { positional: { diff --git a/packages/cli/src/commands/release/deploys.ts b/packages/cli/src/commands/release/deploys.ts index 590466039..35827b5ab 100644 --- a/packages/cli/src/commands/release/deploys.ts +++ b/packages/cli/src/commands/release/deploys.ts @@ -10,8 +10,9 @@ import { buildCommand } from "../../lib/command.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { type Column, formatTable } from "../../lib/formatters/table.js"; import { formatRelativeTime } from "../../lib/formatters/time-utils.js"; -import { type SentryDeploy, SentryDeploySchema } from "../../types/index.js"; +import type { SentryDeploy } from "../../types/index.js"; import { type ReleaseTarget, releaseParameter } from "./parse.js"; +import { SentryDeploySchema } from "../../generated/output-schemas/release.js"; const USAGE_HINT = "sentry release deploys [/]"; diff --git a/packages/cli/src/commands/release/finalize.ts b/packages/cli/src/commands/release/finalize.ts index 6c28ceb9f..ca01ac49e 100644 --- a/packages/cli/src/commands/release/finalize.ts +++ b/packages/cli/src/commands/release/finalize.ts @@ -5,14 +5,6 @@ */ import { inlineCode, text } from "@sentry/toolkit-core/markdown"; -import { - description, - looseObject, - object, - optional, - pipe, - string, -} from "valibot"; import type { SentryContext } from "../../context.js"; import { updateRelease } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; @@ -24,28 +16,17 @@ import { } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { DRY_RUN_ALIASES, DRY_RUN_FLAG } from "../../lib/mutate-command.js"; -import { - ReleaseUpdateOutputFields, - type SentryRelease, - SentryReleaseSchema, - type WithOutputFields, -} from "../../types/index.js"; +import type { SentryRelease, WithOutputFields } from "../../types/index.js"; import { type ReleaseTarget, releaseParameter } from "./parse.js"; +import { ReleaseFinalizeOutputSchema } from "../../generated/output-schemas/release.js"; +import type { ReleaseFinalizeOutputFields } from "../../output-schemas/release.js"; const USAGE_HINT = "sentry release finalize [/]"; -/** Fields `--dry-run` prints instead of the finalized release. */ -const FinalizeOutputFields = object({ - ...ReleaseUpdateOutputFields.entries, - released: optional( - pipe(string(), description("Release date to set (--dry-run)")), - ), -}); - /** JSON output: the finalized release, or the `--dry-run` preview. */ type FinalizeOutput = WithOutputFields< SentryRelease, - typeof FinalizeOutputFields + typeof ReleaseFinalizeOutputFields >; function formatReleaseFinalized(release: FinalizeOutput): string { @@ -91,10 +72,7 @@ export const finalizeCommand = buildCommand({ }, output: { human: formatReleaseFinalized, - schema: looseObject({ - ...SentryReleaseSchema.entries, - ...FinalizeOutputFields.entries, - }), + schema: ReleaseFinalizeOutputSchema, }, parameters: { positional: { diff --git a/packages/cli/src/commands/release/list.ts b/packages/cli/src/commands/release/list.ts index d39030a47..fdcf83040 100644 --- a/packages/cli/src/commands/release/list.ts +++ b/packages/cli/src/commands/release/list.ts @@ -6,14 +6,6 @@ */ import { text } from "@sentry/toolkit-core/markdown"; -import { - description, - looseObject, - object, - optional, - pipe, - string, -} from "valibot"; import type { SentryContext } from "../../context.js"; import { type ListReleasesOptions, @@ -46,7 +38,6 @@ import { jsonTransformListResult, type ListResult, type OrgListConfig, - OrgListItemOutputFields, } from "../../lib/org-list.js"; import { type ResolvedTarget, @@ -55,25 +46,15 @@ import { } from "../../lib/resolve-target.js"; import { buildReleaseUrl } from "../../lib/sentry-web-urls.js"; import type { ListCommandTarget } from "../../lib/target-positional.js"; -import { - type SentryRelease, - SentryReleaseSchema, - type WithOutputFields, -} from "../../types/index.js"; +import type { SentryRelease, WithOutputFields } from "../../types/index.js"; import { fmtCrashFree } from "./view.js"; +import { ReleaseListItemSchema } from "../../generated/output-schemas/release.js"; +import type { ReleaseListItemOutputFields } from "../../output-schemas/release.js"; const log = logger.withTag("release.list"); export const PAGINATION_KEY = "release-list"; -/** Fields the list adds to each release: its org and, when listing several projects, its project. */ -const ReleaseListItemOutputFields = object({ - ...OrgListItemOutputFields.entries, - targetProject: optional( - pipe(string(), description("Project slug, when listing several")), - ), -}); - type ReleaseWithOrg = WithOutputFields< SentryRelease, typeof ReleaseListItemOutputFields @@ -701,10 +682,7 @@ export const listCommand = buildListCommand("release", { human: formatListHuman, jsonTransform: (result: ListResult, fields?: string[]) => jsonTransformListResult(result, fields), - schema: looseObject({ - ...SentryReleaseSchema.entries, - ...ReleaseListItemOutputFields.entries, - }), + schema: ReleaseListItemSchema, }, parameters: { positional: LIST_ORG_TARGET_POSITIONAL, diff --git a/packages/cli/src/commands/release/restore.ts b/packages/cli/src/commands/release/restore.ts index 52c6f4d0a..96ff504c5 100644 --- a/packages/cli/src/commands/release/restore.ts +++ b/packages/cli/src/commands/release/restore.ts @@ -16,11 +16,9 @@ import { } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { DRY_RUN_ALIASES, DRY_RUN_FLAG } from "../../lib/mutate-command.js"; -import { - type ReleaseUpdateOutput, - ReleaseUpdateOutputSchema, -} from "../../types/index.js"; +import type { ReleaseUpdateOutput } from "../../types/index.js"; import { type ReleaseTarget, releaseParameter } from "./parse.js"; +import { ReleaseUpdateOutputSchema } from "../../generated/output-schemas/release.js"; const USAGE_HINT = "sentry release restore [/]"; diff --git a/packages/cli/src/commands/release/set-commits.ts b/packages/cli/src/commands/release/set-commits.ts index 6e62012ee..e9837c72a 100644 --- a/packages/cli/src/commands/release/set-commits.ts +++ b/packages/cli/src/commands/release/set-commits.ts @@ -34,8 +34,9 @@ import { isShallowRepository, } from "../../lib/git.js"; import { logger } from "../../lib/logger.js"; -import { type SentryRelease, SentryReleaseSchema } from "../../types/index.js"; +import type { SentryRelease } from "../../types/index.js"; import { type ReleaseTarget, releaseParameter } from "./parse.js"; +import { SentryReleaseSchema } from "../../generated/output-schemas/release.js"; const log = logger.withTag("release.set-commits"); diff --git a/packages/cli/src/commands/release/view.ts b/packages/cli/src/commands/release/view.ts index e013019ea..aa2b15acf 100644 --- a/packages/cli/src/commands/release/view.ts +++ b/packages/cli/src/commands/release/view.ts @@ -23,8 +23,9 @@ import { FRESH_ALIASES, FRESH_FLAG, } from "../../lib/list-command.js"; -import { type SentryRelease, SentryReleaseSchema } from "../../types/index.js"; +import type { SentryRelease } from "../../types/index.js"; import { type ReleaseTarget, releaseParameter } from "./parse.js"; +import { SentryReleaseSchema } from "../../generated/output-schemas/release.js"; const USAGE_HINT = "sentry release view [/]"; diff --git a/packages/cli/src/commands/replay/list.ts b/packages/cli/src/commands/replay/list.ts index c25df646a..5a5d0e079 100644 --- a/packages/cli/src/commands/replay/list.ts +++ b/packages/cli/src/commands/replay/list.ts @@ -59,11 +59,8 @@ import { type TimeRange, timeRangeToApiParams, } from "../../lib/time-range.js"; -import { - REPLAY_LIST_FIELDS, - type ReplayListItem, - ReplayListItemOutputSchema, -} from "../../types/index.js"; +import { REPLAY_LIST_FIELDS, type ReplayListItem } from "../../types/index.js"; +import { ReplayListItemOutputSchema } from "../../generated/output-schemas/replay.js"; type ListFlags = { readonly environment?: readonly string[]; diff --git a/packages/cli/src/commands/replay/view.ts b/packages/cli/src/commands/replay/view.ts index f5ab631f0..8b3a574c8 100644 --- a/packages/cli/src/commands/replay/view.ts +++ b/packages/cli/src/commands/replay/view.ts @@ -51,7 +51,7 @@ import type { ReplayRelatedIssue, ReplayRelatedTrace, } from "../../types/index.js"; -import { ReplayViewOutputSchema } from "../../types/index.js"; +import { ReplayViewOutputSchema } from "../../generated/output-schemas/replay.js"; type ViewFlags = { readonly json: boolean; diff --git a/packages/cli/src/commands/repo/list.ts b/packages/cli/src/commands/repo/list.ts index 377fbef2c..cec1a3f89 100644 --- a/packages/cli/src/commands/repo/list.ts +++ b/packages/cli/src/commands/repo/list.ts @@ -21,10 +21,8 @@ import { type OrgListCommandDocs, } from "../../lib/list-command.js"; import type { OrgListConfig, WithOrg } from "../../lib/org-list.js"; -import { - type SentryRepository, - SentryRepositorySchema, -} from "../../types/index.js"; +import type { SentryRepository } from "../../types/index.js"; +import { RepositoryListItemSchema } from "../../generated/output-schemas/repo.js"; /** Command key for pagination cursor storage */ export const PAGINATION_KEY = "repo-list"; @@ -45,7 +43,7 @@ const REPO_COLUMNS: Column[] = [ const repoListConfig: OrgListConfig< SentryRepository, RepositoryWithOrg, - typeof SentryRepositorySchema + typeof RepositoryListItemSchema > = { paginationKey: PAGINATION_KEY, entityPlural: "repositories", @@ -62,7 +60,7 @@ const repoListConfig: OrgListConfig< searchesNames: true, displayTable: (repos: RepositoryWithOrg[]) => formatTable(repos, REPO_COLUMNS), - schema: SentryRepositorySchema, + schema: RepositoryListItemSchema, }; const docs: OrgListCommandDocs = { diff --git a/packages/cli/src/commands/span/list.ts b/packages/cli/src/commands/span/list.ts index 0e5a72b91..c9c5aed5a 100644 --- a/packages/cli/src/commands/span/list.ts +++ b/packages/cli/src/commands/span/list.ts @@ -76,7 +76,7 @@ import { type TraceTargetInput, warnIfNormalized, } from "../../lib/trace-target.js"; -import { FlatSpanSchema } from "../../types/index.js"; +import { FlatSpanSchema } from "../../generated/output-schemas/span.js"; type ListFlags = { readonly limit: number; diff --git a/packages/cli/src/commands/team/list.ts b/packages/cli/src/commands/team/list.ts index b593c5fd0..9dda8cdf3 100644 --- a/packages/cli/src/commands/team/list.ts +++ b/packages/cli/src/commands/team/list.ts @@ -22,7 +22,8 @@ import { type OrgListCommandDocs, } from "../../lib/list-command.js"; import type { OrgListConfig, WithOrg } from "../../lib/org-list.js"; -import { type SentryTeam, SentryTeamSchema } from "../../types/index.js"; +import type { SentryTeam } from "../../types/index.js"; +import { TeamListItemSchema } from "../../generated/output-schemas/team.js"; /** Command key for pagination cursor storage */ export const PAGINATION_KEY = "team-list"; @@ -46,7 +47,7 @@ const TEAM_COLUMNS: Column[] = [ const teamListConfig: OrgListConfig< SentryTeam, TeamWithOrg, - typeof SentryTeamSchema + typeof TeamListItemSchema > = { paginationKey: PAGINATION_KEY, entityPlural: "teams", @@ -58,7 +59,7 @@ const teamListConfig: OrgListConfig< searchesNames: true, displayTable: (teams: TeamWithOrg[]) => formatTable(teams, TEAM_COLUMNS), listForProject: (org, project) => listProjectTeams(org, project), - schema: SentryTeamSchema, + schema: TeamListItemSchema, }; const docs: OrgListCommandDocs = { diff --git a/packages/cli/src/commands/trace/list.ts b/packages/cli/src/commands/trace/list.ts index ae0bba6d8..38b58a82c 100644 --- a/packages/cli/src/commands/trace/list.ts +++ b/packages/cli/src/commands/trace/list.ts @@ -44,10 +44,8 @@ import { type TimeRange, timeRangeToApiParams, } from "../../lib/time-range.js"; -import { - type TransactionListItem, - TransactionListItemSchema, -} from "../../types/index.js"; +import type { TransactionListItem } from "../../types/index.js"; +import { TransactionListItemSchema } from "../../generated/output-schemas/trace.js"; type ListFlags = { readonly limit: number; diff --git a/packages/cli/src/commands/trial/list.ts b/packages/cli/src/commands/trial/list.ts index 44f341ce5..dcc985903 100644 --- a/packages/cli/src/commands/trial/list.ts +++ b/packages/cli/src/commands/trial/list.ts @@ -6,16 +6,6 @@ * 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"; @@ -32,46 +22,17 @@ import { getTrialStatus, type TrialStatus, } from "../../lib/trials.js"; -import { - type CustomerTrialInfo, - ProductTrialSchema, - type Writer, -} from "../../types/index.js"; +import type { CustomerTrialInfo, Writer } from "../../types/index.js"; +import { TrialListEntrySchema } from "../../generated/output-schemas/trial.js"; +import type { TrialListEntryOutput } from "../../output-schemas/trial.js"; type ListFlags = { readonly json: boolean; readonly fields?: string[]; }; -const { category, isStarted, lengthDays, startDate, endDate } = - ProductTrialSchema.pipe[0].entries; - -/** - * JSON output of one trial: the API trial's fields plus the ones the CLI - * derives (name, status, daysRemaining). - */ -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 & { +type TrialListEntry = TrialListEntryOutput & { /** Human-readable product name (e.g., "Seer") or plan upgrade name */ displayName: string; }; diff --git a/packages/cli/src/lib/command.ts b/packages/cli/src/lib/command.ts index 03f295630..40464d57b 100644 --- a/packages/cli/src/lib/command.ts +++ b/packages/cli/src/lib/command.ts @@ -59,8 +59,6 @@ import { CommandOutput, type CommandReturn, type CommandYield, - extractSchemaFieldPaths, - extractSchemaFields, formatSchemaForHelp, type HumanRenderer, type OutputConfig, @@ -430,17 +428,14 @@ export function applyOrgProjectFlags( */ // oxlint-disable-next-line typescript/no-explicit-any -- OutputConfig type is erased at the builder level function buildFieldsFlag(outputConfig?: OutputConfig) { - if (!outputConfig?.schema) { + const schema = outputConfig?.schema; + if (!schema?.fields.length) { return FIELDS_FLAG; } - const schemaFields = extractSchemaFields(outputConfig.schema); - if (schemaFields.length === 0) { - return FIELDS_FLAG; - } - const fieldNames = schemaFields.map((f) => f.name).join(", "); + const fieldNames = schema.fields.map((f) => f.name).join(", "); return { ...FIELDS_FLAG, - parse: choiceListParser(extractSchemaFieldPaths(outputConfig.schema)), + parse: choiceListParser(schema.paths), brief: `${FIELDS_FLAG.brief}. Available: ${fieldNames}`, }; } @@ -455,11 +450,8 @@ function enrichDocsWithSchema( // oxlint-disable-next-line typescript/no-explicit-any -- OutputConfig type is erased at the builder level outputConfig?: OutputConfig, ): CommandDocumentation { - if (!outputConfig?.schema) { - return docs; - } - const schemaFields = extractSchemaFields(outputConfig.schema); - if (schemaFields.length === 0) { + const schemaFields = outputConfig?.schema?.fields; + if (!schemaFields?.length) { return docs; } const jsonFieldsDoc = formatSchemaForHelp(schemaFields); @@ -983,12 +975,12 @@ export function buildCommand< (cmd as unknown as Record).__destructive = true; } - // Attach the JSON schema to the built command as a non-standard property. + // Attach the JSON fields to the built command as a non-standard property. // introspect.ts reads this to populate CommandInfo.jsonFields for help // output and SKILL.md generation. if (outputConfig?.schema) { - (cmd as unknown as Record).__jsonSchema = - outputConfig.schema; + (cmd as unknown as Record).__jsonFields = + outputConfig.schema.fields; } // Expose the unwrapped generator for internal dispatchers (e.g. bare diff --git a/packages/cli/src/lib/db/accounts.ts b/packages/cli/src/lib/db/accounts.ts index 1706f9eed..41b8f70af 100644 --- a/packages/cli/src/lib/db/accounts.ts +++ b/packages/cli/src/lib/db/accounts.ts @@ -10,7 +10,7 @@ import { isSaaSTrustOrigin } from "@sentry/toolkit-core/sentry-origin"; import { uuidv7 } from "uuidv7"; import type { InferOutput } from "valibot"; -import type { AccountSummarySchema } from "../../types/account.js"; +import type { AccountSummarySchema } from "../../output-schemas/account.js"; import { normalizeUrl } from "../constants.js"; import { ValidationError } from "../errors.js"; import { fuzzyMatch } from "../fuzzy.js"; diff --git a/packages/cli/src/lib/formatters/dashboard.ts b/packages/cli/src/lib/formatters/dashboard.ts index cd8c6819a..f783f76b3 100644 --- a/packages/cli/src/lib/formatters/dashboard.ts +++ b/packages/cli/src/lib/formatters/dashboard.ts @@ -14,7 +14,6 @@ import wrapAnsi from "wrap-ansi"; import type { InferOutput } from "valibot"; import type { - DashboardViewOutputSchema, ScalarResult, TableResult, TimeseriesResult, @@ -354,6 +353,7 @@ import { formatCompactWithUnit, formatWithUnit, } from "./numbers.js"; +import type { DashboardViewOutputSchema } from "../../output-schemas/dashboard.js"; /** * Format a value as a short Y-axis tick label (max ~4 chars). diff --git a/packages/cli/src/lib/formatters/issue.ts b/packages/cli/src/lib/formatters/issue.ts index bd2943218..e346ade89 100644 --- a/packages/cli/src/lib/formatters/issue.ts +++ b/packages/cli/src/lib/formatters/issue.ts @@ -3,11 +3,12 @@ */ import type { InferOutput } from "valibot"; -import type { IssueViewOutputFields, SentryIssue } from "../../types/index.js"; +import type { SentryIssue } from "../../types/index.js"; import { getReplayIdFromEvent } from "../replay-search.js"; import { formatEventDetails, formatIssueDetails } from "./human.js"; import { filterFieldsIfSet } from "./json.js"; import { renderMarkdown } from "./markdown.js"; +import type { IssueViewOutputFields } from "../../output-schemas/issue.js"; /** * Data rendered for one issue: the issue, and the fields JSON output adds to diff --git a/packages/cli/src/lib/formatters/output.ts b/packages/cli/src/lib/formatters/output.ts index a347933d2..27178ccfc 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, type InferOutput } from "valibot"; +import type { GenericSchema, InferOutput } from "valibot"; import type { Writer } from "../../types/index.js"; import { plainSafeMuted } from "./human.js"; import { filterFields, formatJson } from "./json.js"; @@ -209,10 +209,13 @@ type OutputConfigBase = { * 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. + * `schema` describes the JSON output: the build-time copy of a valibot + * schema in `src/output-schemas/`, imported from + * `src/generated/output-schemas/` (see {@link OutputSchemaFields}). Declare + * the schema 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 @@ -222,7 +225,8 @@ type OutputConfigBase = { * * @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 S - The valibot schema `schema` was read from, inferred by the + * command builders * @typeParam R - Type `jsonTransform` returns, inferred by the builders */ export type OutputConfig< @@ -231,7 +235,10 @@ export type OutputConfig< R = unknown, > = OutputConfigBase & ( - | { jsonTransform?: undefined; schema?: S & SchemaMatches } + | { + jsonTransform?: undefined; + schema?: OutputSchemaFields & SchemaMatches; + } | { /** * Custom JSON serialization transform. @@ -247,7 +254,7 @@ export type OutputConfig< * When `jsonTransform` is set, `jsonExclude` is ignored. */ jsonTransform: (data: T, fields?: string[]) => R; - schema?: S & TransformSchemaMatches; + schema?: OutputSchemaFields & TransformSchemaMatches; } ); @@ -444,9 +451,10 @@ export function renderCommandOutput( // --------------------------------------------------------------------------- /** - * Field metadata extracted from a Zod schema for documentation. + * Metadata of one top-level field of an output schema. * - * Populated by {@link extractSchemaFields} and consumed by: + * Read from the valibot schema at build time (`script/lib/output-schema-fields.ts`) + * and consumed by: * - `introspect.ts` → `CommandInfo.jsonFields` * - `help.ts` → human help output * - `generate-skill.ts` → SKILL.md field tables @@ -462,221 +470,30 @@ export type SchemaFieldInfo = { optional: boolean; }; -/** Minimal structural view of a valibot schema's runtime shape. */ -type ValibotSchemaNode = { - type: string; - /** Inner schema for wrapper types (optional / nullable / nullish). */ - wrapped?: ValibotSchemaNode; - /** Member schemas for union / variant / picklist. */ - options?: unknown[]; - /** Item schema for array. */ - item?: ValibotSchemaNode; - /** Field schemas for object / loose_object. */ - entries?: Record; - /** - * Pipe steps for a `SchemaWithPipe` (e.g. `pipe(string(), description(...))` - * or the `pipe(unknown(), transform(Number), number())` coercion pattern). - * Each step carries a `kind` of `"schema"`, `"validation"`, `"transformation"`, - * or `"metadata"`. - */ - pipe?: Array<{ type: string; kind: string }>; -}; - -/** - * Resolve a piped schema (`pipe(...)`) to the node whose `type` reflects its - * output — the last step with `kind === "schema"`. For the coercion pattern - * `pipe(unknown(), transform(Number), number())` this is `number()`, not the - * leading `unknown()`. Non-piped schemas are returned unchanged. - */ -function unwrapPipe(schema: ValibotSchemaNode): ValibotSchemaNode { - if (!schema.pipe?.length) { - return schema; - } - const schemaSteps = schema.pipe.filter((step) => step.kind === "schema"); - const last = schemaSteps.at(-1); - return (last as ValibotSchemaNode | undefined) ?? schema; -} - -/** Leaf-type name mapping for {@link valibotTypeToString}. */ -const VALIBOT_TYPE_MAP: Record = { - string: "string", - number: "number", - boolean: "boolean", - object: "object", - loose_object: "object", - array: "array", - record: "object", - null: "null", - unknown: "unknown", - any: "any", - picklist: "string", - enum: "string", - literal: "string", -}; - -/** - * Resolve a valibot `union` into a deduplicated `" | "`-joined type string. - * - * Used by auto-generated `@sentry/api/valibot` schemas that represent nullable - * fields as `union([string(), null_()])` instead of `nullable(string())`. - */ -function resolveValibotUnion(options: ValibotSchemaNode[]): { - type: string; - optional: boolean; -} { - let optional = false; - const parts: string[] = []; - for (const opt of options) { - const resolved = valibotTypeToString(opt); - if (resolved.optional) { - optional = true; - } - if (!parts.includes(resolved.type)) { - parts.push(resolved.type); - } - } - return { type: parts.join(" | "), optional }; -} - -/** - * Map a valibot schema's runtime `type` to a human-readable string. - * - * Unwraps wrapper types (optional, nullable, nullish) and builds a composite - * type string (e.g. "string | null" for `nullable(string())`). Delegates - * union handling to {@link resolveValibotUnion}. - */ -function valibotTypeToString(input: ValibotSchemaNode): { - type: string; - optional: boolean; -} { - // Resolve `pipe(...)` schemas (incl. the coercion pattern) to their output - // schema before inspecting the type. - const schema = input ? unwrapPipe(input) : input; - if (!schema?.type) { - return { type: "unknown", optional: false }; - } - - // Unwrap wrapper types recursively. - if (schema.type === "optional" && schema.wrapped) { - const inner = valibotTypeToString(schema.wrapped); - return { type: inner.type, optional: true }; - } - if (schema.type === "nullable" && schema.wrapped) { - const inner = valibotTypeToString(schema.wrapped); - const nullableType = inner.type.includes(" | null") - ? inner.type - : `${inner.type} | null`; - return { type: nullableType, optional: inner.optional }; - } - if (schema.type === "nullish" && schema.wrapped) { - const inner = valibotTypeToString(schema.wrapped); - const nullableType = inner.type.includes(" | null") - ? inner.type - : `${inner.type} | null`; - return { type: nullableType, optional: true }; - } - if ( - (schema.type === "union" || schema.type === "variant") && - schema.options?.length - ) { - return resolveValibotUnion(schema.options as ValibotSchemaNode[]); - } - - return { type: VALIBOT_TYPE_MAP[schema.type] ?? "unknown", optional: false }; -} - /** - * Extract field metadata from a valibot object schema. + * What the CLI needs at runtime from a command's output schema, read from it + * at build time: its top-level fields and every `--fields` path. * - * Returns an array of {@link SchemaFieldInfo} describing each top-level - * field's name, type, description, and optionality. Returns an empty - * array for non-object schemas. + * The schemas themselves live in `src/output-schemas/`, which only the build + * loads. `script/generate-output-schemas.ts` writes one of these for each of + * their exports to `src/generated/output-schemas/`, under the same name. * - * @param schema - A valibot schema (typically `object({...})`) + * @typeParam S - The valibot schema it was read from. Type only: commands + * derive their output type from it, and {@link OutputConfig} checks the + * command's data against it. */ -export function extractSchemaFields(schema: GenericSchema): SchemaFieldInfo[] { - const node = schema as unknown as ValibotSchemaNode; - - if (node.type !== "object" && node.type !== "loose_object") { - return []; - } - - const entries = node.entries; - if (!entries) { - return []; - } - - return Object.entries(entries).map(([name, fieldSchema]) => { - const { type, optional } = valibotTypeToString(fieldSchema); - return { - name, - type, - description: findDescription(fieldSchema), - optional, - }; - }); -} - -/** Wrapper types {@link objectsOf} looks through to the schema they hold. */ -const WRAPPER_TYPES = new Set(["optional", "nullable", "nullish", "array"]); - -/** - * The object schemas a value of `schema` can be: through pipes, wrappers, - * array items (`--fields` paths reach into arrays), and union and variant - * members. - */ -function objectsOf(schema: ValibotSchemaNode): ValibotSchemaNode[] { - const node = unwrapPipe(schema); - if (WRAPPER_TYPES.has(node.type)) { - const inner = node.wrapped ?? node.item; - return inner ? objectsOf(inner) : []; - } - if (node.type === "union" || node.type === "variant") { - return (node.options as ValibotSchemaNode[]).flatMap(objectsOf); - } - return node.entries ? [node] : []; -} - -/** - * Every field path a valibot schema describes, in `--fields` dot notation: - * each top-level field, then the fields of nested objects - * (`metadata.title`), parents before children. - * - * @param schema - A valibot schema (typically `object({...})`) - */ -export function extractSchemaFieldPaths(schema: GenericSchema): string[] { - const paths = new Set(); - const visit = (node: ValibotSchemaNode, prefix: string) => { - for (const { entries = {} } of objectsOf(node)) { - for (const [name, field] of Object.entries(entries)) { - paths.add(`${prefix}${name}`); - visit(field, `${prefix}${name}.`); - } - } - }; - visit(schema as unknown as ValibotSchemaNode, ""); - return [...paths]; -} +export type OutputSchemaFields = { + /** Top-level fields, for `--help`, `sentry help ` and the skill. */ + readonly fields: readonly SchemaFieldInfo[]; + /** Every field path in `--fields` dot notation, parents before children. */ + readonly paths: readonly string[]; + /** Never set; carries the schema's type. */ + readonly "~schema"?: S; +}; -/** - * Find a `description()` pipe action for a field schema. - * - * `getDescription` does not recurse, and the description may sit under one or - * more wrapper schemas — e.g. `optional(nullable(pipe(string(), description(...))))` - * places it two `.wrapped` levels down. Walk the wrapper chain until a - * description is found. - */ -function findDescription(schema: ValibotSchemaNode): string | undefined { - let node: ValibotSchemaNode | undefined = schema; - while (node) { - const description = getDescription(node as unknown as GenericSchema); - if (description !== undefined) { - return description; - } - node = node.wrapped; - } - return; -} +/** The valibot schema type an {@link OutputSchemaFields} was read from. */ +export type SchemaOfFields = + F extends OutputSchemaFields ? S : never; /** * Format schema fields as a help text block for `--help` output. @@ -688,7 +505,9 @@ function findDescription(schema: ValibotSchemaNode): string | undefined { * count (string, optional) — Total event count * ``` */ -export function formatSchemaForHelp(fields: SchemaFieldInfo[]): string { +export function formatSchemaForHelp( + fields: readonly SchemaFieldInfo[], +): string { if (fields.length === 0) { return ""; } diff --git a/packages/cli/src/lib/formatters/replay.ts b/packages/cli/src/lib/formatters/replay.ts index 459799a33..6d1a1ed87 100644 --- a/packages/cli/src/lib/formatters/replay.ts +++ b/packages/cli/src/lib/formatters/replay.ts @@ -13,7 +13,6 @@ import type { ReplayDetails, ReplayRelatedIssue, ReplayRelatedTrace, - ReplayViewOutputFields, } from "../../types/index.js"; import { getReplayUserLabel } from "../replay-search.js"; import { buildReplayUrl } from "../sentry-web-urls.js"; @@ -22,6 +21,7 @@ import { formatDurationCompactMs, formatDurationVerbose, } from "./time-utils.js"; +import type { ReplayViewOutputFields } from "../../output-schemas/replay.js"; /** * Data bag assembled by replay view before rendering: the replay plus the diff --git a/packages/cli/src/lib/formatters/trace.ts b/packages/cli/src/lib/formatters/trace.ts index 56d6d62e2..a9bf21040 100644 --- a/packages/cli/src/lib/formatters/trace.ts +++ b/packages/cli/src/lib/formatters/trace.ts @@ -8,7 +8,6 @@ import { tableCell, text } from "@sentry/toolkit-core/markdown"; import type { InferOutput } from "valibot"; import type { - FlatSpanSchema, SpanListItem, TraceSpan, TransactionListItem, @@ -29,6 +28,7 @@ import { colorizeSql, formatSqlBlock, isDbSpanOp } from "./sql.js"; import { type Column, formatTable } from "./table.js"; import { renderTextTable } from "./text-table.js"; import { computeSpanDurationMs, formatRelativeTime } from "./time-utils.js"; +import type { FlatSpanSchema } from "../../output-schemas/span.js"; /** * Format a duration in milliseconds to a human-readable string. diff --git a/packages/cli/src/lib/introspect.ts b/packages/cli/src/lib/introspect.ts index 7b6f84381..26711cf11 100644 --- a/packages/cli/src/lib/introspect.ts +++ b/packages/cli/src/lib/introspect.ts @@ -21,10 +21,7 @@ import { getCompletionTarget, } from "./completion-target.js"; import type { ApiReference, CommandApiReference } from "./api-schema.js"; -import { - extractSchemaFields, - type SchemaFieldInfo, -} from "./formatters/output.js"; +import type { SchemaFieldInfo } from "./formatters/output.js"; import { fuzzyMatch } from "./fuzzy.js"; // --------------------------------------------------------------------------- @@ -61,11 +58,11 @@ export type Command = { aliases?: Record; }; /** - * JSON output schema attached by `buildCommand` when `output.schema` is set. - * Non-standard property — Stricli doesn't know about it, but introspection - * reads it to populate {@link CommandInfo.jsonFields}. + * JSON output fields attached by `buildCommand` when `output.schema` is + * set. Non-standard property — Stricli doesn't know about it, but + * introspection reads it to populate {@link CommandInfo.jsonFields}. */ - __jsonSchema?: import("valibot").GenericSchema; + __jsonFields?: readonly SchemaFieldInfo[]; /** * Primary Stricli custom usage line, retained by `buildCommand` for * introspection because Stricli does not expose it on the built command. @@ -310,9 +307,6 @@ export function extractFlags( * @param path - Full command path (e.g. "sentry issue list") */ export function buildCommandInfo(cmd: Command, path: string): CommandInfo { - const jsonFields = cmd.__jsonSchema - ? extractSchemaFields(cmd.__jsonSchema) - : undefined; const positionals = extractPositionals(cmd.parameters.positional); const [positional] = positionals; if (cmd.__primaryUsage && positional && positionals.length === 1) { @@ -332,7 +326,7 @@ export function buildCommandInfo(cmd: Command, path: string): CommandInfo { (example) => `# ${example.description}\n${exampleSteps(example).join("\n")}`, ), - jsonFields: jsonFields?.length ? jsonFields : undefined, + jsonFields: cmd.__jsonFields?.length ? [...cmd.__jsonFields] : undefined, ...(cmd.__destructive ? { destructive: true as const } : {}), }; } diff --git a/packages/cli/src/lib/list-command.ts b/packages/cli/src/lib/list-command.ts index 37a5d06fc..758c4a75f 100644 --- a/packages/cli/src/lib/list-command.ts +++ b/packages/cli/src/lib/list-command.ts @@ -33,7 +33,6 @@ import { jsonTransformListResult, type ListResult, type OrgListConfig, - orgListItemSchema, } from "./org-list.js"; import { applyBoundPositionals, @@ -691,7 +690,7 @@ export function buildOrgListCommand( human: (result: ListResult) => formatListHuman(result, config), jsonTransform: (result: ListResult, fields?: string[]) => jsonTransformListResult(result, fields), - schema: config.schema && orgListItemSchema(config.schema), + schema: config.schema, }; return buildListCommand(routeName, { docs, diff --git a/packages/cli/src/lib/org-list.ts b/packages/cli/src/lib/org-list.ts index c6787f9e7..e77b2ad8e 100644 --- a/packages/cli/src/lib/org-list.ts +++ b/packages/cli/src/lib/org-list.ts @@ -30,16 +30,7 @@ * how to render the result — JSON envelope, human table, or custom formatting. */ -import { - description, - type GenericSchema, - looseObject, - type ObjectEntries, - object, - optional, - pipe, - string, -} from "valibot"; +import type { OrgListItemOutputFields } from "../output-schemas/org-list.js"; import type { WithOutputFields } from "../types/index.js"; import { API_MAX_PER_PAGE, @@ -67,7 +58,11 @@ import { withAuthGuard, } from "./errors.js"; import { filterFields } from "./formatters/json.js"; -import type { SchemaMatches } from "./formatters/output.js"; +import type { + OutputSchemaFields, + SchemaMatches, + SchemaOfFields, +} from "./formatters/output.js"; import { paginationHint } from "./list-command.js"; import { fetchByName, @@ -182,32 +177,15 @@ export function jsonTransformListResult( return items; } -/** An object schema (possibly piped) whose entries can be spread. */ -export type OrgListEntitySchema = GenericSchema & { - readonly entries: ObjectEntries; -}; - -/** The field `withOrg` adds to each item of an org-scoped list. */ -export const OrgListItemOutputFields = object({ - orgSlug: optional(pipe(string(), description("Organization slug"))), -}); - -/** An item of an org-scoped list: the entity with the org it came from. */ +/** + * An item of an org-scoped list: the entity with the org it came from. Its + * output schema is `orgListItemSchema(entity)` (`output-schemas/org-list.ts`). + */ export type WithOrg = WithOutputFields< TEntity, typeof OrgListItemOutputFields >; -/** Output schema of an org-scoped list item: the entity's, plus `orgSlug`. */ -export function orgListItemSchema(entity: { - readonly entries: TEntries; -}) { - return looseObject({ - ...entity.entries, - ...OrgListItemOutputFields.entries, - }); -} - /** * Per-target (or per-org) fetch result for budget-aware list commands. * @@ -459,7 +437,7 @@ export type BaseListFlags = { export type OrgListConfig< TEntity, TWithOrg, - TSchema extends OrgListEntitySchema = OrgListEntitySchema, + TSchema extends OutputSchemaFields = OutputSchemaFields, > = ListCommandMeta & { /** * Fetch all entities for one org (non-paginated). @@ -506,13 +484,13 @@ export type OrgListConfig< listForProject?: (orgSlug: string, projectSlug: string) => Promise; /** - * 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`. + * Output schema of a list item, `orgListItemSchema(entity)` in + * `src/output-schemas/`. `buildOrgListCommand` 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?: TSchema & SchemaMatches; + schema?: TSchema & SchemaMatches>; /** * The names a name filter matches against (e.g. slug and display name). diff --git a/packages/cli/src/types/account.ts b/packages/cli/src/output-schemas/account.ts similarity index 100% rename from packages/cli/src/types/account.ts rename to packages/cli/src/output-schemas/account.ts diff --git a/packages/cli/src/output-schemas/agent-conversation.ts b/packages/cli/src/output-schemas/agent-conversation.ts new file mode 100644 index 000000000..6ea7409e0 --- /dev/null +++ b/packages/cli/src/output-schemas/agent-conversation.ts @@ -0,0 +1,7 @@ +/** + * Output schemas of the `sentry agent-conversation` commands. + */ + +// Also validates the conversations API response at runtime, so it stays in +// `types/conversation.ts`. +export { ConversationListItemSchema } from "../types/conversation.js"; diff --git a/packages/cli/src/commands/alert/output-schemas.ts b/packages/cli/src/output-schemas/alert.ts similarity index 98% rename from packages/cli/src/commands/alert/output-schemas.ts rename to packages/cli/src/output-schemas/alert.ts index 4f45b5884..72c7b72a0 100644 --- a/packages/cli/src/commands/alert/output-schemas.ts +++ b/packages/cli/src/output-schemas/alert.ts @@ -43,8 +43,7 @@ import { string, unknown, } from "valibot"; -import { DryRunOutputField } from "../../types/index.js"; -import type { WithOutputFields } from "../../types/output.js"; +import { DryRunOutputField, type WithOutputFields } from "./output.js"; // Fields the CLI adds to every alert mutation's output. const org = pipe(string(), description("Organization slug")); diff --git a/packages/cli/src/output-schemas/dashboard.ts b/packages/cli/src/output-schemas/dashboard.ts new file mode 100644 index 000000000..8bc53156e --- /dev/null +++ b/packages/cli/src/output-schemas/dashboard.ts @@ -0,0 +1,138 @@ +/** + * Output schemas of the `sentry dashboard` commands. + */ + +import { + array, + boolean, + description, + type InferOutput, + looseObject, + number, + object, + optional, + pipe, + string, +} from "valibot"; +import { + DashboardDetailSchema, + DashboardWidgetLayoutSchema, + DashboardWidgetQuerySchema, + DashboardWidgetSchema, + WidgetDataResultSchema, +} from "../types/dashboard.js"; +import { DryRunOutputField } from "./output.js"; + +// Also validates the revisions API response at runtime, so it stays in +// `types/dashboard.ts`. +export { DashboardRevisionSchema } from "../types/dashboard.js"; + +/** Schema for dashboard list items (lightweight, from GET /dashboards/) */ +export const DashboardListItemSchema = looseObject({ + id: string(), + title: string(), + dateCreated: optional(string()), + createdBy: optional( + object({ + name: optional(string()), + email: optional(string()), + }), + ), + widgetDisplay: optional(array(string())), +}); + +/** + * JSON output of `dashboard view`: the dashboard with each widget's query + * result. The type the command yields is derived from it + * (`DashboardViewData` in `lib/formatters/dashboard.ts`). + */ +export const DashboardViewOutputSchema = object({ + id: pipe(string(), description("Dashboard ID")), + title: pipe(string(), description("Dashboard title")), + period: pipe(string(), description("Time range of the widget data")), + fetchedAt: pipe( + string(), + description("When the data was fetched (ISO 8601)"), + ), + url: pipe(string(), description("Dashboard URL")), + dateCreated: optional( + pipe(string(), description("Creation date (ISO 8601)")), + ), + environment: optional( + pipe(array(string()), description("Environment filter")), + ), + widgets: pipe( + array( + object({ + title: pipe(string(), description("Widget title")), + displayType: pipe(string(), description("Display type")), + widgetType: optional(pipe(string(), description("Dataset"))), + description: optional( + pipe(string(), description("Markdown content of a text widget")), + ), + layout: optional(DashboardWidgetLayoutSchema), + queries: optional(array(DashboardWidgetQuerySchema)), + data: WidgetDataResultSchema, + }), + ), + description("Widgets with their query results"), + ), +}); + +/** + * JSON output of `dashboard widget add` and `dashboard widget edit`, for + * `--fields`: the saved dashboard, the added or edited widget, and its URL. + */ +export const DashboardWidgetChangeOutputSchema = object({ + dashboard: pipe(DashboardDetailSchema, description("The saved dashboard")), + widget: pipe( + DashboardWidgetSchema, + description("The added or edited widget"), + ), + url: pipe(string(), description("Dashboard URL")), +}); + +/** + * JSON output of `dashboard widget delete`: the deleted (or, with + * `--dry-run`, matched) widget and the dashboard's widget count. + */ +export const DashboardWidgetDeleteOutputSchema = object({ + dryRun: DryRunOutputField, + deleted: optional( + pipe(boolean(), description("True when the widget was deleted")), + ), + widgetTitle: pipe(string(), description("Title of the deleted widget")), + widgetCount: pipe( + number(), + description( + "Dashboard widget count after the delete (before it, with --dry-run)", + ), + ), + url: pipe(string(), description("Dashboard URL")), +}); + +/** JSON output of `dashboard create`: the created dashboard and its URL. */ +export const DashboardCreateOutputSchema = looseObject({ + ...DashboardDetailSchema.entries, + url: pipe(string(), description("Dashboard URL")), +}); + +/** JSON output of `dashboard create`. */ +export type DashboardCreateOutput = InferOutput< + typeof DashboardCreateOutputSchema +>; + +/** + * JSON output of `dashboard restore`: the restored dashboard and the + * revision it was restored to. + */ +export const DashboardRestoreOutputSchema = object({ + dashboard: pipe(DashboardDetailSchema, description("Restored dashboard")), + orgSlug: pipe(string(), description("Organization slug")), + revisionId: pipe(string(), description("Restored revision ID")), +}); + +/** JSON output of `dashboard restore`. */ +export type DashboardRestoreOutput = InferOutput< + typeof DashboardRestoreOutputSchema +>; diff --git a/packages/cli/src/output-schemas/event.ts b/packages/cli/src/output-schemas/event.ts new file mode 100644 index 000000000..d807f98e0 --- /dev/null +++ b/packages/cli/src/output-schemas/event.ts @@ -0,0 +1,143 @@ +/** + * Output schemas of the `sentry event` commands, and of `sentry issue events`. + */ + +import { + vEventAttachmentDetailsResponse, + vGroupEventsResponseDict, + vIssueEventDetailsResponse, +} from "@sentry/api/valibot"; +import { + array, + description, + looseObject, + nullable, + object, + optional, + partial, + pipe, + string, + unknown, + url, +} from "valibot"; + +/** Fields `event view` adds to each attachment of the event. */ +export const EventViewAttachmentFields = object({ + download: pipe( + string(), + url(), + description("Absolute authenticated API URL for the attachment bytes"), + ), +}); + +const EventViewAttachmentSchema = looseObject({ + ...vEventAttachmentDetailsResponse.entries, + ...EventViewAttachmentFields.entries, +}); + +/** + * Fields `event view` adds to the event in its JSON output. The command's + * data type (`SingleEventViewData`) is derived from them. + */ +export const EventViewOutputFields = object({ + trace: pipe( + nullable( + object({ + traceId: pipe(string(), description("Trace ID")), + spans: pipe(array(unknown()), description("Span tree data")), + }), + ), + description("Trace context, or null when unavailable"), + ), + attachments: pipe( + array(EventViewAttachmentSchema), + description( + "Event attachments; each includes metadata and an absolute authenticated download URL", + ), + ), +}); + +/** + * Documentation schema for the flattened `event view` JSON output. + * + * Event API fields remain optional because responses vary by event type. The + * CLI always adds {@link EventViewOutputFields}; multi-event output is an + * array of this shape. + */ +export const EventViewOutputSchema = pipe( + looseObject({ + ...partial(vIssueEventDetailsResponse).entries, + eventID: pipe(string(), description("UUID-format event ID")), + ...EventViewOutputFields.entries, + }), + description("Event view output"), +); + +/** + * Valibot schema for {@link IssueEvent} — used for `--fields` documentation in `--help`. + * + * Derived from the auto-generated `vGroupEventsResponseDict` element schema. + * All generated fields are widened to optional via `.partial()`, then the core + * identifiers (id, event.type, eventID) are re-required via `.extend()`. + */ +const _IssueEventElement = vGroupEventsResponseDict.item; +export const IssueEventSchema = pipe( + looseObject({ + ...partial(_IssueEventElement).entries, + id: pipe(string(), description("Internal event ID")), + "event.type": pipe( + string(), + description("Event type (error, default, transaction)"), + ), + groupID: optional( + pipe(_IssueEventElement.entries.groupID, description("Group (issue) ID")), + ), + eventID: pipe(string(), description("UUID-format event ID")), + projectID: optional( + pipe(_IssueEventElement.entries.projectID, description("Project ID")), + ), + message: optional( + pipe(_IssueEventElement.entries.message, description("Event message")), + ), + title: optional( + pipe(_IssueEventElement.entries.title, description("Event title")), + ), + location: optional( + pipe( + _IssueEventElement.entries.location, + description("Source location (file:line)"), + ), + ), + culprit: optional( + pipe( + _IssueEventElement.entries.culprit, + description("Culprit function/module"), + ), + ), + user: optional( + pipe(_IssueEventElement.entries.user, description("User context")), + ), + tags: optional( + pipe(_IssueEventElement.entries.tags, description("Event tags")), + ), + platform: optional( + pipe( + _IssueEventElement.entries.platform, + description("Platform (python, javascript, etc.)"), + ), + ), + dateCreated: optional( + pipe( + _IssueEventElement.entries.dateCreated, + description("ISO 8601 creation timestamp"), + ), + ), + crashFile: optional( + pipe(_IssueEventElement.entries.crashFile, description("Crash file URL")), + ), + metadata: optional( + pipe(_IssueEventElement.entries.metadata, description("Event metadata")), + ), + }), + description("Issue event (list endpoint)"), +); diff --git a/packages/cli/src/output-schemas/feedback.ts b/packages/cli/src/output-schemas/feedback.ts new file mode 100644 index 000000000..d829f09e7 --- /dev/null +++ b/packages/cli/src/output-schemas/feedback.ts @@ -0,0 +1,113 @@ +/** + * Output schemas of the `sentry feedback` commands. + */ + +import { vEventAttachmentDetailsResponse } from "@sentry/api/valibot"; +import { + array, + boolean, + description, + literal, + looseObject, + nullable, + number, + object, + optional, + pipe, + string, +} from "valibot"; +import type { SentryEvent } from "../types/index.js"; +import { SentryIssueSchema } from "./issue.js"; +import { opaque } from "./output.js"; + +/** Documentation schema for Feedback metadata. */ +export const FeedbackMetadataSchema = pipe( + looseObject({ + message: pipe(string(), description("Feedback message")), + contact_email: optional( + nullable(pipe(string(), description("End-user email address"))), + ), + name: optional( + nullable(pipe(string(), description("End-user display name"))), + ), + title: optional(pipe(string(), description("Feedback title"))), + value: optional(pipe(string(), description("Feedback display value"))), + initial_priority: optional( + pipe( + number(), + description("Initial priority assigned when the feedback was created"), + ), + ), + source: optional( + nullable(pipe(string(), description("Feedback capture source"))), + ), + summary: optional( + nullable(pipe(string(), description("AI-generated summary"))), + ), + sdk: optional( + pipe( + object({ + name: pipe(string(), description("SDK name")), + name_normalized: optional( + pipe(string(), description("Normalized SDK name")), + ), + }), + description("SDK that captured the feedback"), + ), + ), + associated_event_id: optional( + pipe(string(), description("Associated error event ID")), + ), + }), + description("Feedback metadata"), +); + +/** Documentation schema for a modern Feedback issue. */ +export const SentryFeedbackSchema = pipe( + looseObject({ + ...SentryIssueSchema.entries, + issueCategory: pipe( + literal("feedback"), + description("Issue category discriminator"), + ), + issueType: pipe( + literal("feedback"), + description("Issue type discriminator"), + ), + metadata: FeedbackMetadataSchema, + hasSeen: optional( + pipe(boolean(), description("Whether the feedback has been read")), + ), + latestEventHasAttachments: optional( + pipe(boolean(), description("Whether the latest event has attachments")), + ), + }), + description("Sentry User Feedback"), +); + +/** + * Fields `feedback view` adds to the Feedback issue in its JSON output; the + * rest of {@link FeedbackViewResult} is derived from them. + */ +export const FeedbackViewOutputFields = object({ + /** Resolved organization slug, or null when unavailable. */ + org: pipe(nullable(string()), description("Organization slug")), + event: pipe( + nullable(opaque()), + description("Latest feedback event"), + ), + replayIds: pipe(array(string()), description("Related Session Replay IDs")), + attachments: pipe( + array(vEventAttachmentDetailsResponse), + description("Attachments on the latest feedback event"), + ), +}); + +/** Documentation schema for flattened `feedback view` JSON output. */ +export const FeedbackViewOutputSchema = pipe( + looseObject({ + ...SentryFeedbackSchema.entries, + ...FeedbackViewOutputFields.entries, + }), + description("Feedback view output"), +); diff --git a/packages/cli/src/output-schemas/integration.ts b/packages/cli/src/output-schemas/integration.ts new file mode 100644 index 000000000..8d01a06c8 --- /dev/null +++ b/packages/cli/src/output-schemas/integration.ts @@ -0,0 +1,52 @@ +/** + * Output schemas of the `sentry integration` commands. + */ + +import { + description, + looseObject, + nullable, + optional, + pipe, + string, +} from "valibot"; +import { orgListItemSchema } from "./org-list.js"; + +/** Provider summary embedded in an installed integration. */ +export const IntegrationProviderSchema = looseObject({ + key: pipe(string(), description("Provider key (e.g. github, slack)")), + name: pipe(string(), description("Provider display name")), +}); + +/** + * An integration installed on a Sentry organization. + * + * Returned by `/organizations/{org}/integrations/`. The list includes + * integrations that are pending deletion, so `organizationIntegrationStatus` + * can be `active`, `disabled`, or `pending_deletion`. + */ +export const SentryIntegrationSchema = pipe( + looseObject({ + id: pipe(string(), description("Integration ID")), + name: pipe(string(), description("Integration name (e.g. GitHub org)")), + domainName: pipe( + nullable(string()), + description("External domain (e.g. github.com/getsentry)"), + ), + provider: pipe(IntegrationProviderSchema, description("Provider")), + status: pipe(string(), description("Integration status")), + organizationIntegrationStatus: pipe( + string(), + description("Status of the install in this organization"), + ), + externalId: optional( + pipe(string(), description("Provider-side installation ID")), + ), + }), + description("Installed Sentry integration"), +); + +/** JSON output of `integration list`: an installed integration and the org it came from. */ +export const IntegrationListItemSchema = orgListItemSchema( + SentryIntegrationSchema, +); diff --git a/packages/cli/src/output-schemas/issue.ts b/packages/cli/src/output-schemas/issue.ts new file mode 100644 index 000000000..b131c928d --- /dev/null +++ b/packages/cli/src/output-schemas/issue.ts @@ -0,0 +1,211 @@ +/** + * Output schemas of the `sentry issue` commands. + */ + +import { + vGetOrganizationIssueResponse, + vGetOrganizationIssueTagResponse, +} from "@sentry/api/valibot"; +import { + array, + boolean, + description, + looseObject, + nullable, + number, + object, + optional, + partial, + pick, + pipe, + string, + unknown, +} from "valibot"; +import type { SentryEvent } from "../types/index.js"; +import { opaque } from "./output.js"; + +/** + * Valibot schema describing the key fields of a {@link SentryIssue} for JSON output. + * + * This is a documentation-oriented schema — it describes the commonly-available + * fields that appear in `--json` output, used by the help system and SKILL.md + * generation to inform agents and users about available `--fields` selections. + * + * Not a validation schema — the actual API response may include additional + * SDK-derived fields not listed here. Fields listed as optional may still be + * present in most responses; optionality reflects the TypeScript type. + */ +/** + * Derived from the auto-generated `vGetOrganizationIssueResponse` schema. + * + * The generated schema makes all API-documented fields required. We widen it + * with `.partial()` so only the core identifiers (id, shortId, title) are + * required — matching how the CLI uses partial API responses and test mocks. + * Extra fields not in the OpenAPI spec (substatus, priority, isUnhandled, + * seerFixabilityScore) are added via `.extend()`. + */ +export const SentryIssueSchema = pipe( + looseObject({ + ...partial( + pick(vGetOrganizationIssueResponse, [ + "id", + "shortId", + "title", + "culprit", + "count", + "userCount", + "firstSeen", + "lastSeen", + "level", + "status", + "permalink", + "project", + "metadata", + "assignedTo", + ]), + ).entries, + id: pipe(string(), description("Numeric issue ID")), + shortId: pipe( + string(), + description("Human-readable short ID (e.g. PROJ-ABC)"), + ), + title: pipe(string(), description("Issue title")), + culprit: optional( + pipe( + vGetOrganizationIssueResponse.entries.culprit, + description("Culprit string"), + ), + ), + count: optional( + pipe( + vGetOrganizationIssueResponse.entries.count, + description("Total event count"), + ), + ), + userCount: optional( + pipe( + vGetOrganizationIssueResponse.entries.userCount, + description("Number of affected users"), + ), + ), + firstSeen: optional( + pipe( + vGetOrganizationIssueResponse.entries.firstSeen, + description("First occurrence (ISO 8601)"), + ), + ), + lastSeen: optional( + pipe( + vGetOrganizationIssueResponse.entries.lastSeen, + description("Most recent occurrence (ISO 8601)"), + ), + ), + level: optional( + pipe( + vGetOrganizationIssueResponse.entries.level, + description("Severity level"), + ), + ), + status: optional( + pipe( + vGetOrganizationIssueResponse.entries.status, + description("Issue status"), + ), + ), + permalink: optional( + pipe( + vGetOrganizationIssueResponse.entries.permalink, + description("URL to the issue in Sentry"), + ), + ), + project: optional( + pipe( + vGetOrganizationIssueResponse.entries.project, + description("Project info"), + ), + ), + metadata: optional( + pipe( + vGetOrganizationIssueResponse.entries.metadata, + description("Issue metadata"), + ), + ), + assignedTo: optional( + pipe( + vGetOrganizationIssueResponse.entries.assignedTo, + description("Assigned user or team"), + ), + ), + priority: optional(pipe(string(), description("Triage priority"))), + platform: optional(pipe(string(), description("Platform"))), + substatus: optional( + pipe(nullable(string()), description("Issue substatus")), + ), + isUnhandled: optional( + pipe(boolean(), description("Whether the issue is unhandled")), + ), + seerFixabilityScore: optional( + pipe(nullable(number()), description("Seer AI fixability score (0-1)")), + ), + }), + description("Sentry issue"), +); + +/** + * Fields `issue view` adds to the issue in its JSON output. The command's + * data type (`SingleIssueViewData`) is derived from them. + */ +export const IssueViewOutputFields = object({ + event: pipe( + nullable(opaque()), + description( + "Latest event for the issue (full detail). Select named fields with " + + "`--fields event.id,event.title` to avoid pulling the whole payload; " + + "the `request` entry may include live session data.", + ), + ), + org: pipe(nullable(string()), description("Organization slug")), + replayIds: pipe(array(string()), description("Related Session Replay IDs")), + trace: pipe( + nullable( + object({ + traceId: pipe(string(), description("Trace ID from the latest event")), + spans: pipe(array(unknown()), description("Span tree data")), + }), + ), + description("Trace context from the latest event's span tree"), + ), +}); + +/** + * Documentation-oriented schema for `issue view` JSON output. + * + * The view command's jsonTransform spreads all issue fields at the top level + * and adds {@link IssueViewOutputFields}. This schema describes that + * flattened shape for `--help`, `sentry help issue view`, and SKILL.md field + * table generation. + */ +export const IssueViewOutputSchema = pipe( + looseObject({ + ...SentryIssueSchema.entries, + ...IssueViewOutputFields.entries, + }), + description("Issue view output"), +); + +/** JSON output of `issue tags`: the tag's top values across retained events. */ +export const IssueTagsOutputSchema = object({ + ...vGetOrganizationIssueTagResponse.entries, + totalValues: pipe( + vGetOrganizationIssueTagResponse.entries.totalValues, + description( + "Total retained events, including missing tag values; not a lifetime count", + ), + ), + topValues: pipe( + vGetOrganizationIssueTagResponse.entries.topValues, + description( + "Up to 9 values ordered by count, with nullable counts and first/last seen timestamps", + ), + ), +}); diff --git a/packages/cli/src/output-schemas/log.ts b/packages/cli/src/output-schemas/log.ts new file mode 100644 index 000000000..02d47bbc9 --- /dev/null +++ b/packages/cli/src/output-schemas/log.ts @@ -0,0 +1,7 @@ +/** + * Output schemas of the `sentry log` commands. + */ + +// Also validates the logs API response at runtime, so it stays in +// `types/sentry.ts`. +export { SentryLogSchema } from "../types/sentry.js"; diff --git a/packages/cli/src/output-schemas/monitor.ts b/packages/cli/src/output-schemas/monitor.ts new file mode 100644 index 000000000..edd16ed9d --- /dev/null +++ b/packages/cli/src/output-schemas/monitor.ts @@ -0,0 +1,120 @@ +/** + * Output schemas of the `sentry monitor` commands. + */ + +import { + array, + boolean, + description, + looseObject, + nullable, + number, + optional, + pipe, + string, + union, +} from "valibot"; +import { orgListItemSchema } from "./org-list.js"; + +/** + * Configuration of a cron monitor's expected schedule and thresholds. + * + * Returned by the `/organizations/{org}/monitors/` endpoint. The `schedule` + * field is either a crontab string (when `schedule_type` is `"crontab"`) or a + * `[value, unit]` tuple (when `"interval"`). Other fields are nullable because + * the API returns `null` for unset thresholds. + */ +export const MonitorConfigSchema = pipe( + looseObject({ + schedule_type: optional( + pipe(string(), description("Schedule type: 'crontab' or 'interval'")), + ), + schedule: optional( + pipe( + union([string(), array(union([string(), number()]))]), + description("Crontab string or [value, unit] interval tuple"), + ), + ), + timezone: optional( + nullable( + pipe(string(), description("Schedule timezone (tz database string)")), + ), + ), + checkin_margin: optional( + nullable( + pipe( + number(), + description("Allowed minutes after the expected check-in time"), + ), + ), + ), + max_runtime: optional( + nullable( + pipe( + number(), + description("Allowed minutes a check-in may run before timing out"), + ), + ), + ), + failure_issue_threshold: optional( + nullable( + pipe( + number(), + description("Consecutive failures before an issue is created"), + ), + ), + ), + recovery_threshold: optional( + nullable( + pipe( + number(), + description("Consecutive successes before an issue is resolved"), + ), + ), + ), + }), + description("Monitor configuration"), +); + +/** + * A cron monitor configured in a Sentry organization. + * + * Cron monitors are not modeled by the `@sentry/api` types this project + * re-exports, so this is a hand-written internal schema (Pattern B). Core + * identifiers (id, slug, name, status) are required; richer fields are widened + * to optional and `.passthrough()` preserves any unmodeled API fields. + */ +export const SentryMonitorSchema = pipe( + looseObject({ + id: pipe(string(), description("Monitor ID")), + slug: pipe(string(), description("Monitor slug")), + name: pipe(string(), description("Monitor name")), + status: pipe( + string(), + description("Monitor status (e.g. active, disabled)"), + ), + isMuted: optional( + pipe(boolean(), description("Whether the monitor is muted")), + ), + config: optional( + pipe(MonitorConfigSchema, description("Schedule configuration")), + ), + dateCreated: optional( + pipe(string(), description("Creation date (ISO 8601)")), + ), + project: optional( + pipe( + looseObject({ + id: optional(pipe(string(), description("Project ID"))), + slug: optional(pipe(string(), description("Project slug"))), + name: optional(pipe(string(), description("Project name"))), + }), + description("Owning project"), + ), + ), + }), + description("Sentry monitor"), +); + +/** JSON output of `monitor list`: a cron monitor and the org it came from. */ +export const MonitorListItemSchema = orgListItemSchema(SentryMonitorSchema); diff --git a/packages/cli/src/output-schemas/org-list.ts b/packages/cli/src/output-schemas/org-list.ts new file mode 100644 index 000000000..e83724150 --- /dev/null +++ b/packages/cli/src/output-schemas/org-list.ts @@ -0,0 +1,29 @@ +/** + * Output schema building blocks of org-scoped lists: each item is the + * entity with the `orgSlug` it came from. + */ + +import { + description, + looseObject, + object, + type ObjectEntries, + optional, + pipe, + string, +} from "valibot"; + +/** The field `withOrg` adds to each item of an org-scoped list. */ +export const OrgListItemOutputFields = object({ + orgSlug: optional(pipe(string(), description("Organization slug"))), +}); + +/** Output schema of an org-scoped list item: the entity's, plus `orgSlug`. */ +export function orgListItemSchema(entity: { + readonly entries: TEntries; +}) { + return looseObject({ + ...entity.entries, + ...OrgListItemOutputFields.entries, + }); +} diff --git a/packages/cli/src/output-schemas/org.ts b/packages/cli/src/output-schemas/org.ts new file mode 100644 index 000000000..31838ff0e --- /dev/null +++ b/packages/cli/src/output-schemas/org.ts @@ -0,0 +1,117 @@ +/** + * Output schemas of the `sentry org` commands. + */ + +import { vOrganizationSummary } from "@sentry/api/valibot"; +import { + description, + looseObject, + object, + optional, + pipe, + string, +} from "valibot"; + +const sdkOrg = vOrganizationSummary.entries; + +/** + * `--fields` / SKILL.md documentation schema for an organization. + * + * Like {@link SentryTeamSchema}, it is not used for runtime validation: it + * curates the user-facing fields of the auto-generated `vOrganizationSummary` + * (the list-organizations item). Only core identifiers are required. + */ +export const SentryOrganizationSchema = looseObject({ + id: pipe(string(), description("Organization ID")), + slug: pipe(string(), description("Organization slug")), + name: pipe(string(), description("Organization name")), + dateCreated: optional( + pipe(sdkOrg.dateCreated, description("Creation date (ISO 8601)")), + ), + status: optional(pipe(sdkOrg.status, description("Organization status"))), + isEarlyAdopter: optional( + pipe( + sdkOrg.isEarlyAdopter, + description("Whether the org is an early adopter"), + ), + ), + require2FA: optional( + pipe(sdkOrg.require2FA, description("Whether members must use 2FA")), + ), + features: optional(pipe(sdkOrg.features, description("Enabled features"))), + avatar: optional(pipe(sdkOrg.avatar, description("Avatar"))), + links: optional( + pipe(sdkOrg.links, description("Organization and region URLs")), + ), + hasAuthProvider: optional( + pipe(sdkOrg.hasAuthProvider, description("Whether SSO is configured")), + ), + allowMemberInvite: optional( + pipe(sdkOrg.allowMemberInvite, description("Whether members can invite")), + ), + allowMemberProjectCreation: optional( + pipe( + sdkOrg.allowMemberProjectCreation, + description("Whether members can create projects"), + ), + ), + orgRole: optional( + pipe(string(), description("Your role in the organization")), + ), +}); + +/** + * The account an organization was listed under (no secrets): a stored + * account, or the env token's virtual account. + */ +export const OrgListAccountSchema = object({ + /** + * What `--account` accepts: `:`, e.g. + * `jane@acme.com:sentry.io` (just `` when no server is recorded), as + * `sentry auth login` does too; `env:SENTRY_AUTH_TOKEN` for the env token. + */ + name: pipe(string(), description("What --account accepts")), + id: pipe(string(), description("Account ID")), + email: optional(pipe(string(), description("Account email"))), + username: optional(pipe(string(), description("Account username"))), + /** + * The server part of `name`: `host[:port]`, or the full origin such as + * `http://localhost:8000` for a non-HTTPS server. + */ + server: optional(pipe(string(), description("Account server"))), + /** The server's full origin, e.g. `https://sentry.io`. */ + host: optional(pipe(string(), description("Server origin"))), + /** Human label, e.g. `Jane on sentry.io`. */ + label: pipe(string(), description("Human-readable account label")), + /** The variable providing the token, for the env token's account. */ + envVar: optional( + pipe(string(), description("Variable providing the env token")), + ), +}); + +/** + * Fields the list adds to each organization: a region display name and the + * stored account it was listed under. + * + * The `region` field is only present when the user's orgs span multiple + * regions (e.g. both US and EU). `account` is only present when orgs from + * several stored accounts are listed. Both are included in JSON output as + * well, providing context to machine consumers. + */ +export const OrgListEntryFields = object({ + region: optional( + pipe(string(), description("Region, when orgs span several (e.g. US, EU)")), + ), + account: optional( + pipe( + OrgListAccountSchema, + description("Account the org was listed under, across accounts"), + ), + ), +}); + +/** JSON output of `org list`: each organization with {@link OrgListEntryFields}. */ +export const OrgListEntrySchema = looseObject({ + ...SentryOrganizationSchema.entries, + ...OrgListEntryFields.entries, +}); diff --git a/packages/cli/src/types/output.ts b/packages/cli/src/output-schemas/output.ts similarity index 86% rename from packages/cli/src/types/output.ts rename to packages/cli/src/output-schemas/output.ts index 6c015efdd..219982acb 100644 --- a/packages/cli/src/types/output.ts +++ b/packages/cli/src/output-schemas/output.ts @@ -1,8 +1,9 @@ /** * Building blocks for command output schemas. * - * A command declares its JSON output once, as a valibot schema - * (`OutputConfig.schema`), and derives the TypeScript type its `func` + * A command declares its JSON output once, as a valibot schema in + * `src/output-schemas/` (build-only: the command's `OutputConfig.schema` is + * the field data generated from it), and derives the TypeScript type its `func` * yields and its `human` formatter takes from that schema, so the * compiler keeps the two in sync. Fields that come from the API keep the * `@sentry/api` SDK type; fields the CLI adds are written once as a valibot diff --git a/packages/cli/src/output-schemas/project.ts b/packages/cli/src/output-schemas/project.ts new file mode 100644 index 000000000..1f2477b00 --- /dev/null +++ b/packages/cli/src/output-schemas/project.ts @@ -0,0 +1,84 @@ +/** + * Output schemas of the `sentry project` commands. + */ + +import { vOrganizationProjectResponseDict } from "@sentry/api/valibot"; +import { + description, + looseObject, + nullable, + object, + optional, + pipe, + string, +} from "valibot"; +import { orgListItemSchema } from "./org-list.js"; + +const sdkProject = vOrganizationProjectResponseDict.item.entries; + +/** + * `--fields` / SKILL.md documentation schema for a project. + * + * Curates the user-facing fields of the auto-generated list-projects item, + * like {@link SentryOrganizationSchema}; not used for runtime validation. + */ +export const SentryProjectSchema = looseObject({ + id: pipe(string(), description("Project ID")), + slug: pipe(string(), description("Project slug")), + name: pipe(string(), description("Project name")), + platform: optional(pipe(sdkProject.platform, description("Platform"))), + platforms: optional( + pipe(sdkProject.platforms, description("Platforms seen in events")), + ), + dateCreated: optional( + pipe(sdkProject.dateCreated, description("Creation date (ISO 8601)")), + ), + firstEvent: optional( + pipe(sdkProject.firstEvent, description("First event date (ISO 8601)")), + ), + status: optional(pipe(string(), description("Project status"))), + isBookmarked: optional( + pipe(sdkProject.isBookmarked, description("Whether you bookmarked it")), + ), + isMember: optional( + pipe(sdkProject.isMember, description("Whether you are a member")), + ), + hasAccess: optional( + pipe(sdkProject.hasAccess, description("Whether you have access")), + ), + features: optional( + pipe(sdkProject.features, description("Enabled features")), + ), + team: optional(pipe(sdkProject.team, description("Owning team"))), + teams: optional(pipe(sdkProject.teams, description("Teams with access"))), + environments: optional( + pipe(sdkProject.environments, description("Environments seen")), + ), + latestRelease: optional( + pipe(sdkProject.latestRelease, description("Latest release")), + ), + organization: optional( + pipe( + looseObject({ + id: pipe(string(), description("Organization ID")), + slug: pipe(string(), description("Organization slug")), + name: optional(pipe(string(), description("Organization name"))), + }), + description("Organization (detail responses only)"), + ), + ), +}); + +/** JSON output of `project list`: a project and the org it came from. */ +export const ProjectListItemSchema = orgListItemSchema(SentryProjectSchema); + +/** The field `project view` adds to each project in its JSON output. */ +export const ProjectViewOutputFields = object({ + dsn: pipe(nullable(string()), description("Primary DSN")), +}); + +/** JSON output of `project view`: the project with its primary DSN. */ +export const ProjectViewOutputSchema = looseObject({ + ...SentryProjectSchema.entries, + ...ProjectViewOutputFields.entries, +}); diff --git a/packages/cli/src/output-schemas/release.ts b/packages/cli/src/output-schemas/release.ts new file mode 100644 index 000000000..ec7da162f --- /dev/null +++ b/packages/cli/src/output-schemas/release.ts @@ -0,0 +1,195 @@ +/** + * Output schemas of the `sentry release` commands. + */ + +import { vDeployResponse, vOrgReleaseResponse } from "@sentry/api/valibot"; +import { + array, + boolean, + description, + type InferOutput, + looseObject, + object, + optional, + pipe, + string, + union, +} from "valibot"; +import { DryRunOutputField } from "./output.js"; +import { OrgListItemOutputFields } from "./org-list.js"; + +const sdkRelease = vOrgReleaseResponse.entries; + +/** + * `--fields` / SKILL.md documentation schema for a release. + * + * Curates the user-facing fields of the auto-generated `vOrgReleaseResponse`, + * like {@link SentryOrganizationSchema}; not used for runtime validation. + */ +export const SentryReleaseSchema = looseObject({ + id: optional(pipe(sdkRelease.id, description("Release ID"))), + version: pipe(string(), description("Release version")), + shortVersion: optional( + pipe(sdkRelease.shortVersion, description("Shortened version")), + ), + versionInfo: optional( + pipe(sdkRelease.versionInfo, description("Parsed version information")), + ), + status: optional( + pipe(sdkRelease.status, description("Release status (open, archived)")), + ), + ref: optional(pipe(sdkRelease.ref, description("VCS reference"))), + url: optional(pipe(sdkRelease.url, description("Release URL"))), + dateCreated: optional( + pipe(sdkRelease.dateCreated, description("Creation date (ISO 8601)")), + ), + dateReleased: optional( + pipe(sdkRelease.dateReleased, description("Release date (ISO 8601)")), + ), + firstEvent: optional( + pipe(sdkRelease.firstEvent, description("First event date (ISO 8601)")), + ), + lastEvent: optional( + pipe(sdkRelease.lastEvent, description("Last event date (ISO 8601)")), + ), + newGroups: optional( + pipe(sdkRelease.newGroups, description("Number of new issues")), + ), + commitCount: optional( + pipe(sdkRelease.commitCount, description("Number of commits")), + ), + deployCount: optional( + pipe(sdkRelease.deployCount, description("Number of deploys")), + ), + lastCommit: optional( + pipe(sdkRelease.lastCommit, description("Most recent commit")), + ), + lastDeploy: optional( + pipe(sdkRelease.lastDeploy, description("Most recent deploy")), + ), + authors: optional(pipe(sdkRelease.authors, description("Commit authors"))), + owner: optional(pipe(sdkRelease.owner, description("Release owner"))), + projects: optional( + pipe(sdkRelease.projects, description("Projects in the release")), + ), + adoptionStages: optional( + pipe(sdkRelease.adoptionStages, description("Adoption stage per project")), + ), +}); + +/** + * Fields the release commands that update a release (`archive`, `restore`, + * `finalize`) add with `--dry-run`, which prints `{ dryRun, version, org }` + * instead of the updated release. + */ +export const ReleaseUpdateOutputFields = object({ + dryRun: DryRunOutputField, + org: optional(pipe(string(), description("Organization slug (--dry-run)"))), +}); + +/** JSON output of `release archive` and `release restore`. */ +export const ReleaseUpdateOutputSchema = looseObject({ + ...SentryReleaseSchema.entries, + ...ReleaseUpdateOutputFields.entries, +}); + +/** `--fields` / SKILL.md documentation schema for a deploy. */ +export const SentryDeploySchema = looseObject({ + id: pipe(string(), description("Deploy ID")), + environment: pipe(string(), description("Environment")), + name: optional(pipe(vDeployResponse.entries.name, description("Name"))), + url: optional(pipe(vDeployResponse.entries.url, description("Deploy URL"))), + dateStarted: optional( + pipe( + vDeployResponse.entries.dateStarted, + description("Start date (ISO 8601)"), + ), + ), + dateFinished: optional( + pipe( + vDeployResponse.entries.dateFinished, + description("Finish date (ISO 8601)"), + ), + ), +}); + +/** + * The fields `--dry-run` changes on the release it reports: the `dryRun` + * flag, and `projects` as `{ slug }` objects instead of full projects. + */ +export 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 of `release create`: the created release, or with `--dry-run` the release that + * would be created, in the same shape. + */ +export const ReleaseCreateOutputSchema = looseObject({ + ...SentryReleaseSchema.entries, + ...ReleaseCreateOutputFields.entries, +}); + +/** JSON output of `release delete`: the release that was (or, with `--dry-run`, would be) deleted. */ +export const ReleaseDeleteOutputSchema = object({ + deleted: pipe(boolean(), description("Whether the release was deleted")), + org: pipe(string(), description("Organization slug")), + version: pipe(string(), description("Release version")), + dryRun: DryRunOutputField, +}); + +/** JSON output of `release delete`. */ +export type ReleaseDeleteOutput = InferOutput; + +/** + * Fields `--dry-run` changes: it prints the deploy that would be created, + * which has no `id` yet, with its release `version`. + */ +export const ReleaseDeployOutputFields = object({ + id: optional(SentryDeploySchema.entries.id), + dryRun: DryRunOutputField, + version: optional(pipe(string(), description("Release version (--dry-run)"))), +}); + +/** JSON output of `release deploy`: the created deploy, or the `--dry-run` preview. */ +export const ReleaseDeployOutputSchema = looseObject({ + ...SentryDeploySchema.entries, + ...ReleaseDeployOutputFields.entries, +}); + +/** Fields `release finalize --dry-run` prints instead of the finalized release. */ +export const ReleaseFinalizeOutputFields = object({ + ...ReleaseUpdateOutputFields.entries, + released: optional( + pipe(string(), description("Release date to set (--dry-run)")), + ), +}); + +/** JSON output of `release finalize`: the finalized release, or the `--dry-run` preview. */ +export const ReleaseFinalizeOutputSchema = looseObject({ + ...SentryReleaseSchema.entries, + ...ReleaseFinalizeOutputFields.entries, +}); + +/** Fields `release list` adds to each release: its org and, when listing several projects, its project. */ +export const ReleaseListItemOutputFields = object({ + ...OrgListItemOutputFields.entries, + targetProject: optional( + pipe(string(), description("Project slug, when listing several")), + ), +}); + +/** JSON output of `release list`: each release with {@link ReleaseListItemOutputFields}. */ +export const ReleaseListItemSchema = looseObject({ + ...SentryReleaseSchema.entries, + ...ReleaseListItemOutputFields.entries, +}); diff --git a/packages/cli/src/output-schemas/replay.ts b/packages/cli/src/output-schemas/replay.ts new file mode 100644 index 000000000..54e605c99 --- /dev/null +++ b/packages/cli/src/output-schemas/replay.ts @@ -0,0 +1,148 @@ +/** + * Output schemas of the `sentry replay` commands. + */ + +import { + array, + description, + nullable, + nullish, + number, + object, + optional, + pipe, + record, + string, +} from "valibot"; +import { + buildReplayListItemShape, + ReplayClickSchema, + ReplayOtaUpdatesSchema, + replayStringArray, +} from "../types/replay.js"; + +/** + * Documentation-oriented replay list schema used for `--help` and SKILL docs. + * + * Keeps the field types explicit even though the runtime parser accepts a few + * legacy/nullish payload variants from archived replay rows. + */ +const ReplayListItemOutputSchemaBase = object( + buildReplayListItemShape({ + errorIds: replayStringArray(), + infoIds: replayStringArray(), + otaUpdates: nullish(ReplayOtaUpdatesSchema), + projectId: optional(nullable(string())), + releases: replayStringArray(), + tags: record(string(), array(string())), + traceIds: replayStringArray(), + urls: replayStringArray(), + warningIds: replayStringArray(), + }), +); +export const ReplayListItemOutputSchema = pipe( + ReplayListItemOutputSchemaBase, + description("Replay list row"), +); + +/** Documentation-oriented replay detail schema used for command metadata. */ +export const ReplayDetailsOutputSchema = pipe( + object({ + ...ReplayListItemOutputSchemaBase.entries, + clicks: pipe( + optional(array(ReplayClickSchema)), + description("Replay click summaries"), + ), + replay_type: pipe(optional(nullable(string())), description("Replay type")), + }), + description("Replay details"), +); + +/** A summarized replay activity event extracted from recording segments. */ +export const ReplayActivityEventSchema = pipe( + object({ + timestampMs: pipe( + nullable(number()), + description("Milliseconds since UNIX epoch for the activity event"), + ), + label: pipe(string(), description("Activity label")), + details: pipe( + array(string()), + description("Supplemental activity details"), + ), + }), + description("Summarized replay activity event"), +); + +/** Related issue metadata extracted from replay-linked event IDs. */ +export const ReplayRelatedIssueSchema = pipe( + object({ + eventId: pipe(string(), description("Replay-linked event ID")), + issueId: pipe( + optional(nullable(string())), + description("Resolved issue ID"), + ), + shortId: pipe( + optional(nullable(string())), + description("Resolved issue short ID"), + ), + title: pipe( + optional(nullable(string())), + description("Resolved issue title"), + ), + }), + description("Replay-related issue"), +); + +/** Related trace metadata extracted from replay trace IDs. */ +export const ReplayRelatedTraceSchema = pipe( + object({ + traceId: pipe(string(), description("Replay-linked trace ID")), + errorCount: pipe( + optional(nullable(number())), + description("Trace error count"), + ), + logCount: pipe( + optional(nullable(number())), + description("Trace log count"), + ), + performanceIssueCount: pipe( + optional(nullable(number())), + description("Trace performance issue count"), + ), + spanCount: pipe( + optional(nullable(number())), + description("Trace span count"), + ), + }), + description("Replay-related trace"), +); + +/** + * Fields `replay view` adds to the replay in its JSON output. The command's + * data type (`ReplayViewData`) is derived from them. + */ +export const ReplayViewOutputFields = object({ + org: pipe(string(), description("Organization slug")), + activity: pipe( + array(ReplayActivityEventSchema), + description("Summarized replay activity"), + ), + relatedIssues: pipe( + array(ReplayRelatedIssueSchema), + description("Replay-related issues"), + ), + relatedTraces: pipe( + array(ReplayRelatedTraceSchema), + description("Replay-related traces"), + ), +}); + +/** Replay view output with related context and summarized activity. */ +export const ReplayViewOutputSchema = pipe( + object({ + ...ReplayDetailsOutputSchema.entries, + ...ReplayViewOutputFields.entries, + }), + description("Replay view output"), +); diff --git a/packages/cli/src/output-schemas/repo.ts b/packages/cli/src/output-schemas/repo.ts new file mode 100644 index 000000000..e679b7840 --- /dev/null +++ b/packages/cli/src/output-schemas/repo.ts @@ -0,0 +1,50 @@ +/** + * Output schemas of the `sentry repo` commands. + */ + +import { + description, + looseObject, + nullable, + object, + optional, + pipe, + string, +} from "valibot"; +import { orgListItemSchema } from "./org-list.js"; + +/** Repository provider (e.g., GitHub, GitLab) */ +export const RepositoryProviderSchema = object({ + id: string(), + name: string(), +}); + +/** A repository connected to a Sentry organization */ +export const SentryRepositorySchema = pipe( + looseObject({ + // Core identifiers (required) + id: pipe(string(), description("Repository ID")), + name: pipe(string(), description("Repository name")), + url: pipe(nullable(string()), description("Repository URL")), + provider: pipe( + RepositoryProviderSchema, + description("Version control provider"), + ), + status: pipe(string(), description("Integration status")), + // Optional metadata + dateCreated: optional( + pipe(string(), description("Creation date (ISO 8601)")), + ), + integrationId: optional(pipe(string(), description("Integration ID"))), + externalSlug: optional( + nullable(pipe(string(), description("External slug (e.g. org/repo)"))), + ), + externalId: optional(nullable(pipe(string(), description("External ID")))), + }), + description("Sentry repository"), +); + +/** JSON output of `repo list`: a repository and the org it came from. */ +export const RepositoryListItemSchema = orgListItemSchema( + SentryRepositorySchema, +); diff --git a/packages/cli/src/output-schemas/span.ts b/packages/cli/src/output-schemas/span.ts new file mode 100644 index 000000000..8310a990e --- /dev/null +++ b/packages/cli/src/output-schemas/span.ts @@ -0,0 +1,39 @@ +/** + * Output schemas of the `sentry span` commands. + */ + +import { + description, + looseObject, + nullable, + number, + optional, + pipe, + string, +} from "valibot"; + +/** + * 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"), +); diff --git a/packages/cli/src/output-schemas/team.ts b/packages/cli/src/output-schemas/team.ts new file mode 100644 index 000000000..5d64d2f33 --- /dev/null +++ b/packages/cli/src/output-schemas/team.ts @@ -0,0 +1,57 @@ +/** + * Output schemas of the `sentry team` commands. + */ + +import { vBaseTeam } from "@sentry/api/valibot"; +import { + description, + looseObject, + optional, + partial, + pick, + pipe, + string, +} from "valibot"; +import { orgListItemSchema } from "./org-list.js"; + +/** + * A team in a Sentry organization. + * + * Derived from the auto-generated `vBaseTeam` schema, picking only the + * fields used in CLI output. All picked fields are widened to optional via + * `.partial()`, then core identifiers (id, slug, name) are re-required. + */ +export const SentryTeamSchema = looseObject({ + ...partial( + pick(vBaseTeam, [ + "id", + "slug", + "name", + "dateCreated", + "isMember", + "teamRole", + "memberCount", + ]), + ).entries, + id: pipe(string(), description("Team ID")), + slug: pipe(string(), description("Team slug")), + name: pipe(string(), description("Team name")), + dateCreated: optional( + pipe( + vBaseTeam.entries.dateCreated, + description("Creation date (ISO 8601)"), + ), + ), + isMember: optional( + pipe(vBaseTeam.entries.isMember, description("Whether you are a member")), + ), + teamRole: optional( + pipe(vBaseTeam.entries.teamRole, description("Your role in the team")), + ), + memberCount: optional( + pipe(vBaseTeam.entries.memberCount, description("Number of members")), + ), +}); + +/** JSON output of `team list`: a team and the org it came from. */ +export const TeamListItemSchema = orgListItemSchema(SentryTeamSchema); diff --git a/packages/cli/src/output-schemas/trace.ts b/packages/cli/src/output-schemas/trace.ts new file mode 100644 index 000000000..eda1d944d --- /dev/null +++ b/packages/cli/src/output-schemas/trace.ts @@ -0,0 +1,7 @@ +/** + * Output schemas of the `sentry trace` commands. + */ + +// Also validates the transactions API response at runtime, so it stays in +// `types/sentry.ts`. +export { TransactionListItemSchema } from "../types/sentry.js"; diff --git a/packages/cli/src/output-schemas/trial.ts b/packages/cli/src/output-schemas/trial.ts new file mode 100644 index 000000000..caf60c145 --- /dev/null +++ b/packages/cli/src/output-schemas/trial.ts @@ -0,0 +1,45 @@ +/** + * Output schemas of the `sentry trial` commands. + */ + +import { + description, + type InferOutput, + nullable, + number, + object, + picklist, + pipe, + string, +} from "valibot"; +import { ProductTrialSchema } from "../types/sentry.js"; + +const { category, isStarted, lengthDays, startDate, endDate } = + ProductTrialSchema.pipe[0].entries; + +/** + * JSON output of `trial list`, one per trial: the API trial's fields plus the + * ones the CLI derives (name, status, daysRemaining). + */ +export 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, +}); + +/** JSON output of one trial of `trial list`. */ +export type TrialListEntryOutput = InferOutput; diff --git a/packages/cli/src/types/dashboard.ts b/packages/cli/src/types/dashboard.ts index 4cd311e0b..1a4ea544a 100644 --- a/packages/cli/src/types/dashboard.ts +++ b/packages/cli/src/types/dashboard.ts @@ -37,7 +37,11 @@ import { import { ValidationError } from "../lib/errors.js"; import { logger } from "../lib/logger.js"; -import { DryRunOutputField } from "./output.js"; +import type { + DashboardListItemSchema, + DashboardWidgetChangeOutputSchema, + DashboardWidgetDeleteOutputSchema, +} from "../output-schemas/dashboard.js"; // --------------------------------------------------------------------------- // Widget type and display type enums @@ -133,20 +137,6 @@ export const DashboardWidgetSchema = looseObject({ dateCreated: optional(string()), }); -/** Schema for dashboard list items (lightweight, from GET /dashboards/) */ -export const DashboardListItemSchema = looseObject({ - id: string(), - title: string(), - dateCreated: optional(string()), - createdBy: optional( - object({ - name: optional(string()), - email: optional(string()), - }), - ), - widgetDisplay: optional(array(string())), -}); - /** Schema for full dashboard detail (from GET /dashboards/{id}/) */ export const DashboardDetailSchema = looseObject({ id: string(), @@ -1047,44 +1037,6 @@ export type UnsupportedResult = InferOutput; export type ErrorResult = InferOutput; export type WidgetDataResult = InferOutput; -/** - * JSON output of `dashboard view`: the dashboard with each widget's query - * result. The type the command yields is derived from it - * (`DashboardViewData` in `lib/formatters/dashboard.ts`). - */ -export const DashboardViewOutputSchema = object({ - id: pipe(string(), description("Dashboard ID")), - title: pipe(string(), description("Dashboard title")), - period: pipe(string(), description("Time range of the widget data")), - fetchedAt: pipe( - string(), - description("When the data was fetched (ISO 8601)"), - ), - url: pipe(string(), description("Dashboard URL")), - dateCreated: optional( - pipe(string(), description("Creation date (ISO 8601)")), - ), - environment: optional( - pipe(array(string()), description("Environment filter")), - ), - widgets: pipe( - array( - object({ - title: pipe(string(), description("Widget title")), - displayType: pipe(string(), description("Display type")), - widgetType: optional(pipe(string(), description("Dataset"))), - description: optional( - pipe(string(), description("Markdown content of a text widget")), - ), - layout: optional(DashboardWidgetLayoutSchema), - queries: optional(array(DashboardWidgetQuerySchema)), - data: WidgetDataResultSchema, - }), - ), - description("Widgets with their query results"), - ), -}); - // --------------------------------------------------------------------------- // Dataset mapping // --------------------------------------------------------------------------- @@ -1154,43 +1106,11 @@ export const DashboardRevisionSchema = looseObject({ export type DashboardRevision = InferOutput; -/** - * JSON output of `dashboard widget add` and `dashboard widget edit`, for - * `--fields`: the saved dashboard, the added or edited widget, and its URL. - */ -export const DashboardWidgetChangeOutputSchema = object({ - dashboard: pipe(DashboardDetailSchema, description("The saved dashboard")), - widget: pipe( - DashboardWidgetSchema, - description("The added or edited widget"), - ), - url: pipe(string(), description("Dashboard URL")), -}); - /** JSON output of `dashboard widget add` and `dashboard widget edit`. */ export type DashboardWidgetChangeOutput = InferOutput< typeof DashboardWidgetChangeOutputSchema >; -/** - * JSON output of `dashboard widget delete`: the deleted (or, with - * `--dry-run`, matched) widget and the dashboard's widget count. - */ -export const DashboardWidgetDeleteOutputSchema = object({ - dryRun: DryRunOutputField, - deleted: optional( - pipe(boolean(), description("True when the widget was deleted")), - ), - widgetTitle: pipe(string(), description("Title of the deleted widget")), - widgetCount: pipe( - number(), - description( - "Dashboard widget count after the delete (before it, with --dry-run)", - ), - ), - url: pipe(string(), description("Dashboard URL")), -}); - /** JSON output of `dashboard widget delete`. */ export type DashboardWidgetDeleteOutput = InferOutput< typeof DashboardWidgetDeleteOutputSchema diff --git a/packages/cli/src/types/feedback.ts b/packages/cli/src/types/feedback.ts index dee6b57b6..98eb7b7a6 100644 --- a/packages/cli/src/types/feedback.ts +++ b/packages/cli/src/types/feedback.ts @@ -1,25 +1,11 @@ /** - * Modern Sentry User Feedback types and output schemas. + * Modern Sentry User Feedback types. The output schemas are in + * `src/output-schemas/feedback.ts`. */ -import { vEventAttachmentDetailsResponse } from "@sentry/api/valibot"; -import { - array, - boolean, - description, - type InferOutput, - literal, - looseObject, - nullable, - number, - object, - optional, - pipe, - string, -} from "valibot"; -import type { SentryEvent, SentryIssue } from "./sentry.js"; -import { SentryIssueSchema } from "./sentry.js"; -import { opaque } from "./output.js"; +import type { InferOutput } from "valibot"; +import type { FeedbackViewOutputFields } from "../output-schemas/feedback.js"; +import type { SentryIssue } from "./sentry.js"; /** Metadata stored on a modern Feedback issue group. */ export type FeedbackMetadata = { @@ -92,95 +78,3 @@ export type FeedbackViewResult = InferOutput< /** The modern Feedback issue. */ feedback: SentryFeedback; }; - -/** Documentation schema for Feedback metadata. */ -export const FeedbackMetadataSchema = pipe( - looseObject({ - message: pipe(string(), description("Feedback message")), - contact_email: optional( - nullable(pipe(string(), description("End-user email address"))), - ), - name: optional( - nullable(pipe(string(), description("End-user display name"))), - ), - title: optional(pipe(string(), description("Feedback title"))), - value: optional(pipe(string(), description("Feedback display value"))), - initial_priority: optional( - pipe( - number(), - description("Initial priority assigned when the feedback was created"), - ), - ), - source: optional( - nullable(pipe(string(), description("Feedback capture source"))), - ), - summary: optional( - nullable(pipe(string(), description("AI-generated summary"))), - ), - sdk: optional( - pipe( - object({ - name: pipe(string(), description("SDK name")), - name_normalized: optional( - pipe(string(), description("Normalized SDK name")), - ), - }), - description("SDK that captured the feedback"), - ), - ), - associated_event_id: optional( - pipe(string(), description("Associated error event ID")), - ), - }), - description("Feedback metadata"), -); - -/** Documentation schema for a modern Feedback issue. */ -export const SentryFeedbackSchema = pipe( - looseObject({ - ...SentryIssueSchema.entries, - issueCategory: pipe( - literal("feedback"), - description("Issue category discriminator"), - ), - issueType: pipe( - literal("feedback"), - description("Issue type discriminator"), - ), - metadata: FeedbackMetadataSchema, - hasSeen: optional( - pipe(boolean(), description("Whether the feedback has been read")), - ), - latestEventHasAttachments: optional( - pipe(boolean(), description("Whether the latest event has attachments")), - ), - }), - description("Sentry User Feedback"), -); - -/** - * Fields `feedback view` adds to the Feedback issue in its JSON output; the - * rest of {@link FeedbackViewResult} is derived from them. - */ -export const FeedbackViewOutputFields = object({ - /** Resolved organization slug, or null when unavailable. */ - org: pipe(nullable(string()), description("Organization slug")), - event: pipe( - nullable(opaque()), - description("Latest feedback event"), - ), - replayIds: pipe(array(string()), description("Related Session Replay IDs")), - attachments: pipe( - array(vEventAttachmentDetailsResponse), - description("Attachments on the latest feedback event"), - ), -}); - -/** Documentation schema for flattened `feedback view` JSON output. */ -export const FeedbackViewOutputSchema = pipe( - looseObject({ - ...SentryFeedbackSchema.entries, - ...FeedbackViewOutputFields.entries, - }), - description("Feedback view output"), -); diff --git a/packages/cli/src/types/index.ts b/packages/cli/src/types/index.ts index 746af2401..5b92ad4ca 100644 --- a/packages/cli/src/types/index.ts +++ b/packages/cli/src/types/index.ts @@ -42,7 +42,6 @@ export type { } from "./dashboard.js"; export { DashboardDetailSchema, - DashboardListItemSchema, DashboardWidgetLayoutSchema, DashboardWidgetQuerySchema, DashboardWidgetSchema, @@ -54,11 +53,6 @@ export type { FeedbackViewResult, SentryFeedback, } from "./feedback.js"; -export { - FeedbackMetadataSchema, - FeedbackViewOutputSchema, - SentryFeedbackSchema, -} from "./feedback.js"; // OAuth types and schemas export type { DeviceCodeResponse, @@ -91,25 +85,18 @@ export type { } from "./replay.js"; export { REPLAY_LIST_FIELDS, - ReplayActivityEventSchema, ReplayBrowserSchema, - ReplayDetailsOutputSchema, ReplayDetailsResponseSchema, ReplayDetailsSchema, ReplayDeviceSchema, ReplayGeoSchema, ReplayIdsByResourceSchema, - ReplayListItemOutputSchema, ReplayListItemSchema, ReplayListResponseSchema, ReplayOsSchema, ReplayOtaUpdatesSchema, - ReplayRelatedIssueSchema, - ReplayRelatedTraceSchema, ReplaySdkSchema, ReplayUserSchema, - ReplayViewOutputFields, - ReplayViewOutputSchema, } from "./replay.js"; export type { AutofixResponse, @@ -182,31 +169,12 @@ export { DetailedLogsResponseSchema, DetailedSentryLogSchema, type EventViewAttachment, - EventViewOutputFields, - EventViewOutputSchema, - FlatSpanSchema, ISSUE_LEVELS, ISSUE_STATUSES, - IssueEventSchema, - IssueViewOutputFields, - IssueViewOutputSchema, LogsResponseSchema, - MonitorConfigSchema, ProductTrialSchema, RegionSchema, - ReleaseUpdateOutputFields, - ReleaseUpdateOutputSchema, - RepositoryProviderSchema, - SentryDeploySchema, - SentryIntegrationSchema, - SentryIssueSchema, SentryLogSchema, - SentryMonitorSchema, - SentryOrganizationSchema, - SentryProjectSchema, - SentryReleaseSchema, - SentryRepositorySchema, - SentryTeamSchema, SentryUserSchema, SpansResponseSchema, TraceItemAttributeSchema, @@ -219,7 +187,7 @@ export { UserRegionsResponseSchema, } from "./sentry.js"; -export { DryRunOutputField, opaque, type WithOutputFields } from "./output.js"; +export type { WithOutputFields } from "../output-schemas/output.js"; // I/O types diff --git a/packages/cli/src/types/replay.ts b/packages/cli/src/types/replay.ts index 4d312da5d..234ea63da 100644 --- a/packages/cli/src/types/replay.ts +++ b/packages/cli/src/types/replay.ts @@ -17,6 +17,11 @@ import { union, unknown, } from "valibot"; +import type { + ReplayActivityEventSchema, + ReplayRelatedIssueSchema, + ReplayRelatedTraceSchema, +} from "../output-schemas/replay.js"; export type ReplayTags = Record; @@ -177,7 +182,7 @@ function replayNullishObject( return pipe(nullish(schema), description(descriptionText)); } -function replayStringArray() { +export function replayStringArray() { return array(string()); } @@ -185,7 +190,7 @@ function replayStringArrayWithFallback() { return fallback(replayStringArray(), []); } -function buildReplayListItemShape< +export function buildReplayListItemShape< TErrorIds extends GenericSchema, TInfoIds extends GenericSchema, TOtaUpdates extends GenericSchema, @@ -305,132 +310,6 @@ export const ReplayDetailsResponseSchema = looseObject({ data: ReplayDetailsSchema, }); -/** - * Documentation-oriented replay list schema used for `--help` and SKILL docs. - * - * Keeps the field types explicit even though the runtime parser accepts a few - * legacy/nullish payload variants from archived replay rows. - */ -const ReplayListItemOutputSchemaBase = object( - buildReplayListItemShape({ - errorIds: replayStringArray(), - infoIds: replayStringArray(), - otaUpdates: nullish(ReplayOtaUpdatesSchema), - projectId: optional(nullable(string())), - releases: replayStringArray(), - tags: record(string(), array(string())), - traceIds: replayStringArray(), - urls: replayStringArray(), - warningIds: replayStringArray(), - }), -); -export const ReplayListItemOutputSchema = pipe( - ReplayListItemOutputSchemaBase, - description("Replay list row"), -); - -/** Documentation-oriented replay detail schema used for command metadata. */ -export const ReplayDetailsOutputSchema = pipe( - object({ - ...ReplayListItemOutputSchemaBase.entries, - clicks: pipe( - optional(array(ReplayClickSchema)), - description("Replay click summaries"), - ), - replay_type: pipe(optional(nullable(string())), description("Replay type")), - }), - description("Replay details"), -); - -/** A summarized replay activity event extracted from recording segments. */ -export const ReplayActivityEventSchema = pipe( - object({ - timestampMs: pipe( - nullable(number()), - description("Milliseconds since UNIX epoch for the activity event"), - ), - label: pipe(string(), description("Activity label")), - details: pipe( - array(string()), - description("Supplemental activity details"), - ), - }), - description("Summarized replay activity event"), -); - -/** Related issue metadata extracted from replay-linked event IDs. */ -export const ReplayRelatedIssueSchema = pipe( - object({ - eventId: pipe(string(), description("Replay-linked event ID")), - issueId: pipe( - optional(nullable(string())), - description("Resolved issue ID"), - ), - shortId: pipe( - optional(nullable(string())), - description("Resolved issue short ID"), - ), - title: pipe( - optional(nullable(string())), - description("Resolved issue title"), - ), - }), - description("Replay-related issue"), -); - -/** Related trace metadata extracted from replay trace IDs. */ -export const ReplayRelatedTraceSchema = pipe( - object({ - traceId: pipe(string(), description("Replay-linked trace ID")), - errorCount: pipe( - optional(nullable(number())), - description("Trace error count"), - ), - logCount: pipe( - optional(nullable(number())), - description("Trace log count"), - ), - performanceIssueCount: pipe( - optional(nullable(number())), - description("Trace performance issue count"), - ), - spanCount: pipe( - optional(nullable(number())), - description("Trace span count"), - ), - }), - description("Replay-related trace"), -); - -/** - * Fields `replay view` adds to the replay in its JSON output. The command's - * data type (`ReplayViewData`) is derived from them. - */ -export const ReplayViewOutputFields = object({ - org: pipe(string(), description("Organization slug")), - activity: pipe( - array(ReplayActivityEventSchema), - description("Summarized replay activity"), - ), - relatedIssues: pipe( - array(ReplayRelatedIssueSchema), - description("Replay-related issues"), - ), - relatedTraces: pipe( - array(ReplayRelatedTraceSchema), - description("Replay-related traces"), - ), -}); - -/** Replay view output with related context and summarized activity. */ -export const ReplayViewOutputSchema = pipe( - object({ - ...ReplayDetailsOutputSchema.entries, - ...ReplayViewOutputFields.entries, - }), - description("Replay view output"), -); - /** Replay IDs keyed by resource identifier (issue ID, trace ID, replay ID). */ export const ReplayIdsByResourceSchema = pipe( record(string(), array(string())), diff --git a/packages/cli/src/types/sentry.ts b/packages/cli/src/types/sentry.ts index f38d5b13c..3e6241e24 100644 --- a/packages/cli/src/types/sentry.ts +++ b/packages/cli/src/types/sentry.ts @@ -26,22 +26,22 @@ import type { OrgReleaseResponse as SdkReleaseResponse, BaseTeam as SdkTeam, } from "@sentry/api"; -import { DryRunOutputField, opaque, type WithOutputFields } from "./output.js"; +import type { EventViewAttachmentFields } from "../output-schemas/event.js"; +import type { SentryIntegrationSchema } from "../output-schemas/integration.js"; +import type { + MonitorConfigSchema, + SentryMonitorSchema, +} from "../output-schemas/monitor.js"; +import type { WithOutputFields } from "../output-schemas/output.js"; +import type { ReleaseUpdateOutputFields } from "../output-schemas/release.js"; +import type { + RepositoryProviderSchema, + SentryRepositorySchema, +} from "../output-schemas/repo.js"; import type { SentryIssueIdentity, SentryNamedResource, } from "@sentry/toolkit-core/resource-identity"; -import { - vBaseTeam, - vDeployResponse, - vEventAttachmentDetailsResponse, - vGetOrganizationIssueResponse, - vGroupEventsResponseDict, - vIssueEventDetailsResponse, - vOrganizationProjectResponseDict, - vOrganizationSummary, - vOrgReleaseResponse, -} from "@sentry/api/valibot"; import { array, boolean, @@ -55,7 +55,6 @@ import { object, optional, partial, - pick, pipe, record, string, @@ -91,54 +90,6 @@ export type SentryOrganization = Partial & orgRole?: string; }; -const sdkOrg = vOrganizationSummary.entries; - -/** - * `--fields` / SKILL.md documentation schema for an organization. - * - * Like {@link SentryTeamSchema}, it is not used for runtime validation: it - * curates the user-facing fields of the auto-generated `vOrganizationSummary` - * (the list-organizations item). Only core identifiers are required. - */ -export const SentryOrganizationSchema = looseObject({ - id: pipe(string(), description("Organization ID")), - slug: pipe(string(), description("Organization slug")), - name: pipe(string(), description("Organization name")), - dateCreated: optional( - pipe(sdkOrg.dateCreated, description("Creation date (ISO 8601)")), - ), - status: optional(pipe(sdkOrg.status, description("Organization status"))), - isEarlyAdopter: optional( - pipe( - sdkOrg.isEarlyAdopter, - description("Whether the org is an early adopter"), - ), - ), - require2FA: optional( - pipe(sdkOrg.require2FA, description("Whether members must use 2FA")), - ), - features: optional(pipe(sdkOrg.features, description("Enabled features"))), - avatar: optional(pipe(sdkOrg.avatar, description("Avatar"))), - links: optional( - pipe(sdkOrg.links, description("Organization and region URLs")), - ), - hasAuthProvider: optional( - pipe(sdkOrg.hasAuthProvider, description("Whether SSO is configured")), - ), - allowMemberInvite: optional( - pipe(sdkOrg.allowMemberInvite, description("Whether members can invite")), - ), - allowMemberProjectCreation: optional( - pipe( - sdkOrg.allowMemberProjectCreation, - description("Whether members can create projects"), - ), - ), - orgRole: optional( - pipe(string(), description("Your role in the organization")), - ), -}); - // Project /** Element type of the SDK's list-projects response */ @@ -179,61 +130,6 @@ export type SentryProject = Partial & status?: string; }; -const sdkProject = vOrganizationProjectResponseDict.item.entries; - -/** - * `--fields` / SKILL.md documentation schema for a project. - * - * Curates the user-facing fields of the auto-generated list-projects item, - * like {@link SentryOrganizationSchema}; not used for runtime validation. - */ -export const SentryProjectSchema = looseObject({ - id: pipe(string(), description("Project ID")), - slug: pipe(string(), description("Project slug")), - name: pipe(string(), description("Project name")), - platform: optional(pipe(sdkProject.platform, description("Platform"))), - platforms: optional( - pipe(sdkProject.platforms, description("Platforms seen in events")), - ), - dateCreated: optional( - pipe(sdkProject.dateCreated, description("Creation date (ISO 8601)")), - ), - firstEvent: optional( - pipe(sdkProject.firstEvent, description("First event date (ISO 8601)")), - ), - status: optional(pipe(string(), description("Project status"))), - isBookmarked: optional( - pipe(sdkProject.isBookmarked, description("Whether you bookmarked it")), - ), - isMember: optional( - pipe(sdkProject.isMember, description("Whether you are a member")), - ), - hasAccess: optional( - pipe(sdkProject.hasAccess, description("Whether you have access")), - ), - features: optional( - pipe(sdkProject.features, description("Enabled features")), - ), - team: optional(pipe(sdkProject.team, description("Owning team"))), - teams: optional(pipe(sdkProject.teams, description("Teams with access"))), - environments: optional( - pipe(sdkProject.environments, description("Environments seen")), - ), - latestRelease: optional( - pipe(sdkProject.latestRelease, description("Latest release")), - ), - organization: optional( - pipe( - looseObject({ - id: pipe(string(), description("Organization ID")), - slug: pipe(string(), description("Organization slug")), - name: optional(pipe(string(), description("Organization name"))), - }), - description("Organization (detail responses only)"), - ), - ), -}); - // Issue Constants /** @@ -310,175 +206,6 @@ export type SentryIssue = Omit, "metadata"> & filtered?: SdkIssueList[number]["filtered"]; }; -/** - * Valibot schema describing the key fields of a {@link SentryIssue} for JSON output. - * - * This is a documentation-oriented schema — it describes the commonly-available - * fields that appear in `--json` output, used by the help system and SKILL.md - * generation to inform agents and users about available `--fields` selections. - * - * Not a validation schema — the actual API response may include additional - * SDK-derived fields not listed here. Fields listed as optional may still be - * present in most responses; optionality reflects the TypeScript type. - */ -/** - * Derived from the auto-generated `vGetOrganizationIssueResponse` schema. - * - * The generated schema makes all API-documented fields required. We widen it - * with `.partial()` so only the core identifiers (id, shortId, title) are - * required — matching how the CLI uses partial API responses and test mocks. - * Extra fields not in the OpenAPI spec (substatus, priority, isUnhandled, - * seerFixabilityScore) are added via `.extend()`. - */ -export const SentryIssueSchema = pipe( - looseObject({ - ...partial( - pick(vGetOrganizationIssueResponse, [ - "id", - "shortId", - "title", - "culprit", - "count", - "userCount", - "firstSeen", - "lastSeen", - "level", - "status", - "permalink", - "project", - "metadata", - "assignedTo", - ]), - ).entries, - id: pipe(string(), description("Numeric issue ID")), - shortId: pipe( - string(), - description("Human-readable short ID (e.g. PROJ-ABC)"), - ), - title: pipe(string(), description("Issue title")), - culprit: optional( - pipe( - vGetOrganizationIssueResponse.entries.culprit, - description("Culprit string"), - ), - ), - count: optional( - pipe( - vGetOrganizationIssueResponse.entries.count, - description("Total event count"), - ), - ), - userCount: optional( - pipe( - vGetOrganizationIssueResponse.entries.userCount, - description("Number of affected users"), - ), - ), - firstSeen: optional( - pipe( - vGetOrganizationIssueResponse.entries.firstSeen, - description("First occurrence (ISO 8601)"), - ), - ), - lastSeen: optional( - pipe( - vGetOrganizationIssueResponse.entries.lastSeen, - description("Most recent occurrence (ISO 8601)"), - ), - ), - level: optional( - pipe( - vGetOrganizationIssueResponse.entries.level, - description("Severity level"), - ), - ), - status: optional( - pipe( - vGetOrganizationIssueResponse.entries.status, - description("Issue status"), - ), - ), - permalink: optional( - pipe( - vGetOrganizationIssueResponse.entries.permalink, - description("URL to the issue in Sentry"), - ), - ), - project: optional( - pipe( - vGetOrganizationIssueResponse.entries.project, - description("Project info"), - ), - ), - metadata: optional( - pipe( - vGetOrganizationIssueResponse.entries.metadata, - description("Issue metadata"), - ), - ), - assignedTo: optional( - pipe( - vGetOrganizationIssueResponse.entries.assignedTo, - description("Assigned user or team"), - ), - ), - priority: optional(pipe(string(), description("Triage priority"))), - platform: optional(pipe(string(), description("Platform"))), - substatus: optional( - pipe(nullable(string()), description("Issue substatus")), - ), - isUnhandled: optional( - pipe(boolean(), description("Whether the issue is unhandled")), - ), - seerFixabilityScore: optional( - pipe(nullable(number()), description("Seer AI fixability score (0-1)")), - ), - }), - description("Sentry issue"), -); - -/** - * Fields `issue view` adds to the issue in its JSON output. The command's - * data type (`SingleIssueViewData`) is derived from them. - */ -export const IssueViewOutputFields = object({ - event: pipe( - nullable(opaque()), - description( - "Latest event for the issue (full detail). Select named fields with " + - "`--fields event.id,event.title` to avoid pulling the whole payload; " + - "the `request` entry may include live session data.", - ), - ), - org: pipe(nullable(string()), description("Organization slug")), - replayIds: pipe(array(string()), description("Related Session Replay IDs")), - trace: pipe( - nullable( - object({ - traceId: pipe(string(), description("Trace ID from the latest event")), - spans: pipe(array(unknown()), description("Span tree data")), - }), - ), - description("Trace context from the latest event's span tree"), - ), -}); - -/** - * Documentation-oriented schema for `issue view` JSON output. - * - * The view command's jsonTransform spreads all issue fields at the top level - * and adds {@link IssueViewOutputFields}. This schema describes that - * flattened shape for `--help`, `sentry help issue view`, and SKILL.md field - * table generation. - */ -export const IssueViewOutputSchema = pipe( - looseObject({ - ...SentryIssueSchema.entries, - ...IssueViewOutputFields.entries, - }), - description("Issue view output"), -); - // Event /** @@ -529,64 +256,12 @@ export type SentryEvent = Omit< culprit?: string | null; }; -/** Fields `event view` adds to each attachment of the event. */ -const EventViewAttachmentFields = object({ - download: pipe( - string(), - url(), - description("Absolute authenticated API URL for the attachment bytes"), - ), -}); - -const EventViewAttachmentSchema = looseObject({ - ...vEventAttachmentDetailsResponse.entries, - ...EventViewAttachmentFields.entries, -}); - /** An event attachment with its authenticated download URL. */ export type EventViewAttachment = WithOutputFields< EventAttachmentDetailsResponse, typeof EventViewAttachmentFields >; -/** - * Fields `event view` adds to the event in its JSON output. The command's - * data type (`SingleEventViewData`) is derived from them. - */ -export const EventViewOutputFields = object({ - trace: pipe( - nullable( - object({ - traceId: pipe(string(), description("Trace ID")), - spans: pipe(array(unknown()), description("Span tree data")), - }), - ), - description("Trace context, or null when unavailable"), - ), - attachments: pipe( - array(EventViewAttachmentSchema), - description( - "Event attachments; each includes metadata and an absolute authenticated download URL", - ), - ), -}); - -/** - * Documentation schema for the flattened `event view` JSON output. - * - * Event API fields remain optional because responses vary by event type. The - * CLI always adds {@link EventViewOutputFields}; multi-event output is an - * array of this shape. - */ -export const EventViewOutputSchema = pipe( - looseObject({ - ...partial(vIssueEventDetailsResponse).entries, - eventID: pipe(string(), description("UUID-format event ID")), - ...EventViewOutputFields.entries, - }), - description("Event view output"), -); - // Issue Event (list endpoint) /** @@ -598,75 +273,6 @@ export const EventViewOutputSchema = pipe( */ export type IssueEvent = SdkGroupEventsList[number]; -/** - * Valibot schema for {@link IssueEvent} — used for `--fields` documentation in `--help`. - * - * Derived from the auto-generated `vGroupEventsResponseDict` element schema. - * All generated fields are widened to optional via `.partial()`, then the core - * identifiers (id, event.type, eventID) are re-required via `.extend()`. - */ -const _IssueEventElement = vGroupEventsResponseDict.item; -export const IssueEventSchema = pipe( - looseObject({ - ...partial(_IssueEventElement).entries, - id: pipe(string(), description("Internal event ID")), - "event.type": pipe( - string(), - description("Event type (error, default, transaction)"), - ), - groupID: optional( - pipe(_IssueEventElement.entries.groupID, description("Group (issue) ID")), - ), - eventID: pipe(string(), description("UUID-format event ID")), - projectID: optional( - pipe(_IssueEventElement.entries.projectID, description("Project ID")), - ), - message: optional( - pipe(_IssueEventElement.entries.message, description("Event message")), - ), - title: optional( - pipe(_IssueEventElement.entries.title, description("Event title")), - ), - location: optional( - pipe( - _IssueEventElement.entries.location, - description("Source location (file:line)"), - ), - ), - culprit: optional( - pipe( - _IssueEventElement.entries.culprit, - description("Culprit function/module"), - ), - ), - user: optional( - pipe(_IssueEventElement.entries.user, description("User context")), - ), - tags: optional( - pipe(_IssueEventElement.entries.tags, description("Event tags")), - ), - platform: optional( - pipe( - _IssueEventElement.entries.platform, - description("Platform (python, javascript, etc.)"), - ), - ), - dateCreated: optional( - pipe( - _IssueEventElement.entries.dateCreated, - description("ISO 8601 creation timestamp"), - ), - ), - crashFile: optional( - pipe(_IssueEventElement.entries.crashFile, description("Crash file URL")), - ), - metadata: optional( - pipe(_IssueEventElement.entries.metadata, description("Event metadata")), - ), - }), - description("Issue event (list endpoint)"), -); - // Project Keys (DSN) /** @@ -827,81 +433,6 @@ export type SentryRelease = Partial & { version: string; }; -const sdkRelease = vOrgReleaseResponse.entries; - -/** - * `--fields` / SKILL.md documentation schema for a release. - * - * Curates the user-facing fields of the auto-generated `vOrgReleaseResponse`, - * like {@link SentryOrganizationSchema}; not used for runtime validation. - */ -export const SentryReleaseSchema = looseObject({ - id: optional(pipe(sdkRelease.id, description("Release ID"))), - version: pipe(string(), description("Release version")), - shortVersion: optional( - pipe(sdkRelease.shortVersion, description("Shortened version")), - ), - versionInfo: optional( - pipe(sdkRelease.versionInfo, description("Parsed version information")), - ), - status: optional( - pipe(sdkRelease.status, description("Release status (open, archived)")), - ), - ref: optional(pipe(sdkRelease.ref, description("VCS reference"))), - url: optional(pipe(sdkRelease.url, description("Release URL"))), - dateCreated: optional( - pipe(sdkRelease.dateCreated, description("Creation date (ISO 8601)")), - ), - dateReleased: optional( - pipe(sdkRelease.dateReleased, description("Release date (ISO 8601)")), - ), - firstEvent: optional( - pipe(sdkRelease.firstEvent, description("First event date (ISO 8601)")), - ), - lastEvent: optional( - pipe(sdkRelease.lastEvent, description("Last event date (ISO 8601)")), - ), - newGroups: optional( - pipe(sdkRelease.newGroups, description("Number of new issues")), - ), - commitCount: optional( - pipe(sdkRelease.commitCount, description("Number of commits")), - ), - deployCount: optional( - pipe(sdkRelease.deployCount, description("Number of deploys")), - ), - lastCommit: optional( - pipe(sdkRelease.lastCommit, description("Most recent commit")), - ), - lastDeploy: optional( - pipe(sdkRelease.lastDeploy, description("Most recent deploy")), - ), - authors: optional(pipe(sdkRelease.authors, description("Commit authors"))), - owner: optional(pipe(sdkRelease.owner, description("Release owner"))), - projects: optional( - pipe(sdkRelease.projects, description("Projects in the release")), - ), - adoptionStages: optional( - pipe(sdkRelease.adoptionStages, description("Adoption stage per project")), - ), -}); - -/** - * Fields the release commands that update a release (`archive`, `restore`, - * `finalize`) add with `--dry-run`, which prints `{ dryRun, version, org }` - * instead of the updated release. - */ -export const ReleaseUpdateOutputFields = object({ - dryRun: DryRunOutputField, - org: optional(pipe(string(), description("Organization slug (--dry-run)"))), -}); - -/** JSON output of `release archive` and `release restore`. */ -export const ReleaseUpdateOutputSchema = looseObject({ - ...SentryReleaseSchema.entries, - ...ReleaseUpdateOutputFields.entries, -}); - /** The updated release, or the `--dry-run` preview. */ export type ReleaseUpdateOutput = WithOutputFields< SentryRelease, @@ -923,26 +454,6 @@ export type SentryDeploy = Partial & { environment: string; }; -/** `--fields` / SKILL.md documentation schema for a deploy. */ -export const SentryDeploySchema = looseObject({ - id: pipe(string(), description("Deploy ID")), - environment: pipe(string(), description("Environment")), - name: optional(pipe(vDeployResponse.entries.name, description("Name"))), - url: optional(pipe(vDeployResponse.entries.url, description("Deploy URL"))), - dateStarted: optional( - pipe( - vDeployResponse.entries.dateStarted, - description("Start date (ISO 8601)"), - ), - ), - dateFinished: optional( - pipe( - vDeployResponse.entries.dateFinished, - description("Finish date (ISO 8601)"), - ), - ), -}); - // Issue // Span (for trace tree display) @@ -1409,32 +920,6 @@ 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), @@ -1449,226 +934,22 @@ export type SpansResponse = InferOutput; // Repository -/** Repository provider (e.g., GitHub, GitLab) */ -export const RepositoryProviderSchema = object({ - id: string(), - name: string(), -}); - export type RepositoryProvider = InferOutput; -/** A repository connected to a Sentry organization */ -export const SentryRepositorySchema = pipe( - looseObject({ - // Core identifiers (required) - id: pipe(string(), description("Repository ID")), - name: pipe(string(), description("Repository name")), - url: pipe(nullable(string()), description("Repository URL")), - provider: pipe( - RepositoryProviderSchema, - description("Version control provider"), - ), - status: pipe(string(), description("Integration status")), - // Optional metadata - dateCreated: optional( - pipe(string(), description("Creation date (ISO 8601)")), - ), - integrationId: optional(pipe(string(), description("Integration ID"))), - externalSlug: optional( - nullable(pipe(string(), description("External slug (e.g. org/repo)"))), - ), - externalId: optional(nullable(pipe(string(), description("External ID")))), - }), - description("Sentry repository"), -); - export type SentryRepository = InferOutput; // Integrations -/** Provider summary embedded in an installed integration. */ -export const IntegrationProviderSchema = looseObject({ - key: pipe(string(), description("Provider key (e.g. github, slack)")), - name: pipe(string(), description("Provider display name")), -}); - -/** - * An integration installed on a Sentry organization. - * - * Returned by `/organizations/{org}/integrations/`. The list includes - * integrations that are pending deletion, so `organizationIntegrationStatus` - * can be `active`, `disabled`, or `pending_deletion`. - */ -export const SentryIntegrationSchema = pipe( - looseObject({ - id: pipe(string(), description("Integration ID")), - name: pipe(string(), description("Integration name (e.g. GitHub org)")), - domainName: pipe( - nullable(string()), - description("External domain (e.g. github.com/getsentry)"), - ), - provider: pipe(IntegrationProviderSchema, description("Provider")), - status: pipe(string(), description("Integration status")), - organizationIntegrationStatus: pipe( - string(), - description("Status of the install in this organization"), - ), - externalId: optional( - pipe(string(), description("Provider-side installation ID")), - ), - }), - description("Installed Sentry integration"), -); - export type SentryIntegration = InferOutput; // Cron Monitor -/** - * Configuration of a cron monitor's expected schedule and thresholds. - * - * Returned by the `/organizations/{org}/monitors/` endpoint. The `schedule` - * field is either a crontab string (when `schedule_type` is `"crontab"`) or a - * `[value, unit]` tuple (when `"interval"`). Other fields are nullable because - * the API returns `null` for unset thresholds. - */ -export const MonitorConfigSchema = pipe( - looseObject({ - schedule_type: optional( - pipe(string(), description("Schedule type: 'crontab' or 'interval'")), - ), - schedule: optional( - pipe( - union([string(), array(union([string(), number()]))]), - description("Crontab string or [value, unit] interval tuple"), - ), - ), - timezone: optional( - nullable( - pipe(string(), description("Schedule timezone (tz database string)")), - ), - ), - checkin_margin: optional( - nullable( - pipe( - number(), - description("Allowed minutes after the expected check-in time"), - ), - ), - ), - max_runtime: optional( - nullable( - pipe( - number(), - description("Allowed minutes a check-in may run before timing out"), - ), - ), - ), - failure_issue_threshold: optional( - nullable( - pipe( - number(), - description("Consecutive failures before an issue is created"), - ), - ), - ), - recovery_threshold: optional( - nullable( - pipe( - number(), - description("Consecutive successes before an issue is resolved"), - ), - ), - ), - }), - description("Monitor configuration"), -); - export type MonitorConfig = InferOutput; -/** - * A cron monitor configured in a Sentry organization. - * - * Cron monitors are not modeled by the `@sentry/api` types this project - * re-exports, so this is a hand-written internal schema (Pattern B). Core - * identifiers (id, slug, name, status) are required; richer fields are widened - * to optional and `.passthrough()` preserves any unmodeled API fields. - */ -export const SentryMonitorSchema = pipe( - looseObject({ - id: pipe(string(), description("Monitor ID")), - slug: pipe(string(), description("Monitor slug")), - name: pipe(string(), description("Monitor name")), - status: pipe( - string(), - description("Monitor status (e.g. active, disabled)"), - ), - isMuted: optional( - pipe(boolean(), description("Whether the monitor is muted")), - ), - config: optional( - pipe(MonitorConfigSchema, description("Schedule configuration")), - ), - dateCreated: optional( - pipe(string(), description("Creation date (ISO 8601)")), - ), - project: optional( - pipe( - looseObject({ - id: optional(pipe(string(), description("Project ID"))), - slug: optional(pipe(string(), description("Project slug"))), - name: optional(pipe(string(), description("Project name"))), - }), - description("Owning project"), - ), - ), - }), - description("Sentry monitor"), -); - export type SentryMonitor = InferOutput; // Team -/** - * A team in a Sentry organization. - * - * Derived from the auto-generated `vBaseTeam` schema, picking only the - * fields used in CLI output. All picked fields are widened to optional via - * `.partial()`, then core identifiers (id, slug, name) are re-required. - */ -export const SentryTeamSchema = looseObject({ - ...partial( - pick(vBaseTeam, [ - "id", - "slug", - "name", - "dateCreated", - "isMember", - "teamRole", - "memberCount", - ]), - ).entries, - id: pipe(string(), description("Team ID")), - slug: pipe(string(), description("Team slug")), - name: pipe(string(), description("Team name")), - dateCreated: optional( - pipe( - vBaseTeam.entries.dateCreated, - description("Creation date (ISO 8601)"), - ), - ), - isMember: optional( - pipe(vBaseTeam.entries.isMember, description("Whether you are a member")), - ), - teamRole: optional( - pipe(vBaseTeam.entries.teamRole, description("Your role in the team")), - ), - memberCount: optional( - pipe(vBaseTeam.entries.memberCount, description("Number of members")), - ), -}); - /** * A Sentry team. * @@ -1677,7 +958,7 @@ export const SentryTeamSchema = looseObject({ * hasAccess, isPending, memberCount, avatar) are widened to optional so test * mocks and partial list-endpoint responses can omit them. * - * `SentryTeamSchema` above is kept separately as the `--fields` / SKILL.md + * `SentryTeamSchema` (`output-schemas/team.ts`) is the `--fields` / SKILL.md * documentation schema — it is NOT used for runtime validation (team list * responses are cast `as unknown as SentryTeam[]` in `api/teams.ts`), so the * schema and type are allowed to diverge: the schema curates a user-facing diff --git a/packages/cli/test/commands/span/list.test.ts b/packages/cli/test/commands/span/list.test.ts index f67104907..dceee184b 100644 --- a/packages/cli/test/commands/span/list.test.ts +++ b/packages/cli/test/commands/span/list.test.ts @@ -55,11 +55,10 @@ 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"; +import { FlatSpanSchema } from "../../../src/generated/output-schemas/span.js"; const VALID_TRACE_ID = "aaaa1111bbbb2222cccc3333dddd4444"; @@ -622,7 +621,7 @@ describe("listCommand.func (trace mode)", () => { VALID_TRACE_ID, ); - const documented = extractSchemaFields(FlatSpanSchema).map((f) => f.name); + const documented = FlatSpanSchema.fields.map((f) => f.name); const [span] = JSON.parse(getStdout()).data; expect(documented).toEqual(expect.arrayContaining(Object.keys(span))); }); diff --git a/packages/cli/test/lib/api/monitors.test.ts b/packages/cli/test/lib/api/monitors.test.ts index 80e3c822b..040f89e4f 100644 --- a/packages/cli/test/lib/api/monitors.test.ts +++ b/packages/cli/test/lib/api/monitors.test.ts @@ -8,7 +8,7 @@ import { safeParse } from "valibot"; import { describe, expect, test } from "vitest"; -import { SentryMonitorSchema } from "../../../src/types/sentry.js"; +import { SentryMonitorSchema } from "../../../src/output-schemas/monitor.js"; const crontabMonitor = { id: "12345", diff --git a/packages/cli/test/lib/formatters/output.test.ts b/packages/cli/test/lib/formatters/output.test.ts index 7d1e33655..a091593b9 100644 --- a/packages/cli/test/lib/formatters/output.test.ts +++ b/packages/cli/test/lib/formatters/output.test.ts @@ -1,7 +1,5 @@ -import { array, nullable, object, optional, string } from "valibot"; import { describe, expect, test } from "vitest"; import { - extractSchemaFieldPaths, type OutputConfig, renderCommandOutput, resolveRenderer, @@ -362,26 +360,3 @@ describe("renderCommandOutput", () => { expect(renderer.finalize).toBeUndefined(); }); }); - -describe("extractSchemaFieldPaths", () => { - test("lists nested fields in dot notation, through wrappers and arrays", () => { - const schema = object({ - id: string(), - metadata: optional(nullable(object({ title: string() }))), - tags: array(object({ key: string(), value: string() })), - }); - - expect(extractSchemaFieldPaths(schema)).toEqual([ - "id", - "metadata", - "metadata.title", - "tags", - "tags.key", - "tags.value", - ]); - }); - - test("returns nothing for a schema that is not an object", () => { - expect(extractSchemaFieldPaths(string())).toEqual([]); - }); -}); diff --git a/packages/cli/test/script/output-schema-fields.test.ts b/packages/cli/test/script/output-schema-fields.test.ts new file mode 100644 index 000000000..218fc01e0 --- /dev/null +++ b/packages/cli/test/script/output-schema-fields.test.ts @@ -0,0 +1,26 @@ +import { array, nullable, object, optional, string } from "valibot"; +import { describe, expect, test } from "vitest"; +import { extractSchemaFieldPaths } from "../../script/lib/output-schema-fields.js"; + +describe("extractSchemaFieldPaths", () => { + test("lists nested fields in dot notation, through wrappers and arrays", () => { + const schema = object({ + id: string(), + metadata: optional(nullable(object({ title: string() }))), + tags: array(object({ key: string(), value: string() })), + }); + + expect(extractSchemaFieldPaths(schema)).toEqual([ + "id", + "metadata", + "metadata.title", + "tags", + "tags.key", + "tags.value", + ]); + }); + + test("returns nothing for a schema that is not an object", () => { + expect(extractSchemaFieldPaths(string())).toEqual([]); + }); +}); From 0928539f315bd665433ff3b8a7b6332b58c4e3ce Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 09:47:06 +0000 Subject: [PATCH 2/9] build(cli): Run generators in one process Every `generate:*` step used to be its own `pnpm tsx` process, and seven of them loaded the route tree, so `generate:docs` paid pnpm's startup ten times and loaded the tree six times. `script/generate.ts` now runs the requested steps in one process, in two phases: 1. Steps the route tree imports: the API schema, the grammars, the output-schema field data, and the banner. 2. Stubs for the generated files the tree imports (now written once, in `script/lib/route-tree-stubs.ts`), one load of the tree, then each tree step's exported `emit()`, with the SDK last. Each generator exports `emit()` instead of doing its work at import, and imports the tree statically rather than after its own copy of the stub code. `docs-sections --check` is an option instead of an argv lookup, and sets the exit code instead of exiting. The `package.json` scripts call the runner directly; `generate` (all steps) replaces the three-step chain in `@sentry/mcp-server`'s `bundle`. Generated files, docs pages and SDK files are byte-identical to the previous pipeline, warm and from a clean `src/generated/`. Part of #1914. Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- packages/cli/AGENTS.md | 4 +- packages/cli/package.json | 39 +- packages/cli/script/check-doc-examples.ts | 19 +- packages/cli/script/check-fragments.ts | 19 +- packages/cli/script/generate-api-schema.ts | 35 +- packages/cli/script/generate-banner-image.ts | 11 +- packages/cli/script/generate-command-api.ts | 222 +++++---- packages/cli/script/generate-command-docs.ts | 167 +++---- .../cli/script/generate-completion-targets.ts | 67 ++- packages/cli/script/generate-docs-sections.ts | 425 +++++++++--------- .../cli/script/generate-output-schemas.ts | 48 +- packages/cli/script/generate-sdk.ts | 176 ++++---- packages/cli/script/generate-skill.ts | 137 +++--- .../cli/script/generate-top-level-routes.ts | 41 +- packages/cli/script/generate.ts | 115 +++++ packages/cli/script/lib/route-tree-stubs.ts | 35 ++ packages/mcp-server/package.json | 2 +- 17 files changed, 807 insertions(+), 755 deletions(-) create mode 100644 packages/cli/script/generate.ts create mode 100644 packages/cli/script/lib/route-tree-stubs.ts diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 925f67aaf..7df94e57f 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -820,7 +820,9 @@ Use `"date"` for timestamp-based sort (not `"time"`). Export sort types from the ### Generated Docs & Skills -All command docs and skill files are generated via `pnpm run generate:docs` (which runs `generate:banner`, `generate:completions`, `generate:command-docs`, `generate:skill`, then `generate:docs-sections`). This runs automatically as part of `dev`, `build`, `typecheck`, and `test` scripts. +All command docs and skill files are generated via `pnpm run generate:docs`. This runs automatically as part of `dev`, `build`, `typecheck`, and `test` scripts. + +- **One process**: the `generate:*` scripts (except `generate:grammar`, a plain Node script) call `script/generate.ts`, which runs the requested steps in one process so the route tree loads once. Phase 1 writes what the tree imports (API schema, grammars, output-schema field data, banner) and must not load the tree. Phase 2 stubs the generated files the tree imports (`script/lib/route-tree-stubs.ts`), then runs each step's exported `emit()`. Phase-2 modules may import the tree statically, because the runner imports them only after the stubs exist. A new generator exports `emit()` and gets a step in `script/generate.ts`, with no stub code of its own. In `package.json`, call the runner (`tsx script/generate.ts `) rather than chaining `pnpm run generate:*`. `docs` names every step but `sdk`. - **Command docs** (`apps/cli-docs/src/content/docs/commands/*.md`) and the configuration page (`apps/cli-docs/src/content/docs/configuration.md`) are **gitignored** and generated from CLI metadata + hand-written fragments in `apps/cli-docs/src/fragments/` (paths live in `script/paths.ts`). - **Skill files** (`plugins/sentry-cli/skills/sentry-cli/`) are **committed** (consumed by external plugin systems) and auto-committed by CI when stale. diff --git a/packages/cli/package.json b/packages/cli/package.json index 7ada146a2..5e78f3f56 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -56,30 +56,31 @@ "scripts": { "tsx": "tsx --env-file-if-exists=.env.local --import ./script/require-shim.mjs", "cli": "node script/ensure-generated.mjs && tsx --env-file-if-exists=.env.local --import ./script/require-shim.mjs --import ./script/strip-arg-separator.mjs src/bin.ts", - "dev": "pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && tsx --import ./script/require-shim.mjs --import ./script/strip-arg-separator.mjs src/bin.ts", - "build": "pnpm --filter @sentry/mcp-core run build && pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/build.ts --single", - "build:all": "pnpm --filter @sentry/mcp-core run build && pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/build.ts", - "bundle": "pnpm --filter @sentry/mcp-core run build && pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/bundle.ts", - "typecheck": "pnpm --filter @sentry/mcp-core run build && pnpm run generate:docs && pnpm run generate:sdk && tsc --noEmit", + "dev": "tsx script/generate.ts docs sdk && tsx --import ./script/require-shim.mjs --import ./script/strip-arg-separator.mjs src/bin.ts", + "build": "pnpm --filter @sentry/mcp-core run build && tsx script/generate.ts docs sdk && pnpm tsx script/build.ts --single", + "build:all": "pnpm --filter @sentry/mcp-core run build && tsx script/generate.ts docs sdk && pnpm tsx script/build.ts", + "bundle": "pnpm --filter @sentry/mcp-core run build && tsx script/generate.ts docs sdk && pnpm tsx script/bundle.ts", + "typecheck": "pnpm --filter @sentry/mcp-core run build && tsx script/generate.ts docs sdk && tsc --noEmit", "lint": "pnpm -w exec oxlint --report-unused-disable-directives packages/cli && pnpm -w exec oxfmt --check packages/cli", "lint:fix": "pnpm -w exec oxlint --fix packages/cli && pnpm -w exec oxfmt --write packages/cli", "test": "pnpm run test:unit", - "test:unit": "pnpm run generate:docs && pnpm run generate:sdk && vitest run test/lib test/commands test/types test/script --coverage", - "test:changed": "pnpm run generate:docs && pnpm run generate:sdk && vitest run --changed", + "test:unit": "tsx script/generate.ts docs sdk && vitest run test/lib test/commands test/types test/script --coverage", + "test:changed": "tsx script/generate.ts docs sdk && vitest run --changed", "test:e2e": "pnpm tsx script/prepare-e2e-bundle.ts && vitest run test/e2e", "test:init-eval": "vitest run test/init-eval --testTimeout 600000", - "generate:sdk": "pnpm tsx script/generate-sdk.ts", - "generate:skill": "pnpm tsx script/generate-skill.ts", - "generate:banner": "pnpm tsx script/generate-banner-image.ts", - "generate:docs": "node script/ensure-generated.mjs schema && pnpm run generate:grammar && pnpm run generate:output-schemas && pnpm run generate:command-api && pnpm run generate:banner && pnpm run generate:completions && pnpm run generate:top-level-routes && pnpm run generate:command-docs && pnpm run generate:docs-sections && pnpm run generate:skill", + "generate": "tsx script/generate.ts docs sdk", + "generate:sdk": "tsx script/generate.ts sdk", + "generate:skill": "tsx script/generate.ts skill", + "generate:banner": "tsx script/generate.ts banner", + "generate:docs": "tsx script/generate.ts docs", "generate:grammar": "node ../../scripts/generate-parsers.mjs .", - "generate:output-schemas": "pnpm tsx script/generate-output-schemas.ts", - "generate:completions": "pnpm tsx script/generate-completion-targets.ts", - "generate:top-level-routes": "pnpm tsx script/generate-top-level-routes.ts", - "generate:docs-sections": "pnpm tsx script/generate-docs-sections.ts", - "generate:schema": "pnpm tsx script/generate-api-schema.ts", - "generate:command-api": "pnpm tsx script/generate-command-api.ts", - "generate:command-docs": "pnpm tsx script/generate-command-docs.ts", + "generate:output-schemas": "tsx script/generate.ts output-schemas", + "generate:completions": "tsx script/generate.ts completions", + "generate:top-level-routes": "tsx script/generate.ts top-level-routes", + "generate:docs-sections": "tsx script/generate.ts docs-sections", + "generate:schema": "tsx script/generate.ts schema", + "generate:command-api": "tsx script/generate.ts command-api", + "generate:command-docs": "tsx script/generate.ts command-docs", "eval:skill": "pnpm tsx script/eval-skill.ts", "bench": "pnpm tsx script/bench.ts", "bench:save": "pnpm tsx script/bench.ts --save-baseline", @@ -89,7 +90,7 @@ "check:deps": "pnpm tsx script/check-no-deps.ts", "check:errors": "pnpm tsx script/check-error-patterns.ts", "check:patches": "pnpm tsx script/check-patches.ts", - "check:docs-sections": "pnpm tsx script/generate-docs-sections.ts --check", + "check:docs-sections": "tsx script/generate.ts docs-sections --check", "check:env-coverage": "pnpm tsx script/check-env-coverage.ts", "check:stale-refs": "pnpm tsx script/check-stale-references.ts", "check:doc-examples": "pnpm tsx script/check-doc-examples.ts", diff --git a/packages/cli/script/check-doc-examples.ts b/packages/cli/script/check-doc-examples.ts index ad6689cbd..648048435 100644 --- a/packages/cli/script/check-doc-examples.ts +++ b/packages/cli/script/check-doc-examples.ts @@ -27,8 +27,8 @@ * 1 - At least one example names a missing command, flag, or ambiguous target */ -import { mkdirSync, readdirSync } from "node:fs"; -import { access, readFile, writeFile } from "node:fs/promises"; +import { readdirSync } from "node:fs"; +import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { exampleSteps } from "../src/lib/command-example.js"; import type { @@ -37,6 +37,7 @@ import type { RouteMap, RouteTarget, } from "../src/lib/introspect.js"; +import { writeRouteTreeStubs } from "./lib/route-tree-stubs.js"; import { blankCodeBlocks, markdownCodeBlocks } from "./markdown.js"; import { DOCS_CONTENT, DOCS_FRAGMENTS } from "./paths.js"; import { @@ -46,19 +47,7 @@ import { } from "./sdk-run-calls.js"; import { shellCommands } from "./shell-words.js"; -// Bootstrap: skill-content stub (same pattern as generate-docs-sections.ts) -const SKILL_CONTENT_PATH = "src/generated/skill-content.ts"; -const skillContentExists = await access(SKILL_CONTENT_PATH).then( - () => true, - () => false, -); -if (!skillContentExists) { - mkdirSync("src/generated", { recursive: true }); - await writeFile( - SKILL_CONTENT_PATH, - "export const SKILL_FILES: [string, string][] = [];\n", - ); -} +await writeRouteTreeStubs(); const { routes } = await import("../src/app.js"); const { isCommand, isRouteMap } = await import("../src/lib/introspect.js"); diff --git a/packages/cli/script/check-fragments.ts b/packages/cli/script/check-fragments.ts index 00b18ade8..fe97a9d60 100644 --- a/packages/cli/script/check-fragments.ts +++ b/packages/cli/script/check-fragments.ts @@ -20,26 +20,15 @@ * tsx script/check-fragments.ts --strict # Errors for missing subcommands */ -import { mkdirSync, readdirSync } from "node:fs"; -import { access, readFile, writeFile } from "node:fs/promises"; +import { readdirSync } from "node:fs"; +import { access, readFile } from "node:fs/promises"; import type { RouteInfo, RouteMap } from "../src/lib/introspect.js"; import { findFragmentCommandIssues } from "./fragment-commands.js"; +import { writeRouteTreeStubs } from "./lib/route-tree-stubs.js"; import { markdownHeadings } from "./markdown.js"; import { DOCS_FRAGMENTS } from "./paths.js"; -// Ensure skill-content stub exists (see generate-command-docs.ts for rationale) -const SKILL_CONTENT_PATH = "src/generated/skill-content.ts"; -const skillContentExists = await access(SKILL_CONTENT_PATH).then( - () => true, - () => false, -); -if (!skillContentExists) { - mkdirSync("src/generated", { recursive: true }); - await writeFile( - SKILL_CONTENT_PATH, - "export const SKILL_FILES: [string, string][] = [];\n", - ); -} +await writeRouteTreeStubs(); const { routes } = await import("../src/app.js"); const { extractAllRoutes } = await import("../src/lib/introspect.js"); diff --git a/packages/cli/script/generate-api-schema.ts b/packages/cli/script/generate-api-schema.ts index b746eb772..9fb87d615 100644 --- a/packages/cli/script/generate-api-schema.ts +++ b/packages/cli/script/generate-api-schema.ts @@ -1,8 +1,7 @@ -#!/usr/bin/env tsx /** * Generate the CLI discovery index from the installed SDK's operation catalog. * - * Usage: tsx script/generate-api-schema.ts + * Usage: tsx script/generate.ts schema * Outputs: src/generated/api-schema.json and src/generated/idempotent-operations.json */ @@ -14,22 +13,26 @@ import { projectApiOperations } from "./lib/api-schema.js"; const OUTPUT_PATH = "src/generated/api-schema.json"; const IDEMPOTENT_OUTPUT_PATH = "src/generated/idempotent-operations.json"; -const endpoints = projectApiOperations(operations); -await mkdir(dirname(OUTPUT_PATH), { recursive: true }); -await writeFile(OUTPUT_PATH, JSON.stringify(endpoints, null, 2)); +/** Write the endpoint index and the idempotent write operations. */ +export async function emit(): Promise { + const endpoints = projectApiOperations(operations); -console.log( - `Generated ${OUTPUT_PATH} (${endpoints.length} endpoints, ${Math.round(JSON.stringify(endpoints).length / 1024)}KB)`, -); + await mkdir(dirname(OUTPUT_PATH), { recursive: true }); + await writeFile(OUTPUT_PATH, JSON.stringify(endpoints, null, 2)); -const idempotentOperations = selectIdempotentWrites(operations); + console.log( + `Generated ${OUTPUT_PATH} (${endpoints.length} endpoints, ${Math.round(JSON.stringify(endpoints).length / 1024)}KB)`, + ); -await writeFile( - IDEMPOTENT_OUTPUT_PATH, - JSON.stringify(idempotentOperations, null, 2), -); + const idempotentOperations = selectIdempotentWrites(operations); -console.log( - `Generated ${IDEMPOTENT_OUTPUT_PATH} (${idempotentOperations.length} operations)`, -); + await writeFile( + IDEMPOTENT_OUTPUT_PATH, + JSON.stringify(idempotentOperations, null, 2), + ); + + console.log( + `Generated ${IDEMPOTENT_OUTPUT_PATH} (${idempotentOperations.length} operations)`, + ); +} diff --git a/packages/cli/script/generate-banner-image.ts b/packages/cli/script/generate-banner-image.ts index 235b4cf4b..43979afc9 100644 --- a/packages/cli/script/generate-banner-image.ts +++ b/packages/cli/script/generate-banner-image.ts @@ -1,4 +1,3 @@ -#!/usr/bin/env tsx /** * Generate the Graphics Banner * @@ -16,7 +15,7 @@ * Output is a committed module (like `src/generated/skill-content.ts`) so the * runtime never needs to read the asset from disk. * - * Usage: tsx script/generate-banner-image.ts + * Usage: tsx script/generate.ts banner * Output: src/generated/banner-image.ts */ @@ -119,7 +118,8 @@ function encodePngBase64(mask: PNG): string { return PNG.sync.write(out, { deflateLevel: 9 }).toString("base64"); } -async function main(): Promise { +/** Write `src/generated/banner-image.ts` from the banner mask. */ +export async function emit(): Promise { const png = PNG.sync.read(readFileSync(MASK_PATH)); const pngBase64 = encodePngBase64(png); @@ -149,8 +149,3 @@ export const BANNER_IMAGE = { `Wrote ${OUT_PATH} (${png.width}x${png.height}, png ${pngBase64.length} base64 bytes)\n`, ); } - -main().catch((err) => { - process.stderr.write(`generate-banner-image failed: ${String(err)}\n`); - process.exit(1); -}); diff --git a/packages/cli/script/generate-command-api.ts b/packages/cli/script/generate-command-api.ts index e6cf730dd..06b23fbdd 100644 --- a/packages/cli/script/generate-command-api.ts +++ b/packages/cli/script/generate-command-api.ts @@ -1,138 +1,124 @@ -#!/usr/bin/env tsx /** Generate command/API relationships from source calls and the public route tree. */ -import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import { readFile, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { glob } from "tinyglobby"; +import { routes } from "../src/app.js"; import type { ApiEndpoint, ApiReference } from "../src/lib/api-schema.js"; -import type { RouteMap, RouteTarget } from "../src/lib/introspect.js"; +import { + isCommand, + publicTargetPaths, + type RouteMap, + type RouteTarget, +} from "../src/lib/introspect.js"; import { analyzeSourceApiUsage } from "./lib/source-api-usage.js"; const OUTPUT_PATH = "src/generated/command-api.json"; -// The route tree imports these generated files. Bootstrap before importing it; -// generate:top-level-routes and generate:skill replace their stubs later in -// the same generate:docs pipeline. -await mkdir("src/generated", { recursive: true }); -for (const [path, stub] of [ - [OUTPUT_PATH, "{}\n"], - ["src/generated/top-level-routes.json", "{}\n"], - [ - "src/generated/skill-content.ts", - "export const SKILL_FILES: [string, string][] = [];\n", - ], -] as const) { - if ( - !(await access(path).then( - () => true, - () => false, - )) - ) { - await writeFile(path, stub); - } -} - -const files = await glob(["src/**/*.{ts,tsx}"], { - ignore: ["src/generated/**"], -}); -const sources = new Map( - await Promise.all( - files - .sort() - .map(async (file) => [file, await readFile(file, "utf8")] as const), - ), -); -const endpoints = JSON.parse( - await readFile("src/generated/api-schema.json", "utf8"), -) as ApiEndpoint[]; -const analysis = analyzeSourceApiUsage( - sources, - endpoints - .filter((entry) => entry.fn) - .map((entry) => ({ - functionName: entry.fn, - method: entry.method, - path: entry.path, - })), -); +/** Write which API operations each public command calls. */ +export async function emit(): Promise { + const files = await glob(["src/**/*.{ts,tsx}"], { + ignore: ["src/generated/**"], + }); + const sources = new Map( + await Promise.all( + files + .sort() + .map(async (file) => [file, await readFile(file, "utf8")] as const), + ), + ); + const endpoints = JSON.parse( + await readFile("src/generated/api-schema.json", "utf8"), + ) as ApiEndpoint[]; + const analysis = analyzeSourceApiUsage( + sources, + endpoints + .filter((entry) => entry.fn) + .map((entry) => ({ + functionName: entry.fn, + method: entry.method, + path: entry.path, + })), + ); -const { routes } = await import("../src/app.js"); -const { isCommand, publicTargetPaths } = - await import("../src/lib/introspect.js"); -const paths = publicTargetPaths(routes as unknown as RouteMap); -const matched = new Set(); -const references: Record = {}; -const unresolved = new Map; reason: string }>(); + const paths = publicTargetPaths(routes as unknown as RouteMap); + const matched = new Set(); + const references: Record = {}; + const unresolved = new Map< + string, + { commands: Set; reason: string } + >(); -for (const { file, name } of analysis.commands) { - const module = await import(pathToFileURL(resolve(file)).href); - const target = module[name] as RouteTarget | undefined; - const path = target && paths.get(target); - if (!target || !path || !isCommand(target)) continue; - matched.add(target); - const command = `sentry ${path.join(" ")}`; - const usage = analysis.exports.get(file)?.get(name); - if (!usage) continue; - const entries = (references[command] ??= []); - for (const operation of usage.operations) { - const context = Object.entries(operation.query ?? {}) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([key, value]) => `${key}=${value}`) - .join(", "); - const reference: ApiReference = { - fn: operation.functionName, - ...(context - ? { - note: `Known query context: ${context}. The command may add filters or requests.`, - } - : {}), - }; - if ( - !entries.some( - (entry) => entry.fn === reference.fn && entry.note === reference.note, + for (const { file, name } of analysis.commands) { + const module = await import(pathToFileURL(resolve(file)).href); + const target = module[name] as RouteTarget | undefined; + const path = target && paths.get(target); + if (!target || !path || !isCommand(target)) continue; + matched.add(target); + const command = `sentry ${path.join(" ")}`; + const usage = analysis.exports.get(file)?.get(name); + if (!usage) continue; + const entries = (references[command] ??= []); + for (const operation of usage.operations) { + const context = Object.entries(operation.query ?? {}) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, value]) => `${key}=${value}`) + .join(", "); + const reference: ApiReference = { + fn: operation.functionName, + ...(context + ? { + note: `Known query context: ${context}. The command may add filters or requests.`, + } + : {}), + }; + if ( + !entries.some( + (entry) => entry.fn === reference.fn && entry.note === reference.note, + ) ) - ) - entries.push(reference); - } - for (const request of usage.unresolvedRequests) { - const key = `${request.file}:${request.line}`; - const diagnostic = unresolved.get(key) ?? { - commands: new Set(), - reason: request.reason, - }; - diagnostic.commands.add(command); - unresolved.set(key, diagnostic); + entries.push(reference); + } + for (const request of usage.unresolvedRequests) { + const key = `${request.file}:${request.line}`; + const diagnostic = unresolved.get(key) ?? { + commands: new Set(), + reason: request.reason, + }; + diagnostic.commands.add(command); + unresolved.set(key, diagnostic); + } } -} -for (const [target, path] of paths) { - if (isCommand(target) && !matched.has(target)) { + for (const [target, path] of paths) { + if (isCommand(target) && !matched.has(target)) { + console.warn( + `API discovery: no source command export found for sentry ${path.join(" ")}`, + ); + } + } + for (const [location, { commands, reason }] of unresolved) { console.warn( - `API discovery: no source command export found for sentry ${path.join(" ")}`, + `API discovery: ${location}: ${reason} (${[...commands].sort().join(", ")})`, ); } -} -for (const [location, { commands, reason }] of unresolved) { - console.warn( - `API discovery: ${location}: ${reason} (${[...commands].sort().join(", ")})`, + + const catalog = Object.fromEntries( + Object.entries(references) + .filter(([, entries]) => entries.length) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([command, entries]) => [ + command, + entries.sort( + (a, b) => + a.fn.localeCompare(b.fn) || + (a.note ?? "").localeCompare(b.note ?? ""), + ), + ]), + ); + await writeFile(OUTPUT_PATH, `${JSON.stringify(catalog, null, 2)}\n`); + console.log( + `Generated ${OUTPUT_PATH} (${Object.keys(catalog).length} commands; ${unresolved.size} unresolved requests)`, ); } - -const catalog = Object.fromEntries( - Object.entries(references) - .filter(([, entries]) => entries.length) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([command, entries]) => [ - command, - entries.sort( - (a, b) => - a.fn.localeCompare(b.fn) || - (a.note ?? "").localeCompare(b.note ?? ""), - ), - ]), -); -await writeFile(OUTPUT_PATH, `${JSON.stringify(catalog, null, 2)}\n`); -console.log( - `Generated ${OUTPUT_PATH} (${Object.keys(catalog).length} commands; ${unresolved.size} unresolved requests)`, -); diff --git a/packages/cli/script/generate-command-docs.ts b/packages/cli/script/generate-command-docs.ts index 36e735a5a..6827a57d0 100644 --- a/packages/cli/script/generate-command-docs.ts +++ b/packages/cli/script/generate-command-docs.ts @@ -1,4 +1,3 @@ -#!/usr/bin/env tsx /** * Generate Command Reference & Configuration Documentation * @@ -15,7 +14,7 @@ * source of truth for custom content. * * Usage: - * tsx script/generate-command-docs.ts + * tsx script/generate.ts command-docs * * Output: * docs/src/content/docs/commands/{route}.md (one per visible route) @@ -24,7 +23,16 @@ */ import { mkdirSync, rmSync } from "node:fs"; -import { access, readFile, writeFile } from "node:fs/promises"; +import { readFile, writeFile } from "node:fs/promises"; +import { routes } from "../src/app.js"; +import { ENV_VAR_REGISTRY, type EnvVarEntry } from "../src/lib/env-registry.js"; +import { + type CommandInfo, + extractAllRoutes, + type FlagInfo, + type RouteInfo, + type RouteMap, +} from "../src/lib/introspect.js"; import { formatCommandArguments, formatCommandExamples, @@ -33,36 +41,6 @@ import { import { stripAllowCommandMarkers } from "./fragment-commands.js"; import { DOCS_CONTENT, DOCS_FRAGMENTS } from "./paths.js"; -// Ensure src/generated/skill-content.ts exists before importing the route tree. -// The route tree transitively imports agent-skills.ts → skill-content.ts, which -// is generated by generate:skill. On a fresh checkout (or CI without prior runs), -// the file won't exist yet. We create a minimal stub so the import resolves; -// generate:skill will overwrite it with real content afterward. -// Must run before the dynamic import below (static imports hoist above top-level await). -const SKILL_CONTENT_PATH = "src/generated/skill-content.ts"; -const SKILL_CONTENT_STUB = - "export const SKILL_FILES: [string, string][] = [];\n"; -const skillContentExists = await access(SKILL_CONTENT_PATH).then( - () => true, - () => false, -); -if (!skillContentExists) { - mkdirSync("src/generated", { recursive: true }); - await writeFile(SKILL_CONTENT_PATH, SKILL_CONTENT_STUB); -} - -import type { EnvVarEntry } from "../src/lib/env-registry.js"; -import type { - CommandInfo, - FlagInfo, - RouteInfo, - RouteMap, -} from "../src/lib/introspect.js"; - -const { routes } = await import("../src/app.js"); -const { extractAllRoutes } = await import("../src/lib/introspect.js"); -const { ENV_VAR_REGISTRY } = await import("../src/lib/env-registry.js"); - const DOCS_DIR = `${DOCS_CONTENT}/commands`; const DOCS_CONTENT_DIR = DOCS_CONTENT; const FRAGMENTS_DIR = `${DOCS_FRAGMENTS}/commands`; @@ -385,72 +363,75 @@ async function readTopLevelFragment(fragmentName: string): Promise { // Main // --------------------------------------------------------------------------- -const routeMap = routes as unknown as RouteMap; -const routeInfos = extractAllRoutes(routeMap).filter( - (r) => !SKIP_ROUTES.has(r.name), -); +/** Write the command reference pages, their index, and the configuration page. */ +export async function emit(): Promise { + const routeMap = routes as unknown as RouteMap; + const routeInfos = extractAllRoutes(routeMap).filter( + (r) => !SKIP_ROUTES.has(r.name), + ); -const generatedFiles: string[] = []; + const generatedFiles: string[] = []; -// Ensure output directory exists (it's gitignored, may be absent on fresh checkout) -mkdirSync(DOCS_DIR, { recursive: true }); + // Ensure output directory exists (it's gitignored, may be absent on fresh checkout) + mkdirSync(DOCS_DIR, { recursive: true }); -// Clean up legacy cli/ subdirectory if it exists (we now use cli.md) -try { - rmSync(`${DOCS_DIR}/cli`, { recursive: true, force: true }); -} catch { - // Directory may not exist -} + // Clean up legacy cli/ subdirectory if it exists (we now use cli.md) + try { + rmSync(`${DOCS_DIR}/cli`, { recursive: true, force: true }); + } catch { + // Directory may not exist + } -// Generate command doc pages -for (const route of routeInfos) { - const filePath = `${DOCS_DIR}/${route.name}.md`; - const pageContent = generatePage(route); - const customContent = await readCustomContent(route.name); - const fullContent = customContent - ? pageContent + customContent - : `${pageContent}\n`; - - await writeFile(filePath, fullContent); - generatedFiles.push(filePath); -} + // Generate command doc pages + for (const route of routeInfos) { + const filePath = `${DOCS_DIR}/${route.name}.md`; + const pageContent = generatePage(route); + const customContent = await readCustomContent(route.name); + const fullContent = customContent + ? pageContent + customContent + : `${pageContent}\n`; + + await writeFile(filePath, fullContent); + generatedFiles.push(filePath); + } -// Update commands/index.md -const indexCustomContent = await readCustomContent("index"); -const indexLines: string[] = []; -indexLines.push("---"); -indexLines.push("title: Commands"); -indexLines.push("description: Available commands in the Sentry CLI"); -indexLines.push("---"); -indexLines.push(""); -indexLines.push( - "The Sentry CLI provides commands for interacting with various Sentry resources.", -); -indexLines.push(""); -indexLines.push("## Available Commands"); -indexLines.push(""); -indexLines.push(generateCommandsTable(routeInfos)); -indexLines.push(""); -indexLines.push(GENERATED_END_MARKER); - -const indexContent = indexCustomContent - ? indexLines.join("\n") + indexCustomContent - : `${indexLines.join("\n")}\n`; - -await writeFile(INDEX_PATH, indexContent); + // Update commands/index.md + const indexCustomContent = await readCustomContent("index"); + const indexLines: string[] = []; + indexLines.push("---"); + indexLines.push("title: Commands"); + indexLines.push("description: Available commands in the Sentry CLI"); + indexLines.push("---"); + indexLines.push(""); + indexLines.push( + "The Sentry CLI provides commands for interacting with various Sentry resources.", + ); + indexLines.push(""); + indexLines.push("## Available Commands"); + indexLines.push(""); + indexLines.push(generateCommandsTable(routeInfos)); + indexLines.push(""); + indexLines.push(GENERATED_END_MARKER); -// --------------------------------------------------------------------------- -// Generate configuration.md (env var reference + fragment) -// --------------------------------------------------------------------------- + const indexContent = indexCustomContent + ? indexLines.join("\n") + indexCustomContent + : `${indexLines.join("\n")}\n`; + + await writeFile(INDEX_PATH, indexContent); + + // --------------------------------------------------------------------------- + // Generate configuration.md (env var reference + fragment) + // --------------------------------------------------------------------------- -const configGenerated = generateConfigurationPage(ENV_VAR_REGISTRY); -const configFragment = await readTopLevelFragment("configuration"); -const configContent = configFragment - ? configGenerated + configFragment - : `${configGenerated}\n`; + const configGenerated = generateConfigurationPage(ENV_VAR_REGISTRY); + const configFragment = await readTopLevelFragment("configuration"); + const configContent = configFragment + ? configGenerated + configFragment + : `${configGenerated}\n`; -await writeFile(CONFIG_PATH, configContent); + await writeFile(CONFIG_PATH, configContent); -console.log( - `Generated ${generatedFiles.length} command doc pages + ${INDEX_PATH} + ${CONFIG_PATH}`, -); + console.log( + `Generated ${generatedFiles.length} command doc pages + ${INDEX_PATH} + ${CONFIG_PATH}`, + ); +} diff --git a/packages/cli/script/generate-completion-targets.ts b/packages/cli/script/generate-completion-targets.ts index 8512fe98e..714ed4e30 100644 --- a/packages/cli/script/generate-completion-targets.ts +++ b/packages/cli/script/generate-completion-targets.ts @@ -1,4 +1,3 @@ -#!/usr/bin/env tsx /** * Generate Shell-Completion Target Sets * @@ -16,50 +15,38 @@ * The output is gitignored and regenerated by `generate:docs`, which runs as * part of `dev`, `build`, `bundle`, `typecheck`, and `test`. * - * Usage: tsx script/generate-completion-targets.ts + * Usage: tsx script/generate.ts completions * Output: src/generated/completion-targets.json */ import { mkdirSync } from "node:fs"; -import { access, writeFile } from "node:fs/promises"; - -// Bootstrap: skill-content stub (same pattern as generate-command-docs.ts). -// The route tree transitively imports skill-content.ts, which may not exist -// yet on a fresh checkout. Must run before the dynamic import below. -const SKILL_CONTENT_PATH = "src/generated/skill-content.ts"; -const SKILL_CONTENT_STUB = - "export const SKILL_FILES: [string, string][] = [];\n"; -const skillContentExists = await access(SKILL_CONTENT_PATH).then( - () => true, - () => false, -); -if (!skillContentExists) { - mkdirSync("src/generated", { recursive: true }); - await writeFile(SKILL_CONTENT_PATH, SKILL_CONTENT_STUB); -} - -import type { RouteMap } from "../src/lib/introspect.js"; - -const { routes } = await import("../src/app.js"); -const { extractCommandCompletions, extractRouteGroups } = - await import("../src/lib/introspect.js"); -const { COMPLETION_VALUES } = await import("../src/lib/completion-target.js"); -const { getApiCompletionPaths } = await import("../src/lib/api-schema.js"); +import { writeFile } from "node:fs/promises"; +import { routes } from "../src/app.js"; +import { getApiCompletionPaths } from "../src/lib/api-schema.js"; +import { COMPLETION_VALUES } from "../src/lib/completion-target.js"; +import { + extractCommandCompletions, + extractRouteGroups, + type RouteMap, +} from "../src/lib/introspect.js"; const OUTPUT_PATH = "src/generated/completion-targets.json"; -const routeMap = routes as unknown as RouteMap; -const commands = extractCommandCompletions(routeMap); -const groups = extractRouteGroups(routeMap); +/** Write the completion kinds, route groups, and completer values. */ +export async function emit(): Promise { + const routeMap = routes as unknown as RouteMap; + const commands = extractCommandCompletions(routeMap); + const groups = extractRouteGroups(routeMap); -// Plain JSON (like api-schema.json) so the output is produced by a real -// serializer rather than hand-assembled source text. -mkdirSync("src/generated", { recursive: true }); -await writeFile( - OUTPUT_PATH, - `${JSON.stringify({ commands, groups, values: { ...COMPLETION_VALUES, apiPaths: getApiCompletionPaths() } }, null, 2)}\n`, - "utf8", -); -process.stdout.write( - `Wrote ${OUTPUT_PATH} (${Object.keys(commands).length} commands)\n`, -); + // Plain JSON (like api-schema.json) so the output is produced by a real + // serializer rather than hand-assembled source text. + mkdirSync("src/generated", { recursive: true }); + await writeFile( + OUTPUT_PATH, + `${JSON.stringify({ commands, groups, values: { ...COMPLETION_VALUES, apiPaths: getApiCompletionPaths() } }, null, 2)}\n`, + "utf8", + ); + process.stdout.write( + `Wrote ${OUTPUT_PATH} (${Object.keys(commands).length} commands)\n`, + ); +} diff --git a/packages/cli/script/generate-docs-sections.ts b/packages/cli/script/generate-docs-sections.ts index 0bcca0e6c..3db221fad 100644 --- a/packages/cli/script/generate-docs-sections.ts +++ b/packages/cli/script/generate-docs-sections.ts @@ -1,4 +1,3 @@ -#!/usr/bin/env tsx /** * Generate Documentation Sections (Marker-Based) * @@ -26,45 +25,35 @@ * this script edits committed files in-place between marker pairs. * * Usage: - * tsx script/generate-docs-sections.ts # Generate (write) - * tsx script/generate-docs-sections.ts --check # Dry-run, exit 1 if stale + * tsx script/generate.ts docs-sections # Generate (write) + * tsx script/generate.ts docs-sections --check # Dry-run, exit 1 if stale */ -import { existsSync, mkdirSync } from "node:fs"; -import { access, readFile, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { readFile, writeFile } from "node:fs/promises"; import peggy from "peggy"; import { DOCS_CONTENT, DOCS_FRAGMENTS } from "./paths.js"; import { shellCommands } from "./shell-words.js"; -// Bootstrap: skill-content stub (same pattern as generate-command-docs.ts) -const SKILL_CONTENT_PATH = "src/generated/skill-content.ts"; -const SKILL_CONTENT_STUB = - "export const SKILL_FILES: [string, string][] = [];\n"; -const skillContentExists = await access(SKILL_CONTENT_PATH).then( - () => true, - () => false, -); -if (!skillContentExists) { - mkdirSync("src/generated", { recursive: true }); - await writeFile(SKILL_CONTENT_PATH, SKILL_CONTENT_STUB); -} - -import type { EnvVarEntry } from "../src/lib/env-registry.js"; +import { routes } from "../src/app.js"; +import { parse as parseIssueSyntax } from "../src/generated/issue-argument-grammar.js"; +import { buildOrgAwareAliases } from "../src/lib/alias.js"; +import { parseSelector } from "../src/lib/arg-parsing.js"; import { exampleSteps } from "../src/lib/command-example.js"; -import type { Command, RouteInfo, RouteMap } from "../src/lib/introspect.js"; - -const { routes } = await import("../src/app.js"); -const { extractAllRoutes, isCommand, isRouteMap } = - await import("../src/lib/introspect.js"); -const { OAUTH_SCOPES } = await import("../src/lib/oauth.js"); -const { ENV_VAR_REGISTRY } = await import("../src/lib/env-registry.js"); -const { AGENT_DISPLAY_NAMES } = await import("../src/lib/detect-agent.js"); -const { TEXT_EXTENSIONS } = await import("../src/lib/scan/constants.js"); -const { buildOrgAwareAliases } = await import("../src/lib/alias.js"); -const { parseIssueArgument } = await import("../src/lib/issue-arguments.js"); -const { parseSelector } = await import("../src/lib/arg-parsing.js"); -const { parse: parseIssueSyntax } = - await import("../src/generated/issue-argument-grammar.js"); +import { AGENT_DISPLAY_NAMES } from "../src/lib/detect-agent.js"; +import { ENV_VAR_REGISTRY, type EnvVarEntry } from "../src/lib/env-registry.js"; +import { + type Command, + extractAllRoutes, + isCommand, + isRouteMap, + type RouteInfo, + type RouteMap, +} from "../src/lib/introspect.js"; +import { parseIssueArgument } from "../src/lib/issue-arguments.js"; +import { OAUTH_SCOPES } from "../src/lib/oauth.js"; +import { TEXT_EXTENSIONS } from "../src/lib/scan/constants.js"; + const pkg = JSON.parse(await readFile("package.json", "utf-8")); const TARGET_GRAMMAR = "grammar/org-project-target.pegjs"; const targetGrammar = await readFile(TARGET_GRAMMAR, "utf-8"); @@ -72,7 +61,7 @@ const ISSUE_GRAMMAR = "grammar/issue-argument.pegjs"; const issueGrammar = await readFile(ISSUE_GRAMMAR, "utf-8"); const workspace = JSON.parse(await readFile("../../package.json", "utf-8")); -const isCheck = process.argv.includes("--check"); +const routeMap = routes as unknown as RouteMap; // --------------------------------------------------------------------------- // Marker Replacement @@ -978,193 +967,199 @@ type SectionDef = { // Main // --------------------------------------------------------------------------- -const routeMap = routes as unknown as RouteMap; -const routeInfos = extractAllRoutes(routeMap); - -const sections: SectionDef[] = [ - // -- Existing sections -- - { - filePath: `${DOCS_CONTENT}/contributing.md`, - sectionName: "project-structure", - generate: () => generateProjectStructure(routeInfos), - }, - { - filePath: "DEVELOPMENT.md", - sectionName: "oauth-scopes", - generate: () => generateScopesBulletList(OAUTH_SCOPES), - }, - { - filePath: `${DOCS_CONTENT}/self-hosted.md`, - sectionName: "oauth-scopes", - generate: () => generateScopesInline(OAUTH_SCOPES), - }, - // -- Prerequisites (version numbers from package.json) -- - { - filePath: "README.md", - sectionName: "dev-prereq", - generate: generateDevPrereq, - }, - { - filePath: "README.md", - sectionName: "library-prereq", - generate: generateLibraryPrereq, - }, - { - filePath: `${DOCS_CONTENT}/contributing.md`, - sectionName: "dev-prereq", - generate: generateDevPrereqContributing, - }, - { - filePath: "DEVELOPMENT.md", - sectionName: "dev-prereq", - generate: generateDevPrereqDevelopment, - }, - { - filePath: "DEVELOPMENT.md", - sectionName: "build-toolchain", - generate: generateBuildToolchain, - }, - { - filePath: `${DOCS_CONTENT}/contributing.md`, - sectionName: "build-commands", - generate: generateBuildCommands, - }, - // -- Dev scripts (README.md) -- - { - filePath: "README.md", - sectionName: "dev-scripts", - generate: generateDevScripts, - }, - // -- Env var tables -- - { - filePath: "DEVELOPMENT.md", - sectionName: "dev-env-vars", - generate: generateDevEnvVarsTable, - }, - { - filePath: `${DOCS_CONTENT}/self-hosted.md`, - sectionName: "self-hosted-env-vars", - generate: generateSelfHostedEnvVarsTable, - }, - // -- Platform support (getting-started.mdx) -- - { - filePath: `${DOCS_CONTENT}/getting-started.mdx`, - sectionName: "platform-support", - generate: generatePlatformSupport, - markerStyle: "mdx", - }, - // -- Lists that mirror code -- - { - filePath: `${DOCS_CONTENT}/agent-guidance.md`, - sectionName: "destructive-commands", - generate: () => generateDestructiveCommands(routeInfos), - }, - { - filePath: `${DOCS_CONTENT}/agentic-usage.md`, - sectionName: "detected-agents", - generate: generateDetectedAgents, - }, - { - filePath: `${DOCS_FRAGMENTS}/commands/index.md`, - sectionName: "target-syntax", - generate: generateTargetSyntax, - }, - { - filePath: "CONTRIBUTING.md", - sectionName: "target-syntax", - generate: generateTargetSyntax, - }, - { - filePath: "plugins/README.md", - sectionName: "skill-layout", - generate: generateSkillLayout, - }, - { - filePath: `${DOCS_CONTENT}/features.md`, - sectionName: "dsn-extensions", - generate: generateDsnExtensions, - }, - { - filePath: `${DOCS_CONTENT}/features.md`, - sectionName: "alias-unique-prefix", - generate: () => - generateAliasTable( - "Project Slug", - inOrg("acme", ["frontend", "functions", "backend"]), - projectSlug, - ), - }, - { - filePath: `${DOCS_CONTENT}/features.md`, - sectionName: "alias-common-prefix", - generate: () => - generateAliasTable( - "Project Slug", - inOrg("acme", [ - "spotlight-electron", - "spotlight-website", - "spotlight-backend", - ]), - projectSlug, - ), - }, - { - filePath: `${DOCS_CONTENT}/features.md`, - sectionName: "alias-cross-org", - generate: () => - generateAliasTable( - "Org/Project", - [ - { org: "acme", project: "api" }, - { org: "globex", project: "api" }, - ], - orgProject, - ), - }, - { - filePath: `${DOCS_CONTENT}/features.md`, - sectionName: "issue-id-formats", - generate: generateIssueIdFormats, - }, - { - filePath: `${DOCS_CONTENT}/library-usage.md`, - sectionName: "sdk-run-examples", - generate: generateSdkRunExamples, - }, -]; +/** + * Regenerate every marker section. With `check`, write nothing and set a + * failing exit code when any section is stale. + */ +export async function emit({ check }: { check: boolean }): Promise { + const routeInfos = extractAllRoutes(routeMap); + + const sections: SectionDef[] = [ + // -- Existing sections -- + { + filePath: `${DOCS_CONTENT}/contributing.md`, + sectionName: "project-structure", + generate: () => generateProjectStructure(routeInfos), + }, + { + filePath: "DEVELOPMENT.md", + sectionName: "oauth-scopes", + generate: () => generateScopesBulletList(OAUTH_SCOPES), + }, + { + filePath: `${DOCS_CONTENT}/self-hosted.md`, + sectionName: "oauth-scopes", + generate: () => generateScopesInline(OAUTH_SCOPES), + }, + // -- Prerequisites (version numbers from package.json) -- + { + filePath: "README.md", + sectionName: "dev-prereq", + generate: generateDevPrereq, + }, + { + filePath: "README.md", + sectionName: "library-prereq", + generate: generateLibraryPrereq, + }, + { + filePath: `${DOCS_CONTENT}/contributing.md`, + sectionName: "dev-prereq", + generate: generateDevPrereqContributing, + }, + { + filePath: "DEVELOPMENT.md", + sectionName: "dev-prereq", + generate: generateDevPrereqDevelopment, + }, + { + filePath: "DEVELOPMENT.md", + sectionName: "build-toolchain", + generate: generateBuildToolchain, + }, + { + filePath: `${DOCS_CONTENT}/contributing.md`, + sectionName: "build-commands", + generate: generateBuildCommands, + }, + // -- Dev scripts (README.md) -- + { + filePath: "README.md", + sectionName: "dev-scripts", + generate: generateDevScripts, + }, + // -- Env var tables -- + { + filePath: "DEVELOPMENT.md", + sectionName: "dev-env-vars", + generate: generateDevEnvVarsTable, + }, + { + filePath: `${DOCS_CONTENT}/self-hosted.md`, + sectionName: "self-hosted-env-vars", + generate: generateSelfHostedEnvVarsTable, + }, + // -- Platform support (getting-started.mdx) -- + { + filePath: `${DOCS_CONTENT}/getting-started.mdx`, + sectionName: "platform-support", + generate: generatePlatformSupport, + markerStyle: "mdx", + }, + // -- Lists that mirror code -- + { + filePath: `${DOCS_CONTENT}/agent-guidance.md`, + sectionName: "destructive-commands", + generate: () => generateDestructiveCommands(routeInfos), + }, + { + filePath: `${DOCS_CONTENT}/agentic-usage.md`, + sectionName: "detected-agents", + generate: generateDetectedAgents, + }, + { + filePath: `${DOCS_FRAGMENTS}/commands/index.md`, + sectionName: "target-syntax", + generate: generateTargetSyntax, + }, + { + filePath: "CONTRIBUTING.md", + sectionName: "target-syntax", + generate: generateTargetSyntax, + }, + { + filePath: "plugins/README.md", + sectionName: "skill-layout", + generate: generateSkillLayout, + }, + { + filePath: `${DOCS_CONTENT}/features.md`, + sectionName: "dsn-extensions", + generate: generateDsnExtensions, + }, + { + filePath: `${DOCS_CONTENT}/features.md`, + sectionName: "alias-unique-prefix", + generate: () => + generateAliasTable( + "Project Slug", + inOrg("acme", ["frontend", "functions", "backend"]), + projectSlug, + ), + }, + { + filePath: `${DOCS_CONTENT}/features.md`, + sectionName: "alias-common-prefix", + generate: () => + generateAliasTable( + "Project Slug", + inOrg("acme", [ + "spotlight-electron", + "spotlight-website", + "spotlight-backend", + ]), + projectSlug, + ), + }, + { + filePath: `${DOCS_CONTENT}/features.md`, + sectionName: "alias-cross-org", + generate: () => + generateAliasTable( + "Org/Project", + [ + { org: "acme", project: "api" }, + { org: "globex", project: "api" }, + ], + orgProject, + ), + }, + { + filePath: `${DOCS_CONTENT}/features.md`, + sectionName: "issue-id-formats", + generate: generateIssueIdFormats, + }, + { + filePath: `${DOCS_CONTENT}/library-usage.md`, + sectionName: "sdk-run-examples", + generate: generateSdkRunExamples, + }, + ]; -let staleCount = 0; + let staleCount = 0; -for (const section of sections) { - const original = await readFile(section.filePath, "utf-8"); - const generated = section.generate(); - const updated = replaceMarkerSection( - original, - section.sectionName, - generated, - section.markerStyle ?? "html", - ); + for (const section of sections) { + const original = await readFile(section.filePath, "utf-8"); + const generated = section.generate(); + const updated = replaceMarkerSection( + original, + section.sectionName, + generated, + section.markerStyle ?? "html", + ); - if (updated !== original) { - if (isCheck) { - console.error(`STALE: ${section.filePath} [${section.sectionName}]`); - staleCount += 1; + if (updated !== original) { + if (check) { + console.error(`STALE: ${section.filePath} [${section.sectionName}]`); + staleCount += 1; + } else { + await writeFile(section.filePath, updated); + console.log(`Updated: ${section.filePath} [${section.sectionName}]`); + } } else { - await writeFile(section.filePath, updated); - console.log(`Updated: ${section.filePath} [${section.sectionName}]`); + console.log(`Up to date: ${section.filePath} [${section.sectionName}]`); } - } else { - console.log(`Up to date: ${section.filePath} [${section.sectionName}]`); } -} -if (isCheck && staleCount > 0) { - console.error( - `\n${staleCount} section(s) are stale. Run: pnpm run generate:docs`, - ); - process.exit(1); -} + if (check && staleCount > 0) { + console.error( + `\n${staleCount} section(s) are stale. Run: pnpm run generate:docs`, + ); + process.exitCode = 1; + return; + } -if (!isCheck) { - console.log("All docs sections generated."); + if (!check) { + console.log("All docs sections generated."); + } } diff --git a/packages/cli/script/generate-output-schemas.ts b/packages/cli/script/generate-output-schemas.ts index 126b5fb04..4c66cd88d 100644 --- a/packages/cli/script/generate-output-schemas.ts +++ b/packages/cli/script/generate-output-schemas.ts @@ -1,4 +1,3 @@ -#!/usr/bin/env tsx /** * Generate Output Schema Fields * @@ -21,7 +20,7 @@ * The output is gitignored and regenerated by `generate:docs`, which runs as * part of `dev`, `build`, `bundle`, `typecheck`, and `test`. * - * Usage: tsx script/generate-output-schemas.ts + * Usage: tsx script/generate.ts output-schemas * Output: src/generated/output-schemas/*.ts */ @@ -75,28 +74,31 @@ function renderModule( return `${lines.join("\n")}\n`; } -const files = (await readdir(SOURCE_DIR)) - .filter((file) => file.endsWith(".ts")) - .sort(); +/** Write the field data of every output schema. */ +export async function emit(): Promise { + const files = (await readdir(SOURCE_DIR)) + .filter((file) => file.endsWith(".ts")) + .sort(); -await rm(OUTPUT_DIR, { recursive: true, force: true }); -await mkdir(OUTPUT_DIR, { recursive: true }); + await rm(OUTPUT_DIR, { recursive: true, force: true }); + await mkdir(OUTPUT_DIR, { recursive: true }); -let count = 0; -for (const file of files) { - const name = basename(file, ".ts"); - const exports: Record = await import( - pathToFileURL(resolve(SOURCE_DIR, file)).href - ); - const schemas = Object.entries(exports).filter( - (entry): entry is [string, GenericSchema] => - entry[0].endsWith("Schema") && isSchema(entry[1]), - ); - if (schemas.length === 0) { - continue; + let count = 0; + for (const file of files) { + const name = basename(file, ".ts"); + const exports: Record = await import( + pathToFileURL(resolve(SOURCE_DIR, file)).href + ); + const schemas = Object.entries(exports).filter( + (entry): entry is [string, GenericSchema] => + entry[0].endsWith("Schema") && isSchema(entry[1]), + ); + if (schemas.length === 0) { + continue; + } + count += schemas.length; + await writeFile(`${OUTPUT_DIR}/${name}.ts`, renderModule(name, schemas)); } - count += schemas.length; - await writeFile(`${OUTPUT_DIR}/${name}.ts`, renderModule(name, schemas)); -} -console.log(`Generated ${OUTPUT_DIR}/ (${count} output schemas)`); + console.log(`Generated ${OUTPUT_DIR}/ (${count} output schemas)`); +} diff --git a/packages/cli/script/generate-sdk.ts b/packages/cli/script/generate-sdk.ts index c91c1bc6a..910d2cad3 100644 --- a/packages/cli/script/generate-sdk.ts +++ b/packages/cli/script/generate-sdk.ts @@ -1,4 +1,3 @@ -#!/usr/bin/env tsx /** * Generate typed SDK methods from the Stricli command tree. * @@ -14,7 +13,7 @@ * * Zero manual config — all commands are auto-discovered. * - * Run: tsx script/generate-sdk.ts + * Run: tsx script/generate.ts sdk */ import { writeFile } from "node:fs/promises"; @@ -754,94 +753,97 @@ function createSdkMethodsFunction(root: NamespaceNode): ts.FunctionDeclaration { // Main // --------------------------------------------------------------------------- -const allCommands = discoverCommands(routes as unknown as RouteMap, []); -console.log(`Discovered ${allCommands.length} commands`); +/** Write `src/sdk.generated.ts` and `src/sdk.generated.d.cts`. */ +export async function emit(): Promise { + const allCommands = discoverCommands(routes as unknown as RouteMap, []); + console.log(`Discovered ${allCommands.length} commands`); -const paramTypes: ts.Statement[] = []; -const returnTypes: ts.Statement[] = []; -const sdkTree = createNamespaceNode(); + const paramTypes: ts.Statement[] = []; + const returnTypes: ts.Statement[] = []; + const sdkTree = createNamespaceNode(); -for (const { path, command } of allCommands) { - const flags = extractSdkFlags(command); - const positional = derivePositional(command); + for (const { path, command } of allCommands) { + const flags = extractSdkFlags(command); + const positional = derivePositional(command); - const returnType = buildReturnType(command, `${buildTypeName(path)}Result`); - if (returnType) { - returnTypes.push(returnType); - } + const returnType = buildReturnType(command, `${buildTypeName(path)}Result`); + if (returnType) { + returnTypes.push(returnType); + } - const params = buildParamsType( - `${buildTypeName(path)}Params`, - positional, - flags, - ); - if (params) { - paramTypes.push(params); - } - - insertMethod(sdkTree, { - path, - brief: command.brief || path.join(" "), - positional, - flags, - streamingFlags: flags.filter((flag) => STREAMING_FLAGS.has(flag.name)), - paramsTypeName: params?.name.text, - paramsRequired: flags.some((flag) => !flag.optional), - resultTypeName: returnType?.name.text, - }); -} + const params = buildParamsType( + `${buildTypeName(path)}Params`, + positional, + flags, + ); + if (params) { + paramTypes.push(params); + } -const sdkType = exportedObjectType(SDK_TYPE_NAME, [ - ...namespaceType(sdkTree).members, -]); + insertMethod(sdkTree, { + path, + brief: command.brief || path.join(" "), + positional, + flags, + streamingFlags: flags.filter((flag) => STREAMING_FLAGS.has(flag.name)), + paramsTypeName: params?.name.text, + paramsRequired: flags.some((flag) => !flag.optional), + resultTypeName: returnType?.name.text, + }); + } -/** Sections shared verbatim by the implementation file and the published declarations. */ -const sharedTypes = [ - "// --- Return types (derived from __jsonFields) ---", - "", - returnTypes.length > 0 - ? printNodes(returnTypes) - : "// No commands have registered schemas yet.", - "", - "// --- Parameter types ---", - "", - paramTypes.length > 0 - ? printNodes(paramTypes) - : "// No commands have parameters.", - "", - "// --- SDK type ---", - "", - printNodes([sdkType]), - "", -]; - -const header = [ - "// Auto-generated by script/generate-sdk.ts — DO NOT EDIT", - "// Run `pnpm run generate:sdk` to regenerate.", - "", -]; - -const output = [ - ...header, - 'import type { buildInvoker } from "./lib/sdk-invoke.js";', - 'import { positionals } from "./lib/sdk-positionals.js";', - "", - ...sharedTypes, - "// --- SDK factory ---", - "", - "/** Invoke function type from sdk-invoke.ts */", - "type Invoke = ReturnType;", - "", - printNodes([createSdkMethodsFunction(sdkTree)]), - "", -].join("\n"); - -const outPath = "./src/sdk.generated.ts"; -await writeFile(outPath, output); -console.log(`Generated ${outPath}`); - -// Standalone type declarations (.d.cts) for the npm bundle: only the shared type -// exports — no runtime imports or implementations. -const dtsPath = "./src/sdk.generated.d.cts"; -await writeFile(dtsPath, [...header, ...sharedTypes].join("\n")); -console.log(`Generated ${dtsPath}`); + const sdkType = exportedObjectType(SDK_TYPE_NAME, [ + ...namespaceType(sdkTree).members, + ]); + + /** Sections shared verbatim by the implementation file and the published declarations. */ + const sharedTypes = [ + "// --- Return types (derived from __jsonFields) ---", + "", + returnTypes.length > 0 + ? printNodes(returnTypes) + : "// No commands have registered schemas yet.", + "", + "// --- Parameter types ---", + "", + paramTypes.length > 0 + ? printNodes(paramTypes) + : "// No commands have parameters.", + "", + "// --- SDK type ---", + "", + printNodes([sdkType]), + "", + ]; + + const header = [ + "// Auto-generated by script/generate-sdk.ts — DO NOT EDIT", + "// Run `pnpm run generate:sdk` to regenerate.", + "", + ]; + + const output = [ + ...header, + 'import type { buildInvoker } from "./lib/sdk-invoke.js";', + 'import { positionals } from "./lib/sdk-positionals.js";', + "", + ...sharedTypes, + "// --- SDK factory ---", + "", + "/** Invoke function type from sdk-invoke.ts */", + "type Invoke = ReturnType;", + "", + printNodes([createSdkMethodsFunction(sdkTree)]), + "", + ].join("\n"); + + const outPath = "./src/sdk.generated.ts"; + await writeFile(outPath, output); + console.log(`Generated ${outPath}`); + + // Standalone type declarations (.d.cts) for the npm bundle: only the shared type + // exports — no runtime imports or implementations. + const dtsPath = "./src/sdk.generated.d.cts"; + await writeFile(dtsPath, [...header, ...sharedTypes].join("\n")); + console.log(`Generated ${dtsPath}`); +} diff --git a/packages/cli/script/generate-skill.ts b/packages/cli/script/generate-skill.ts index e077a2069..0fb14ea03 100644 --- a/packages/cli/script/generate-skill.ts +++ b/packages/cli/script/generate-skill.ts @@ -1,4 +1,3 @@ -#!/usr/bin/env tsx /** * Generate Skill Files from Stricli Command Metadata and Docs * @@ -22,8 +21,16 @@ * docs/public/.well-known/skills/index.json */ -import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, rmSync } from "node:fs"; import { access, readFile, writeFile } from "node:fs/promises"; +import { routes } from "../src/app.js"; +import { + type CommandInfo, + extractAllRoutes, + type FlagInfo, + type RouteInfo, + type RouteMap, +} from "../src/lib/introspect.js"; import { formatCommandArguments, formatCommandExamples, @@ -36,32 +43,7 @@ import { } from "./markdown.js"; import { DOCS_CONTENT, DOCS_PUBLIC } from "./paths.js"; -// Bootstrap: ensure the generated skill-content module exists before -// importing the route tree (app.ts → agent-skills.ts → skill-content.ts). -// On a fresh checkout the file won't exist, causing a module resolution -// error. Write a minimal stub so the import resolves; the real content -// is generated at the end of this script. -// NOTE: This must run before the dynamic import() below because static -// imports are hoisted by the runtime before any code executes. const SKILL_CONTENT_PATH = "src/generated/skill-content.ts"; -if (!existsSync(SKILL_CONTENT_PATH)) { - mkdirSync("src/generated", { recursive: true }); - writeFileSync( - SKILL_CONTENT_PATH, - "export const SKILL_FILES: ReadonlyMap = new Map();\n", - ); -} - -const { routes } = await import("../src/app.js"); - -import type { - CommandInfo, - FlagInfo, - RouteInfo, - RouteMap, -} from "../src/lib/introspect.js"; -import { extractAllRoutes } from "../src/lib/introspect.js"; - const SKILL_DIR = "plugins/sentry-cli/skills/sentry-cli"; const OUTPUT_PATH = `${SKILL_DIR}/SKILL.md`; const PUBLIC_SKILL_DIR = `${DOCS_PUBLIC}/.well-known/skills/sentry-cli`; @@ -690,48 +672,50 @@ function generateIndexJson(generatedFiles: GeneratedFiles): string { // Main // --------------------------------------------------------------------------- -const files = await generateAllSkillFiles(routes as unknown as RouteMap); - -// The public references are a symlink to these generated plugin references. -rmSync(`${SKILL_DIR}/references`, { recursive: true, force: true }); -for (const [relativePath, content] of files) { - const fullPath = `${SKILL_DIR}/${relativePath}`; - const dir = fullPath.substring(0, fullPath.lastIndexOf("/")); - mkdirSync(dir, { recursive: true }); - await writeFile(fullPath, content); -} +/** Write the skill, its references, the public skill index, and `skill-content.ts`. */ +export async function emit(): Promise { + const files = await generateAllSkillFiles(routes as unknown as RouteMap); + + // The public references are a symlink to these generated plugin references. + rmSync(`${SKILL_DIR}/references`, { recursive: true, force: true }); + for (const [relativePath, content] of files) { + const fullPath = `${SKILL_DIR}/${relativePath}`; + const dir = fullPath.substring(0, fullPath.lastIndexOf("/")); + mkdirSync(dir, { recursive: true }); + await writeFile(fullPath, content); + } -const publicSkill = files.get("SKILL.md"); -if (publicSkill === undefined) { - throw new Error("Generated skill is missing SKILL.md"); -} -mkdirSync(PUBLIC_SKILL_DIR, { recursive: true }); -await writeFile(`${PUBLIC_SKILL_DIR}/SKILL.md`, publicSkill); - -// Write index.json -const indexJson = generateIndexJson(files); -const indexJsonDir = INDEX_JSON_PATH.substring( - 0, - INDEX_JSON_PATH.lastIndexOf("/"), -); -mkdirSync(indexJsonDir, { recursive: true }); -await writeFile(INDEX_JSON_PATH, indexJson); - -// Generate src/generated/skill-content.ts with inlined file contents. -// This embeds all skill files into the binary at build time so that -// agent-skills.ts can install them without any network fetching. -const skillEntries = [...files.entries()] - .sort(([a], [b]) => a.localeCompare(b)) - .map(([path, content]) => { - const escaped = content - .replaceAll("\\", "\\\\") - .replaceAll("`", "\\`") - .replaceAll("${", "\\${"); - return ` ["${path}", \`${escaped}\`],`; - }) - .join("\n"); - -const skillContentModule = `/** + const publicSkill = files.get("SKILL.md"); + if (publicSkill === undefined) { + throw new Error("Generated skill is missing SKILL.md"); + } + mkdirSync(PUBLIC_SKILL_DIR, { recursive: true }); + await writeFile(`${PUBLIC_SKILL_DIR}/SKILL.md`, publicSkill); + + // Write index.json + const indexJson = generateIndexJson(files); + const indexJsonDir = INDEX_JSON_PATH.substring( + 0, + INDEX_JSON_PATH.lastIndexOf("/"), + ); + mkdirSync(indexJsonDir, { recursive: true }); + await writeFile(INDEX_JSON_PATH, indexJson); + + // Generate src/generated/skill-content.ts with inlined file contents. + // This embeds all skill files into the binary at build time so that + // agent-skills.ts can install them without any network fetching. + const skillEntries = [...files.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([path, content]) => { + const escaped = content + .replaceAll("\\", "\\\\") + .replaceAll("`", "\\`") + .replaceAll("${", "\\${"); + return ` ["${path}", \`${escaped}\`],`; + }) + .join("\n"); + + const skillContentModule = `/** * Embedded skill file contents for agent skill installation. * Auto-generated by script/generate-skill.ts — do not edit manually. */ @@ -742,12 +726,13 @@ ${skillEntries} ]); `; -await writeFile(SKILL_CONTENT_PATH, skillContentModule); + await writeFile(SKILL_CONTENT_PATH, skillContentModule); -// Report what was generated -const refCount = [...files.keys()].filter((k) => - k.startsWith("references/"), -).length; -console.log( - `Generated ${OUTPUT_PATH} + ${refCount} reference files + ${INDEX_JSON_PATH} + ${SKILL_CONTENT_PATH}`, -); + // Report what was generated + const refCount = [...files.keys()].filter((k) => + k.startsWith("references/"), + ).length; + console.log( + `Generated ${OUTPUT_PATH} + ${refCount} reference files + ${INDEX_JSON_PATH} + ${SKILL_CONTENT_PATH}`, + ); +} diff --git a/packages/cli/script/generate-top-level-routes.ts b/packages/cli/script/generate-top-level-routes.ts index c2285f04f..201a1daeb 100644 --- a/packages/cli/script/generate-top-level-routes.ts +++ b/packages/cli/script/generate-top-level-routes.ts @@ -1,4 +1,3 @@ -#!/usr/bin/env tsx /** * Generate What the Top-Level Help Shows for Each Route * @@ -12,39 +11,25 @@ * The output is gitignored and regenerated by `generate:docs`, which runs as * part of `dev`, `build`, `bundle`, `typecheck`, and `test`. * - * Usage: tsx script/generate-top-level-routes.ts + * Usage: tsx script/generate.ts top-level-routes * Output: src/generated/top-level-routes.json */ import { mkdirSync } from "node:fs"; -import { access, writeFile } from "node:fs/promises"; - -// Bootstrap: skill-content stub (same pattern as -// generate-completion-targets.ts). The route tree transitively imports -// skill-content.ts, which may not exist yet on a fresh checkout. Must run -// before the dynamic import below. -const SKILL_CONTENT_PATH = "src/generated/skill-content.ts"; -const SKILL_CONTENT_STUB = - "export const SKILL_FILES: [string, string][] = [];\n"; -const skillContentExists = await access(SKILL_CONTENT_PATH).then( - () => true, - () => false, -); -if (!skillContentExists) { - mkdirSync("src/generated", { recursive: true }); - await writeFile(SKILL_CONTENT_PATH, SKILL_CONTENT_STUB); -} - -const { app, routes } = await import("../src/app.js"); -const { describeTopLevelRoutes } = await import("./lib/top-level-routes.js"); +import { writeFile } from "node:fs/promises"; +import { app, routes } from "../src/app.js"; +import { describeTopLevelRoutes } from "./lib/top-level-routes.js"; const OUTPUT_PATH = "src/generated/top-level-routes.json"; -const described = describeTopLevelRoutes(routes, app.config.documentation); +/** Write what the top-level help shows for each visible route. */ +export async function emit(): Promise { + const described = describeTopLevelRoutes(routes, app.config.documentation); -mkdirSync("src/generated", { recursive: true }); -await writeFile(OUTPUT_PATH, `${JSON.stringify(described, null, 2)}\n`); + mkdirSync("src/generated", { recursive: true }); + await writeFile(OUTPUT_PATH, `${JSON.stringify(described, null, 2)}\n`); -console.log( - `Generated ${OUTPUT_PATH} (${Object.keys(described).length} routes)`, -); + console.log( + `Generated ${OUTPUT_PATH} (${Object.keys(described).length} routes)`, + ); +} diff --git a/packages/cli/script/generate.ts b/packages/cli/script/generate.ts new file mode 100644 index 000000000..50a9468b6 --- /dev/null +++ b/packages/cli/script/generate.ts @@ -0,0 +1,115 @@ +#!/usr/bin/env tsx +/** + * Run the CLI's Code Generators in One Process + * + * Most generators read the route tree, and loading it through tsx is most of + * what each of them costs. Run as separate `pnpm tsx` processes, they loaded + * the tree once each and paid pnpm's startup every time. This runner loads it + * once and runs every requested step in order, in two phases: + * + * 1. Steps the route tree depends on: the API schema, the grammar parsers, + * the output-schema field data that commands import, and the banner. + * Nothing in this phase may load the route tree. + * 2. Steps that read the route tree. The runner writes stubs for the + * generated files the tree imports but these steps produce + * (`lib/route-tree-stubs.ts`), loads the tree, then runs each step. Step + * modules import the tree statically, so they are imported only here. + * + * Steps run in the order below, whatever order they are named in. Like + * `pnpm tsx`, the runner loads `.env.local` when it exists and installs + * `require-shim.mjs` before any step. + * + * Usage: + * tsx script/generate.ts docs # what generate:docs writes + * tsx script/generate.ts docs sdk # everything, as `build` runs it + * tsx script/generate.ts skill # one step + * tsx script/generate.ts docs-sections --check # exit 1 if a section is stale + */ + +import "./require-shim.mjs"; +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { writeRouteTreeStubs } from "./lib/route-tree-stubs.js"; + +if (existsSync(".env.local")) { + process.loadEnvFile(".env.local"); +} + +type Options = { check: boolean }; + +type Step = { + name: string; + /** Import the step's module. Route-tree steps import the tree statically. */ + load: () => Promise<{ emit: (options: Options) => Promise }>; +}; + +/** Compile the grammars; a plain Node script shared with other packages. */ +async function generateGrammar(): Promise { + execFileSync(process.execPath, ["../../scripts/generate-parsers.mjs", "."], { + stdio: "inherit", + }); +} + +/** Phase 1: steps whose outputs the route tree imports. */ +const PRE_TREE_STEPS: readonly Step[] = [ + { name: "schema", load: () => import("./generate-api-schema.js") }, + { name: "grammar", load: async () => ({ emit: generateGrammar }) }, + { + name: "output-schemas", + load: () => import("./generate-output-schemas.js"), + }, + { name: "banner", load: () => import("./generate-banner-image.js") }, +]; + +/** Phase 2: steps that read the route tree. */ +const TREE_STEPS: readonly Step[] = [ + { name: "command-api", load: () => import("./generate-command-api.js") }, + { + name: "completions", + load: () => import("./generate-completion-targets.js"), + }, + { + name: "top-level-routes", + load: () => import("./generate-top-level-routes.js"), + }, + { name: "command-docs", load: () => import("./generate-command-docs.js") }, + { name: "docs-sections", load: () => import("./generate-docs-sections.js") }, + { name: "skill", load: () => import("./generate-skill.js") }, + { name: "sdk", load: () => import("./generate-sdk.js") }, +]; + +const STEP_NAMES = [...PRE_TREE_STEPS, ...TREE_STEPS].map((step) => step.name); +/** `docs`: everything but the SDK, which `generate:sdk` writes. */ +const DOCS_STEPS = STEP_NAMES.filter((name) => name !== "sdk"); + +const args = process.argv.slice(2); +const options: Options = { check: args.includes("--check") }; +const requested = new Set(); +for (const arg of args.filter((a) => a !== "--check")) { + for (const name of arg === "docs" ? DOCS_STEPS : [arg]) { + if (!STEP_NAMES.includes(name)) { + throw new Error( + `Unknown generator step "${arg}". Steps: docs, ${STEP_NAMES.join(", ")}`, + ); + } + requested.add(name); + } +} + +const preTreeSteps = PRE_TREE_STEPS.filter((step) => requested.has(step.name)); +const treeSteps = TREE_STEPS.filter((step) => requested.has(step.name)); + +for (const step of preTreeSteps) { + await (await step.load()).emit(options); +} + +if (treeSteps.length > 0) { + await writeRouteTreeStubs(); + await import("../src/app.js"); + for (const step of treeSteps) { + await (await step.load()).emit(options); + if (process.exitCode) { + break; + } + } +} diff --git a/packages/cli/script/lib/route-tree-stubs.ts b/packages/cli/script/lib/route-tree-stubs.ts new file mode 100644 index 000000000..7770b69d9 --- /dev/null +++ b/packages/cli/script/lib/route-tree-stubs.ts @@ -0,0 +1,35 @@ +/** + * Stubs for the Generated Files the Route Tree Imports + * + * `src/app.ts` imports these files, and the generators that read the route + * tree are what write them, so a fresh checkout has none of them when the + * tree first loads. No generator reads them back through the tree, so a stub + * that resolves is enough; the generator that owns each file replaces it + * later in the same `script/generate.ts` run. + * + * Scripts that load the route tree call {@link writeRouteTreeStubs} before + * the first `import("../src/app.js")`. Static imports run before any code, + * so the tree has to be loaded with a dynamic import after this. + */ + +import { existsSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; + +const STUBS = [ + ["src/generated/command-api.json", "{}\n"], + ["src/generated/top-level-routes.json", "{}\n"], + [ + "src/generated/skill-content.ts", + "export const SKILL_FILES: ReadonlyMap = new Map();\n", + ], +] as const; + +/** Write a stub for each route-tree input that does not exist yet. */ +export async function writeRouteTreeStubs(): Promise { + await mkdir("src/generated", { recursive: true }); + for (const [path, stub] of STUBS) { + if (!existsSync(path)) { + await writeFile(path, stub); + } + } +} diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 584cdc4a1..88e39f171 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -40,7 +40,7 @@ "dist/sentry-mcp.cjs" ], "scripts": { - "bundle": "pnpm --filter @sentry/mcp-core run build && pnpm --filter sentry run generate:schema && pnpm --filter sentry run generate:docs && pnpm --filter sentry run generate:sdk && tsx script/bundle.ts", + "bundle": "pnpm --filter @sentry/mcp-core run build && pnpm --filter sentry run generate && tsx script/bundle.ts", "test:bundle": "node --test test/bundle.test.mjs" }, "devDependencies": { From 1e1edf07f2fdefef3eaf63a1130a802c708d4755 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:19:47 +0000 Subject: [PATCH 3/9] build(cli): Skip unchanged phase-1 generator steps The schema, grammar, and banner steps reran on every `generate`, though their inputs rarely change. Each now lists the files it reads and writes, and `script/lib/step-stamp.ts` skips it when the content hash of both matches the stamp its last run left in `src/generated/.stamps/`. Content hashes, not mtimes, which checkouts and CI caches reset. Hashing the outputs too catches outputs rewritten since, and missing outputs never match, so `git clean -fdx src/generated` still regenerates all. `output-schemas` and the route-tree steps keep running: their inputs are the source they import, which is no short list to hash. Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- packages/cli/AGENTS.md | 3 +- packages/cli/script/generate.ts | 103 ++++++++++++++++++-- packages/cli/script/lib/step-stamp.ts | 103 ++++++++++++++++++++ packages/cli/test/script/step-stamp.test.ts | 80 +++++++++++++++ 4 files changed, 280 insertions(+), 9 deletions(-) create mode 100644 packages/cli/script/lib/step-stamp.ts create mode 100644 packages/cli/test/script/step-stamp.test.ts diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 7df94e57f..f0a6f051f 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -822,7 +822,8 @@ Use `"date"` for timestamp-based sort (not `"time"`). Export sort types from the All command docs and skill files are generated via `pnpm run generate:docs`. This runs automatically as part of `dev`, `build`, `typecheck`, and `test` scripts. -- **One process**: the `generate:*` scripts (except `generate:grammar`, a plain Node script) call `script/generate.ts`, which runs the requested steps in one process so the route tree loads once. Phase 1 writes what the tree imports (API schema, grammars, output-schema field data, banner) and must not load the tree. Phase 2 stubs the generated files the tree imports (`script/lib/route-tree-stubs.ts`), then runs each step's exported `emit()`. Phase-2 modules may import the tree statically, because the runner imports them only after the stubs exist. A new generator exports `emit()` and gets a step in `script/generate.ts`, with no stub code of its own. In `package.json`, call the runner (`tsx script/generate.ts `) rather than chaining `pnpm run generate:*`. `docs` names every step but `sdk`. +- **One process**: the `generate:*` scripts (except `generate:grammar`, a plain Node script) call `script/generate.ts`, which runs the requested steps in one process so the route tree loads once. Phase 1 writes what the tree imports (API schema, grammars, output-schema field data, banner) and must not load the tree. Phase 2 stubs the generated files the tree imports (`script/lib/route-tree-stubs.ts`), then runs each step's exported `emit()`. Phase-2 modules may import the tree statically, because the runner imports them only after the stubs exist. A new generator exports `emit()` and gets a step in `script/generate.ts`, with no stub code of its own. In `package.json`, call the runner (`tsx script/generate.ts `) rather than chaining `pnpm run generate:*`. `docs` names every step but `sdk`, including `schema`, so callers need not run `generate:schema` first. +- **Skipped steps**: a phase-1 step with `files` in `script/generate.ts` runs only when the content hash of its inputs or outputs differs from its stamp in `src/generated/.stamps/` (`script/lib/step-stamp.ts`). When such a step starts reading a new file or package, add it to that step's inputs. `output-schemas` and the phase-2 steps have no stamp: their inputs are the source they import, which is no short list. - **Command docs** (`apps/cli-docs/src/content/docs/commands/*.md`) and the configuration page (`apps/cli-docs/src/content/docs/configuration.md`) are **gitignored** and generated from CLI metadata + hand-written fragments in `apps/cli-docs/src/fragments/` (paths live in `script/paths.ts`). - **Skill files** (`plugins/sentry-cli/skills/sentry-cli/`) are **committed** (consumed by external plugin systems) and auto-committed by CI when stale. diff --git a/packages/cli/script/generate.ts b/packages/cli/script/generate.ts index 50a9468b6..2c7e3e99d 100644 --- a/packages/cli/script/generate.ts +++ b/packages/cli/script/generate.ts @@ -9,7 +9,8 @@ * * 1. Steps the route tree depends on: the API schema, the grammar parsers, * the output-schema field data that commands import, and the banner. - * Nothing in this phase may load the route tree. + * Nothing in this phase may load the route tree. A step that lists its + * `files` is skipped when their content matches its last run. * 2. Steps that read the route tree. The runner writes stubs for the * generated files the tree imports but these steps produce * (`lib/route-tree-stubs.ts`), loads the tree, then runs each step. Step @@ -28,8 +29,14 @@ import "./require-shim.mjs"; import { execFileSync } from "node:child_process"; -import { existsSync } from "node:fs"; +import { existsSync, readdirSync } from "node:fs"; +import { fileURLToPath } from "node:url"; import { writeRouteTreeStubs } from "./lib/route-tree-stubs.js"; +import { + packageManifest, + runUnlessUnchanged, + type StepFiles, +} from "./lib/step-stamp.js"; if (existsSync(".env.local")) { process.loadEnvFile(".env.local"); @@ -41,24 +48,97 @@ type Step = { name: string; /** Import the step's module. Route-tree steps import the tree statically. */ load: () => Promise<{ emit: (options: Options) => Promise }>; + /** + * Phase 1 only: what the step reads and writes, so the runner can skip it + * when neither changed (`lib/step-stamp.ts`). Evaluated before `load`, so + * a skipped step's dependencies are never imported. + */ + files?: () => StepFiles; }; +const GRAMMAR_SCRIPT = "../../scripts/generate-parsers.mjs"; + /** Compile the grammars; a plain Node script shared with other packages. */ async function generateGrammar(): Promise { - execFileSync(process.execPath, ["../../scripts/generate-parsers.mjs", "."], { + execFileSync(process.execPath, [GRAMMAR_SCRIPT, "."], { stdio: "inherit", }); } -/** Phase 1: steps whose outputs the route tree imports. */ +/** The API schema: the SDK's operation catalog, projected. */ +function schemaFiles(): StepFiles { + return { + inputs: [ + fileURLToPath(import.meta.resolve("@sentry/api/operations")), + "script/generate-api-schema.ts", + "script/lib/api-schema.ts", + "../toolkit-core/src/api-client/idempotent-operations.ts", + "../toolkit-core/src/api-client/retry.ts", + ], + outputs: [ + "src/generated/api-schema.json", + "src/generated/idempotent-operations.json", + ], + }; +} + +/** Each `grammar/.pegjs` and its `.d.ts` become `-grammar.*`. */ +function grammarFiles(): StepFiles { + const sources = readdirSync("grammar").sort(); + const names = sources + .filter((file) => file.endsWith(".pegjs")) + .map((file) => file.slice(0, -".pegjs".length)); + return { + inputs: [ + ...sources.map((file) => `grammar/${file}`), + GRAMMAR_SCRIPT, + packageManifest("peggy", GRAMMAR_SCRIPT), + packageManifest("typescript", GRAMMAR_SCRIPT), + ], + outputs: names.flatMap((name) => [ + `src/generated/${name}-grammar.js`, + `src/generated/${name}-grammar.d.ts`, + ]), + }; +} + +function bannerFiles(): StepFiles { + const script = "script/generate-banner-image.ts"; + return { + inputs: [ + "assets/banner-mask.png", + script, + packageManifest("pngjs", script), + ], + outputs: ["src/generated/banner-image.ts"], + }; +} + +/** + * Phase 1: steps whose outputs the route tree imports. `output-schemas` + * has no `files`: it loads `src/output-schemas/` and everything that + * imports, which is no short list. + */ const PRE_TREE_STEPS: readonly Step[] = [ - { name: "schema", load: () => import("./generate-api-schema.js") }, - { name: "grammar", load: async () => ({ emit: generateGrammar }) }, + { + name: "schema", + load: () => import("./generate-api-schema.js"), + files: schemaFiles, + }, + { + name: "grammar", + load: async () => ({ emit: generateGrammar }), + files: grammarFiles, + }, { name: "output-schemas", load: () => import("./generate-output-schemas.js"), }, - { name: "banner", load: () => import("./generate-banner-image.js") }, + { + name: "banner", + load: () => import("./generate-banner-image.js"), + files: bannerFiles, + }, ]; /** Phase 2: steps that read the route tree. */ @@ -100,7 +180,14 @@ const preTreeSteps = PRE_TREE_STEPS.filter((step) => requested.has(step.name)); const treeSteps = TREE_STEPS.filter((step) => requested.has(step.name)); for (const step of preTreeSteps) { - await (await step.load()).emit(options); + const emit = async () => { + await (await step.load()).emit(options); + }; + if (step.files) { + await runUnlessUnchanged(step.name, step.files(), emit); + } else { + await emit(); + } } if (treeSteps.length > 0) { diff --git a/packages/cli/script/lib/step-stamp.ts b/packages/cli/script/lib/step-stamp.ts new file mode 100644 index 000000000..8a0c5f95a --- /dev/null +++ b/packages/cli/script/lib/step-stamp.ts @@ -0,0 +1,103 @@ +/** + * Skip a Generator Step When Nothing It Reads Has Changed + * + * A step that declares its inputs and outputs runs only when the content + * hash of either differs from the stamp its last run left in + * `src/generated/.stamps/`. Hashes, not mtimes: a checkout or a CI cache + * restore resets mtimes without changing content. The output hash catches + * outputs that something else rewrote since, such as `generate:grammar` + * run on its own or another branch's pipeline, and missing outputs never + * match, so `git clean -fdx src/generated` regenerates everything. + * + * Inputs are listed by hand. A step only qualifies when that list is short + * and stable: its own script, the source it reads, and the packages it + * reads through (their `package.json`, which pins the installed version). + */ + +import { createHash } from "node:crypto"; +import { existsSync } from "node:fs"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { dirname, join, resolve } from "node:path"; + +const STAMP_DIR = "src/generated/.stamps"; + +/** The files a step reads and the files it writes. */ +export type StepFiles = { + inputs: readonly string[]; + outputs: readonly string[]; +}; + +type Stamp = { inputs: string; outputs: string }; + +/** Hash the paths and contents of `paths`, or `undefined` if one is missing. */ +async function digest(paths: readonly string[]): Promise { + const hash = createHash("sha256"); + for (const path of paths) { + if (!existsSync(path)) { + return; + } + hash + .update(`${path}\0`) + .update(await readFile(path)) + .update("\0"); + } + return hash.digest("hex"); +} + +async function readStamp(path: string): Promise { + try { + return JSON.parse(await readFile(path, "utf8")) as Stamp; + } catch { + return; + } +} + +/** + * The `package.json` of the package `specifier` resolves to from `from`. + * Packages whose `exports` hide their `package.json` are found by walking + * up from the resolved entry point. + */ +export function packageManifest(specifier: string, from: string): string { + let dir = dirname(createRequire(resolve(from)).resolve(specifier)); + while (!existsSync(join(dir, "package.json"))) { + const parent = dirname(dir); + if (parent === dir) { + throw new Error(`No package.json above ${specifier}`); + } + dir = parent; + } + return join(dir, "package.json"); +} + +/** Run `step` unless its inputs and outputs match the last run's stamp. */ +export async function runUnlessUnchanged( + name: string, + files: StepFiles, + step: () => Promise, +): Promise { + const stampPath = join(STAMP_DIR, `${name}.json`); + const inputs = await digest(files.inputs); + const stamp = await readStamp(stampPath); + if ( + inputs !== undefined && + stamp?.inputs === inputs && + stamp.outputs === (await digest(files.outputs)) + ) { + console.log(`Skipped ${name}: inputs and outputs unchanged`); + return; + } + + await step(); + + const outputs = await digest(files.outputs); + if (inputs === undefined || outputs === undefined) { + await rm(stampPath, { force: true }); + return; + } + await mkdir(STAMP_DIR, { recursive: true }); + await writeFile( + stampPath, + `${JSON.stringify({ inputs, outputs } satisfies Stamp)}\n`, + ); +} diff --git a/packages/cli/test/script/step-stamp.test.ts b/packages/cli/test/script/step-stamp.test.ts new file mode 100644 index 000000000..aefffcc77 --- /dev/null +++ b/packages/cli/test/script/step-stamp.test.ts @@ -0,0 +1,80 @@ +import { mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { runUnlessUnchanged } from "../../script/lib/step-stamp.js"; + +const files = { inputs: ["input.txt"], outputs: ["output.txt"] }; + +describe("runUnlessUnchanged", () => { + const originalCwd = process.cwd(); + let dir: string; + let runs: number; + + /** A step that copies its input to its output, counting its runs. */ + async function run(): Promise { + await runUnlessUnchanged("copy", files, async () => { + runs += 1; + writeFileSync("output.txt", `copy of ${runs}`); + }); + } + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "sentry-step-stamp-")); + process.chdir(dir); + writeFileSync("input.txt", "v1"); + runs = 0; + }); + + afterEach(() => { + process.chdir(originalCwd); + rmSync(dir, { recursive: true, force: true }); + }); + + test("skips a step whose inputs and outputs are unchanged", async () => { + await run(); + await run(); + expect(runs).toBe(1); + }); + + test("ignores modification times", async () => { + await run(); + const later = new Date(Date.now() + 60_000); + utimesSync("input.txt", later, later); + utimesSync("output.txt", later, later); + await run(); + expect(runs).toBe(1); + }); + + test("reruns when an input changes", async () => { + await run(); + writeFileSync("input.txt", "v2"); + await run(); + expect(runs).toBe(2); + }); + + test("reruns when an output was rewritten or removed", async () => { + await run(); + writeFileSync("output.txt", "edited"); + await run(); + expect(runs).toBe(2); + + rmSync("output.txt"); + await run(); + expect(runs).toBe(3); + }); + + test("reruns when the stamps are cleaned", async () => { + await run(); + rmSync("src/generated", { recursive: true }); + await run(); + expect(runs).toBe(2); + }); + + test("always runs when an input is missing", async () => { + rmSync("input.txt"); + await run(); + await run(); + expect(runs).toBe(2); + }); +}); From 5468a0a42efa7d6abf4c4e60b5b64a7f7e9ebd88 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:19:50 +0000 Subject: [PATCH 4/9] build: Generate the API schema once for CLI docs `generate:docs` runs the schema step itself, so the docs deploy job, the root `deploy:docs`, and cli-docs' `ci:prepare` no longer run `generate:schema` before it. Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- .github/workflows/cli-build.yml | 2 +- apps/cli-docs/package.json | 2 +- package.json | 2 +- scripts/ci-projects.test.mjs | 1 - 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/cli-build.yml b/.github/workflows/cli-build.yml index afd8c9ef9..b6fad80ad 100644 --- a/.github/workflows/cli-build.yml +++ b/.github/workflows/cli-build.yml @@ -352,7 +352,7 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - name: Generate CLI docs - run: pnpm --filter sentry run generate:schema && pnpm --filter sentry run generate:docs + run: pnpm --filter sentry run generate:docs - name: Set docs release run: | release=$(node -p 'require("./packages/cli/package.json").version') diff --git a/apps/cli-docs/package.json b/apps/cli-docs/package.json index 5d715a96f..14b94bd55 100644 --- a/apps/cli-docs/package.json +++ b/apps/cli-docs/package.json @@ -6,7 +6,7 @@ "dependencies": ["sentry"] }, "scripts": { - "ci:prepare": "pnpm --filter sentry run generate:schema && pnpm --filter sentry run generate:docs", + "ci:prepare": "pnpm --filter sentry run generate:docs", "dev": "astro dev", "build": "astro build", "test": "node --test test/*.test.mjs", diff --git a/package.json b/package.json index 0a4993af9..9b679d6cb 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "build": "dotenv -e .env -e .env.local -- pnpm -r --filter '!sentry' --filter '!sentry-cli-docs' --if-present run build", "build:cli": "dotenv -e .env -e .env.local -- pnpm --filter sentry run build && pnpm --filter sentry-cli-docs run build", "check:generated": "pnpm --filter @sentry/mcp-core generate-definitions && git diff --exit-code -- packages/mcp-core/src/toolDefinitions.json packages/mcp-core/src/skillDefinitions.json plugins/sentry-mcp/agents/sentry-mcp.md plugins/sentry-mcp-experimental/agents/sentry-mcp.md", - "deploy:docs": "pnpm --filter sentry run generate:schema && pnpm --filter sentry run generate:docs && pnpm --filter sentry-cli-docs run build && pnpm --filter sentry-cli-docs run deploy", + "deploy:docs": "pnpm --filter sentry run generate:docs && pnpm --filter sentry-cli-docs run build && pnpm --filter sentry-cli-docs run deploy", "eval": "pnpm --filter '@sentry/mcp-server-evals...' --if-present run build && dotenv -e .env -e .env.local -- pnpm --filter @sentry/mcp-server-evals run eval", "eval:ci": "pnpm --filter '@sentry/mcp-server-evals...' --if-present run build && CI=true dotenv -e .env -e .env.local -- pnpm --stream -r run eval:ci", "format": "oxfmt --write", diff --git a/scripts/ci-projects.test.mjs b/scripts/ci-projects.test.mjs index cc1396274..9be16701c 100644 --- a/scripts/ci-projects.test.mjs +++ b/scripts/ci-projects.test.mjs @@ -40,7 +40,6 @@ describe("workspace CI selection", () => { const docsScripts = JSON.parse( readFileSync(resolve(root, "apps/cli-docs/package.json"), "utf8"), ).scripts; - assert.match(docsScripts[docs.prepare], /sentry run generate:schema/); assert.match(docsScripts[docs.prepare], /sentry run generate:docs/); const cli = matrix.include.find(({ name }) => name === "sentry"); assert.equal(cli.policy, "ci:policy"); From 9b057718b3d4fe0e8077fa60394248877b61e88e Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:30:31 +0000 Subject: [PATCH 5/9] ref(cli): Read and write generator files without existence checks Per review: open the file and handle ENOENT or EEXIST instead of a separate `existsSync` call. The route-tree stubs are written with the `wx` flag, in parallel; the stamp helper reads inputs and outputs in parallel; `.env.local` is loaded directly. `packageManifest` now resolves `/package.json` rather than walking up the tree. Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- packages/cli/script/generate.ts | 8 +++- packages/cli/script/lib/route-tree-stubs.ts | 18 +++++--- packages/cli/script/lib/step-stamp.ts | 48 +++++++++++---------- 3 files changed, 43 insertions(+), 31 deletions(-) diff --git a/packages/cli/script/generate.ts b/packages/cli/script/generate.ts index 2c7e3e99d..4dfe20e73 100644 --- a/packages/cli/script/generate.ts +++ b/packages/cli/script/generate.ts @@ -29,7 +29,7 @@ import "./require-shim.mjs"; import { execFileSync } from "node:child_process"; -import { existsSync, readdirSync } from "node:fs"; +import { readdirSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { writeRouteTreeStubs } from "./lib/route-tree-stubs.js"; import { @@ -38,8 +38,12 @@ import { type StepFiles, } from "./lib/step-stamp.js"; -if (existsSync(".env.local")) { +try { process.loadEnvFile(".env.local"); +} catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } } type Options = { check: boolean }; diff --git a/packages/cli/script/lib/route-tree-stubs.ts b/packages/cli/script/lib/route-tree-stubs.ts index 7770b69d9..9d5bde251 100644 --- a/packages/cli/script/lib/route-tree-stubs.ts +++ b/packages/cli/script/lib/route-tree-stubs.ts @@ -12,7 +12,6 @@ * so the tree has to be loaded with a dynamic import after this. */ -import { existsSync } from "node:fs"; import { mkdir, writeFile } from "node:fs/promises"; const STUBS = [ @@ -27,9 +26,16 @@ const STUBS = [ /** Write a stub for each route-tree input that does not exist yet. */ export async function writeRouteTreeStubs(): Promise { await mkdir("src/generated", { recursive: true }); - for (const [path, stub] of STUBS) { - if (!existsSync(path)) { - await writeFile(path, stub); - } - } + await Promise.all( + STUBS.map(async ([path, stub]) => { + try { + // `wx` fails with EEXIST rather than replace a generated file. + await writeFile(path, stub, { flag: "wx" }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") { + throw error; + } + } + }), + ); } diff --git a/packages/cli/script/lib/step-stamp.ts b/packages/cli/script/lib/step-stamp.ts index 8a0c5f95a..7fad429ed 100644 --- a/packages/cli/script/lib/step-stamp.ts +++ b/packages/cli/script/lib/step-stamp.ts @@ -11,14 +11,13 @@ * * Inputs are listed by hand. A step only qualifies when that list is short * and stable: its own script, the source it reads, and the packages it - * reads through (their `package.json`, which pins the installed version). + * reads through (their `package.json`, which names the installed version). */ import { createHash } from "node:crypto"; -import { existsSync } from "node:fs"; import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { createRequire } from "node:module"; -import { dirname, join, resolve } from "node:path"; +import { join, resolve } from "node:path"; const STAMP_DIR = "src/generated/.stamps"; @@ -30,44 +29,47 @@ export type StepFiles = { type Stamp = { inputs: string; outputs: string }; +/** The contents of `path`, or `undefined` if it does not exist. */ +async function readIfExists(path: string): Promise { + try { + return await readFile(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return; + } + throw error; + } +} + /** Hash the paths and contents of `paths`, or `undefined` if one is missing. */ async function digest(paths: readonly string[]): Promise { + const contents = await Promise.all(paths.map(readIfExists)); const hash = createHash("sha256"); - for (const path of paths) { - if (!existsSync(path)) { + for (const [index, content] of contents.entries()) { + if (content === undefined) { return; } - hash - .update(`${path}\0`) - .update(await readFile(path)) - .update("\0"); + hash.update(`${paths[index]}\0`).update(content).update("\0"); } return hash.digest("hex"); } async function readStamp(path: string): Promise { + const content = await readIfExists(path); try { - return JSON.parse(await readFile(path, "utf8")) as Stamp; + return content && (JSON.parse(content.toString("utf8")) as Stamp); } catch { + // A corrupt stamp only means the step runs again. return; } } /** - * The `package.json` of the package `specifier` resolves to from `from`. - * Packages whose `exports` hide their `package.json` are found by walking - * up from the resolved entry point. + * The `package.json` of package `name` as installed for `from`. As an + * input it stands for the package: it names the installed version. */ -export function packageManifest(specifier: string, from: string): string { - let dir = dirname(createRequire(resolve(from)).resolve(specifier)); - while (!existsSync(join(dir, "package.json"))) { - const parent = dirname(dir); - if (parent === dir) { - throw new Error(`No package.json above ${specifier}`); - } - dir = parent; - } - return join(dir, "package.json"); +export function packageManifest(name: string, from: string): string { + return createRequire(resolve(from)).resolve(`${name}/package.json`); } /** Run `step` unless its inputs and outputs match the last run's stamp. */ From 5f200610ddebb6b94b04bd443766563f42df7c29 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:52:13 +0000 Subject: [PATCH 6/9] perf(cli): Analyze command sources in a worker while the tree loads Per review: `command-api` parsed all of `src/` with the TypeScript compiler on the main thread, about 2.2 s and more than the route tree load. That analysis needs no tree, so the runner now starts it in a worker thread before loading the tree (a step's new `prepare`), and the step's `emit` only maps the posted result onto the tree. `command-api` runs last so the other steps, the SDK's compiler load included, overlap the worker. Nothing in a run reads `command-api.json` back; only `sentry schema` does, at runtime. --- packages/cli/AGENTS.md | 4 +- packages/cli/script/generate-command-api.ts | 31 ++----------- packages/cli/script/generate.ts | 20 ++++++++- .../script/lib/command-api-analysis-worker.ts | 40 +++++++++++++++++ .../cli/script/lib/command-api-analysis.ts | 43 +++++++++++++++++++ 5 files changed, 108 insertions(+), 30 deletions(-) create mode 100644 packages/cli/script/lib/command-api-analysis-worker.ts create mode 100644 packages/cli/script/lib/command-api-analysis.ts diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index f0a6f051f..cb5635faf 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -822,8 +822,8 @@ Use `"date"` for timestamp-based sort (not `"time"`). Export sort types from the All command docs and skill files are generated via `pnpm run generate:docs`. This runs automatically as part of `dev`, `build`, `typecheck`, and `test` scripts. -- **One process**: the `generate:*` scripts (except `generate:grammar`, a plain Node script) call `script/generate.ts`, which runs the requested steps in one process so the route tree loads once. Phase 1 writes what the tree imports (API schema, grammars, output-schema field data, banner) and must not load the tree. Phase 2 stubs the generated files the tree imports (`script/lib/route-tree-stubs.ts`), then runs each step's exported `emit()`. Phase-2 modules may import the tree statically, because the runner imports them only after the stubs exist. A new generator exports `emit()` and gets a step in `script/generate.ts`, with no stub code of its own. In `package.json`, call the runner (`tsx script/generate.ts `) rather than chaining `pnpm run generate:*`. `docs` names every step but `sdk`, including `schema`, so callers need not run `generate:schema` first. -- **Skipped steps**: a phase-1 step with `files` in `script/generate.ts` runs only when the content hash of its inputs or outputs differs from its stamp in `src/generated/.stamps/` (`script/lib/step-stamp.ts`). When such a step starts reading a new file or package, add it to that step's inputs. `output-schemas` and the phase-2 steps have no stamp: their inputs are the source they import, which is no short list. +- **One process**: the `generate:*` scripts (except `generate:grammar`, a plain Node script) call `script/generate.ts`, which runs the requested steps in one process so the route tree loads once. Phase 1 writes what the tree imports (API schema, grammars, output-schema field data, banner) and must not load the tree. Phase 2 stubs the generated files the tree imports (`script/lib/route-tree-stubs.ts`), then runs each step's exported `emit()`. Phase-2 modules may import the tree statically, because the runner imports them only after the stubs exist. A new generator exports `emit()` and gets a step in `script/generate.ts`, with no stub code of its own. A step may also declare `prepare`, which the runner starts before loading the tree: `command-api` uses it to parse `src/` in a worker thread (`script/lib/command-api-analysis.ts`) while the tree loads, and runs last because its `emit` waits for that worker. In `package.json`, call the runner (`tsx script/generate.ts `) rather than chaining `pnpm run generate:*`. `docs` names every step but `sdk`, including `schema`, so callers need not run `generate:schema` first. +- **Skipped steps**: a phase-1 step with `files` in `script/generate.ts` runs only when the content hash of its inputs or outputs differs from its entry in `src/generated/.stamps.json` (`script/lib/step-stamp.ts`). When such a step starts reading a new file or package, add it to that step's inputs. `output-schemas` and the phase-2 steps have no stamp: their inputs are the source they import, which is no short list. - **Command docs** (`apps/cli-docs/src/content/docs/commands/*.md`) and the configuration page (`apps/cli-docs/src/content/docs/configuration.md`) are **gitignored** and generated from CLI metadata + hand-written fragments in `apps/cli-docs/src/fragments/` (paths live in `script/paths.ts`). - **Skill files** (`plugins/sentry-cli/skills/sentry-cli/`) are **committed** (consumed by external plugin systems) and auto-committed by CI when stale. diff --git a/packages/cli/script/generate-command-api.ts b/packages/cli/script/generate-command-api.ts index 06b23fbdd..c6c0e8f03 100644 --- a/packages/cli/script/generate-command-api.ts +++ b/packages/cli/script/generate-command-api.ts @@ -1,46 +1,23 @@ /** Generate command/API relationships from source calls and the public route tree. */ -import { readFile, writeFile } from "node:fs/promises"; +import { writeFile } from "node:fs/promises"; import { resolve } from "node:path"; import { pathToFileURL } from "node:url"; -import { glob } from "tinyglobby"; import { routes } from "../src/app.js"; -import type { ApiEndpoint, ApiReference } from "../src/lib/api-schema.js"; +import type { ApiReference } from "../src/lib/api-schema.js"; import { isCommand, publicTargetPaths, type RouteMap, type RouteTarget, } from "../src/lib/introspect.js"; -import { analyzeSourceApiUsage } from "./lib/source-api-usage.js"; +import { commandApiAnalysis } from "./lib/command-api-analysis.js"; const OUTPUT_PATH = "src/generated/command-api.json"; /** Write which API operations each public command calls. */ export async function emit(): Promise { - const files = await glob(["src/**/*.{ts,tsx}"], { - ignore: ["src/generated/**"], - }); - const sources = new Map( - await Promise.all( - files - .sort() - .map(async (file) => [file, await readFile(file, "utf8")] as const), - ), - ); - const endpoints = JSON.parse( - await readFile("src/generated/api-schema.json", "utf8"), - ) as ApiEndpoint[]; - const analysis = analyzeSourceApiUsage( - sources, - endpoints - .filter((entry) => entry.fn) - .map((entry) => ({ - functionName: entry.fn, - method: entry.method, - path: entry.path, - })), - ); + const analysis = await commandApiAnalysis(); const paths = publicTargetPaths(routes as unknown as RouteMap); const matched = new Set(); diff --git a/packages/cli/script/generate.ts b/packages/cli/script/generate.ts index 4dfe20e73..c9eb0f559 100644 --- a/packages/cli/script/generate.ts +++ b/packages/cli/script/generate.ts @@ -15,6 +15,8 @@ * generated files the tree imports but these steps produce * (`lib/route-tree-stubs.ts`), loads the tree, then runs each step. Step * modules import the tree statically, so they are imported only here. + * Work a step can do without the tree starts first, in its `prepare`: + * `command-api` parses `src/` in a worker thread while the tree loads. * * Steps run in the order below, whatever order they are named in. Like * `pnpm tsx`, the runner loads `.env.local` when it exists and installs @@ -58,6 +60,11 @@ type Step = { * a skipped step's dependencies are never imported. */ files?: () => StepFiles; + /** + * Phase 2 only: start work that needs no route tree, such as a worker + * thread, before the tree loads, so the two overlap. `emit` awaits it. + */ + prepare?: () => Promise; }; const GRAMMAR_SCRIPT = "../../scripts/generate-parsers.mjs"; @@ -147,7 +154,6 @@ const PRE_TREE_STEPS: readonly Step[] = [ /** Phase 2: steps that read the route tree. */ const TREE_STEPS: readonly Step[] = [ - { name: "command-api", load: () => import("./generate-command-api.js") }, { name: "completions", load: () => import("./generate-completion-targets.js"), @@ -160,6 +166,15 @@ const TREE_STEPS: readonly Step[] = [ { name: "docs-sections", load: () => import("./generate-docs-sections.js") }, { name: "skill", load: () => import("./generate-skill.js") }, { name: "sdk", load: () => import("./generate-sdk.js") }, + // Last: its emit waits for the worker `prepare` starts, and no step reads + // `command-api.json` back (only `sentry schema` does, at runtime). + { + name: "command-api", + load: () => import("./generate-command-api.js"), + prepare: async () => { + void (await import("./lib/command-api-analysis.js")).commandApiAnalysis(); + }, + }, ]; const STEP_NAMES = [...PRE_TREE_STEPS, ...TREE_STEPS].map((step) => step.name); @@ -195,6 +210,9 @@ for (const step of preTreeSteps) { } if (treeSteps.length > 0) { + for (const step of treeSteps) { + await step.prepare?.(); + } await writeRouteTreeStubs(); await import("../src/app.js"); for (const step of treeSteps) { diff --git a/packages/cli/script/lib/command-api-analysis-worker.ts b/packages/cli/script/lib/command-api-analysis-worker.ts new file mode 100644 index 000000000..7ff87475c --- /dev/null +++ b/packages/cli/script/lib/command-api-analysis-worker.ts @@ -0,0 +1,40 @@ +/** + * Worker Thread for the `command-api` Source Analysis + * + * Started by `command-api-analysis.ts`. Reads every source file and the + * generated API schema, analyzes which operations each export reaches, and + * posts the result (plain objects and Maps) back to the main thread. + */ + +import { readFile } from "node:fs/promises"; +import { parentPort } from "node:worker_threads"; +import { glob } from "tinyglobby"; +import type { ApiEndpoint } from "../../src/lib/api-schema.js"; +import { analyzeSourceApiUsage } from "./source-api-usage.js"; + +const files = await glob(["src/**/*.{ts,tsx}"], { + ignore: ["src/generated/**"], +}); +const sources = new Map( + await Promise.all( + files + .sort() + .map(async (file) => [file, await readFile(file, "utf8")] as const), + ), +); +const endpoints = JSON.parse( + await readFile("src/generated/api-schema.json", "utf8"), +) as ApiEndpoint[]; + +parentPort?.postMessage( + analyzeSourceApiUsage( + sources, + endpoints + .filter((entry) => entry.fn) + .map((entry) => ({ + functionName: entry.fn, + method: entry.method, + path: entry.path, + })), + ), +); diff --git a/packages/cli/script/lib/command-api-analysis.ts b/packages/cli/script/lib/command-api-analysis.ts new file mode 100644 index 000000000..f7d239d1e --- /dev/null +++ b/packages/cli/script/lib/command-api-analysis.ts @@ -0,0 +1,43 @@ +/** + * Start the `command-api` Source Analysis in a Worker + * + * Finding the API operations each command reaches parses all of `src/` with + * the TypeScript compiler. It is the slowest work in `generate` and needs + * none of the route tree, so `script/generate.ts` starts it in a worker + * thread (`command-api-analysis-worker.ts`) before it loads the tree, and + * the two overlap. `generate-command-api.ts` then awaits the result and maps + * it onto the tree. + * + * This module imports the analyzer only as a type, so starting the worker + * does not load the compiler into the main thread. + */ + +import { Worker } from "node:worker_threads"; +import type { analyzeSourceApiUsage } from "./source-api-usage.js"; + +export type CommandApiAnalysis = ReturnType; + +let analysis: Promise | undefined; + +/** + * The source analysis, started in a worker on the first call. Reads + * `src/generated/api-schema.json`, so call it after the schema step. + */ +export function commandApiAnalysis(): Promise { + if (!analysis) { + analysis = new Promise((resolve, reject) => { + const worker = new Worker( + new URL("./command-api-analysis-worker.ts", import.meta.url), + ); + worker.once("message", resolve); + worker.once("error", reject); + worker.once("exit", (code) => { + reject(new Error(`command-api analysis worker exited with ${code}`)); + }); + }); + // Started early and awaited later: a failure before then must not + // count as unhandled. Whoever awaits it still sees the rejection. + analysis.catch(() => undefined); + } + return analysis; +} From c2b06807a4ba557e0dfe4c9d5364a4845335ec23 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:53:15 +0000 Subject: [PATCH 7/9] ref(cli): Keep generator stamps in one file Per review: one `src/generated/.stamps.json` with an entry per step replaces the `src/generated/.stamps/` directory of one file per step. --- packages/cli/script/lib/step-stamp.ts | 44 ++++++++++++++------------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/packages/cli/script/lib/step-stamp.ts b/packages/cli/script/lib/step-stamp.ts index 7fad429ed..d20a8cbaa 100644 --- a/packages/cli/script/lib/step-stamp.ts +++ b/packages/cli/script/lib/step-stamp.ts @@ -3,7 +3,7 @@ * * A step that declares its inputs and outputs runs only when the content * hash of either differs from the stamp its last run left in - * `src/generated/.stamps/`. Hashes, not mtimes: a checkout or a CI cache + * `src/generated/.stamps.json`, one entry per step. Hashes, not mtimes: a checkout or a CI cache * restore resets mtimes without changing content. The output hash catches * outputs that something else rewrote since, such as `generate:grammar` * run on its own or another branch's pipeline, and missing outputs never @@ -15,11 +15,11 @@ */ import { createHash } from "node:crypto"; -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import { createRequire } from "node:module"; -import { join, resolve } from "node:path"; +import { dirname, resolve } from "node:path"; -const STAMP_DIR = "src/generated/.stamps"; +const STAMPS_PATH = "src/generated/.stamps.json"; /** The files a step reads and the files it writes. */ export type StepFiles = { @@ -29,8 +29,11 @@ export type StepFiles = { type Stamp = { inputs: string; outputs: string }; +/** Each step's stamp, by step name. */ +type Stamps = Partial>; + /** The contents of `path`, or `undefined` if it does not exist. */ -async function readIfExists(path: string): Promise { +async function readIfExists(path: string): Promise { try { return await readFile(path); } catch (error) { @@ -54,13 +57,15 @@ async function digest(paths: readonly string[]): Promise { return hash.digest("hex"); } -async function readStamp(path: string): Promise { - const content = await readIfExists(path); +async function readStamps(): Promise { + const content = await readIfExists(STAMPS_PATH); try { - return content && (JSON.parse(content.toString("utf8")) as Stamp); + return content + ? (JSON.parse(new TextDecoder().decode(content)) as Stamps) + : {}; } catch { - // A corrupt stamp only means the step runs again. - return; + // A corrupt stamp file only means every stamped step runs again. + return {}; } } @@ -78,9 +83,8 @@ export async function runUnlessUnchanged( files: StepFiles, step: () => Promise, ): Promise { - const stampPath = join(STAMP_DIR, `${name}.json`); const inputs = await digest(files.inputs); - const stamp = await readStamp(stampPath); + const stamp = (await readStamps())[name]; if ( inputs !== undefined && stamp?.inputs === inputs && @@ -93,13 +97,11 @@ export async function runUnlessUnchanged( await step(); const outputs = await digest(files.outputs); - if (inputs === undefined || outputs === undefined) { - await rm(stampPath, { force: true }); - return; - } - await mkdir(STAMP_DIR, { recursive: true }); - await writeFile( - stampPath, - `${JSON.stringify({ inputs, outputs } satisfies Stamp)}\n`, - ); + const stamps = await readStamps(); + stamps[name] = + inputs === undefined || outputs === undefined + ? undefined + : { inputs, outputs }; + await mkdir(dirname(STAMPS_PATH), { recursive: true }); + await writeFile(STAMPS_PATH, `${JSON.stringify(stamps, null, 2)}\n`); } From 116111742631f1861f8eef6fab5424c5627e5189 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:14:08 +0000 Subject: [PATCH 8/9] perf(cli): Run phase-1 generator steps concurrently The four phase-1 steps (schema, grammar, output-schemas, banner) read none of each other's outputs, so the runner now starts them together and awaits them with Promise.all. The grammar compiles in a child process started with spawn instead of execFileSync, so it no longer blocks the others. Stamp writes to .stamps.json are chained so concurrent steps don't overwrite each other's entries. The command-api worker now starts as the run begins rather than after phase 1. It loads the TypeScript analyzer and reads src/ right away and reads api-schema.json only once the schema step settles; a schema failure rejects the analysis and stops the worker. Phase 1 (7 runs, median): cold 985 -> 687 ms, warm 392 -> 353 ms. `docs sdk`: cold 3.60 -> 3.03 s, warm 3.17 -> 2.98 s. Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- packages/cli/AGENTS.md | 2 +- packages/cli/script/generate.ts | 58 +++++++++++++------ .../script/lib/command-api-analysis-worker.ts | 8 ++- .../cli/script/lib/command-api-analysis.ts | 22 +++++-- packages/cli/script/lib/step-stamp.ts | 23 +++++--- 5 files changed, 77 insertions(+), 36 deletions(-) diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index cb5635faf..faf981c4e 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -822,7 +822,7 @@ Use `"date"` for timestamp-based sort (not `"time"`). Export sort types from the All command docs and skill files are generated via `pnpm run generate:docs`. This runs automatically as part of `dev`, `build`, `typecheck`, and `test` scripts. -- **One process**: the `generate:*` scripts (except `generate:grammar`, a plain Node script) call `script/generate.ts`, which runs the requested steps in one process so the route tree loads once. Phase 1 writes what the tree imports (API schema, grammars, output-schema field data, banner) and must not load the tree. Phase 2 stubs the generated files the tree imports (`script/lib/route-tree-stubs.ts`), then runs each step's exported `emit()`. Phase-2 modules may import the tree statically, because the runner imports them only after the stubs exist. A new generator exports `emit()` and gets a step in `script/generate.ts`, with no stub code of its own. A step may also declare `prepare`, which the runner starts before loading the tree: `command-api` uses it to parse `src/` in a worker thread (`script/lib/command-api-analysis.ts`) while the tree loads, and runs last because its `emit` waits for that worker. In `package.json`, call the runner (`tsx script/generate.ts `) rather than chaining `pnpm run generate:*`. `docs` names every step but `sdk`, including `schema`, so callers need not run `generate:schema` first. +- **One process**: the `generate:*` scripts (except `generate:grammar`, a plain Node script) call `script/generate.ts`, which runs the requested steps in one process so the route tree loads once. Phase 1 writes what the tree imports (API schema, grammars, output-schema field data, banner), runs those steps concurrently, and must not load the tree; its steps must not read each other's outputs. Phase 2 stubs the generated files the tree imports (`script/lib/route-tree-stubs.ts`), then runs each step's exported `emit()`. Phase-2 modules may import the tree statically, because the runner imports them only after the stubs exist. A new generator exports `emit()` and gets a step in `script/generate.ts`, with no stub code of its own. A phase-2 step may also declare `prepare`, which the runner calls as the run begins: `command-api` uses it to parse `src/` in a worker thread (`script/lib/command-api-analysis.ts`) during phase 1 and the tree load. The worker reads the API schema once the `schema` step finishes, and `command-api` runs last because its `emit` waits for that worker. In `package.json`, call the runner (`tsx script/generate.ts `) rather than chaining `pnpm run generate:*`. `docs` names every step but `sdk`, including `schema`, so callers need not run `generate:schema` first. - **Skipped steps**: a phase-1 step with `files` in `script/generate.ts` runs only when the content hash of its inputs or outputs differs from its entry in `src/generated/.stamps.json` (`script/lib/step-stamp.ts`). When such a step starts reading a new file or package, add it to that step's inputs. `output-schemas` and the phase-2 steps have no stamp: their inputs are the source they import, which is no short list. - **Command docs** (`apps/cli-docs/src/content/docs/commands/*.md`) and the configuration page (`apps/cli-docs/src/content/docs/configuration.md`) are **gitignored** and generated from CLI metadata + hand-written fragments in `apps/cli-docs/src/fragments/` (paths live in `script/paths.ts`). diff --git a/packages/cli/script/generate.ts b/packages/cli/script/generate.ts index c9eb0f559..1cbb7014d 100644 --- a/packages/cli/script/generate.ts +++ b/packages/cli/script/generate.ts @@ -5,21 +5,25 @@ * Most generators read the route tree, and loading it through tsx is most of * what each of them costs. Run as separate `pnpm tsx` processes, they loaded * the tree once each and paid pnpm's startup every time. This runner loads it - * once and runs every requested step in order, in two phases: + * once and runs every requested step in two phases: * * 1. Steps the route tree depends on: the API schema, the grammar parsers, * the output-schema field data that commands import, and the banner. - * Nothing in this phase may load the route tree. A step that lists its - * `files` is skipped when their content matches its last run. + * They are independent of each other and run concurrently; the grammar + * compiles in a child process. Nothing in this phase may load the route + * tree. A step that lists its `files` is skipped when their content + * matches its last run. * 2. Steps that read the route tree. The runner writes stubs for the * generated files the tree imports but these steps produce - * (`lib/route-tree-stubs.ts`), loads the tree, then runs each step. Step - * modules import the tree statically, so they are imported only here. - * Work a step can do without the tree starts first, in its `prepare`: - * `command-api` parses `src/` in a worker thread while the tree loads. + * (`lib/route-tree-stubs.ts`), loads the tree, then runs each step in + * the order below, whatever order they are named in. Step modules import + * the tree statically, so they are imported only here. * - * Steps run in the order below, whatever order they are named in. Like - * `pnpm tsx`, the runner loads `.env.local` when it exists and installs + * Work a phase-2 step can do without the tree starts with the run, in its + * `prepare`: `command-api` parses `src/` in a worker thread while phase 1 + * runs and the tree loads. + * + * Like `pnpm tsx`, the runner loads `.env.local` when it exists and installs * `require-shim.mjs` before any step. * * Usage: @@ -30,9 +34,11 @@ */ import "./require-shim.mjs"; -import { execFileSync } from "node:child_process"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; import { readdirSync } from "node:fs"; import { fileURLToPath } from "node:url"; +import { commandApiAnalysis } from "./lib/command-api-analysis.js"; import { writeRouteTreeStubs } from "./lib/route-tree-stubs.js"; import { packageManifest, @@ -62,18 +68,27 @@ type Step = { files?: () => StepFiles; /** * Phase 2 only: start work that needs no route tree, such as a worker - * thread, before the tree loads, so the two overlap. `emit` awaits it. + * thread, as the run begins, so it overlaps phase 1 and the tree load. + * `after(name)` settles when that phase-1 step does (at once if it was + * not requested). `emit` awaits the work. */ - prepare?: () => Promise; + prepare?: (after: (name: string) => Promise) => void; }; const GRAMMAR_SCRIPT = "../../scripts/generate-parsers.mjs"; /** Compile the grammars; a plain Node script shared with other packages. */ async function generateGrammar(): Promise { - execFileSync(process.execPath, [GRAMMAR_SCRIPT, "."], { + const child = spawn(process.execPath, [GRAMMAR_SCRIPT, "."], { stdio: "inherit", }); + const [code, signal] = (await once(child, "exit")) as [ + number | null, + NodeJS.Signals | null, + ]; + if (code !== 0) { + throw new Error(`${GRAMMAR_SCRIPT} exited with ${signal ?? code}`); + } } /** The API schema: the SDK's operation catalog, projected. */ @@ -171,8 +186,8 @@ const TREE_STEPS: readonly Step[] = [ { name: "command-api", load: () => import("./generate-command-api.js"), - prepare: async () => { - void (await import("./lib/command-api-analysis.js")).commandApiAnalysis(); + prepare: (after) => { + void commandApiAnalysis(after("schema")); }, }, ]; @@ -198,7 +213,7 @@ for (const arg of args.filter((a) => a !== "--check")) { const preTreeSteps = PRE_TREE_STEPS.filter((step) => requested.has(step.name)); const treeSteps = TREE_STEPS.filter((step) => requested.has(step.name)); -for (const step of preTreeSteps) { +async function runPreTreeStep(step: Step): Promise { const emit = async () => { await (await step.load()).emit(options); }; @@ -209,10 +224,15 @@ for (const step of preTreeSteps) { } } +const preTreeRuns = new Map( + preTreeSteps.map((step) => [step.name, runPreTreeStep(step)] as const), +); +for (const step of treeSteps) { + step.prepare?.((name) => preTreeRuns.get(name) ?? Promise.resolve()); +} +await Promise.all(preTreeRuns.values()); + if (treeSteps.length > 0) { - for (const step of treeSteps) { - await step.prepare?.(); - } await writeRouteTreeStubs(); await import("../src/app.js"); for (const step of treeSteps) { diff --git a/packages/cli/script/lib/command-api-analysis-worker.ts b/packages/cli/script/lib/command-api-analysis-worker.ts index 7ff87475c..f59b5c770 100644 --- a/packages/cli/script/lib/command-api-analysis-worker.ts +++ b/packages/cli/script/lib/command-api-analysis-worker.ts @@ -1,9 +1,10 @@ /** * Worker Thread for the `command-api` Source Analysis * - * Started by `command-api-analysis.ts`. Reads every source file and the - * generated API schema, analyzes which operations each export reaches, and - * posts the result (plain objects and Maps) back to the main thread. + * Started by `command-api-analysis.ts`. Loads the analyzer and reads every + * source file at once, then waits for the main thread's message that the + * API schema is written, reads it, analyzes which operations each export + * reaches, and posts the result (plain objects and Maps) back. */ import { readFile } from "node:fs/promises"; @@ -22,6 +23,7 @@ const sources = new Map( .map(async (file) => [file, await readFile(file, "utf8")] as const), ), ); +await new Promise((resolve) => parentPort?.once("message", resolve)); const endpoints = JSON.parse( await readFile("src/generated/api-schema.json", "utf8"), ) as ApiEndpoint[]; diff --git a/packages/cli/script/lib/command-api-analysis.ts b/packages/cli/script/lib/command-api-analysis.ts index f7d239d1e..1abce7534 100644 --- a/packages/cli/script/lib/command-api-analysis.ts +++ b/packages/cli/script/lib/command-api-analysis.ts @@ -4,9 +4,9 @@ * Finding the API operations each command reaches parses all of `src/` with * the TypeScript compiler. It is the slowest work in `generate` and needs * none of the route tree, so `script/generate.ts` starts it in a worker - * thread (`command-api-analysis-worker.ts`) before it loads the tree, and - * the two overlap. `generate-command-api.ts` then awaits the result and maps - * it onto the tree. + * thread (`command-api-analysis-worker.ts`) as the run begins, so it + * overlaps phase 1 and the tree load. `generate-command-api.ts` then awaits + * the result and maps it onto the tree. * * This module imports the analyzer only as a type, so starting the worker * does not load the compiler into the main thread. @@ -20,10 +20,13 @@ export type CommandApiAnalysis = ReturnType; let analysis: Promise | undefined; /** - * The source analysis, started in a worker on the first call. Reads - * `src/generated/api-schema.json`, so call it after the schema step. + * The source analysis, started in a worker on the first call. The worker + * reads `src/generated/api-schema.json` once `schemaReady` resolves; later + * calls share the first one's result. */ -export function commandApiAnalysis(): Promise { +export function commandApiAnalysis( + schemaReady: Promise = Promise.resolve(), +): Promise { if (!analysis) { analysis = new Promise((resolve, reject) => { const worker = new Worker( @@ -34,6 +37,13 @@ export function commandApiAnalysis(): Promise { worker.once("exit", (code) => { reject(new Error(`command-api analysis worker exited with ${code}`)); }); + schemaReady.then( + () => worker.postMessage("schema-ready"), + (error: unknown) => { + reject(error); + void worker.terminate(); + }, + ); }); // Started early and awaited later: a failure before then must not // count as unhandled. Whoever awaits it still sees the rejection. diff --git a/packages/cli/script/lib/step-stamp.ts b/packages/cli/script/lib/step-stamp.ts index d20a8cbaa..dfab48567 100644 --- a/packages/cli/script/lib/step-stamp.ts +++ b/packages/cli/script/lib/step-stamp.ts @@ -32,6 +32,9 @@ type Stamp = { inputs: string; outputs: string }; /** Each step's stamp, by step name. */ type Stamps = Partial>; +/** The last pending write to the stamp file. */ +let stampWrites: Promise = Promise.resolve(); + /** The contents of `path`, or `undefined` if it does not exist. */ async function readIfExists(path: string): Promise { try { @@ -84,11 +87,11 @@ export async function runUnlessUnchanged( step: () => Promise, ): Promise { const inputs = await digest(files.inputs); - const stamp = (await readStamps())[name]; + const last = (await readStamps())[name]; if ( inputs !== undefined && - stamp?.inputs === inputs && - stamp.outputs === (await digest(files.outputs)) + last?.inputs === inputs && + last.outputs === (await digest(files.outputs)) ) { console.log(`Skipped ${name}: inputs and outputs unchanged`); return; @@ -97,11 +100,17 @@ export async function runUnlessUnchanged( await step(); const outputs = await digest(files.outputs); - const stamps = await readStamps(); - stamps[name] = + const stamp = inputs === undefined || outputs === undefined ? undefined : { inputs, outputs }; - await mkdir(dirname(STAMPS_PATH), { recursive: true }); - await writeFile(STAMPS_PATH, `${JSON.stringify(stamps, null, 2)}\n`); + // Steps run concurrently and share one file: chain the read-modify-write + // so no step's update overwrites another's. + stampWrites = stampWrites.then(async () => { + const stamps = await readStamps(); + stamps[name] = stamp; + await mkdir(dirname(STAMPS_PATH), { recursive: true }); + await writeFile(STAMPS_PATH, `${JSON.stringify(stamps, null, 2)}\n`); + }); + await stampWrites; } From a449861a0e266286b569c41d84b4216dc3fc6b2f Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:24:51 +0000 Subject: [PATCH 9/9] fix(cli): Regenerate route-tree stubs left behind by checkers check-fragments and check-doc-examples write the route-tree stubs before they load the tree but never generate the real files. ensure-generated only checked that each docs output existed, so a leftover `{}` top-level-routes.json passed the check and `pnpm run cli help` crashed with "No generated help data". The stub contents move to script/lib/route-tree-stub-files.mjs, which both route-tree-stubs.ts and the plain-Node ensure-generated.mjs import. ensure-generated now treats a file that still holds its stub as missing. It compares sizes first, so a no-op check stays at ~28 ms. --- packages/cli/AGENTS.md | 2 +- packages/cli/script/ensure-generated.mjs | 25 +++++++++++++++++-- .../cli/script/lib/route-tree-stub-files.mjs | 18 +++++++++++++ packages/cli/script/lib/route-tree-stubs.ts | 17 ++++++------- 4 files changed, 49 insertions(+), 13 deletions(-) create mode 100644 packages/cli/script/lib/route-tree-stub-files.mjs diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index faf981c4e..afe0aad7e 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -822,7 +822,7 @@ Use `"date"` for timestamp-based sort (not `"time"`). Export sort types from the All command docs and skill files are generated via `pnpm run generate:docs`. This runs automatically as part of `dev`, `build`, `typecheck`, and `test` scripts. -- **One process**: the `generate:*` scripts (except `generate:grammar`, a plain Node script) call `script/generate.ts`, which runs the requested steps in one process so the route tree loads once. Phase 1 writes what the tree imports (API schema, grammars, output-schema field data, banner), runs those steps concurrently, and must not load the tree; its steps must not read each other's outputs. Phase 2 stubs the generated files the tree imports (`script/lib/route-tree-stubs.ts`), then runs each step's exported `emit()`. Phase-2 modules may import the tree statically, because the runner imports them only after the stubs exist. A new generator exports `emit()` and gets a step in `script/generate.ts`, with no stub code of its own. A phase-2 step may also declare `prepare`, which the runner calls as the run begins: `command-api` uses it to parse `src/` in a worker thread (`script/lib/command-api-analysis.ts`) during phase 1 and the tree load. The worker reads the API schema once the `schema` step finishes, and `command-api` runs last because its `emit` waits for that worker. In `package.json`, call the runner (`tsx script/generate.ts `) rather than chaining `pnpm run generate:*`. `docs` names every step but `sdk`, including `schema`, so callers need not run `generate:schema` first. +- **One process**: the `generate:*` scripts (except `generate:grammar`, a plain Node script) call `script/generate.ts`, which runs the requested steps in one process so the route tree loads once. Phase 1 writes what the tree imports (API schema, grammars, output-schema field data, banner), runs those steps concurrently, and must not load the tree; its steps must not read each other's outputs. Phase 2 stubs the generated files the tree imports (`script/lib/route-tree-stubs.ts`), then runs each step's exported `emit()`. Scripts that load the tree without generating, such as `check-fragments`, leave those stubs behind, so `ensure-generated.mjs` treats a file that still holds its stub (`script/lib/route-tree-stub-files.mjs`) as missing. Phase-2 modules may import the tree statically, because the runner imports them only after the stubs exist. A new generator exports `emit()` and gets a step in `script/generate.ts`, with no stub code of its own. A phase-2 step may also declare `prepare`, which the runner calls as the run begins: `command-api` uses it to parse `src/` in a worker thread (`script/lib/command-api-analysis.ts`) during phase 1 and the tree load. The worker reads the API schema once the `schema` step finishes, and `command-api` runs last because its `emit` waits for that worker. In `package.json`, call the runner (`tsx script/generate.ts `) rather than chaining `pnpm run generate:*`. `docs` names every step but `sdk`, including `schema`, so callers need not run `generate:schema` first. - **Skipped steps**: a phase-1 step with `files` in `script/generate.ts` runs only when the content hash of its inputs or outputs differs from its entry in `src/generated/.stamps.json` (`script/lib/step-stamp.ts`). When such a step starts reading a new file or package, add it to that step's inputs. `output-schemas` and the phase-2 steps have no stamp: their inputs are the source they import, which is no short list. - **Command docs** (`apps/cli-docs/src/content/docs/commands/*.md`) and the configuration page (`apps/cli-docs/src/content/docs/configuration.md`) are **gitignored** and generated from CLI metadata + hand-written fragments in `apps/cli-docs/src/fragments/` (paths live in `script/paths.ts`). diff --git a/packages/cli/script/ensure-generated.mjs b/packages/cli/script/ensure-generated.mjs index 238d133ba..a6345a015 100644 --- a/packages/cli/script/ensure-generated.mjs +++ b/packages/cli/script/ensure-generated.mjs @@ -17,7 +17,8 @@ */ import { execSync } from "node:child_process"; -import { existsSync } from "node:fs"; +import { existsSync, readFileSync, statSync } from "node:fs"; +import { ROUTE_TREE_STUBS } from "./lib/route-tree-stub-files.mjs"; /** Produced by `generate:schema`; imported at runtime by `src/`. */ const SCHEMA_OUTPUTS = [ @@ -40,6 +41,26 @@ const DOCS_OUTPUTS = [ "src/generated/top-level-routes.json", ]; +const STUBS = new Map(ROUTE_TREE_STUBS); + +/** + * Whether `path` holds generated output. A file that still holds its + * route-tree stub does not: scripts that load the tree without generating, + * such as `check-fragments`, write stubs and leave them behind. + */ +function isGenerated(path) { + if (!existsSync(path)) { + return false; + } + const stub = STUBS.get(path); + // Compare sizes first so generated files are not read. + return ( + stub === undefined || + statSync(path).size !== Buffer.byteLength(stub) || + readFileSync(path, "utf8") !== stub + ); +} + function run(script) { console.error(`Missing generated files; running \`pnpm run ${script}\`...`); // Send generator output to stderr: `pnpm run cli mcp` serves JSON-RPC on @@ -59,6 +80,6 @@ if (!SCHEMA_OUTPUTS.every(existsSync)) { if (wants("grammar") && !GRAMMAR_OUTPUTS.every(existsSync)) { run("generate:grammar"); } -if (wants("docs") && !DOCS_OUTPUTS.every(existsSync)) { +if (wants("docs") && !DOCS_OUTPUTS.every(isGenerated)) { run("generate:docs"); } diff --git a/packages/cli/script/lib/route-tree-stub-files.mjs b/packages/cli/script/lib/route-tree-stub-files.mjs new file mode 100644 index 000000000..f5264c714 --- /dev/null +++ b/packages/cli/script/lib/route-tree-stub-files.mjs @@ -0,0 +1,18 @@ +/** + * The Stub Contents of the Generated Files the Route Tree Imports + * + * `route-tree-stubs.ts` writes these before a script loads the tree. They + * live in plain JavaScript so `ensure-generated.mjs`, which runs without + * tsx, can tell a stub left behind by a tree-loading script, such as + * `check-fragments`, from generated output, and regenerate it. + */ + +/** @type {ReadonlyArray} */ +export const ROUTE_TREE_STUBS = [ + ["src/generated/command-api.json", "{}\n"], + ["src/generated/top-level-routes.json", "{}\n"], + [ + "src/generated/skill-content.ts", + "export const SKILL_FILES: ReadonlyMap = new Map();\n", + ], +]; diff --git a/packages/cli/script/lib/route-tree-stubs.ts b/packages/cli/script/lib/route-tree-stubs.ts index 9d5bde251..aacbcb7ab 100644 --- a/packages/cli/script/lib/route-tree-stubs.ts +++ b/packages/cli/script/lib/route-tree-stubs.ts @@ -10,24 +10,21 @@ * Scripts that load the route tree call {@link writeRouteTreeStubs} before * the first `import("../src/app.js")`. Static imports run before any code, * so the tree has to be loaded with a dynamic import after this. + * + * A script that only checks the tree, such as `check-fragments`, leaves its + * stubs behind. `ensure-generated.mjs` treats a file that still holds its + * stub (`route-tree-stub-files.mjs`) as missing, so the CLI never runs on + * one. */ import { mkdir, writeFile } from "node:fs/promises"; - -const STUBS = [ - ["src/generated/command-api.json", "{}\n"], - ["src/generated/top-level-routes.json", "{}\n"], - [ - "src/generated/skill-content.ts", - "export const SKILL_FILES: ReadonlyMap = new Map();\n", - ], -] as const; +import { ROUTE_TREE_STUBS } from "./route-tree-stub-files.mjs"; /** Write a stub for each route-tree input that does not exist yet. */ export async function writeRouteTreeStubs(): Promise { await mkdir("src/generated", { recursive: true }); await Promise.all( - STUBS.map(async ([path, stub]) => { + ROUTE_TREE_STUBS.map(async ([path, stub]) => { try { // `wx` fails with EEXIST rather than replace a generated file. await writeFile(path, stub, { flag: "wx" });