diff --git a/docs/cli/json-output.md b/docs/cli/json-output.md index ed1be7772ae..66ced6f3233 100644 --- a/docs/cli/json-output.md +++ b/docs/cli/json-output.md @@ -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 @@ -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 } @@ -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. diff --git a/packages/eslint-plugin-cli/config.js b/packages/eslint-plugin-cli/config.js index f4b4bbd7682..3b1b90fcbd7 100644 --- a/packages/eslint-plugin-cli/config.js +++ b/packages/eslint-plugin-cli/config.js @@ -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'), @@ -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', diff --git a/packages/eslint-plugin-cli/index.js b/packages/eslint-plugin-cli/index.js index 354636e4018..2475f8e64c7 100644 --- a/packages/eslint-plugin-cli/index.js +++ b/packages/eslint-plugin-cli/index.js @@ -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'), diff --git a/packages/eslint-plugin-cli/rules/command-json-output.js b/packages/eslint-plugin-cli/rules/command-json-output.js new file mode 100644 index 00000000000..62c3476f17b --- /dev/null +++ b/packages/eslint-plugin-cli/rules/command-json-output.js @@ -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 +} diff --git a/packages/eslint-plugin-cli/rules/command-json-output.test.js b/packages/eslint-plugin-cli/rules/command-json-output.test.js new file mode 100644 index 00000000000..d435c247313 --- /dev/null +++ b/packages/eslint-plugin-cli/rules/command-json-output.test.js @@ -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.', + }, + ], + }, + ], +}) diff --git a/packages/eslint-plugin-cli/rules/json-output-legacy-command-paths.js b/packages/eslint-plugin-cli/rules/json-output-legacy-command-paths.js new file mode 100644 index 00000000000..38ea9951b99 --- /dev/null +++ b/packages/eslint-plugin-cli/rules/json-output-legacy-command-paths.js @@ -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}