From 3131658a62bb930bf956085ef757f24020d71b2a Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Fri, 28 Aug 2026 14:35:48 +0200 Subject: [PATCH 1/2] Add JSON result schema infrastructure --- docs/README.md | 1 + docs/cli/json-output.md | 57 ++++++ .../src/cli/commands/app/app-logs/sources.ts | 2 +- packages/app/src/cli/commands/app/build.ts | 2 +- .../app/src/cli/commands/app/bulk/execute.ts | 2 +- .../app/src/cli/commands/app/bulk/status.ts | 2 +- .../app/src/cli/commands/app/config/link.ts | 2 +- .../app/src/cli/commands/app/config/pull.ts | 2 +- .../app/src/cli/commands/app/config/use.ts | 2 +- .../src/cli/commands/app/config/validate.ts | 2 +- packages/app/src/cli/commands/app/deploy.ts | 2 +- packages/app/src/cli/commands/app/dev.ts | 2 +- .../app/src/cli/commands/app/dev/clean.ts | 2 +- packages/app/src/cli/commands/app/env/pull.ts | 2 +- packages/app/src/cli/commands/app/env/show.ts | 2 +- packages/app/src/cli/commands/app/execute.ts | 2 +- .../src/cli/commands/app/function/build.ts | 2 +- .../app/src/cli/commands/app/function/info.ts | 2 +- .../src/cli/commands/app/function/replay.ts | 2 +- .../app/src/cli/commands/app/function/run.ts | 2 +- .../src/cli/commands/app/function/schema.ts | 2 +- .../src/cli/commands/app/function/typegen.ts | 2 +- .../cli/commands/app/generate/extension.ts | 2 +- packages/app/src/cli/commands/app/graphiql.ts | 2 +- .../app/import-custom-data-definitions.ts | 2 +- packages/app/src/cli/commands/app/info.ts | 2 +- packages/app/src/cli/commands/app/logs.ts | 2 +- packages/app/src/cli/commands/app/release.ts | 2 +- .../app/src/cli/commands/app/versions/list.ts | 2 +- .../src/cli/commands/app/webhook/trigger.ts | 2 +- .../app/src/cli/commands/organization/list.ts | 2 +- .../src/public/node/base-command.test.ts | 36 ++++ .../cli-kit/src/public/node/base-command.ts | 31 ++- .../public/node/json-output-schema.test.ts | 100 ++++++++++ .../src/public/node/json-output-schema.ts | 181 ++++++++++++++++++ packages/cli/README.md | 48 ++--- .../cli/commands/config/autoupgrade/off.ts | 2 +- .../src/cli/commands/config/autoupgrade/on.ts | 2 +- .../cli/commands/config/autoupgrade/status.ts | 2 +- packages/cli/src/cli/commands/upgrade.ts | 2 +- packages/cli/src/cli/help.test.ts | 48 +++++ packages/cli/src/cli/help.ts | 40 ++++ .../src/commands/config/autocorrect/off.ts | 2 +- .../src/commands/config/autocorrect/on.ts | 2 +- .../src/commands/config/autocorrect/status.ts | 2 +- packages/store/src/cli/commands/store/auth.ts | 2 +- .../store/src/cli/commands/store/auth/list.ts | 2 +- .../src/cli/commands/store/bulk/cancel.ts | 2 +- .../src/cli/commands/store/bulk/execute.ts | 2 +- .../src/cli/commands/store/bulk/status.ts | 2 +- .../src/cli/commands/store/create/dev.ts | 2 +- .../src/cli/commands/store/create/preview.ts | 2 +- .../store/src/cli/commands/store/delete.ts | 2 +- .../store/src/cli/commands/store/execute.ts | 2 +- .../store/src/cli/commands/store/graphiql.ts | 2 +- packages/store/src/cli/commands/store/info.ts | 2 +- packages/store/src/cli/commands/store/list.ts | 2 +- packages/store/src/cli/commands/store/open.ts | 2 +- .../src/cli/commands/store/stripe-auth.ts | 2 +- .../theme/src/cli/commands/theme/check.ts | 2 +- .../theme/src/cli/commands/theme/console.ts | 2 +- .../theme/src/cli/commands/theme/delete.ts | 2 +- packages/theme/src/cli/commands/theme/dev.ts | 2 +- .../theme/src/cli/commands/theme/duplicate.ts | 2 +- packages/theme/src/cli/commands/theme/init.ts | 2 +- .../src/cli/commands/theme/language-server.ts | 2 +- .../src/cli/commands/theme/metafields/pull.ts | 2 +- packages/theme/src/cli/commands/theme/open.ts | 2 +- .../theme/src/cli/commands/theme/package.ts | 2 +- .../theme/src/cli/commands/theme/preview.ts | 2 +- .../theme/src/cli/commands/theme/profile.ts | 2 +- .../theme/src/cli/commands/theme/publish.ts | 2 +- packages/theme/src/cli/commands/theme/pull.ts | 2 +- packages/theme/src/cli/commands/theme/push.ts | 2 +- .../theme/src/cli/commands/theme/rename.ts | 2 +- .../theme/src/cli/commands/theme/share.ts | 2 +- 76 files changed, 582 insertions(+), 94 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..c850c3fa2be --- /dev/null +++ b/docs/cli/json-output.md @@ -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 +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.descriptionForHelp() + + 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/app/src/cli/commands/app/app-logs/sources.ts b/packages/app/src/cli/commands/app/app-logs/sources.ts index b76cf9f04e7..f7f18b07b15 100644 --- a/packages/app/src/cli/commands/app/app-logs/sources.ts +++ b/packages/app/src/cli/commands/app/app-logs/sources.ts @@ -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, diff --git a/packages/app/src/cli/commands/app/build.ts b/packages/app/src/cli/commands/app/build.ts index bfef69c1ed5..d08a30034b0 100644 --- a/packages/app/src/cli/commands/app/build.ts +++ b/packages/app/src/cli/commands/app/build.ts @@ -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, diff --git a/packages/app/src/cli/commands/app/bulk/execute.ts b/packages/app/src/cli/commands/app/bulk/execute.ts index 8c06f1ddeea..befd68812cb 100644 --- a/packages/app/src/cli/commands/app/bulk/execute.ts +++ b/packages/app/src/cli/commands/app/bulk/execute.ts @@ -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, diff --git a/packages/app/src/cli/commands/app/bulk/status.ts b/packages/app/src/cli/commands/app/bulk/status.ts index 86881ccc977..b8a8d0690b8 100644 --- a/packages/app/src/cli/commands/app/bulk/status.ts +++ b/packages/app/src/cli/commands/app/bulk/status.ts @@ -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, diff --git a/packages/app/src/cli/commands/app/config/link.ts b/packages/app/src/cli/commands/app/config/link.ts index 3f6c5b1e553..ded25562bc5 100644 --- a/packages/app/src/cli/commands/app/config/link.ts +++ b/packages/app/src/cli/commands/app/config/link.ts @@ -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, diff --git a/packages/app/src/cli/commands/app/config/pull.ts b/packages/app/src/cli/commands/app/config/pull.ts index a90dd514364..aeb7c267601 100644 --- a/packages/app/src/cli/commands/app/config/pull.ts +++ b/packages/app/src/cli/commands/app/config/pull.ts @@ -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, diff --git a/packages/app/src/cli/commands/app/config/use.ts b/packages/app/src/cli/commands/app/config/use.ts index 448b565fb29..5ab6dd8200a 100644 --- a/packages/app/src/cli/commands/app/config/use.ts +++ b/packages/app/src/cli/commands/app/config/use.ts @@ -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]` diff --git a/packages/app/src/cli/commands/app/config/validate.ts b/packages/app/src/cli/commands/app/config/validate.ts index d9b90837666..0a77f195952 100644 --- a/packages/app/src/cli/commands/app/config/validate.ts +++ b/packages/app/src/cli/commands/app/config/validate.ts @@ -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, diff --git a/packages/app/src/cli/commands/app/deploy.ts b/packages/app/src/cli/commands/app/deploy.ts index e82a795f61e..4eee47e7100 100644 --- a/packages/app/src/cli/commands/app/deploy.ts +++ b/packages/app/src/cli/commands/app/deploy.ts @@ -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, diff --git a/packages/app/src/cli/commands/app/dev.ts b/packages/app/src/cli/commands/app/dev.ts index cf2ec881bdf..50580db3ea5 100644 --- a/packages/app/src/cli/commands/app/dev.ts +++ b/packages/app/src/cli/commands/app/dev.ts @@ -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, diff --git a/packages/app/src/cli/commands/app/dev/clean.ts b/packages/app/src/cli/commands/app/dev/clean.ts index 847bf1e5153..6b87c55a849 100644 --- a/packages/app/src/cli/commands/app/dev/clean.ts +++ b/packages/app/src/cli/commands/app/dev/clean.ts @@ -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, diff --git a/packages/app/src/cli/commands/app/env/pull.ts b/packages/app/src/cli/commands/app/env/pull.ts index 2689bd336bd..dc0c89c29a0 100644 --- a/packages/app/src/cli/commands/app/env/pull.ts +++ b/packages/app/src/cli/commands/app/env/pull.ts @@ -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, diff --git a/packages/app/src/cli/commands/app/env/show.ts b/packages/app/src/cli/commands/app/env/show.ts index 2c67d50a61b..73448ab9a95 100644 --- a/packages/app/src/cli/commands/app/env/show.ts +++ b/packages/app/src/cli/commands/app/env/show.ts @@ -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, diff --git a/packages/app/src/cli/commands/app/execute.ts b/packages/app/src/cli/commands/app/execute.ts index 656543bd40e..104759340c9 100644 --- a/packages/app/src/cli/commands/app/execute.ts +++ b/packages/app/src/cli/commands/app/execute.ts @@ -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, diff --git a/packages/app/src/cli/commands/app/function/build.ts b/packages/app/src/cli/commands/app/function/build.ts index 7e7e821dd65..f7a4af4c9fb 100644 --- a/packages/app/src/cli/commands/app/function/build.ts +++ b/packages/app/src/cli/commands/app/function/build.ts @@ -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, diff --git a/packages/app/src/cli/commands/app/function/info.ts b/packages/app/src/cli/commands/app/function/info.ts index 345c0790f26..063710dd5a9 100644 --- a/packages/app/src/cli/commands/app/function/info.ts +++ b/packages/app/src/cli/commands/app/function/info.ts @@ -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, diff --git a/packages/app/src/cli/commands/app/function/replay.ts b/packages/app/src/cli/commands/app/function/replay.ts index c7c99d9b198..1aa05a9ca9c 100644 --- a/packages/app/src/cli/commands/app/function/replay.ts +++ b/packages/app/src/cli/commands/app/function/replay.ts @@ -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, diff --git a/packages/app/src/cli/commands/app/function/run.ts b/packages/app/src/cli/commands/app/function/run.ts index 1eaa25a5022..8ca2eaca737 100644 --- a/packages/app/src/cli/commands/app/function/run.ts +++ b/packages/app/src/cli/commands/app/function/run.ts @@ -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, diff --git a/packages/app/src/cli/commands/app/function/schema.ts b/packages/app/src/cli/commands/app/function/schema.ts index d1c5763f303..e98a62810c2 100644 --- a/packages/app/src/cli/commands/app/function/schema.ts +++ b/packages/app/src/cli/commands/app/function/schema.ts @@ -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, diff --git a/packages/app/src/cli/commands/app/function/typegen.ts b/packages/app/src/cli/commands/app/function/typegen.ts index 527c5a1d1bf..9c81f39d7ee 100644 --- a/packages/app/src/cli/commands/app/function/typegen.ts +++ b/packages/app/src/cli/commands/app/function/typegen.ts @@ -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, diff --git a/packages/app/src/cli/commands/app/generate/extension.ts b/packages/app/src/cli/commands/app/generate/extension.ts index 7b831444c69..98d482b5e44 100644 --- a/packages/app/src/cli/commands/app/generate/extension.ts +++ b/packages/app/src/cli/commands/app/generate/extension.ts @@ -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, diff --git a/packages/app/src/cli/commands/app/graphiql.ts b/packages/app/src/cli/commands/app/graphiql.ts index e3bfdd9483b..7a1d730abfa 100644 --- a/packages/app/src/cli/commands/app/graphiql.ts +++ b/packages/app/src/cli/commands/app/graphiql.ts @@ -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', diff --git a/packages/app/src/cli/commands/app/import-custom-data-definitions.ts b/packages/app/src/cli/commands/app/import-custom-data-definitions.ts index fcf95a8760f..b97963f44a1 100644 --- a/packages/app/src/cli/commands/app/import-custom-data-definitions.ts +++ b/packages/app/src/cli/commands/app/import-custom-data-definitions.ts @@ -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, diff --git a/packages/app/src/cli/commands/app/info.ts b/packages/app/src/cli/commands/app/info.ts index bb9859faa60..9923b7dc2fb 100644 --- a/packages/app/src/cli/commands/app/info.ts +++ b/packages/app/src/cli/commands/app/info.ts @@ -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, diff --git a/packages/app/src/cli/commands/app/logs.ts b/packages/app/src/cli/commands/app/logs.ts index d9f07682e4d..54539ec1275 100644 --- a/packages/app/src/cli/commands/app/logs.ts +++ b/packages/app/src/cli/commands/app/logs.ts @@ -20,7 +20,7 @@ export default class Logs extends AppLinkedCommand { \`\`\` ` - static description = this.descriptionWithoutMarkdown() + static description = this.descriptionForHelp() static flags = { ...globalFlags, diff --git a/packages/app/src/cli/commands/app/release.ts b/packages/app/src/cli/commands/app/release.ts index 71d1a49478a..556872645da 100644 --- a/packages/app/src/cli/commands/app/release.ts +++ b/packages/app/src/cli/commands/app/release.ts @@ -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, diff --git a/packages/app/src/cli/commands/app/versions/list.ts b/packages/app/src/cli/commands/app/versions/list.ts index 07d4b9cc892..f06f42d2432 100644 --- a/packages/app/src/cli/commands/app/versions/list.ts +++ b/packages/app/src/cli/commands/app/versions/list.ts @@ -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, diff --git a/packages/app/src/cli/commands/app/webhook/trigger.ts b/packages/app/src/cli/commands/app/webhook/trigger.ts index a76a8b2f05d..c69bb2532e1 100644 --- a/packages/app/src/cli/commands/app/webhook/trigger.ts +++ b/packages/app/src/cli/commands/app/webhook/trigger.ts @@ -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, diff --git a/packages/app/src/cli/commands/organization/list.ts b/packages/app/src/cli/commands/organization/list.ts index 9f56f5605dd..5428d0c8686 100644 --- a/packages/app/src/cli/commands/organization/list.ts +++ b/packages/app/src/cli/commands/organization/list.ts @@ -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, 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 0723434281f..303e29bc50f 100644 --- a/packages/cli-kit/src/public/node/base-command.test.ts +++ b/packages/cli-kit/src/public/node/base-command.test.ts @@ -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' @@ -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 {} + } + + 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) => { 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 c6b392655a9..27aff4104cf 100644 --- a/packages/cli-kit/src/public/node/base-command.ts +++ b/packages/cli-kit/src/public/node/base-command.ts @@ -12,6 +12,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 @@ -33,6 +34,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 @@ -42,10 +48,17 @@ abstract class BaseCommand extends Command { return [] } - // Replace markdown links to plain text like: "link label" (url) + // Include the JSON result schema and convert Markdown links to plain text for command help. + public static descriptionForHelp(): string | undefined { + return appendJsonOutputSchema(this.descriptionWithMarkdown ?? '', this.jsonOutputSchema).replace( + /(\[)(.*?)(])(\()(.*?)(\))/gm, + '"$2" ($5)', + ) + } + + /** @deprecated Use descriptionForHelp instead. */ public static descriptionWithoutMarkdown(): string | undefined { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return ((this as any).descriptionWithMarkdown ?? '').replace(/(\[)(.*?)(])(\()(.*?)(\))/gm, '"$2" ($5)') + return this.descriptionForHelp() } public static analyticsNameOverride(): string | undefined { @@ -397,6 +410,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..b74452288a8 --- /dev/null +++ b/packages/cli-kit/src/public/node/json-output-schema.test.ts @@ -0,0 +1,100 @@ +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('parenthesizes enum array elements to match the encoded result', () => { + const outputSchema = defineJsonOutputSchema({ + name: 'Result', + schema: zod.array(zod.enum(['a', 'b'])), + }) + + expect(outputSchema.typescript).toBe('type Result = ("a" | "b")[]') + expect(outputSchema.validate(['a', 'b'])).toEqual(['a', 'b']) + expect(JSON.parse(outputSchema.encode(['a', 'b']))).toEqual(['a', 'b']) + }) + + test.each([ + ['optional then nullable', zod.string().optional().nullable()], + ['nullable then optional', zod.string().nullable().optional()], + ] as const)('renders optional properties with %s wrappers', (_order, valueSchema) => { + const outputSchema = defineJsonOutputSchema({ + name: 'Result', + schema: zod.object({value: valueSchema}), + }) + + expect(outputSchema.typescript).toBe(`interface Result { + value?: string | null +}`) + expect(outputSchema.validate({})).toEqual({}) + expect(outputSchema.encode({})).toBe('{}') + expect(outputSchema.validate({value: null})).toEqual({value: null}) + expect(outputSchema.validate({value: 'ready'})).toEqual({value: 'ready'}) + }) + + 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..353fc52f918 --- /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.isOptional() + 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 || schema instanceof ZodEnum ? `(${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 60ebf6a74ce..28640944526 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1704,9 +1704,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` @@ -4495,20 +4495,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" } ``` ``` @@ -5253,18 +5253,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/commands/config/autoupgrade/off.ts b/packages/cli/src/cli/commands/config/autoupgrade/off.ts index 533ad6dbe39..267958ee570 100644 --- a/packages/cli/src/cli/commands/config/autoupgrade/off.ts +++ b/packages/cli/src/cli/commands/config/autoupgrade/off.ts @@ -13,7 +13,7 @@ export default class AutoupgradeOff extends Command { To enable auto-upgrade, run \`shopify config autoupgrade on\`. ` - static description = this.descriptionWithoutMarkdown() + static description = this.descriptionForHelp() async run(): Promise { setAutoUpgradeEnabled(false) diff --git a/packages/cli/src/cli/commands/config/autoupgrade/on.ts b/packages/cli/src/cli/commands/config/autoupgrade/on.ts index aeac1a3d8b4..c9cc94aa089 100644 --- a/packages/cli/src/cli/commands/config/autoupgrade/on.ts +++ b/packages/cli/src/cli/commands/config/autoupgrade/on.ts @@ -13,7 +13,7 @@ export default class AutoupgradeOn extends Command { To disable auto-upgrade, run \`shopify config autoupgrade off\`. ` - static description = this.descriptionWithoutMarkdown() + static description = this.descriptionForHelp() async run(): Promise { setAutoUpgradeEnabled(true) diff --git a/packages/cli/src/cli/commands/config/autoupgrade/status.ts b/packages/cli/src/cli/commands/config/autoupgrade/status.ts index dd9f07fd100..4db65534bef 100644 --- a/packages/cli/src/cli/commands/config/autoupgrade/status.ts +++ b/packages/cli/src/cli/commands/config/autoupgrade/status.ts @@ -13,7 +13,7 @@ export default class AutoupgradeStatus extends Command { Run \`shopify config autoupgrade on\` or \`shopify config autoupgrade off\` to configure it. ` - static description = this.descriptionWithoutMarkdown() + static description = this.descriptionForHelp() async run(): Promise { const enabled = getAutoUpgradeEnabled() diff --git a/packages/cli/src/cli/commands/upgrade.ts b/packages/cli/src/cli/commands/upgrade.ts index c8e39ffaf2c..3107f4730e1 100644 --- a/packages/cli/src/cli/commands/upgrade.ts +++ b/packages/cli/src/cli/commands/upgrade.ts @@ -6,7 +6,7 @@ export default class Upgrade extends Command { static descriptionWithMarkdown = 'Upgrades Shopify CLI using your package manager.' - static description = this.descriptionWithoutMarkdown() + static description = this.descriptionForHelp() async run(): Promise { await runCLIUpgrade() 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 diff --git a/packages/plugin-did-you-mean/src/commands/config/autocorrect/off.ts b/packages/plugin-did-you-mean/src/commands/config/autocorrect/off.ts index 5cd30d4b0c4..b56dbb1fd24 100644 --- a/packages/plugin-did-you-mean/src/commands/config/autocorrect/off.ts +++ b/packages/plugin-did-you-mean/src/commands/config/autocorrect/off.ts @@ -13,7 +13,7 @@ export default class AutocorrectOff extends Command { When autocorrection is disabled, you need to confirm that you want to run corrections for mistyped commands. ` - static description = this.descriptionWithoutMarkdown() + static description = this.descriptionForHelp() async run(): Promise { setAutocorrect(false) diff --git a/packages/plugin-did-you-mean/src/commands/config/autocorrect/on.ts b/packages/plugin-did-you-mean/src/commands/config/autocorrect/on.ts index 0129817cdf5..98fad9cbee7 100644 --- a/packages/plugin-did-you-mean/src/commands/config/autocorrect/on.ts +++ b/packages/plugin-did-you-mean/src/commands/config/autocorrect/on.ts @@ -13,7 +13,7 @@ export default class AutocorrectOn extends Command { When autocorrection is disabled, you need to confirm that you want to run corrections for mistyped commands. ` - static description = this.descriptionWithoutMarkdown() + static description = this.descriptionForHelp() async run(): Promise { setAutocorrect(true) diff --git a/packages/plugin-did-you-mean/src/commands/config/autocorrect/status.ts b/packages/plugin-did-you-mean/src/commands/config/autocorrect/status.ts index 41796495c13..7816df38708 100644 --- a/packages/plugin-did-you-mean/src/commands/config/autocorrect/status.ts +++ b/packages/plugin-did-you-mean/src/commands/config/autocorrect/status.ts @@ -13,7 +13,7 @@ export default class AutocorrectStatus extends Command { When autocorrection is disabled, you need to confirm that you want to run corrections for mistyped commands. ` - static description = this.descriptionWithoutMarkdown() + static description = this.descriptionForHelp() async run(): Promise { if (isAutocorrectEnabled()) { diff --git a/packages/store/src/cli/commands/store/auth.ts b/packages/store/src/cli/commands/store/auth.ts index e78e594e1d3..0db4f335911 100644 --- a/packages/store/src/cli/commands/store/auth.ts +++ b/packages/store/src/cli/commands/store/auth.ts @@ -12,7 +12,7 @@ export default class StoreAuth extends StoreCommand { Re-run this command if the stored token is missing, expires, or no longer has the scopes you need.` - static description = this.descriptionWithoutMarkdown() + static description = this.descriptionForHelp() static examples = [ '<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products', diff --git a/packages/store/src/cli/commands/store/auth/list.ts b/packages/store/src/cli/commands/store/auth/list.ts index 2243d2d92d6..98f9949cfb9 100644 --- a/packages/store/src/cli/commands/store/auth/list.ts +++ b/packages/store/src/cli/commands/store/auth/list.ts @@ -11,7 +11,7 @@ export default class StoreAuthList extends Command { Use this command to find stores that can be used with store-authenticated commands such as \`shopify store execute\`. To list stores in a Shopify organization, run \`shopify store list\`.` - static description = this.descriptionWithoutMarkdown() + static description = this.descriptionForHelp() static examples = ['<%= config.bin %> <%= command.id %>', '<%= config.bin %> <%= command.id %> --json'] diff --git a/packages/store/src/cli/commands/store/bulk/cancel.ts b/packages/store/src/cli/commands/store/bulk/cancel.ts index 15facf22a4d..b156515430d 100644 --- a/packages/store/src/cli/commands/store/bulk/cancel.ts +++ b/packages/store/src/cli/commands/store/bulk/cancel.ts @@ -10,7 +10,7 @@ export default class StoreBulkCancel extends StoreCommand { Run \`shopify store auth\` first to create stored auth for the store.` - static description = this.descriptionWithoutMarkdown() + static description = this.descriptionForHelp() static examples = ['<%= config.bin %> <%= command.id %> --store shop.myshopify.com --id 123456789'] diff --git a/packages/store/src/cli/commands/store/bulk/execute.ts b/packages/store/src/cli/commands/store/bulk/execute.ts index 6ad7f726625..7ebe1ef3298 100644 --- a/packages/store/src/cli/commands/store/bulk/execute.ts +++ b/packages/store/src/cli/commands/store/bulk/execute.ts @@ -17,7 +17,7 @@ export default class StoreBulkExecute extends StoreCommand { Use [\`store bulk status\`](https://shopify.dev/docs/api/shopify-cli/store/store-bulk-status) to check the status of your bulk operations.` - static description = this.descriptionWithoutMarkdown() + static description = this.descriptionForHelp() static examples = [ '<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query "query { products { edges { node { id } } } }"', diff --git a/packages/store/src/cli/commands/store/bulk/status.ts b/packages/store/src/cli/commands/store/bulk/status.ts index b4b00ac1c1c..1ec3ff628f0 100644 --- a/packages/store/src/cli/commands/store/bulk/status.ts +++ b/packages/store/src/cli/commands/store/bulk/status.ts @@ -12,7 +12,7 @@ export default class StoreBulkStatus extends StoreCommand { Use [\`store bulk execute\`](https://shopify.dev/docs/api/shopify-cli/store/store-bulk-execute) to start a new bulk operation.` - static description = this.descriptionWithoutMarkdown() + static description = this.descriptionForHelp() static examples = [ '<%= config.bin %> <%= command.id %> --store shop.myshopify.com', diff --git a/packages/store/src/cli/commands/store/create/dev.ts b/packages/store/src/cli/commands/store/create/dev.ts index 333998ea6c9..0535e724ea7 100644 --- a/packages/store/src/cli/commands/store/create/dev.ts +++ b/packages/store/src/cli/commands/store/create/dev.ts @@ -15,7 +15,7 @@ export default class StoreCreateDev extends Command { static descriptionWithMarkdown = 'Creates a new dev store in your organization.' - static description = this.descriptionWithoutMarkdown() + static description = this.descriptionForHelp() static examples = [ '<%= config.bin %> <%= command.id %>', diff --git a/packages/store/src/cli/commands/store/create/preview.ts b/packages/store/src/cli/commands/store/create/preview.ts index f5dc5564f7a..36827dea583 100644 --- a/packages/store/src/cli/commands/store/create/preview.ts +++ b/packages/store/src/cli/commands/store/create/preview.ts @@ -12,7 +12,7 @@ export default class StoreCreatePreview extends StoreCommand { static descriptionWithMarkdown = `Creates a new Shopify store, with no need for an existing account.` - static description = this.descriptionWithoutMarkdown() + static description = this.descriptionForHelp() static examples = [ '<%= config.bin %> <%= command.id %> --name "Lavender Candles"', diff --git a/packages/store/src/cli/commands/store/delete.ts b/packages/store/src/cli/commands/store/delete.ts index 60222785042..2d7487d4d1b 100644 --- a/packages/store/src/cli/commands/store/delete.ts +++ b/packages/store/src/cli/commands/store/delete.ts @@ -13,7 +13,7 @@ export default class StoreDelete extends Command { static descriptionWithMarkdown = 'Deletes a dev store from your organization.' - static description = this.descriptionWithoutMarkdown() + static description = this.descriptionForHelp() static examples = [ '<%= config.bin %> <%= command.id %> --store shop.myshopify.com --organization-id 1234567', diff --git a/packages/store/src/cli/commands/store/execute.ts b/packages/store/src/cli/commands/store/execute.ts index f1648d232d4..56463bc9e3c 100644 --- a/packages/store/src/cli/commands/store/execute.ts +++ b/packages/store/src/cli/commands/store/execute.ts @@ -15,7 +15,7 @@ Run \`shopify store auth\` first to create stored auth for the store. Mutations are disabled by default. Re-run with \`--allow-mutations\` if you intend to modify store data.` - static description = this.descriptionWithoutMarkdown() + static description = this.descriptionForHelp() static examples = [ '<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query "query { shop { name } }"', diff --git a/packages/store/src/cli/commands/store/graphiql.ts b/packages/store/src/cli/commands/store/graphiql.ts index 7351305ec4e..271baa5497a 100644 --- a/packages/store/src/cli/commands/store/graphiql.ts +++ b/packages/store/src/cli/commands/store/graphiql.ts @@ -13,7 +13,7 @@ Run \`shopify store auth\` first to create stored auth for the store. Mutations are disabled by default. Re-run with \`--allow-mutations\` if you intend to modify store data.` - static description = this.descriptionWithoutMarkdown() + static description = this.descriptionForHelp() static examples = [ '<%= config.bin %> <%= command.id %> --store shop.myshopify.com', diff --git a/packages/store/src/cli/commands/store/info.ts b/packages/store/src/cli/commands/store/info.ts index e1aa5a93c1f..972742dd1d2 100644 --- a/packages/store/src/cli/commands/store/info.ts +++ b/packages/store/src/cli/commands/store/info.ts @@ -13,7 +13,7 @@ Some details may be omitted when they are not available for the store. Use \`--json\` for machine-readable output.` - static description = this.descriptionWithoutMarkdown() + static description = this.descriptionForHelp() static examples = [ '<%= config.bin %> <%= command.id %> --store shop.myshopify.com', diff --git a/packages/store/src/cli/commands/store/list.ts b/packages/store/src/cli/commands/store/list.ts index bb2566ef9bc..849c145fdbf 100644 --- a/packages/store/src/cli/commands/store/list.ts +++ b/packages/store/src/cli/commands/store/list.ts @@ -15,7 +15,7 @@ When more than one organization is available, the command prompts you to pick on Run \`<%= config.bin %> organization list\` to find organization IDs.` - static description = this.descriptionWithoutMarkdown() + static description = this.descriptionForHelp() static examples = [ '<%= config.bin %> <%= command.id %>', diff --git a/packages/store/src/cli/commands/store/open.ts b/packages/store/src/cli/commands/store/open.ts index 57cdff7345a..c9844432da3 100644 --- a/packages/store/src/cli/commands/store/open.ts +++ b/packages/store/src/cli/commands/store/open.ts @@ -8,7 +8,7 @@ export default class StoreOpen extends StoreCommand { static descriptionWithMarkdown = `Opens the storefront for a store you have access to in your default web browser.` - static description = this.descriptionWithoutMarkdown() + static description = this.descriptionForHelp() static examples = ['<%= config.bin %> <%= command.id %> --store shop.myshopify.com'] diff --git a/packages/store/src/cli/commands/store/stripe-auth.ts b/packages/store/src/cli/commands/store/stripe-auth.ts index cf33523c17f..48880b1dd20 100644 --- a/packages/store/src/cli/commands/store/stripe-auth.ts +++ b/packages/store/src/cli/commands/store/stripe-auth.ts @@ -13,7 +13,7 @@ export default class StoreStripeAuth extends StoreCommand { static descriptionWithMarkdown = `Authenticates to a store then stores an online access token for later reuse. Pass the provided JWT to --signup or stdin.` - static description = this.descriptionWithoutMarkdown() + static description = this.descriptionForHelp() static examples = [ '<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products --signup ', diff --git a/packages/theme/src/cli/commands/theme/check.ts b/packages/theme/src/cli/commands/theme/check.ts index 8a98b38ef16..5921f6ba370 100644 --- a/packages/theme/src/cli/commands/theme/check.ts +++ b/packages/theme/src/cli/commands/theme/check.ts @@ -31,7 +31,7 @@ export default class Check extends ThemeCommand { static descriptionWithMarkdown = `Calls and runs [Theme Check](https://shopify.dev/docs/themes/tools/theme-check) to analyze your theme code for errors and to ensure that it follows theme and Liquid best practices. [Learn more about the checks that Theme Check runs.](https://shopify.dev/docs/themes/tools/theme-check/checks)` - static description = this.descriptionWithoutMarkdown() + static description = this.descriptionForHelp() static flags = { ...globalFlags, diff --git a/packages/theme/src/cli/commands/theme/console.ts b/packages/theme/src/cli/commands/theme/console.ts index 298c027461f..928c7c100c3 100644 --- a/packages/theme/src/cli/commands/theme/console.ts +++ b/packages/theme/src/cli/commands/theme/console.ts @@ -18,7 +18,7 @@ export default class Console extends ThemeCommand { You can also provide context to the console using a URL, as some Liquid objects are context-specific` - static description = this.descriptionWithoutMarkdown() + static description = this.descriptionForHelp() static flags = { ...globalFlags, diff --git a/packages/theme/src/cli/commands/theme/delete.ts b/packages/theme/src/cli/commands/theme/delete.ts index 31cb4d946d1..cbf2730178f 100644 --- a/packages/theme/src/cli/commands/theme/delete.ts +++ b/packages/theme/src/cli/commands/theme/delete.ts @@ -17,7 +17,7 @@ export default class Delete extends ThemeCommand { You're asked to confirm that you want to delete the specified themes before they are deleted. You can skip this confirmation using the \`--force\` flag.` - static description = this.descriptionWithoutMarkdown() + static description = this.descriptionForHelp() static flags = { ...globalFlags, diff --git a/packages/theme/src/cli/commands/theme/dev.ts b/packages/theme/src/cli/commands/theme/dev.ts index b765e11c0ca..7bbd2231120 100644 --- a/packages/theme/src/cli/commands/theme/dev.ts +++ b/packages/theme/src/cli/commands/theme/dev.ts @@ -45,7 +45,7 @@ Development themes are deleted when you run \`shopify auth logout\`. If you need You can run this command only in a directory that matches the [default Shopify theme folder structure](https://shopify.dev/docs/themes/tools/cli#directory-structure).` - static description = this.descriptionWithoutMarkdown() + static description = this.descriptionForHelp() static flags = { ...globalFlags, diff --git a/packages/theme/src/cli/commands/theme/duplicate.ts b/packages/theme/src/cli/commands/theme/duplicate.ts index 47f92f01f3d..eba7faae604 100644 --- a/packages/theme/src/cli/commands/theme/duplicate.ts +++ b/packages/theme/src/cli/commands/theme/duplicate.ts @@ -44,7 +44,7 @@ Sample JSON output: } \`\`\`` - static description = this.descriptionWithoutMarkdown() + static description = this.descriptionForHelp() static flags = { ...globalFlags, diff --git a/packages/theme/src/cli/commands/theme/init.ts b/packages/theme/src/cli/commands/theme/init.ts index d37ff534996..2ecd11be91e 100644 --- a/packages/theme/src/cli/commands/theme/init.ts +++ b/packages/theme/src/cli/commands/theme/init.ts @@ -29,7 +29,7 @@ export default class Init extends ThemeCommand { > Caution: If you're building a theme for the Shopify Theme Store, then you can use our example theme as a starting point. However, the theme that you submit needs to be [substantively different from existing themes](https://shopify.dev/docs/themes/store/requirements#uniqueness) so that it provides added value for users. ` - static description = this.descriptionWithoutMarkdown() + static description = this.descriptionForHelp() static usage = 'theme init [name] [flags]' diff --git a/packages/theme/src/cli/commands/theme/language-server.ts b/packages/theme/src/cli/commands/theme/language-server.ts index 25f644a8aa5..83d8dad8dfa 100644 --- a/packages/theme/src/cli/commands/theme/language-server.ts +++ b/packages/theme/src/cli/commands/theme/language-server.ts @@ -7,7 +7,7 @@ export default class LanguageServer extends ThemeCommand { static descriptionWithMarkdown = `Starts the [Language Server](https://shopify.dev/docs/themes/tools/cli/language-server).` - static description = this.descriptionWithoutMarkdown() + static description = this.descriptionForHelp() static flags = { ...globalFlags, diff --git a/packages/theme/src/cli/commands/theme/metafields/pull.ts b/packages/theme/src/cli/commands/theme/metafields/pull.ts index 7a0f3e6d426..ae999118b61 100644 --- a/packages/theme/src/cli/commands/theme/metafields/pull.ts +++ b/packages/theme/src/cli/commands/theme/metafields/pull.ts @@ -14,7 +14,7 @@ export default class MetafieldsPull extends ThemeCommand { If the metafields file already exists, it will be overwritten.` - static description = this.descriptionWithoutMarkdown() + static description = this.descriptionForHelp() static flags = { ...globalFlags, diff --git a/packages/theme/src/cli/commands/theme/open.ts b/packages/theme/src/cli/commands/theme/open.ts index fb417d7d296..e2bbd3d2016 100644 --- a/packages/theme/src/cli/commands/theme/open.ts +++ b/packages/theme/src/cli/commands/theme/open.ts @@ -18,7 +18,7 @@ export default class Open extends ThemeCommand { If you don't specify a theme, then you're prompted to select the theme to open from the list of the themes in your store.` - static description = this.descriptionWithoutMarkdown() + static description = this.descriptionForHelp() static flags = { ...globalFlags, diff --git a/packages/theme/src/cli/commands/theme/package.ts b/packages/theme/src/cli/commands/theme/package.ts index aaf1303518e..925d13602f4 100644 --- a/packages/theme/src/cli/commands/theme/package.ts +++ b/packages/theme/src/cli/commands/theme/package.ts @@ -16,7 +16,7 @@ export default class Package extends ThemeCommand { The ZIP file uses the name \`theme_name-theme_version.zip\`, based on parameters in your [settings_schema.json](https://shopify.dev/docs/storefronts/themes/architecture/config/settings-schema-json) file.` - static description = this.descriptionWithoutMarkdown() + static description = this.descriptionForHelp() static flags = { ...globalFlags, diff --git a/packages/theme/src/cli/commands/theme/preview.ts b/packages/theme/src/cli/commands/theme/preview.ts index 1fd41aea797..66b2fea0061 100644 --- a/packages/theme/src/cli/commands/theme/preview.ts +++ b/packages/theme/src/cli/commands/theme/preview.ts @@ -16,7 +16,7 @@ export default class Preview extends ThemeCommand { The command returns a preview URL and a preview identifier. You can reuse the preview identifier with \`--preview-id\` to update an existing preview instead of creating a new one.` - static description = this.descriptionWithoutMarkdown() + static description = this.descriptionForHelp() static flags = { ...globalFlags, diff --git a/packages/theme/src/cli/commands/theme/profile.ts b/packages/theme/src/cli/commands/theme/profile.ts index 182e84479e8..ada34ef65b7 100644 --- a/packages/theme/src/cli/commands/theme/profile.ts +++ b/packages/theme/src/cli/commands/theme/profile.ts @@ -19,7 +19,7 @@ export default class Profile extends ThemeCommand { This command will open a web page with the Speedscope profiler detailing the time spent executing Liquid on the given page.` - static description = this.descriptionWithoutMarkdown() + static description = this.descriptionForHelp() static flags = { ...globalFlags, diff --git a/packages/theme/src/cli/commands/theme/publish.ts b/packages/theme/src/cli/commands/theme/publish.ts index 57665a887be..989b68829cd 100644 --- a/packages/theme/src/cli/commands/theme/publish.ts +++ b/packages/theme/src/cli/commands/theme/publish.ts @@ -19,7 +19,7 @@ You can run this command only in a directory that matches the [default Shopify t If you want to publish your local theme, then you need to run \`shopify theme push\` first. You're asked to confirm that you want to publish the specified theme. You can skip this confirmation using the \`--force\` flag.` - static description = this.descriptionWithoutMarkdown() + static description = this.descriptionForHelp() static flags = { ...globalFlags, diff --git a/packages/theme/src/cli/commands/theme/pull.ts b/packages/theme/src/cli/commands/theme/pull.ts index ca52e10c458..605eaaa5dd5 100644 --- a/packages/theme/src/cli/commands/theme/pull.ts +++ b/packages/theme/src/cli/commands/theme/pull.ts @@ -18,7 +18,7 @@ export default class Pull extends ThemeCommand { If no theme is specified, then you're prompted to select the theme to pull from the list of the themes in your store.` - static description = this.descriptionWithoutMarkdown() + static description = this.descriptionForHelp() static flags = { ...globalFlags, diff --git a/packages/theme/src/cli/commands/theme/push.ts b/packages/theme/src/cli/commands/theme/push.ts index c2e6773d1da..c87c7dcedb7 100644 --- a/packages/theme/src/cli/commands/theme/push.ts +++ b/packages/theme/src/cli/commands/theme/push.ts @@ -46,7 +46,7 @@ export default class Push extends ThemeCommand { \`\`\` ` - static description = this.descriptionWithoutMarkdown() + static description = this.descriptionForHelp() static flags = { ...globalFlags, diff --git a/packages/theme/src/cli/commands/theme/rename.ts b/packages/theme/src/cli/commands/theme/rename.ts index 220dc47a25e..10f0cb980ce 100644 --- a/packages/theme/src/cli/commands/theme/rename.ts +++ b/packages/theme/src/cli/commands/theme/rename.ts @@ -14,7 +14,7 @@ export default class Rename extends ThemeCommand { If no theme is specified, then you're prompted to select the theme that you want to rename from the list of themes in your store. ` - static description = this.descriptionWithoutMarkdown() + static description = this.descriptionForHelp() static flags = { ...globalFlags, diff --git a/packages/theme/src/cli/commands/theme/share.ts b/packages/theme/src/cli/commands/theme/share.ts index 380abec0a3a..3700dfa313e 100644 --- a/packages/theme/src/cli/commands/theme/share.ts +++ b/packages/theme/src/cli/commands/theme/share.ts @@ -19,7 +19,7 @@ export default class Share extends ThemeCommand { This command returns a [preview link](https://help.shopify.com/manual/online-store/themes/adding-themes#share-a-theme-preview-with-others) that you can share with others.` - static description = this.descriptionWithoutMarkdown() + static description = this.descriptionForHelp() static flags = { ...globalFlags, From 546a09de9081a4ed77a9d5c56bfe384b42917396 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Fri, 11 Sep 2026 17:11:58 +0200 Subject: [PATCH 2/2] Update docs/cli/json-output.md Co-authored-by: Donald Merand <1622979+dmerand@users.noreply.github.com> --- docs/cli/json-output.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cli/json-output.md b/docs/cli/json-output.md index c850c3fa2be..c50a94b81a1 100644 --- a/docs/cli/json-output.md +++ b/docs/cli/json-output.md @@ -1,7 +1,7 @@ # 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. +keep running and streaming updates, such as `shopify app dev`, are currently outside this contract. 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.