diff --git a/.changeset/version-json-output.md b/.changeset/version-json-output.md new file mode 100644 index 00000000000..0407fa00ec3 --- /dev/null +++ b/.changeset/version-json-output.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli': minor +--- + +Add JSON output to the `version` command. diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 584c717b8ec..5f9bb2e9afe 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -8566,9 +8566,36 @@ "description": "Print the command's JSON schemas.", "isOptional": true, "environmentValue": "SHOPIFY_FLAG_JSON_SCHEMA" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/version.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--no-color", + "value": "''", + "description": "Disable color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_NO_COLOR" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/version.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--verbose", + "value": "''", + "description": "Increase the verbosity of the output. May include sensitive data.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_VERBOSE" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/version.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-j, --json", + "value": "''", + "description": "Output the result as JSON. Automatically disables color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_JSON" } ], - "value": "export interface version {\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n}" + "value": "export interface version {\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } } } \ No newline at end of file diff --git a/packages/cli/README.md b/packages/cli/README.md index ecea5aaef2e..c043c7988ed 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -5831,14 +5831,47 @@ Shopify CLI version currently installed. ``` USAGE - $ shopify version [--json-schema] + $ shopify version [-j] [--json-schema] [--no-color] [--verbose] FLAGS + -j, --json + Output the result as JSON. Automatically disables color output. + [env: SHOPIFY_FLAG_JSON] + --json-schema Print the command's JSON schemas. [env: SHOPIFY_FLAG_JSON_SCHEMA] + --no-color + Disable color output. + [env: SHOPIFY_FLAG_NO_COLOR] + + --verbose + Increase the verbosity of the output. May include sensitive data. + [env: SHOPIFY_FLAG_VERBOSE] + DESCRIPTION Shopify CLI version currently installed. + + Output from `--json` conforms to the `VersionResult` schema. + + Use `--json-schema` to print the result, error, and event schemas. + + ```json + { + "type": "object", + "properties": { + "version": { + "type": "string" + } + }, + "required": [ + "version" + ], + "additionalProperties": false, + "title": "VersionResult", + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` ``` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 3121725eac6..fa3c699910b 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -11492,15 +11492,41 @@ ], "args": { }, - "description": "Shopify CLI version currently installed.", + "description": "Shopify CLI version currently installed.\n\nOutput from `--json` conforms to the `VersionResult` schema.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"version\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"version\"\n ],\n \"additionalProperties\": false,\n \"title\": \"VersionResult\",\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", + "descriptionWithMarkdown": "Shopify CLI version currently installed.", "enableJsonFlag": false, "flags": { + "json": { + "allowNo": false, + "char": "j", + "description": "Output the result as JSON. Automatically disables color output.", + "env": "SHOPIFY_FLAG_JSON", + "hidden": false, + "name": "json", + "type": "boolean" + }, "json-schema": { "allowNo": false, "description": "Print the command's JSON schemas.", "env": "SHOPIFY_FLAG_JSON_SCHEMA", "name": "json-schema", "type": "boolean" + }, + "no-color": { + "allowNo": false, + "description": "Disable color output.", + "env": "SHOPIFY_FLAG_NO_COLOR", + "hidden": false, + "name": "no-color", + "type": "boolean" + }, + "verbose": { + "allowNo": false, + "description": "Increase the verbosity of the output. May include sensitive data.", + "env": "SHOPIFY_FLAG_VERBOSE", + "hidden": false, + "name": "verbose", + "type": "boolean" } }, "hasDynamicHelp": false, diff --git a/packages/cli/src/cli/commands/version.test.ts b/packages/cli/src/cli/commands/version.test.ts index ff4efeb4f6d..1de7d44d6ce 100644 --- a/packages/cli/src/cli/commands/version.test.ts +++ b/packages/cli/src/cli/commands/version.test.ts @@ -1,15 +1,114 @@ import Version from './version.js' -import {versionService} from '../services/commands/version.js' -import {describe, test, vi, expect} from 'vitest' +import {versionService} from '../services/commands/version/index.js' +import {versionJsonOutputSchema} from '../services/commands/version/types.js' +import {CLI_KIT_VERSION} from '@shopify/cli-kit/common/version' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' +import {execa} from 'execa' +import {afterEach, describe, expect, test, vi} from 'vitest' -vi.mock('../services/commands/version.js') +vi.mock('../services/commands/version/index.js', async (importOriginal) => ({ + ...(await importOriginal()), + versionService: vi.fn(), +})) + +afterEach(() => { + mockAndCaptureOutput().clear() +}) + +const commandUrl = new URL('./version.ts', import.meta.url).href +const sourceLoaderUrl = new URL('../../../../cli-kit/test/fixtures/cli-kit-source-loader.js', import.meta.url).href + +const runVersion = async (arguments_: string[]) => { + const script = ` + const {default: Version} = await import(${JSON.stringify(commandUrl)}) + const argv = ${JSON.stringify(arguments_)} + if (argv.includes('--json-schema')) { + // Schema inspection runs in the launcher before the command lifecycle. + const {launchCLI} = await import('@shopify/cli-kit/node/cli-launcher') + await launchCLI({ + moduleURL: ${JSON.stringify(commandUrl)}, + argv: ['version', ...argv], + lazyCommandLoader: async () => Version, + }) + } else { + await Version.run(argv, ${JSON.stringify(commandUrl)}) + } + ` + + return execa( + process.execPath, + ['--loader', 'ts-node/esm', '--loader', sourceLoaderUrl, '--input-type=module', '--eval', script], + { + env: { + ...process.env, + FORCE_COLOR: '0', + NODE_NO_WARNINGS: '1', + SHOPIFY_CLI_ENV: 'development', + SHOPIFY_CLI_NO_ANALYTICS: '1', + SHOPIFY_UNIT_TEST: 'false', + }, + reject: false, + stripFinalNewline: false, + }, + ) +} describe('version command', () => { - test('launches service', async () => { - vi.mocked(versionService).mockResolvedValue() + test('writes the raw version text by default', async () => { + const outputMock = mockAndCaptureOutput() + vi.mocked(versionService).mockResolvedValue({version: '2.2.2'}) await Version.run([], import.meta.url) - expect(versionService).toHaveBeenCalled() + expect(versionService).toHaveBeenCalledOnce() + expect(outputMock.output()).toBe('2.2.2') + expect(outputMock.warn()).toBe('') + }) + + test('writes one JSON document when requested', async () => { + const outputMock = mockAndCaptureOutput() + vi.mocked(versionService).mockResolvedValue({version: '2.2.2'}) + + await Version.run(['--json'], import.meta.url) + + expect(outputMock.output()).toBe(JSON.stringify({version: '2.2.2'}, null, 2)) + expect(JSON.parse(outputMock.output())).toEqual({version: '2.2.2'}) + expect(outputMock.warn()).toBe('') + }) + + test('exposes the object schema and JSON flags in help', () => { + expect(Version.jsonOutputSchema).toBe(versionJsonOutputSchema) + expect(Version.flags.json).toBeDefined() + expect(Version.description).toContain('Output from `--json` conforms to the `VersionResult` schema.') + expect(Version.description).toContain('"type": "object"') + }) + + test('writes the installed version without stderr output', {timeout: 20000}, async () => { + const result = await runVersion([]) + + expect(result.exitCode).toBe(0) + expect(result.stderr).toBe('') + expect(result.stdout).toBe(`${CLI_KIT_VERSION}\n`) + }) + + test('writes the installed version as JSON without stderr output', {timeout: 20000}, async () => { + const result = await runVersion(['--json']) + + expect(result.exitCode).toBe(0) + expect(result.stderr).toBe('') + expect(result.stdout).toBe(`${JSON.stringify({version: CLI_KIT_VERSION}, null, 2)}\n`) + expect(JSON.parse(result.stdout)).toEqual({version: CLI_KIT_VERSION}) + }) + + test('writes a schema with an object result without stderr output', {timeout: 20000}, async () => { + const result = await runVersion(['--json-schema']) + + expect(result.exitCode).toBe(0) + expect(result.stderr).toBe('') + const definition = JSON.parse(result.stdout).definitions.Result + expect(definition.type).toBe('object') + expect(definition.properties.version).toEqual({type: 'string'}) + expect(definition.required).toEqual(['version']) + expect(definition.additionalProperties).toBe(false) }) }) diff --git a/packages/cli/src/cli/commands/version.ts b/packages/cli/src/cli/commands/version.ts index 482eeb7ec50..2932db951b8 100644 --- a/packages/cli/src/cli/commands/version.ts +++ b/packages/cli/src/cli/commands/version.ts @@ -1,10 +1,27 @@ -import {versionService} from '../services/commands/version.js' +import {versionService} from '../services/commands/version/index.js' +import {presentVersionResult} from '../services/commands/version/result.js' +import {versionJsonOutputSchema} from '../services/commands/version/types.js' import Command from '@shopify/cli-kit/node/base-command' +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' export default class Version extends Command { - static description = 'Shopify CLI version currently installed.' + static descriptionWithMarkdown = 'Shopify CLI version currently installed.' + + static description = this.descriptionForHelp() + + static flags = { + ...globalFlags, + ...jsonFlag, + } + + static get jsonOutputSchema() { + return versionJsonOutputSchema + } async run(): Promise { - await versionService() + const {flags} = await this.parse(Version) + const result = await versionService() + + presentVersionResult(result, flags.json ? 'json' : 'text') } } diff --git a/packages/cli/src/cli/services/commands/version.test.ts b/packages/cli/src/cli/services/commands/version.test.ts deleted file mode 100644 index 4e3680b310e..00000000000 --- a/packages/cli/src/cli/services/commands/version.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import {versionService} from './version.js' -import {afterEach, describe, expect, vi, test} from 'vitest' -import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' - -vi.mock('@shopify/cli-kit/node/node-package-manager') -vi.mock('@shopify/cli-kit/common/version', () => ({CLI_KIT_VERSION: '2.2.2'})) - -afterEach(() => { - mockAndCaptureOutput().clear() -}) - -describe('check CLI version', () => { - test('displays latest version', async () => { - // Given - const outputMock = mockAndCaptureOutput() - - // When - await versionService() - - // Then - expect(outputMock.info()).toMatchInlineSnapshot(` - "2.2.2" - `) - }) -}) diff --git a/packages/cli/src/cli/services/commands/version.ts b/packages/cli/src/cli/services/commands/version.ts deleted file mode 100644 index df92a13da06..00000000000 --- a/packages/cli/src/cli/services/commands/version.ts +++ /dev/null @@ -1,6 +0,0 @@ -import {CLI_KIT_VERSION} from '@shopify/cli-kit/common/version' -import {outputResult} from '@shopify/cli-kit/node/output' - -export async function versionService(): Promise { - outputResult(CLI_KIT_VERSION) -} diff --git a/packages/cli/src/cli/services/commands/version/index.test.ts b/packages/cli/src/cli/services/commands/version/index.test.ts new file mode 100644 index 00000000000..cbb5a766da8 --- /dev/null +++ b/packages/cli/src/cli/services/commands/version/index.test.ts @@ -0,0 +1,19 @@ +import {versionService} from './index.js' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' +import {afterEach, describe, expect, test, vi} from 'vitest' + +vi.mock('@shopify/cli-kit/common/version', () => ({CLI_KIT_VERSION: '2.2.2'})) + +afterEach(() => { + mockAndCaptureOutput().clear() +}) + +describe('version service', () => { + test('returns the installed version as an object without writing output', async () => { + const outputMock = mockAndCaptureOutput() + + await expect(versionService()).resolves.toEqual({version: '2.2.2'}) + + expect(outputMock.output()).toBe('') + }) +}) diff --git a/packages/cli/src/cli/services/commands/version/index.ts b/packages/cli/src/cli/services/commands/version/index.ts new file mode 100644 index 00000000000..7c645567da9 --- /dev/null +++ b/packages/cli/src/cli/services/commands/version/index.ts @@ -0,0 +1,6 @@ +import {CLI_KIT_VERSION} from '@shopify/cli-kit/common/version' +import type {VersionResult} from './types.js' + +export async function versionService(): Promise { + return {version: CLI_KIT_VERSION} +} diff --git a/packages/cli/src/cli/services/commands/version/result.test.ts b/packages/cli/src/cli/services/commands/version/result.test.ts new file mode 100644 index 00000000000..8b11bfc0980 --- /dev/null +++ b/packages/cli/src/cli/services/commands/version/result.test.ts @@ -0,0 +1,47 @@ +import {presentVersionResult} from './result.js' +import {versionJsonOutputSchema} from './types.js' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' +import {afterEach, describe, expect, test} from 'vitest' + +afterEach(() => { + mockAndCaptureOutput().clear() +}) + +describe('presentVersionResult', () => { + test('writes the raw version text by default', () => { + const outputMock = mockAndCaptureOutput() + + presentVersionResult({version: '2.2.2'}, 'text') + + expect(outputMock.output()).toBe('2.2.2') + expect(outputMock.warn()).toBe('') + }) + + test('writes one JSON document when requested', () => { + const outputMock = mockAndCaptureOutput() + + presentVersionResult({version: '2.2.2'}, 'json') + + expect(outputMock.output()).toBe(JSON.stringify({version: '2.2.2'}, null, 2)) + expect(JSON.parse(outputMock.output())).toEqual({version: '2.2.2'}) + }) +}) + +describe('version JSON result contract', () => { + test('encodes an object with a version string', () => { + expect(versionJsonOutputSchema.encode({version: '2.2.2'})).toBe(JSON.stringify({version: '2.2.2'}, null, 2)) + }) + + test.each([['2.2.2'], [{}], [{version: 1}]])('rejects %j', (value) => { + expect(() => versionJsonOutputSchema.validate(value)).toThrow() + }) + + test('declares a closed object schema with a required version string', () => { + expect(versionJsonOutputSchema.jsonSchema).toMatchObject({ + type: 'object', + properties: {version: {type: 'string'}}, + required: ['version'], + additionalProperties: false, + }) + }) +}) diff --git a/packages/cli/src/cli/services/commands/version/result.ts b/packages/cli/src/cli/services/commands/version/result.ts new file mode 100644 index 00000000000..bc1dde85933 --- /dev/null +++ b/packages/cli/src/cli/services/commands/version/result.ts @@ -0,0 +1,6 @@ +import {versionJsonOutputSchema, type VersionResult} from './types.js' +import {outputResult} from '@shopify/cli-kit/node/output' + +export function presentVersionResult(result: VersionResult, format: 'json' | 'text'): void { + outputResult(format === 'json' ? versionJsonOutputSchema.encode(result) : result.version) +} diff --git a/packages/cli/src/cli/services/commands/version/types.ts b/packages/cli/src/cli/services/commands/version/types.ts new file mode 100644 index 00000000000..dee9479723a --- /dev/null +++ b/packages/cli/src/cli/services/commands/version/types.ts @@ -0,0 +1,9 @@ +import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' + +export const versionJsonOutputSchema = defineJsonOutputSchema({ + name: 'VersionResult', + schema: zod.object({version: zod.string()}), +}) + +export type VersionResult = InferJsonOutputSchema diff --git a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js index 9eb6f761bf7..b2279a74075 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -61,7 +61,6 @@ const commandExceptions = [ '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',