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
67 changes: 64 additions & 3 deletions docs/cli/json-output.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
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.

New finite query and operation commands must include `jsonFlag` and expose a `jsonOutputSchema`. The repository lint
check enforces both. Existing commands are recorded in a temporary migration baseline in
`packages/eslint-plugin-cli/rules/json-output-legacy-command-paths.js`; remove a command from that baseline when it is
converted, and never add new commands to it.

## 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
Expand Down Expand Up @@ -35,6 +40,11 @@ Expose the contract from the command and encode through it. Encoding validates t

```ts
export default class WidgetList extends Command {
static flags = {
...globalFlags,
...jsonFlag,
}

static get jsonOutputSchema() {
return widgetListJsonOutputSchema
}
Expand All @@ -49,6 +59,57 @@ export default class WidgetList extends Command {
}
```

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.
## Keep data and presentation separate

A finite command should have these boundaries:

- The domain service returns typed data and doesn't print terminal output.
- A command-specific codec maps the service result to the stable public JSON shape when they differ.
- The schema validates and encodes that public result.
- A presenter turns the same result into human-readable terminal output.

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.

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

## Preserve compatibility

Treat the JSON result as a public API. Keep existing keys, omission rules, nullability, collection shapes, and exit
behavior when converting a command. Put compatibility mappings in the codec instead of changing domain models or
leaking presenter details into the schema. Add regression tests for the exact encoded result as well as schema
validation.

`--json` selects the output format. `--no-input` controls interactivity. They are independent: JSON output must not
silently disable prompts, and non-interactive execution must not silently select JSON. A command that can prompt should
support and test the relevant combinations explicitly.

## Exempt only streaming commands

Long-lived commands that produce an open-ended event stream don't have one finite result. Mark those commands
explicitly instead of inventing a final JSON document:

```ts
export default class WidgetWatch extends Command {
static jsonOutputSupport = 'streaming' as const
}
```

This exemption is only for commands whose lifetime or output is inherently streaming. A finite operation remains a
finite command even when it emits progress events, writes a file, or has no interesting return value.

## Test a new command

Tests should verify:

- the domain service result without terminal concerns;
- codec compatibility and schema validation;
- the exact `--json` document;
- human presentation independently from JSON encoding;
- errors and exit behavior; and
- prompt behavior independently from `--json` and `--no-input`.

Command help includes the generated TypeScript contract automatically through `jsonOutputSchema`. Run the manifest,
README, and code-documentation refresh commands required by CI after changing command metadata.
2 changes: 2 additions & 0 deletions packages/eslint-plugin-cli/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const rules = {
'command-flags-with-env': require('./rules/command-flags-with-env'),
'command-conventional-flag-env': require('./rules/command-conventional-flag-env'),
'command-reserved-flags': require('./rules/command-reserved-flags'),
'command-json-output': require('./rules/command-json-output'),
'no-error-factory-functions': require('./rules/no-error-factory-functions'),
'no-process-cwd': require('./rules/no-process-cwd'),
'no-trailing-js-in-cli-kit-imports': require('./rules/no-trailing-js-in-cli-kit-imports'),
Expand Down Expand Up @@ -158,6 +159,7 @@ const baseRules = {
'@shopify/cli/command-flags-with-env': 'error',
'@shopify/cli/command-conventional-flag-env': 'error',
'@shopify/cli/command-reserved-flags': 'error',
'@shopify/cli/command-json-output': 'error',
'@shopify/cli/no-error-factory-functions': 'error',
'@shopify/cli/no-process-cwd': 'error',
'@shopify/cli/no-trailing-js-in-cli-kit-imports': 'error',
Expand Down
1 change: 1 addition & 0 deletions packages/eslint-plugin-cli/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const plugin = {
'command-flags-with-env': require('./rules/command-flags-with-env'),
'command-conventional-flag-env': require('./rules/command-conventional-flag-env'),
'command-reserved-flags': require('./rules/command-reserved-flags'),
'command-json-output': require('./rules/command-json-output'),
'no-error-factory-functions': require('./rules/no-error-factory-functions'),
'no-process-cwd': require('./rules/no-process-cwd'),
'no-trailing-js-in-cli-kit-imports': require('./rules/no-trailing-js-in-cli-kit-imports'),
Expand Down
88 changes: 88 additions & 0 deletions packages/eslint-plugin-cli/rules/command-json-output.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
const {legacyCommandPaths} = require('./json-output-legacy-command-paths')

const legacyCommands = new Set(legacyCommandPaths)

module.exports = {
meta: {
type: 'problem',
docs: {
description: 'require typed JSON output for new finite commands',
},
schema: [],
messages: {
missingJsonFlag:
'New finite commands must include ...jsonFlag in their static flags. See docs/cli/json-output.md.',
missingJsonOutputSchema:
'New finite commands must declare a static jsonOutputSchema. See docs/cli/json-output.md.',
},
},
create(context) {
const commandPath = repositoryPath(context.filename)
if (!isCommandPath(commandPath) || legacyCommands.has(commandPath)) return {}

return {
ExportDefaultDeclaration(node) {
if (node.declaration.type !== 'ClassDeclaration') return

const classMembers = node.declaration.body.body
if (hasStreamingExemption(classMembers)) return

if (!hasJsonOutputSchema(classMembers)) {
context.report({node: node.declaration, messageId: 'missingJsonOutputSchema'})
}
if (!hasJsonFlag(classMembers)) {
context.report({node: node.declaration, messageId: 'missingJsonFlag'})
}
},
}
},
}

function isCommandPath(commandPath) {
return /\/src\/(?:cli\/)?commands\//.test(commandPath)
}

function repositoryPath(filename) {
const normalizedFilename = filename.replaceAll('\\', '/')
const packagesDirectory = normalizedFilename.lastIndexOf('/packages/')
return packagesDirectory === -1 ? normalizedFilename : normalizedFilename.slice(packagesDirectory + 1)
}

function hasStreamingExemption(classMembers) {
return classMembers.some(
(member) =>
isStaticMemberNamed(member, 'jsonOutputSupport') &&
unwrapTypeScriptExpression(member.value)?.value === 'streaming',
)
}

function hasJsonOutputSchema(classMembers) {
return classMembers.some(
(member) =>
member.type === 'MethodDefinition' && member.kind === 'get' && isStaticMemberNamed(member, 'jsonOutputSchema'),
)
}

function hasJsonFlag(classMembers) {
const flags = classMembers.find((member) => isStaticMemberNamed(member, 'flags'))
return flags?.value?.type === 'ObjectExpression' && flags.value.properties.some(isJsonFlagSpread)
}

function isStaticMemberNamed(member, name) {
return member.static && !member.computed && member.key?.name === name
}

function isJsonFlagSpread(property) {
return (
property.type === 'SpreadElement' &&
property.argument.type === 'Identifier' &&
property.argument.name === 'jsonFlag'
)
}

function unwrapTypeScriptExpression(expression) {
if (expression?.type === 'TSAsExpression' || expression?.type === 'TSSatisfiesExpression') {
return unwrapTypeScriptExpression(expression.expression)
}
return expression
}
101 changes: 101 additions & 0 deletions packages/eslint-plugin-cli/rules/command-json-output.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
const {RuleTester} = require('eslint')
const typescriptParser = require('@typescript-eslint/parser')

const rule = require('./command-json-output')

const ruleTester = new RuleTester({
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
parser: typescriptParser,
},
})

ruleTester.run('command-json-output', rule, {
valid: [
{
name: 'finite query command',
filename: '/repo/packages/app/src/cli/commands/app/widgets/list.ts',
code: `
export default class WidgetList extends Command {
static flags = {...jsonFlag}
static get jsonOutputSchema() {
return widgetListJsonOutputSchema
}
}
`,
},
{
name: 'finite operation command',
filename: '/repo/packages/app/src/cli/commands/app/widgets/delete.ts',
code: `
export default class WidgetDelete extends Command {
static flags = {...globalFlags, ...jsonFlag}
static get jsonOutputSchema() {
return widgetDeleteJsonOutputSchema
}
}
`,
},
{
name: 'streaming command exemption',
filename: '/repo/packages/app/src/cli/commands/app/widgets/watch.ts',
code: `
export default class WidgetWatch extends Command {
static jsonOutputSupport = 'streaming' as const
}
`,
},
{
name: 'legacy command baseline',
filename: '/repo/packages/app/src/cli/commands/app/build.ts',
code: 'export default class Build extends Command {}',
},
{
name: 'non-command module',
filename: '/repo/packages/app/src/cli/services/widgets.ts',
code: 'export default class WidgetService {}',
},
],
invalid: [
{
name: 'new command without JSON support',
filename: '/repo/packages/app/src/cli/commands/app/widgets/create.ts',
code: 'export default class WidgetCreate extends Command {}',
errors: [
{
message: 'New finite commands must declare a static jsonOutputSchema. See docs/cli/json-output.md.',
},
{
message: 'New finite commands must include ...jsonFlag in their static flags. See docs/cli/json-output.md.',
},
],
},
{
name: 'command missing its schema',
filename: '/repo/packages/app/src/cli/commands/app/widgets/search.ts',
code: 'export default class WidgetSearch extends Command { static flags = {...jsonFlag} }',
errors: [
{
message: 'New finite commands must declare a static jsonOutputSchema. See docs/cli/json-output.md.',
},
],
},
{
name: 'command missing its JSON flag',
filename: '/repo/packages/app/src/cli/commands/app/widgets/update.ts',
code: `
export default class WidgetUpdate extends Command {
static get jsonOutputSchema() {
return widgetUpdateJsonOutputSchema
}
}
`,
errors: [
{
message: 'New finite commands must include ...jsonFlag in their static flags. See docs/cli/json-output.md.',
},
],
},
],
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Temporary migration baseline. Remove a path when its command adopts a typed JSON result.
// New command paths must never be added here; only streaming commands can use the explicit exemption.
const legacyCommandPaths = [
'packages/app/src/cli/commands/app/app-logs/sources.ts',
'packages/app/src/cli/commands/app/build.ts',
'packages/app/src/cli/commands/app/bulk/cancel.ts',
'packages/app/src/cli/commands/app/bulk/execute.ts',
'packages/app/src/cli/commands/app/bulk/status.ts',
'packages/app/src/cli/commands/app/config/link.ts',
'packages/app/src/cli/commands/app/config/pull.ts',
'packages/app/src/cli/commands/app/config/use.ts',
'packages/app/src/cli/commands/app/config/validate.ts',
'packages/app/src/cli/commands/app/demo/watcher.ts',
'packages/app/src/cli/commands/app/deploy.ts',
'packages/app/src/cli/commands/app/dev.ts',
'packages/app/src/cli/commands/app/dev/clean.ts',
'packages/app/src/cli/commands/app/env/pull.ts',
'packages/app/src/cli/commands/app/env/show.ts',
'packages/app/src/cli/commands/app/execute.ts',
'packages/app/src/cli/commands/app/function/build.ts',
'packages/app/src/cli/commands/app/function/info.ts',
'packages/app/src/cli/commands/app/function/replay.ts',
'packages/app/src/cli/commands/app/function/run.ts',
'packages/app/src/cli/commands/app/function/schema.ts',
'packages/app/src/cli/commands/app/function/typegen.ts',
'packages/app/src/cli/commands/app/generate/extension.ts',
'packages/app/src/cli/commands/app/graphiql.ts',
'packages/app/src/cli/commands/app/import-custom-data-definitions.ts',
'packages/app/src/cli/commands/app/import-extensions.ts',
'packages/app/src/cli/commands/app/info.ts',
'packages/app/src/cli/commands/app/init.ts',
'packages/app/src/cli/commands/app/logs.ts',
'packages/app/src/cli/commands/app/release.ts',
'packages/app/src/cli/commands/app/versions/list.ts',
'packages/app/src/cli/commands/app/webhook/trigger.ts',
'packages/app/src/cli/commands/organization/list.ts',
'packages/cli/src/cli/commands/auth/login.ts',
'packages/cli/src/cli/commands/auth/logout.ts',
'packages/cli/src/cli/commands/cache/clear.ts',
'packages/cli/src/cli/commands/config/autoupgrade/off.ts',
'packages/cli/src/cli/commands/config/autoupgrade/on.ts',
'packages/cli/src/cli/commands/config/autoupgrade/status.ts',
'packages/cli/src/cli/commands/debug/command-flags.ts',
'packages/cli/src/cli/commands/doc/fetch.ts',
'packages/cli/src/cli/commands/doc/search.ts',
'packages/cli/src/cli/commands/docs/generate.ts',
'packages/cli/src/cli/commands/doctor-release/doctor-release.ts',
'packages/cli/src/cli/commands/doctor-release/theme/index.ts',
'packages/cli/src/cli/commands/help.ts',
'packages/cli/src/cli/commands/kitchen-sink/async.ts',
'packages/cli/src/cli/commands/kitchen-sink/index.ts',
'packages/cli/src/cli/commands/kitchen-sink/prompts.ts',
'packages/cli/src/cli/commands/kitchen-sink/static.ts',
'packages/cli/src/cli/commands/notifications/generate.ts',
'packages/cli/src/cli/commands/notifications/list.ts',
'packages/cli/src/cli/commands/search.ts',
'packages/cli/src/cli/commands/send-analytics.ts',
'packages/cli/src/cli/commands/upgrade.ts',
'packages/cli/src/cli/commands/version.ts',
'packages/plugin-did-you-mean/src/commands/config/autocorrect/off.ts',
'packages/plugin-did-you-mean/src/commands/config/autocorrect/on.ts',
'packages/plugin-did-you-mean/src/commands/config/autocorrect/status.ts',
'packages/store/src/cli/commands/store/auth.ts',
'packages/store/src/cli/commands/store/auth/list.ts',
'packages/store/src/cli/commands/store/bulk/cancel.ts',
'packages/store/src/cli/commands/store/bulk/execute.ts',
'packages/store/src/cli/commands/store/bulk/status.ts',
'packages/store/src/cli/commands/store/create/dev.ts',
'packages/store/src/cli/commands/store/create/preview.ts',
'packages/store/src/cli/commands/store/delete.ts',
'packages/store/src/cli/commands/store/execute.ts',
'packages/store/src/cli/commands/store/graphiql.ts',
'packages/store/src/cli/commands/store/info.ts',
'packages/store/src/cli/commands/store/list.ts',
'packages/store/src/cli/commands/store/open.ts',
'packages/store/src/cli/commands/store/stripe-auth.ts',
'packages/theme/src/cli/commands/theme/check.ts',
'packages/theme/src/cli/commands/theme/console.ts',
'packages/theme/src/cli/commands/theme/delete.ts',
'packages/theme/src/cli/commands/theme/dev.ts',
'packages/theme/src/cli/commands/theme/duplicate.ts',
'packages/theme/src/cli/commands/theme/info.ts',
'packages/theme/src/cli/commands/theme/init.ts',
'packages/theme/src/cli/commands/theme/language-server.ts',
'packages/theme/src/cli/commands/theme/list.ts',
'packages/theme/src/cli/commands/theme/metafields/pull.ts',
'packages/theme/src/cli/commands/theme/open.ts',
'packages/theme/src/cli/commands/theme/package.ts',
'packages/theme/src/cli/commands/theme/preview.ts',
'packages/theme/src/cli/commands/theme/profile.ts',
'packages/theme/src/cli/commands/theme/publish.ts',
'packages/theme/src/cli/commands/theme/pull.ts',
'packages/theme/src/cli/commands/theme/push.ts',
'packages/theme/src/cli/commands/theme/rename.ts',
'packages/theme/src/cli/commands/theme/share.ts',
]

module.exports = {legacyCommandPaths}
Loading