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
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
57 changes: 57 additions & 0 deletions docs/cli/json-output.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# JSON output contracts

A finite command finishes its work, returns one final result, and exits, such as `shopify store list`. Commands that
keep running and streaming updates, such as `shopify app dev`, are outside this contract.

Finite commands expose their successful result as typed data independently from terminal presentation. The command's
Comment thread
dmerand marked this conversation as resolved.
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.descriptionForHelp()

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.
2 changes: 1 addition & 1 deletion packages/app/src/cli/commands/app/app-logs/sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export default class Sources extends AppLinkedCommand {

static descriptionWithMarkdown = `The output source names can be used with the \`--source\` argument of \`shopify app logs\` to filter log output. Currently only function extensions are supported as sources.`

static description = this.descriptionWithoutMarkdown()
static description = this.descriptionForHelp()

static flags = {
...globalFlags,
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/cli/commands/app/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export default class Build extends AppUnlinkedCommand {

If you're building a [theme app extension](https://shopify.dev/docs/apps/online-store/theme-app-extensions), then running the \`build\` command runs [Theme Check](https://shopify.dev/docs/themes/tools/theme-check) against your extension to ensure that it's valid.`

static description = this.descriptionWithoutMarkdown()
static description = this.descriptionForHelp()

static flags = {
...globalFlags,
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/cli/commands/app/bulk/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export default class BulkExecute extends AppLinkedCommand {

Use [\`bulk status\`](https://shopify.dev/docs/api/shopify-cli/app/app-bulk-status) to check the status of your bulk operations.`

static description = this.descriptionWithoutMarkdown()
static description = this.descriptionForHelp()

static flags = {
...globalFlags,
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/cli/commands/app/bulk/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export default class BulkStatus extends AppLinkedCommand {

Use [\`bulk execute\`](https://shopify.dev/docs/api/shopify-cli/app/app-bulk-execute) to start a new bulk operation.`

static description = this.descriptionWithoutMarkdown()
static description = this.descriptionForHelp()

static flags = {
...globalFlags,
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/cli/commands/app/config/link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export default class ConfigLink extends AppLinkedCommand {
For more information on the format of the created TOML configuration file, refer to the [App configuration](https://shopify.dev/docs/apps/tools/cli/configuration) page.
`

static description = this.descriptionWithoutMarkdown()
static description = this.descriptionForHelp()

static flags = {
...globalFlags,
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/cli/commands/app/config/pull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export default class ConfigPull extends AppLinkedCommand {

This command reuses the existing linked app and organization and skips all interactive prompts. Use \`--config\` to target a specific configuration file, or omit it to use the default one.`

static description = this.descriptionWithoutMarkdown()
static description = this.descriptionForHelp()

static flags = {
...globalFlags,
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/cli/commands/app/config/use.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export default class ConfigUse extends AppUnlinkedCommand {

static descriptionWithMarkdown = `Sets default configuration when you run app-related CLI commands. If you omit the \`config-name\` parameter, then you'll be prompted to choose from the configuration files in your project.`

static description = this.descriptionWithoutMarkdown()
static description = this.descriptionForHelp()

static usage = `app config use [config] [flags]`

Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/cli/commands/app/config/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export default class Validate extends AppLinkedCommand {

static descriptionWithMarkdown = `Validates the selected app configuration file and all extension configurations against their schemas and reports any errors found.`

static description = this.descriptionWithoutMarkdown()
static description = this.descriptionForHelp()

static flags = {
...globalFlags,
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/cli/commands/app/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export default class Deploy extends AppLinkedCommand {
This command doesn't deploy your [web app](https://shopify.dev/docs/apps/tools/cli/structure#web-components). You need to [deploy your web app](https://shopify.dev/docs/apps/deployment/web) to your own hosting solution.
`

static description = this.descriptionWithoutMarkdown()
static description = this.descriptionForHelp()

static flags = {
...globalFlags,
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/cli/commands/app/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export default class Dev extends AppLinkedCommand {

static descriptionWithMarkdown = `Builds and previews your app on a dev store, and watches for changes. [Read more about testing apps locally](https://shopify.dev/docs/apps/build/cli-for-apps/test-apps-locally).`

static description = this.descriptionWithoutMarkdown()
static description = this.descriptionForHelp()

static flags = {
...globalFlags,
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/cli/commands/app/dev/clean.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export default class DevClean extends AppLinkedCommand {
It restores the app's active version to the selected development store.
`

static description = this.descriptionWithoutMarkdown()
static description = this.descriptionForHelp()

static flags = {
...globalFlags,
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/cli/commands/app/env/pull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export default class EnvPull extends AppLinkedCommand {

When an existing \`.env\` file is updated, changes to the variables are displayed in the terminal output. Existing variables and commented variables are preserved.`

static description = this.descriptionWithoutMarkdown()
static description = this.descriptionForHelp()

static flags = {
...globalFlags,
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/cli/commands/app/env/show.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export default class EnvShow extends AppLinkedCommand {

static descriptionWithMarkdown = `Displays environment variables that can be used to deploy apps and app extensions.`

static description = this.descriptionWithoutMarkdown()
static description = this.descriptionForHelp()

static flags = {
...globalFlags,
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/cli/commands/app/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export default class Execute extends AppLinkedCommand {

For operations that process large amounts of data, use [\`bulk execute\`](https://shopify.dev/docs/api/shopify-cli/app/app-bulk-execute) instead.`

static description = this.descriptionWithoutMarkdown()
static description = this.descriptionForHelp()

static flags = {
...globalFlags,
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/cli/commands/app/function/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export default class FunctionBuild extends AppUnlinkedCommand {

static descriptionWithMarkdown = `Compiles the function in your current directory to WebAssembly (Wasm) for testing purposes.`

static description = this.descriptionWithoutMarkdown()
static description = this.descriptionForHelp()

static flags = {
...globalFlags,
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/cli/commands/app/function/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export default class FunctionInfo extends AppUnlinkedCommand {
- The WASM path
- The function runner path`

static description = this.descriptionWithoutMarkdown()
static description = this.descriptionForHelp()

static flags = {
...globalFlags,
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/cli/commands/app/function/replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export default class FunctionReplay extends AppLinkedCommand {

static descriptionWithMarkdown = `Runs the function from your current directory for [testing purposes](https://shopify.dev/docs/apps/functions/testing-and-debugging). To learn how you can monitor and debug functions when errors occur, refer to [Shopify Functions error handling](https://shopify.dev/docs/api/functions/errors).`

static description = this.descriptionWithoutMarkdown()
static description = this.descriptionForHelp()

static flags = {
...globalFlags,
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/cli/commands/app/function/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export default class FunctionRun extends AppUnlinkedCommand {

static descriptionWithMarkdown = `Runs the function from your current directory for [testing purposes](https://shopify.dev/docs/apps/functions/testing-and-debugging). To learn how you can monitor and debug functions when errors occur, refer to [Shopify Functions error handling](https://shopify.dev/docs/api/functions/errors).`

static description = this.descriptionWithoutMarkdown()
static description = this.descriptionForHelp()

static flags = {
...globalFlags,
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/cli/commands/app/function/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export default class FetchSchema extends AppLinkedCommand {

This command uses the API type and version of your function, as defined in your extension TOML file, to generate the latest GraphQL schema. The schema is written to the \`schema.graphql\` file.`

static description = this.descriptionWithoutMarkdown()
static description = this.descriptionForHelp()

static flags = {
...globalFlags,
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/cli/commands/app/function/typegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export default class FunctionTypegen extends AppUnlinkedCommand {

static descriptionWithMarkdown = `Creates GraphQL types based on your [input query](https://shopify.dev/docs/apps/functions/input-output#input) for a function. Supports JavaScript functions out of the box, or any language via the \`build.typegen_command\` configuration.`

static description = this.descriptionWithoutMarkdown()
static description = this.descriptionForHelp()

static flags = {
...globalFlags,
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/cli/commands/app/generate/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export default class AppGenerateExtension extends AppLinkedCommand {
Each new app extension is created in a folder under \`extensions/\`. To learn more about the extensions file structure, refer to [App structure](https://shopify.dev/docs/apps/build/cli-for-apps/app-structure) and the documentation for your extension.
`

static description = this.descriptionWithoutMarkdown()
static description = this.descriptionForHelp()

static flags = {
...globalFlags,
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/cli/commands/app/graphiql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export default class AppGraphiQL extends AppLinkedCommand {

The app must be installed on the store.`

static description = this.descriptionWithoutMarkdown()
static description = this.descriptionForHelp()

static examples = [
'<%= config.bin %> <%= command.id %> --store shop.myshopify.com',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export default class ImportCustomDataDefinitions extends AppLinkedCommand {

static descriptionWithMarkdown = `Import metafield and metaobject definitions from your development store. [Read more about declarative custom data definitions](https://shopify.dev/docs/apps/build/custom-data/declarative-custom-data-definitions).`

static description = this.descriptionWithoutMarkdown()
static description = this.descriptionForHelp()

static flags = {
...globalFlags,
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/cli/commands/app/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export default class AppInfo extends AppLinkedCommand {
- The [access scopes](https://shopify.dev/docs/api/usage) your app has requested.
- System information, including the package manager and version of Shopify CLI used in the project.`

static description = this.descriptionWithoutMarkdown()
static description = this.descriptionForHelp()

static flags = {
...globalFlags,
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/cli/commands/app/logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export default class Logs extends AppLinkedCommand {
\`\`\`
`

static description = this.descriptionWithoutMarkdown()
static description = this.descriptionForHelp()

static flags = {
...globalFlags,
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/cli/commands/app/release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export default class Release extends AppLinkedCommand {

static descriptionWithMarkdown = `Releases an existing app version. Pass the name of the version that you want to release using the \`--version\` flag.`

static description = this.descriptionWithoutMarkdown()
static description = this.descriptionForHelp()

static flags = {
...globalFlags,
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/cli/commands/app/versions/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export default class VersionsList extends AppLinkedCommand {

static descriptionWithMarkdown = `Lists the deployed app versions. An app version is a snapshot of your app extensions.`

static description = this.descriptionWithoutMarkdown()
static description = this.descriptionForHelp()

static flags = {
...globalFlags,
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/cli/commands/app/webhook/trigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export default class WebhookTrigger extends AppLinkedCommand {
- You can't use this method to validate your API webhook subscriptions.
`

static description = this.descriptionWithoutMarkdown()
static description = this.descriptionForHelp()

static flags = {
...appFlags,
Expand Down
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 @@ -9,7 +9,7 @@ export default class OrganizationList extends BaseCommand {

static descriptionWithMarkdown = `Lists the Shopify organizations that you have access to, along with their organization IDs.`

static description = this.descriptionWithoutMarkdown()
static description = this.descriptionForHelp()

static flags = {
...globalFlags,
Expand Down
36 changes: 36 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 @@ -7,6 +7,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 @@ -282,6 +284,40 @@ describe('command events', () => {
})
})

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.descriptionForHelp()

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.descriptionForHelp()
expect(CommandWithJsonOutput.descriptionWithMarkdown).toBe('Returns a value. [Learn more](https://shopify.dev).')
expect(CommandWithJsonOutput.descriptionWithoutMarkdown()).toBe(CommandWithJsonOutput.descriptionForHelp())
})
})

describe('applying environments', async () => {
const runTestInTmpDir = (testName: string, testFunc: (tmpDir: string) => Promise<void>) => {
test(testName, async () => {
Expand Down
Loading
Loading