Skip to content

Commit 02fbf6e

Browse files
Add JSON schema flag to commands
1 parent 8cb4775 commit 02fbf6e

21 files changed

Lines changed: 2985 additions & 488 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@shopify/cli-kit': minor
3+
'@shopify/cli': minor
4+
---
5+
6+
Show JSON Schema in command help and use `--json-schema` to print result, error, and event schemas.

‎docs-shopify.dev/generated/generated_docs_data_v2.json‎

Lines changed: 1053 additions & 116 deletions
Large diffs are not rendered by default.

‎docs/cli/json-output.md‎

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ commands; remove each entry when converted, and never add new finite commands to
1414
## Define the result beside the domain service
1515

1616
Keep the schema beside the service that produces the result. One Zod schema supplies runtime validation, the inferred
17-
TypeScript type, JSON encoding, and the type shown in command help.
17+
TypeScript type, JSON encoding, and the JSON Schema shown in command help.
1818

1919
```ts
2020
import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema'
@@ -34,8 +34,8 @@ export const widgetListJsonOutputSchema = defineJsonOutputSchema({
3434
export type WidgetListResult = InferJsonOutputSchema<typeof widgetListJsonOutputSchema>
3535
```
3636
37-
Add nested object schemas to `definitions` so generated help gives them stable names. Use `.passthrough()` only when
38-
the public result deliberately permits additional keys.
37+
Optionally add nested object schemas to `definitions` to give them stable names and references in JSON Schema.
38+
Use `.passthrough()` only when the public result deliberately permits additional keys.
3939
4040
## Connect the command and encoder
4141
@@ -75,8 +75,8 @@ Presenters continue to own terminal text, output channels, files, and exit behav
7575
on terminal rendering (including React/Ink), Oclif, filesystem output, or CLI errors.
7676

7777
Events are separate from finite results. Progress events can drive spinners or status messages while the command is
78-
running, but they aren't fields in the final JSON result. Errors continue through the standard CLI error path and
79-
stderr; don't encode failures as successful result shapes merely to support `--json`.
78+
running, but they aren't fields in the final JSON result. Errors continue through the standard CLI error path;
79+
don't encode failures as successful result shapes merely to support `--json`.
8080

8181
## Preserve compatibility
8282

@@ -104,6 +104,22 @@ The lint rule only exempts paths in that list; a `jsonOutputSupport` property al
104104
This exemption is only for commands whose lifetime or output is inherently streaming. A finite operation remains a
105105
finite command even when it emits progress events, writes a file, or has no interesting return value.
106106

107+
## Plugin authors
108+
109+
Plugins must adopt the result contract and control their output before their commands can be used reliably in JSON
110+
mode. Inheriting `--json-schema` or enabling `SHOPIFY_FLAG_JSON=1` doesn't convert all plugin output automatically.
111+
112+
- Oclif `init` hooks run before the command's error handling. A hook that renders a warning and calls `process.exit(1)`
113+
bypasses the JSON fatal error path and can leave stdout empty. Put command validation in the command lifecycle and
114+
throw an `AbortError` so CLI Kit can encode the failure.
115+
- In the command event context, `outputInfo`, `outputWarn`, and `outputDebug` use diagnostic events in JSON mode when
116+
using their default logger. Banners such as `renderSuccess` and `renderWarning` still render terminal text to stderr;
117+
they aren't automatically converted to events. Use `emitCommandEvent` from `@shopify/cli-kit/node/command-events`
118+
for diagnostics, and keep human-only banners in the text presenter.
119+
- Third-party loggers and child processes aren't automatically converted or silenced. Use `jsonOutputEnabled()` from
120+
`@shopify/cli-kit/node/environment` to silence or capture their output in JSON mode. Reserve stdout for the encoded
121+
result or fatal error document, and send diagnostics through the event helpers to stderr.
122+
107123
## Test a new command
108124

109125
Tests should verify:
@@ -115,5 +131,9 @@ Tests should verify:
115131
- errors and exit behavior; and
116132
- prompt behavior independently from `--json` and `--no-input`.
117133

118-
Command help includes the generated TypeScript contract automatically through `jsonOutputSchema`. Run the manifest,
134+
Command help includes the result's JSON Schema automatically through `jsonOutputSchema`. `--json-schema` prints one
135+
JSON Schema (draft-07) accepting a result, a fatal error document, or a side event. The `Result`, `Error`, and `Event`
136+
definitions describe these separately; results and fatal errors go to stdout, and side events go to stderr.
137+
138+
Both outputs come from the same Zod definitions used to validate and encode results. Run the manifest,
119139
README, and code-documentation refresh commands required by CI after changing command metadata.

‎packages/app/src/cli/commands/organization/list.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {authAliasFlag, globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'
33
import BaseCommand from '@shopify/cli-kit/node/base-command'
44

55
export default class OrganizationList extends BaseCommand {
6-
static baseFlags = authAliasFlag
6+
static baseFlags = {...BaseCommand.baseFlags, ...authAliasFlag}
77

88
static summary = 'List Shopify organizations you have access to.'
99

‎packages/app/src/cli/utilities/app-command.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ interface AppCommandOutput {
88
}
99

1010
export default abstract class AppCommand extends BaseCommand {
11-
static baseFlags = authAliasFlag
11+
static baseFlags = {...BaseCommand.baseFlags, ...authAliasFlag}
1212

1313
environmentsFilename(): string {
1414
return configurationFileNames.appEnvironments

‎packages/cli-kit/package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,8 @@
154154
"strip-ansi": "7.2.0",
155155
"supports-hyperlinks": "3.2.0",
156156
"which": "4.0.0",
157-
"zod": "3.25.76"
157+
"zod": "3.25.76",
158+
"zod-to-json-schema": "3.25.2"
158159
},
159160
"devDependencies": {
160161
"@types/archiver": "5.3.2",
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import {AbortError} from '../../public/node/error.js'
2+
import {commandEventOutputSchema} from '../../public/node/command-events.js'
3+
import {jsonErrorOutputSchema} from '../../public/node/error/schema.js'
4+
import {defineJsonOutputSchema, type JsonOutputSchema} from '../../public/node/json-output-schema.js'
5+
import {flushStdout, outputResult} from '../../public/node/output.js'
6+
import {zod} from '../../public/node/schema.js'
7+
import {normalizeArgv} from '@oclif/core/help'
8+
import type {LazyCommandLoader} from '../../public/node/custom-oclif-loader.js'
9+
import type {Config} from '@oclif/core'
10+
11+
/**
12+
* Loads a command's schema without running its lifecycle or Oclif hooks.
13+
*
14+
* @param config - The loaded CLI configuration.
15+
* @param argv - The CLI arguments, including the command name.
16+
* @param lazyCommandLoader - The optional loader for bundled commands.
17+
*/
18+
export async function printCommandJsonSchema(
19+
config: Config,
20+
argv: string[],
21+
lazyCommandLoader?: LazyCommandLoader,
22+
): Promise<void> {
23+
const [id] = normalizeArgv(config, argv)
24+
if (!id || id.startsWith('-')) throw new AbortError('Specify a command to inspect its JSON output schema.')
25+
26+
const command = config.findCommand(id)
27+
if (!command) throw new AbortError(`Command "${id}" not found.`)
28+
29+
const commandClass = (await lazyCommandLoader?.(command.id)) ?? (await command.load())
30+
const outputSchema = (commandClass as typeof commandClass & {jsonOutputSchema?: JsonOutputSchema}).jsonOutputSchema
31+
if (!outputSchema) throw new AbortError('This command does not define a JSON output schema.')
32+
33+
const commandSchema = defineJsonOutputSchema({
34+
name: 'CommandOutput',
35+
schema: zod.union([outputSchema.schema, jsonErrorOutputSchema.schema, commandEventOutputSchema.schema]),
36+
definitions: {
37+
Result: outputSchema.schema,
38+
Error: jsonErrorOutputSchema.schema,
39+
Event: commandEventOutputSchema.schema,
40+
},
41+
})
42+
outputResult(JSON.stringify(commandSchema.jsonSchema, null, 2))
43+
await flushStdout()
44+
}

‎packages/cli-kit/src/public/node/base-command.test.ts‎

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {defineJsonOutputSchema} from './json-output-schema.js'
1111
import {zod} from './schema.js'
1212
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'
1313
import {Flags} from '@oclif/core'
14+
import {Ajv} from 'ajv'
1415

1516
let originalStdinIsTTY: boolean | undefined
1617
let originalStdoutIsTTY: boolean | undefined
@@ -285,6 +286,20 @@ describe('command events', () => {
285286
})
286287

287288
describe('command descriptions', () => {
289+
test('preserves schema patterns that resemble Markdown links', () => {
290+
class CommandWithPattern extends Command {
291+
static get jsonOutputSchema() {
292+
return defineJsonOutputSchema({name: 'Result', schema: zod.string().regex(/[a-z](value)/)})
293+
}
294+
295+
public async run(): Promise<void> {}
296+
}
297+
298+
const description = CommandWithPattern.descriptionForHelp()!
299+
const schema = JSON.parse(description.match(/```json\n([\s\S]+)\n```/)![1]!)
300+
expect(schema.pattern).toBe('[a-z](value)')
301+
})
302+
288303
test('includes a JSON output schema without mutating the Markdown description', () => {
289304
class CommandWithJsonOutput extends Command {
290305
static get jsonOutputSchema() {
@@ -301,15 +316,22 @@ describe('command descriptions', () => {
301316
public async run(): Promise<void> {}
302317
}
303318

304-
expect(CommandWithJsonOutput.description).toBe(`Returns a value. "Learn more" (https://shopify.dev).
305-
306-
With \`--json\`, the command returns \`CommandResult\`:
307-
308-
\`\`\`ts
309-
interface CommandResult {
310-
value: string
311-
}
312-
\`\`\``)
319+
expect(CommandWithJsonOutput.description).toContain('Returns a value. "Learn more" (https://shopify.dev).')
320+
expect(CommandWithJsonOutput.description).toContain(
321+
'Use `--json-schema` to print the result, error, and event schemas.',
322+
)
323+
const helpSchema = JSON.parse(CommandWithJsonOutput.description!.match(/```json\n([\s\S]+)\n```/)![1]!)
324+
expect(helpSchema).toEqual({
325+
$schema: 'http://json-schema.org/draft-07/schema#',
326+
title: 'CommandResult',
327+
type: 'object',
328+
properties: {value: {type: 'string'}},
329+
required: ['value'],
330+
additionalProperties: false,
331+
})
332+
const validate = new Ajv().compile(helpSchema)
333+
expect(validate({value: 'ready'})).toBe(true)
334+
expect(validate({value: 1})).toBe(false)
313335
expect(CommandWithJsonOutput.descriptionWithMarkdown).toBe('Returns a value. [Learn more](https://shopify.dev).')
314336

315337
CommandWithJsonOutput.descriptionForHelp()

‎packages/cli-kit/src/public/node/base-command.ts‎

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@ import {terminalSupportsPrompting} from './system.js'
88
import {hashString} from './crypto.js'
99
import {isTruthy} from './context/utilities.js'
1010
import {setCurrentCommandId} from './global-context.js'
11+
import {type JsonOutputSchema} from './json-output-schema.js'
1112
import {JsonMap} from '../../private/common/json.js'
1213
import {underscore} from '../common/string.js'
13-
import {Command, Config, Errors} from '@oclif/core'
14+
import {Command, Config, Errors, Flags} from '@oclif/core'
1415
import {OutputFlags, Input, ParserOutput, FlagInput, OutputArgs} from '@oclif/core/parser'
15-
import type {JsonOutputSchema} from './json-output-schema.js'
1616

1717
// eslint-disable-next-line @typescript-eslint/no-explicit-any
1818
export type ArgOutput = OutputArgs<any>
@@ -33,7 +33,13 @@ interface EnvironmentFlags {
3333
}
3434

3535
abstract class BaseCommand extends Command {
36-
static baseFlags: FlagInput<{}> = {}
36+
static baseFlags: FlagInput<{}> = {
37+
'json-schema': Flags.boolean({
38+
description: "Print the command's JSON schemas.",
39+
env: 'SHOPIFY_FLAG_JSON_SCHEMA',
40+
}),
41+
}
42+
3743
static descriptionWithMarkdown?: string
3844

3945
public static get jsonOutputSchema(): JsonOutputSchema | undefined {
@@ -50,10 +56,8 @@ abstract class BaseCommand extends Command {
5056

5157
// Include the JSON result schema and convert Markdown links to plain text for command help.
5258
public static descriptionForHelp(): string | undefined {
53-
return appendJsonOutputSchema(this.descriptionWithMarkdown ?? '', this.jsonOutputSchema).replace(
54-
/(\[)(.*?)(])(\()(.*?)(\))/gm,
55-
'"$2" ($5)',
56-
)
59+
const description = (this.descriptionWithMarkdown ?? '').replace(/(\[)(.*?)(])(\()(.*?)(\))/gm, '"$2" ($5)')
60+
return appendJsonOutputSchema(description, this.jsonOutputSchema)
5761
}
5862

5963
/** @deprecated Use descriptionForHelp instead. */
@@ -413,10 +417,12 @@ function commandSupportsFlag(flags: FlagInput | undefined, flagName: string): bo
413417
function appendJsonOutputSchema(description: string, outputSchema: JsonOutputSchema | undefined): string {
414418
if (!outputSchema) return description
415419

416-
const jsonOutputDescription = `With \`--json\`, the command returns \`${outputSchema.name}\`:
420+
const jsonOutputDescription = `Output from \`--json\` conforms to the \`${outputSchema.name}\` schema.
421+
422+
Use \`--json-schema\` to print the result, error, and event schemas.
417423
418-
\`\`\`ts
419-
${outputSchema.typescript}
424+
\`\`\`json
425+
${JSON.stringify(outputSchema.jsonSchema, null, 2)}
420426
\`\`\``
421427

422428
return [description, jsonOutputDescription].filter(Boolean).join('\n\n')

0 commit comments

Comments
 (0)