Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/quiet-json-schema-help.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@shopify/cli-kit': minor
'@shopify/cli': minor
---

Add `--json-schema` to print a command's result, error, and event schemas.
1,147 changes: 1,033 additions & 114 deletions docs-shopify.dev/generated/generated_docs_data_v2.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/app/src/cli/commands/organization/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {authAliasFlag, globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'
import BaseCommand from '@shopify/cli-kit/node/base-command'

export default class OrganizationList extends BaseCommand {
static baseFlags = authAliasFlag
static baseFlags = {...BaseCommand.baseFlags, ...authAliasFlag}

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

Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/cli/utilities/app-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ interface AppCommandOutput {
}

export default abstract class AppCommand extends BaseCommand {
static baseFlags = authAliasFlag
static baseFlags = {...BaseCommand.baseFlags, ...authAliasFlag}

environmentsFilename(): string {
return configurationFileNames.appEnvironments
Expand Down
110 changes: 108 additions & 2 deletions packages/cli-kit/src/public/node/base-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ 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'
import {Flags, type Config} from '@oclif/core'

let originalStdinIsTTY: boolean | undefined
let originalStdoutIsTTY: boolean | undefined
Expand Down Expand Up @@ -228,7 +228,9 @@ describe('command descriptions', () => {

expect(CommandWithJsonOutput.description).toBe(`Returns a value. "Learn more" (https://shopify.dev).

With \`--json\`, the command returns \`CommandResult\`:
Output from \`--json\` conforms to the \`CommandResult\` schema.

Use \`--json-schema\` to print the schema directly:

\`\`\`ts
interface CommandResult {
Expand All @@ -242,6 +244,110 @@ interface CommandResult {
})
})

describe('JSON output schema flag', () => {
class CommandWithJsonOutput extends Command {
static get jsonOutputSchema() {
return defineJsonOutputSchema({
name: 'CommandResult',
schema: zod.object({value: zod.string()}),
})
}

public async run(): Promise<void> {}
}

test('prints only the schema and exits', () => {
const outputMock = mockAndCaptureOutput()
const command = new CommandWithJsonOutput(['--json-schema'], {} as Config)
const exit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit')
})

try {
expect(() =>
(
command as unknown as {
exitWithJsonSchemaWhenRequested(): void
}
).exitWithJsonSchemaWhenRequested(),
).toThrow('process.exit')
expect(outputMock.output()).toBe(`interface CommandResult {
value: string
}

interface JsonErrorDocument {
error: JsonError
}

type JsonError = JsonAbortError | JsonBugError | JsonExternalError

interface JsonErrorCustomSection {
title?: string
body: string | string[][]
}

interface JsonAbortError {
type: "abort"
message: string
tryMessage?: string
nextSteps?: string[]
customSections?: JsonErrorCustomSection[]
}

interface JsonBugError {
type: "bug"
message: string
tryMessage?: string
nextSteps?: string[]
customSections?: JsonErrorCustomSection[]
stack?: string
}

interface JsonExternalError {
type: "external"
message: string
tryMessage?: string
nextSteps?: string[]
customSections?: JsonErrorCustomSection[]
command: string
args: string[]
}

type CommandEvent = CommandDiagnosticEvent | CommandProgressEvent

interface CommandDiagnosticEvent {
type: "diagnostic"
timestamp: string
level: "debug" | "info" | "warning"
message: string
code?: string
}

interface CommandProgressEvent {
type: "progress"
timestamp: string
message: string
current?: number
total?: number
}`)
} finally {
exit.mockRestore()
}
})

test('throws an error when the command has no schema', () => {
const command = new MockCommand(['--json-schema'], {} as Config)

expect(() =>
(
command as unknown as {
exitWithJsonSchemaWhenRequested(): void
}
).exitWithJsonSchemaWhenRequested(),
).toThrow('This command does not define a JSON output schema.')
})
})

describe('applying environments', async () => {
const runTestInTmpDir = (testName: string, testFunc: (tmpDir: string) => Promise<void>) => {
test(testName, async () => {
Expand Down
32 changes: 29 additions & 3 deletions packages/cli-kit/src/public/node/base-command.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {isDevelopment} from './context/local.js'
import {addPublicMetadata} from './metadata.js'
import {AbortError} from './error.js'
import {jsonErrorOutputSchema} from './error/schema.js'
import {commandEventOutputSchema} from './command-events.js'
import {outputContent, outputResult, outputToken} from './output.js'
import {setCurrentSessionAlias} from './session.js'
import {terminalSupportsPrompting} from './system.js'
Expand All @@ -9,7 +11,7 @@ import {isTruthy} from './context/utilities.js'
import {setCurrentCommandId} from './global-context.js'
import {JsonMap} from '../../private/common/json.js'
import {underscore} from '../common/string.js'
import {Command, Config, Errors} from '@oclif/core'
import {Command, Config, Errors, Flags} from '@oclif/core'
import {OutputFlags, Input, ParserOutput, FlagInput, OutputArgs} from '@oclif/core/parser'
import type {JsonOutputSchema} from './json-output-schema.js'

Expand All @@ -32,7 +34,13 @@ interface EnvironmentFlags {
}

abstract class BaseCommand extends Command {
static baseFlags: FlagInput<{}> = {}
static baseFlags: FlagInput<{}> = {
'json-schema': Flags.boolean({
description: "Print the command's JSON schemas.",
env: 'SHOPIFY_FLAG_JSON_SCHEMA',
}),
}

static descriptionWithMarkdown?: string

public static get jsonOutputSchema(): JsonOutputSchema | undefined {
Expand Down Expand Up @@ -71,6 +79,7 @@ abstract class BaseCommand extends Command {
}

protected async init(): Promise<unknown> {
this.exitWithJsonSchemaWhenRequested()
this.exitWithTimestampWhenEnvVariablePresent()
setCurrentCommandId(this.id ?? '')
if (!isDevelopment()) {
Expand Down Expand Up @@ -119,6 +128,21 @@ abstract class BaseCommand extends Command {
}
}

protected exitWithJsonSchemaWhenRequested(): void {
if (!this.argv.includes('--json-schema') && !isTruthy(process.env.SHOPIFY_FLAG_JSON_SCHEMA)) return

const command = this.constructor as typeof BaseCommand
const outputSchema = command.jsonOutputSchema
if (!outputSchema) {
throw new AbortError('This command does not define a JSON output schema.')
}

outputResult(
[outputSchema.typescript, jsonErrorOutputSchema.typescript, commandEventOutputSchema.typescript].join('\n\n'),
)
process.exit(0)
}

protected async parse<
TFlags extends FlagOutput & {path?: string; verbose?: boolean; 'auth-alias'?: string},
TGlobalFlags extends FlagOutput,
Expand Down Expand Up @@ -403,7 +427,9 @@ function commandSupportsFlag(flags: FlagInput | undefined, flagName: string): bo
function appendJsonOutputSchema(description: string, outputSchema: JsonOutputSchema | undefined): string {
if (!outputSchema) return description

const jsonOutputDescription = `With \`--json\`, the command returns \`${outputSchema.name}\`:
const jsonOutputDescription = `Output from \`--json\` conforms to the \`${outputSchema.name}\` schema.

Use \`--json-schema\` to print the schema directly:

\`\`\`ts
${outputSchema.typescript}
Expand Down
24 changes: 23 additions & 1 deletion packages/cli-kit/src/public/node/command-events.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {renderCommandEvent, renderCommandEventAsJson} from './command-events.js'
import {commandEventOutputSchema, renderCommandEvent, renderCommandEventAsJson} from './command-events.js'
import {mockAndCaptureOutput} from './testing/output.js'
import {beforeEach, describe, expect, test} from 'vitest'

Expand All @@ -8,6 +8,28 @@ beforeEach(() => {
outputMock.clear()
})

describe('commandEventOutputSchema', () => {
test('renders the event schemas as TypeScript', () => {
expect(commandEventOutputSchema.typescript).toBe(`type CommandEvent = CommandDiagnosticEvent | CommandProgressEvent

interface CommandDiagnosticEvent {
type: "diagnostic"
timestamp: string
level: "debug" | "info" | "warning"
message: string
code?: string
}

interface CommandProgressEvent {
type: "progress"
timestamp: string
message: string
current?: number
total?: number
}`)
})
})

describe('renderCommandEvent', () => {
test('renders debug diagnostics to stderr through the debug output path', () => {
renderCommandEvent({
Expand Down
17 changes: 16 additions & 1 deletion packages/cli-kit/src/public/node/command-events.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
import {outputDebug, outputInfo, outputWarn} from './output.js'
import type {CommandEvent} from '../common/command-events.js'
import {defineJsonOutputSchema} from './json-output-schema.js'
import {
commandDiagnosticEventSchema,
commandEventSchema,
commandProgressEventSchema,
type CommandEvent,
} from '../common/command-events.js'

export const commandEventOutputSchema = defineJsonOutputSchema({
name: 'CommandEvent',
schema: commandEventSchema,
definitions: {
CommandDiagnosticEvent: commandDiagnosticEventSchema,
CommandProgressEvent: commandProgressEventSchema,
},
})

/**
* Renders a command side event to stderr using the existing CLI output behavior.
Expand Down
7 changes: 5 additions & 2 deletions packages/cli-kit/src/public/node/json-output-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
ZodAny,
ZodArray,
ZodBoolean,
ZodDiscriminatedUnion,
ZodEnum,
ZodLiteral,
ZodNull,
Expand Down Expand Up @@ -139,7 +140,7 @@ function renderType(
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<string, ${renderType(schema.valueSchema, namedSchemas)}>`
if (schema instanceof ZodUnion) {
if (schema instanceof ZodUnion || schema instanceof ZodDiscriminatedUnion) {
return schema.options.map((option: ZodTypeAny) => renderType(option, namedSchemas)).join(' | ')
}

Expand All @@ -152,7 +153,9 @@ function renderType(

function renderArrayElementType(schema: ZodTypeAny, namedSchemas: ReadonlyMap<ZodTypeAny, string>): string {
const type = renderType(schema, namedSchemas)
return schema instanceof ZodUnion || schema instanceof ZodNullable ? `(${type})` : type
return schema instanceof ZodUnion || schema instanceof ZodDiscriminatedUnion || schema instanceof ZodNullable
? `(${type})`
: type
}

function renderLiteral(value: unknown): string {
Expand Down
Loading
Loading