From 02e9c32166423f5fac03cef2da53f114cce6bfc6 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Fri, 28 Aug 2026 14:35:48 +0200 Subject: [PATCH] Add JSON result schema infrastructure --- docs/README.md | 1 + docs/cli/json-output.md | 54 ++++++ .../src/public/node/base-command.test.ts | 35 ++++ .../cli-kit/src/public/node/base-command.ts | 24 ++- .../public/node/json-output-schema.test.ts | 71 +++++++ .../src/public/node/json-output-schema.ts | 181 ++++++++++++++++++ packages/cli/README.md | 48 ++--- packages/cli/src/cli/help.test.ts | 48 +++++ packages/cli/src/cli/help.ts | 40 ++++ 9 files changed, 476 insertions(+), 26 deletions(-) create mode 100644 docs/cli/json-output.md create mode 100644 packages/cli-kit/src/public/node/json-output-schema.test.ts create mode 100644 packages/cli-kit/src/public/node/json-output-schema.ts diff --git a/docs/README.md b/docs/README.md index 5e6e3c44c9b..5cc37bf77e5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,7 @@ The list below contains valuable resources for people interested in contributing * [Get started](./cli/get-started.md) * [Architecture](./cli/architecture.md) * [Conventions](./cli/conventions.md) +* [JSON output contracts](./cli/json-output.md) * [Performance](./cli/performance.md) * [Debugging](./cli/debugging.md) * [ESLint rules](./cli/eslint-rules.md) diff --git a/docs/cli/json-output.md b/docs/cli/json-output.md new file mode 100644 index 00000000000..ed1be7772ae --- /dev/null +++ b/docs/cli/json-output.md @@ -0,0 +1,54 @@ +# JSON output contracts + +Finite commands expose their successful result as typed data independently from terminal presentation. The command's +domain package owns this contract; CLI Kit only provides the shared schema and help infrastructure. + +## Define the result beside the domain service + +Keep the schema beside the service that produces the result. One Zod schema supplies runtime validation, the inferred +TypeScript type, JSON encoding, and the type shown in command help. + +```ts +import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' + +const WidgetSchema = zod.object({ + id: zod.string(), + name: zod.string(), +}) + +export const widgetListJsonOutputSchema = defineJsonOutputSchema({ + name: 'WidgetListResult', + schema: zod.object({widgets: zod.array(WidgetSchema)}), + definitions: {Widget: WidgetSchema}, +}) + +export type WidgetListResult = InferJsonOutputSchema +``` + +Add nested object schemas to `definitions` so generated help gives them stable names. Use `.passthrough()` only when +the public result deliberately permits additional keys. + +## Connect the command and encoder + +Expose the contract from the command and encode through it. Encoding validates the value before serialization. + +```ts +export default class WidgetList extends Command { + static get jsonOutputSchema() { + return widgetListJsonOutputSchema + } + + static descriptionWithMarkdown = 'Lists widgets.' + static description = this.descriptionWithoutMarkdown() + + async run(): Promise { + const result = await listWidgets() + outputResult(widgetListJsonOutputSchema.encode(result)) + } +} +``` + +If the service result and public JSON document differ, keep that mapping in a command-specific codec and validate the +mapped value with the schema. Presenters continue to own terminal text, output channels, files, and exit behavior. A +result contract must not depend on terminal rendering, Oclif, filesystem output, or CLI errors. diff --git a/packages/cli-kit/src/public/node/base-command.test.ts b/packages/cli-kit/src/public/node/base-command.test.ts index 582b5986327..1d2a232c7be 100644 --- a/packages/cli-kit/src/public/node/base-command.test.ts +++ b/packages/cli-kit/src/public/node/base-command.test.ts @@ -6,6 +6,8 @@ import {inTemporaryDirectory, mkdir, writeFile} from './fs.js' import {joinPath, resolvePath, cwd} from './path.js' import {mockAndCaptureOutput} from './testing/output.js' import {unstyled} from './output.js' +import {defineJsonOutputSchema} from './json-output-schema.js' +import {zod} from './schema.js' import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' import {Flags} from '@oclif/core' @@ -207,6 +209,39 @@ const allEnvironments: Environments = { }, } +describe('command descriptions', () => { + test('includes a JSON output schema without mutating the Markdown description', () => { + class CommandWithJsonOutput extends Command { + static get jsonOutputSchema() { + return defineJsonOutputSchema({ + name: 'CommandResult', + schema: zod.object({value: zod.string()}), + }) + } + + static descriptionWithMarkdown = 'Returns a value. [Learn more](https://shopify.dev).' + + static description = this.descriptionWithoutMarkdown() + + public async run(): Promise {} + } + + expect(CommandWithJsonOutput.description).toBe(`Returns a value. "Learn more" (https://shopify.dev). + +With \`--json\`, the command returns \`CommandResult\`: + +\`\`\`ts +interface CommandResult { + value: string +} +\`\`\``) + expect(CommandWithJsonOutput.descriptionWithMarkdown).toBe('Returns a value. [Learn more](https://shopify.dev).') + + CommandWithJsonOutput.descriptionWithoutMarkdown() + expect(CommandWithJsonOutput.descriptionWithMarkdown).toBe('Returns a value. [Learn more](https://shopify.dev).') + }) +}) + describe('applying environments', async () => { const runTestInTmpDir = (testName: string, testFunc: (tmpDir: string) => Promise) => { test(testName, async () => { diff --git a/packages/cli-kit/src/public/node/base-command.ts b/packages/cli-kit/src/public/node/base-command.ts index ec80b0ad220..78d1811ed16 100644 --- a/packages/cli-kit/src/public/node/base-command.ts +++ b/packages/cli-kit/src/public/node/base-command.ts @@ -11,6 +11,7 @@ import {JsonMap} from '../../private/common/json.js' import {underscore} from '../common/string.js' import {Command, Config, Errors} from '@oclif/core' import {OutputFlags, Input, ParserOutput, FlagInput, OutputArgs} from '@oclif/core/parser' +import type {JsonOutputSchema} from './json-output-schema.js' // eslint-disable-next-line @typescript-eslint/no-explicit-any export type ArgOutput = OutputArgs @@ -32,6 +33,11 @@ interface EnvironmentFlags { abstract class BaseCommand extends Command { static baseFlags: FlagInput<{}> = {} + static descriptionWithMarkdown?: string + + public static get jsonOutputSchema(): JsonOutputSchema | undefined { + return undefined + } public static get requiresSyncAnalytics(): boolean { return false @@ -43,8 +49,10 @@ abstract class BaseCommand extends Command { // Replace markdown links to plain text like: "link label" (url) public static descriptionWithoutMarkdown(): string | undefined { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return ((this as any).descriptionWithMarkdown ?? '').replace(/(\[)(.*?)(])(\()(.*?)(\))/gm, '"$2" ($5)') + return appendJsonOutputSchema(this.descriptionWithMarkdown ?? '', this.jsonOutputSchema).replace( + /(\[)(.*?)(])(\()(.*?)(\))/gm, + '"$2" ($5)', + ) } public static analyticsNameOverride(): string | undefined { @@ -392,6 +400,18 @@ function commandSupportsFlag(flags: FlagInput | undefined, flagName: string): bo return Boolean(flags) && Object.prototype.hasOwnProperty.call(flags, flagName) } +function appendJsonOutputSchema(description: string, outputSchema: JsonOutputSchema | undefined): string { + if (!outputSchema) return description + + const jsonOutputDescription = `With \`--json\`, the command returns \`${outputSchema.name}\`: + +\`\`\`ts +${outputSchema.typescript} +\`\`\`` + + return [description, jsonOutputDescription].filter(Boolean).join('\n\n') +} + async function removeDuplicatedPlugins(config: Config): Promise { const plugins = Array.from(config.plugins.values()) const bundlePlugins = ['@shopify/app', '@shopify/plugin-cloudflare'] diff --git a/packages/cli-kit/src/public/node/json-output-schema.test.ts b/packages/cli-kit/src/public/node/json-output-schema.test.ts new file mode 100644 index 00000000000..0322f29cd1a --- /dev/null +++ b/packages/cli-kit/src/public/node/json-output-schema.test.ts @@ -0,0 +1,71 @@ +import {defineJsonOutputSchema, type InferJsonOutputSchema} from './json-output-schema.js' +import {zod} from './schema.js' +import {describe, expect, expectTypeOf, test} from 'vitest' + +describe('JSON output schemas', () => { + test('infers, validates, and encodes the result from one schema', () => { + const outputSchema = defineJsonOutputSchema({ + name: 'Result', + schema: zod.object({value: zod.string(), count: zod.number().optional()}).strict(), + }) + type Result = InferJsonOutputSchema + + expectTypeOf().toEqualTypeOf<{value: string; count?: number}>() + expect(outputSchema.validate({value: 'ready'})).toEqual({value: 'ready'}) + expect(outputSchema.encode({value: 'ready', count: 2})).toBe(`{ + "value": "ready", + "count": 2 +}`) + expect(() => outputSchema.validate({value: 1})).toThrow() + }) + + test('renders named collections, optional fields, and records', () => { + const ItemSchema = zod.object({id: zod.string(), labels: zod.record(zod.string()).optional()}) + const outputSchema = defineJsonOutputSchema({ + name: 'Result', + schema: zod.array(ItemSchema), + definitions: {Item: ItemSchema}, + }) + + expect(outputSchema.typescript).toBe(`type Result = Item[] + +interface Item { + id: string + labels?: Record +}`) + }) + + test('documents and preserves passthrough fields', () => { + const outputSchema = defineJsonOutputSchema({ + name: 'Result', + schema: zod.object({status: zod.string()}).passthrough(), + }) + + expect(outputSchema.typescript).toBe(`interface Result { + status: string + [key: string]: unknown +}`) + expect(JSON.parse(outputSchema.encode({status: 'ready', extension: {id: 1}}))).toEqual({ + status: 'ready', + extension: {id: 1}, + }) + }) + + test('requires nested object schemas to have names', () => { + expect(() => + defineJsonOutputSchema({ + name: 'Result', + schema: zod.object({item: zod.object({id: zod.string()})}), + }), + ).toThrow('Nested JSON output object schemas must be included in definitions.') + }) + + test('quotes property names that are not TypeScript identifiers', () => { + const outputSchema = defineJsonOutputSchema({ + name: 'Result', + schema: zod.object({'api-version': zod.string()}), + }) + + expect(outputSchema.typescript).toContain('"api-version": string') + }) +}) diff --git a/packages/cli-kit/src/public/node/json-output-schema.ts b/packages/cli-kit/src/public/node/json-output-schema.ts new file mode 100644 index 00000000000..69797561f61 --- /dev/null +++ b/packages/cli-kit/src/public/node/json-output-schema.ts @@ -0,0 +1,181 @@ +import { + ZodAny, + ZodArray, + ZodBoolean, + ZodEnum, + ZodLiteral, + ZodNull, + ZodNullable, + ZodNumber, + ZodObject, + ZodOptional, + ZodRecord, + ZodString, + ZodTypeAny, + ZodUnion, + ZodUnknown, + type ZodRawShape, + type z, +} from 'zod' + +interface JsonOutputSchemaDefinition { + readonly name: string + readonly schema: TSchema + readonly definitions: Readonly> +} + +export interface JsonOutputSchema extends JsonOutputSchemaDefinition { + readonly typescript: string + validate(value: unknown): z.output + encode(value: z.input): string +} + +export type InferJsonOutputSchema = z.output + +interface DefineJsonOutputSchemaOptions { + name: string + schema: TSchema + definitions?: Readonly> +} + +/** + * Defines the runtime validator, encoder, and documented TypeScript type for a command's JSON output. + * + * @param options - The root type name, its Zod schema, and any named nested schemas. + * @returns The complete JSON output contract. + */ +export function defineJsonOutputSchema( + options: DefineJsonOutputSchemaOptions, +): JsonOutputSchema { + const definition = { + name: options.name, + schema: options.schema, + definitions: options.definitions ?? {}, + } + + return { + ...definition, + typescript: renderJsonOutputSchema(definition), + validate: (value) => definition.schema.parse(value), + encode: (value) => encodeJsonOutput(definition.schema.parse(value)), + } +} + +/** + * Renders the named schemas in a JSON output contract as TypeScript declarations. + * + * @param outputSchema - The root schema and its named nested schemas. + * @returns TypeScript declarations suitable for command help. + */ +export function renderJsonOutputSchema(outputSchema: JsonOutputSchemaDefinition): string { + const namedSchemas = buildNamedSchemas(outputSchema) + + return [ + renderDeclaration(outputSchema.name, outputSchema.schema, namedSchemas), + ...Object.entries(outputSchema.definitions).map(([name, schema]) => renderDeclaration(name, schema, namedSchemas)), + ].join('\n\n') +} + +function buildNamedSchemas(outputSchema: JsonOutputSchemaDefinition): ReadonlyMap { + const namedSchemas = new Map() + + const definitions: [string, ZodTypeAny][] = [ + [outputSchema.name, outputSchema.schema], + ...Object.entries(outputSchema.definitions), + ] + + for (const [name, schema] of definitions) { + assertTypeScriptIdentifier(name) + const existingName = namedSchemas.get(schema) + if (existingName) { + throw new TypeError(`JSON output schema ${name} is already named ${existingName}.`) + } + namedSchemas.set(schema, name) + } + + return namedSchemas +} + +function renderDeclaration(name: string, schema: ZodTypeAny, namedSchemas: ReadonlyMap): string { + if (schema instanceof ZodObject) return renderInterface(name, schema, namedSchemas) + return `type ${name} = ${renderType(schema, namedSchemas, schema)}` +} + +function renderInterface( + name: string, + schema: ZodObject, + namedSchemas: ReadonlyMap, +): string { + const properties = Object.entries(schema.shape).map(([propertyName, propertySchema]) => { + const optional = propertySchema instanceof ZodOptional + const type = renderType(propertySchema, namedSchemas) + return ` ${renderPropertyName(propertyName)}${optional ? '?' : ''}: ${type}` + }) + + if (schema._def.unknownKeys === 'passthrough') properties.push(' [key: string]: unknown') + + return [`interface ${name} {`, ...properties, '}'].join('\n') +} + +function renderType( + schema: ZodTypeAny, + namedSchemas: ReadonlyMap, + declarationSchema?: ZodTypeAny, +): string { + if (schema instanceof ZodOptional) return renderType(schema.unwrap(), namedSchemas) + if (schema instanceof ZodNullable) return `${renderType(schema.unwrap(), namedSchemas)} | null` + + if (schema !== declarationSchema) { + const namedType = namedSchemas.get(schema) + if (namedType) return namedType + } + + if (schema instanceof ZodString) return 'string' + if (schema instanceof ZodNumber) return 'number' + if (schema instanceof ZodBoolean) return 'boolean' + if (schema instanceof ZodNull) return 'null' + if (schema instanceof ZodUnknown || schema instanceof ZodAny) return 'unknown' + if (schema instanceof ZodLiteral) return renderLiteral(schema.value) + if (schema instanceof ZodEnum) return schema.options.map((value: string) => JSON.stringify(value)).join(' | ') + if (schema instanceof ZodArray) return `${renderArrayElementType(schema.element, namedSchemas)}[]` + if (schema instanceof ZodRecord) return `Record` + if (schema instanceof ZodUnion) { + return schema.options.map((option: ZodTypeAny) => renderType(option, namedSchemas)).join(' | ') + } + + if (schema instanceof ZodObject) { + throw new TypeError('Nested JSON output object schemas must be included in definitions.') + } + + throw new TypeError(`Unsupported JSON output schema type: ${schema.constructor.name}.`) +} + +function renderArrayElementType(schema: ZodTypeAny, namedSchemas: ReadonlyMap): string { + const type = renderType(schema, namedSchemas) + return schema instanceof ZodUnion || schema instanceof ZodNullable ? `(${type})` : type +} + +function renderLiteral(value: unknown): string { + if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return encodeJsonOutput(value) + } + throw new TypeError(`Unsupported JSON output literal: ${String(value)}.`) +} + +function encodeJsonOutput(value: unknown): string { + const encoded = JSON.stringify(value, null, 2) + if (encoded === undefined) throw new TypeError('JSON output must be serializable.') + return encoded +} + +function renderPropertyName(name: string): string { + return isTypeScriptIdentifier(name) ? name : JSON.stringify(name) +} + +function assertTypeScriptIdentifier(name: string): void { + if (!isTypeScriptIdentifier(name)) throw new TypeError(`Invalid JSON output type name: ${name}.`) +} + +function isTypeScriptIdentifier(value: string): boolean { + return /^[$A-Z_a-z][$\w]*$/.test(value) +} diff --git a/packages/cli/README.md b/packages/cli/README.md index 445154f2fa8..fdaf59c458b 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1702,9 +1702,9 @@ DESCRIPTION Use the `--source` argument to limit output to a particular log source, such as a specific Shopify Function handle. Use the `shopify app logs sources` command to view a list of sources. Use the `--status` argument to filter on status, either `success` or `failure`. - ``` - shopify app logs --status=success --source=extension.discount-function - ``` + ``` + shopify app logs --status=success --source=extension.discount-function + ``` ``` ## `shopify app logs sources` @@ -4359,20 +4359,20 @@ DESCRIPTION ```json { - "theme": { - "id": 108267175958, - "name": "A Duplicated Theme", - "role": "unpublished", - "shop": "mystore.myshopify.com" - } + "theme": { + "id": 108267175958, + "name": "A Duplicated Theme", + "role": "unpublished", + "shop": "mystore.myshopify.com" + } } ``` ```json { - "message": "The theme 'Summer Edition' could not be duplicated due to errors", - "errors": ["Maximum number of themes reached"], - "requestId": "12345-abcde-67890" + "message": "The theme 'Summer Edition' could not be duplicated due to errors", + "errors": ["Maximum number of themes reached"], + "requestId": "12345-abcde-67890" } ``` ``` @@ -5117,18 +5117,18 @@ DESCRIPTION Sample output: - ```json - { - "theme": { - "id": 108267175958, - "name": "MyTheme", - "role": "unpublished", - "shop": "mystore.myshopify.com", - "editor_url": "https://mystore.myshopify.com/admin/themes/108267175958/editor", - "preview_url": "https://mystore.myshopify.com/?preview_theme_id=108267175958" - } - } - ``` + ```json + { + "theme": { + "id": 108267175958, + "name": "MyTheme", + "role": "unpublished", + "shop": "mystore.myshopify.com", + "editor_url": "https://mystore.myshopify.com/admin/themes/108267175958/editor", + "preview_url": "https://mystore.myshopify.com/?preview_theme_id=108267175958" + } + } + ``` ``` ## `shopify theme rename` diff --git a/packages/cli/src/cli/help.test.ts b/packages/cli/src/cli/help.test.ts index f0430a807a9..39539611329 100644 --- a/packages/cli/src/cli/help.test.ts +++ b/packages/cli/src/cli/help.test.ts @@ -14,7 +14,55 @@ function renderFlags(flags: Command.Flag.Any[]): [string, string | undefined][] return (rows ?? []).map(([left, right]) => [stripAnsi(left), right === undefined ? undefined : stripAnsi(right)]) } +function renderDescription(command: Partial, maxWidth = 80): string | undefined { + const help = new ShopifyCommandHelp( + command as Command.Loadable, + {} as Interfaces.Config, + {maxWidth} as Interfaces.HelpOptions, + ) + return (help as unknown as {description: () => string | undefined}).description() +} + describe('ShopifyCommandHelp', () => { + test('wraps prose without changing fenced code', () => { + const description = renderDescription( + { + summary: 'Return a value.', + description: `The result is represented by the following TypeScript type: + +\`\`\`ts +interface Result { + value: string +} +\`\`\``, + }, + 50, + ) + + expect(description).toBe(`Return a value. + +The result is represented by the following +TypeScript type: + +\`\`\`ts +interface Result { + value: string +} +\`\`\``) + }) + + test('uses the default description formatting when there are no code blocks', () => { + const command = {summary: 'Return a value.', description: 'A regular command description.'} + const defaultHelp = new CommandHelp( + command as Command.Loadable, + {} as Interfaces.Config, + {maxWidth: 80} as Interfaces.HelpOptions, + ) + const defaultDescription = (defaultHelp as unknown as {description: () => string | undefined}).description() + + expect(renderDescription(command)).toBe(defaultDescription) + }) + test('moves the env metadata to the end of a boolean flag description', () => { // Given const flags = [ diff --git a/packages/cli/src/cli/help.ts b/packages/cli/src/cli/help.ts index a627b0fd650..2daf50f18fe 100644 --- a/packages/cli/src/cli/help.ts +++ b/packages/cli/src/cli/help.ts @@ -46,6 +46,19 @@ export class ShopifyCommandHelp extends CommandHelp { return super.section(header, body) } + protected override description(): string | undefined { + const command = this.command + let description: string | undefined + + if (this.opts.hideCommandSummaryInDescription) { + description = command.description?.split(/\r?\n/).at(-1) ?? '' + } else if (command.description) { + description = command.summary ? `${command.summary}\n\n${command.description}` : command.description + } + + return description ? wrapDescription(description, (prose) => this.wrap(prose)) : undefined + } + protected flags(flags: Command.Flag.Any[]): [string, string | undefined][] | undefined { const relocated = flags.map((flag) => { if (!flag.env) return flag @@ -62,6 +75,33 @@ export class ShopifyCommandHelp extends CommandHelp { } } +function wrapDescription(description: string, wrapProse: (prose: string) => string): string { + const output: string[] = [] + let prose: string[] = [] + let insideCodeBlock = false + + const flushProse = () => { + if (prose.length === 0) return + output.push(wrapProse(prose.join('\n'))) + prose = [] + } + + for (const line of description.split(/\r?\n/)) { + if (line.trimStart().startsWith('```')) { + flushProse() + output.push(line) + insideCodeBlock = !insideCodeBlock + } else if (insideCodeBlock) { + output.push(line) + } else { + prose.push(line) + } + } + flushProse() + + return output.join('\n') +} + /** * Custom help class, wired up via `oclif.helpClass` in this package's * `package.json`. It only swaps in {@link ShopifyCommandHelp}; everything else