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
68 changes: 65 additions & 3 deletions docs/cli/json-output.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ keep running and streaming updates, such as `shopify app dev`, are outside this
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
Comment thread
gonzaloriestra marked this conversation as resolved.
check enforces both. Exceptions are recorded in
`packages/eslint-plugin-cli/rules/json-output-command-exceptions.js`. Its migration section tracks existing finite
commands; remove each entry when converted, and never add new finite 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 @@ -38,6 +43,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 @@ -52,6 +62,58 @@ 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 (including React/Ink), 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. Track these exemptions in
`packages/eslint-plugin-cli/rules/json-output-command-exceptions.js`, in its streaming section. Add the command's
repository-relative path:

```js
'packages/app/src/cli/commands/app/widgets/watch.ts',
```

The lint rule only exempts paths in that list; a `jsonOutputSupport` property alone does not exempt a new command.

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
72 changes: 72 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,72 @@
const {commandExceptions} = require('./json-output-command-exceptions')

const exemptCommands = new Set(commandExceptions)

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) || exemptCommands.has(commandPath)) return {}

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

const classMembers = node.declaration.body.body

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 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'
)
}
113 changes: 113 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,113 @@
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: 'allow-listed streaming command',
filename: '/repo/packages/app/src/cli/commands/app/dev.ts',
code: 'export default class Dev extends Command {}',
},
{
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: 'streaming marker without an allow-list entry',
filename: '/repo/packages/app/src/cli/commands/app/widgets/watch.ts',
code: `
export default class WidgetWatch extends Command {
static jsonOutputSupport = 'streaming' as const
}
`,
errors: [{messageId: 'missingJsonOutputSchema'}, {messageId: 'missingJsonFlag'}],
},
{
name: 'new subcommand of an allow-listed streaming command',
filename: '/repo/packages/app/src/cli/commands/app/dev/status.ts',
code: 'export default class DevStatus extends Command {}',
errors: [{messageId: 'missingJsonOutputSchema'}, {messageId: 'missingJsonFlag'}],
},
{
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.',
},
],
},
],
})
Loading
Loading