Skip to content
Draft
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
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
54 changes: 54 additions & 0 deletions docs/cli/json-output.md
Original file line number Diff line number Diff line change
@@ -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<typeof widgetListJsonOutputSchema>
```

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<void> {
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.
35 changes: 35 additions & 0 deletions packages/cli-kit/src/public/node/base-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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<void> {}
}

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<void>) => {
test(testName, async () => {
Expand Down
24 changes: 22 additions & 2 deletions packages/cli-kit/src/public/node/base-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>
Expand All @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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<void> {
const plugins = Array.from(config.plugins.values())
const bundlePlugins = ['@shopify/app', '@shopify/plugin-cloudflare']
Expand Down
71 changes: 71 additions & 0 deletions packages/cli-kit/src/public/node/json-output-schema.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof outputSchema>

expectTypeOf<Result>().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<string, string>
}`)
})

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')
})
})
Loading
Loading