From f88ee510da6ec6419544f7f0b7d6ad8af68f514a Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Mon, 14 Sep 2026 12:04:04 -0400 Subject: [PATCH 1/4] Add JSON output to version command Assisted-By: devx/a32d335f-7f31-4c67-9d95-211a1bec4a2a --- .changeset/version-json-output.md | 5 ++ .../generated/generated_docs_data_v2.json | 29 ++++++- packages/cli/README.md | 26 +++++- packages/cli/oclif.manifest.json | 28 ++++++- packages/cli/src/cli/commands/version.test.ts | 82 +++++++++++++++++-- packages/cli/src/cli/commands/version.ts | 22 ++++- .../src/cli/services/commands/version.test.ts | 23 +++--- .../cli/src/cli/services/commands/version.ts | 14 +++- .../rules/json-output-command-exceptions.js | 1 - 9 files changed, 202 insertions(+), 28 deletions(-) create mode 100644 .changeset/version-json-output.md 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..66ae973dc32 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -5831,14 +5831,38 @@ 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": "string", + "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..7a516d22905 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\": \"string\",\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..972c4d7cae7 100644 --- a/packages/cli/src/cli/commands/version.test.ts +++ b/packages/cli/src/cli/commands/version.test.ts @@ -1,15 +1,85 @@ import Version from './version.js' -import {versionService} from '../services/commands/version.js' -import {describe, test, vi, expect} from 'vitest' +import {versionJsonOutputSchema, versionService} from '../services/commands/version.js' +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.js', async (importOriginal) => ({ + ...(await importOriginal()), + versionService: vi.fn(), +})) + +afterEach(() => { + mockAndCaptureOutput().clear() +}) 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('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 scalar JSON document when requested', async () => { + const outputMock = mockAndCaptureOutput() + vi.mocked(versionService).mockResolvedValue('2.2.2') + + await Version.run(['--json'], import.meta.url) + + expect(outputMock.output()).toBe('"2.2.2"') + expect(JSON.parse(outputMock.output())).toBe('2.2.2') + expect(outputMock.warn()).toBe('') + }) + + test('exposes the scalar 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": "string"') + }) + + test('writes raw, JSON, and JSON Schema output without stderr output', {timeout: 20000}, async () => { + 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 run = async (arguments_: string[]) => { + const script = ` + const {default: Version} = await import(${JSON.stringify(commandUrl)}) + await Version.run(${JSON.stringify(arguments_)}, ${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, + }, + ) + } + + const text = await run([]) + const json = await run(['--json']) + const jsonSchema = await run(['--json-schema']) + + expect(text.exitCode, text.stderr).toBe(0) + expect(json.exitCode, json.stderr).toBe(0) + expect(jsonSchema.exitCode, jsonSchema.stderr).toBe(0) + expect(text.stdout).toMatch(/^.+\n$/) + expect(json.stdout).toMatch(/^".+"\n$/) + expect(JSON.parse(json.stdout)).toBe(text.stdout.trim()) + expect(JSON.parse(jsonSchema.stdout).definitions.Result).toMatchObject({type: 'string'}) }) }) diff --git a/packages/cli/src/cli/commands/version.ts b/packages/cli/src/cli/commands/version.ts index 482eeb7ec50..b4ccd7fbbb0 100644 --- a/packages/cli/src/cli/commands/version.ts +++ b/packages/cli/src/cli/commands/version.ts @@ -1,10 +1,26 @@ -import {versionService} from '../services/commands/version.js' +import {versionJsonOutputSchema, versionService} from '../services/commands/version.js' import Command from '@shopify/cli-kit/node/base-command' +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' +import {outputResult} from '@shopify/cli-kit/node/output' 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 version = await versionService() + + outputResult(flags.json ? versionJsonOutputSchema.encode(version) : version) } } diff --git a/packages/cli/src/cli/services/commands/version.test.ts b/packages/cli/src/cli/services/commands/version.test.ts index 4e3680b310e..114d3e5ac8c 100644 --- a/packages/cli/src/cli/services/commands/version.test.ts +++ b/packages/cli/src/cli/services/commands/version.test.ts @@ -1,25 +1,24 @@ -import {versionService} from './version.js' -import {afterEach, describe, expect, vi, test} from 'vitest' +import {versionJsonOutputSchema, versionService} from './version.js' +import {afterEach, describe, expect, test, vi} 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 +describe('version service', () => { + test('returns the installed version without writing output', async () => { const outputMock = mockAndCaptureOutput() - // When - await versionService() + await expect(versionService()).resolves.toBe('2.2.2') - // Then - expect(outputMock.info()).toMatchInlineSnapshot(` - "2.2.2" - `) + expect(outputMock.output()).toBe('') + }) + + test('defines a scalar JSON result contract', () => { + expect(versionJsonOutputSchema.encode('2.2.2')).toBe('"2.2.2"') + expect(() => versionJsonOutputSchema.validate({version: '2.2.2'})).toThrow() }) }) diff --git a/packages/cli/src/cli/services/commands/version.ts b/packages/cli/src/cli/services/commands/version.ts index df92a13da06..31eb2058fed 100644 --- a/packages/cli/src/cli/services/commands/version.ts +++ b/packages/cli/src/cli/services/commands/version.ts @@ -1,6 +1,14 @@ import {CLI_KIT_VERSION} from '@shopify/cli-kit/common/version' -import {outputResult} from '@shopify/cli-kit/node/output' +import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' -export async function versionService(): Promise { - outputResult(CLI_KIT_VERSION) +export const versionJsonOutputSchema = defineJsonOutputSchema({ + name: 'VersionResult', + schema: zod.string(), +}) + +export type VersionResult = InferJsonOutputSchema + +export async function versionService(): Promise { + return CLI_KIT_VERSION } 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', From dee5655fc2851fb890bc31fecde6a17aa0eb5629 Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Mon, 14 Sep 2026 12:20:53 -0400 Subject: [PATCH 2/4] test: split version subprocess checks Assisted-By: devx/70389700-3e7a-45b8-a7a5-6e2869df0516 --- packages/cli/src/cli/commands/version.test.ts | 89 +++++++++++-------- 1 file changed, 51 insertions(+), 38 deletions(-) diff --git a/packages/cli/src/cli/commands/version.test.ts b/packages/cli/src/cli/commands/version.test.ts index 972c4d7cae7..6ff5fa5a8e3 100644 --- a/packages/cli/src/cli/commands/version.test.ts +++ b/packages/cli/src/cli/commands/version.test.ts @@ -1,5 +1,6 @@ import Version from './version.js' import {versionJsonOutputSchema, versionService} from '../services/commands/version.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' @@ -13,6 +14,33 @@ 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)}) + await Version.run(${JSON.stringify(arguments_)}, ${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('writes the raw version text by default', async () => { const outputMock = mockAndCaptureOutput() @@ -43,43 +71,28 @@ describe('version command', () => { expect(Version.description).toContain('"type": "string"') }) - test('writes raw, JSON, and JSON Schema output without stderr output', {timeout: 20000}, async () => { - 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 run = async (arguments_: string[]) => { - const script = ` - const {default: Version} = await import(${JSON.stringify(commandUrl)}) - await Version.run(${JSON.stringify(arguments_)}, ${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, - }, - ) - } - - const text = await run([]) - const json = await run(['--json']) - const jsonSchema = await run(['--json-schema']) - - expect(text.exitCode, text.stderr).toBe(0) - expect(json.exitCode, json.stderr).toBe(0) - expect(jsonSchema.exitCode, jsonSchema.stderr).toBe(0) - expect(text.stdout).toMatch(/^.+\n$/) - expect(json.stdout).toMatch(/^".+"\n$/) - expect(JSON.parse(json.stdout)).toBe(text.stdout.trim()) - expect(JSON.parse(jsonSchema.stdout).definitions.Result).toMatchObject({type: 'string'}) + 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(CLI_KIT_VERSION)}\n`) + expect(JSON.parse(result.stdout)).toBe(CLI_KIT_VERSION) + }) + + test('writes a schema with a string result without stderr output', {timeout: 20000}, async () => { + const result = await runVersion(['--json-schema']) + + expect(result.exitCode).toBe(0) + expect(result.stderr).toBe('') + expect(JSON.parse(result.stdout).definitions.Result.type).toBe('string') }) }) From 70e2c8791f1081468d67e07d49a5c3f93c567927 Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Tue, 15 Sep 2026 10:03:44 -0400 Subject: [PATCH 3/4] Return version result as an object Assisted-By: devx/c4197d39-027e-4413-9b97-5c38707a9864 --- packages/cli/README.md | 11 ++++- packages/cli/oclif.manifest.json | 2 +- packages/cli/src/cli/commands/version.test.ts | 33 +++++++------ packages/cli/src/cli/commands/version.ts | 9 ++-- .../src/cli/services/commands/version.test.ts | 24 ---------- .../services/commands/version/index.test.ts | 19 ++++++++ .../cli/services/commands/version/index.ts | 6 +++ .../services/commands/version/result.test.ts | 47 +++++++++++++++++++ .../cli/services/commands/version/result.ts | 6 +++ .../commands/{version.ts => version/types.ts} | 7 +-- 10 files changed, 114 insertions(+), 50 deletions(-) delete mode 100644 packages/cli/src/cli/services/commands/version.test.ts create mode 100644 packages/cli/src/cli/services/commands/version/index.test.ts create mode 100644 packages/cli/src/cli/services/commands/version/index.ts create mode 100644 packages/cli/src/cli/services/commands/version/result.test.ts create mode 100644 packages/cli/src/cli/services/commands/version/result.ts rename packages/cli/src/cli/services/commands/{version.ts => version/types.ts} (64%) diff --git a/packages/cli/README.md b/packages/cli/README.md index 66ae973dc32..c043c7988ed 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -5859,7 +5859,16 @@ DESCRIPTION ```json { - "type": "string", + "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 7a516d22905..fa3c699910b 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -11492,7 +11492,7 @@ ], "args": { }, - "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\": \"string\",\n \"title\": \"VersionResult\",\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", + "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": { diff --git a/packages/cli/src/cli/commands/version.test.ts b/packages/cli/src/cli/commands/version.test.ts index 6ff5fa5a8e3..962b9ab76bf 100644 --- a/packages/cli/src/cli/commands/version.test.ts +++ b/packages/cli/src/cli/commands/version.test.ts @@ -1,12 +1,13 @@ import Version from './version.js' -import {versionJsonOutputSchema, versionService} from '../services/commands/version.js' +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', async (importOriginal) => ({ - ...(await importOriginal()), +vi.mock('../services/commands/version/index.js', async (importOriginal) => ({ + ...(await importOriginal()), versionService: vi.fn(), })) @@ -44,7 +45,7 @@ const runVersion = async (arguments_: string[]) => { describe('version command', () => { test('writes the raw version text by default', async () => { const outputMock = mockAndCaptureOutput() - vi.mocked(versionService).mockResolvedValue('2.2.2') + vi.mocked(versionService).mockResolvedValue({version: '2.2.2'}) await Version.run([], import.meta.url) @@ -53,22 +54,22 @@ describe('version command', () => { expect(outputMock.warn()).toBe('') }) - test('writes one scalar JSON document when requested', async () => { + test('writes one JSON document when requested', async () => { const outputMock = mockAndCaptureOutput() - vi.mocked(versionService).mockResolvedValue('2.2.2') + vi.mocked(versionService).mockResolvedValue({version: '2.2.2'}) await Version.run(['--json'], import.meta.url) - expect(outputMock.output()).toBe('"2.2.2"') - expect(JSON.parse(outputMock.output())).toBe('2.2.2') + 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 scalar schema and JSON flags in help', () => { + 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": "string"') + expect(Version.description).toContain('"type": "object"') }) test('writes the installed version without stderr output', {timeout: 20000}, async () => { @@ -84,15 +85,19 @@ describe('version command', () => { expect(result.exitCode).toBe(0) expect(result.stderr).toBe('') - expect(result.stdout).toBe(`${JSON.stringify(CLI_KIT_VERSION)}\n`) - expect(JSON.parse(result.stdout)).toBe(CLI_KIT_VERSION) + 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 a string result without stderr output', {timeout: 20000}, async () => { + 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('') - expect(JSON.parse(result.stdout).definitions.Result.type).toBe('string') + 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 b4ccd7fbbb0..2932db951b8 100644 --- a/packages/cli/src/cli/commands/version.ts +++ b/packages/cli/src/cli/commands/version.ts @@ -1,7 +1,8 @@ -import {versionJsonOutputSchema, 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' -import {outputResult} from '@shopify/cli-kit/node/output' export default class Version extends Command { static descriptionWithMarkdown = 'Shopify CLI version currently installed.' @@ -19,8 +20,8 @@ export default class Version extends Command { async run(): Promise { const {flags} = await this.parse(Version) - const version = await versionService() + const result = await versionService() - outputResult(flags.json ? versionJsonOutputSchema.encode(version) : version) + 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 114d3e5ac8c..00000000000 --- a/packages/cli/src/cli/services/commands/version.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import {versionJsonOutputSchema, versionService} from './version.js' -import {afterEach, describe, expect, test, vi} from 'vitest' -import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' - -vi.mock('@shopify/cli-kit/common/version', () => ({CLI_KIT_VERSION: '2.2.2'})) - -afterEach(() => { - mockAndCaptureOutput().clear() -}) - -describe('version service', () => { - test('returns the installed version without writing output', async () => { - const outputMock = mockAndCaptureOutput() - - await expect(versionService()).resolves.toBe('2.2.2') - - expect(outputMock.output()).toBe('') - }) - - test('defines a scalar JSON result contract', () => { - expect(versionJsonOutputSchema.encode('2.2.2')).toBe('"2.2.2"') - expect(() => versionJsonOutputSchema.validate({version: '2.2.2'})).toThrow() - }) -}) 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.ts b/packages/cli/src/cli/services/commands/version/types.ts similarity index 64% rename from packages/cli/src/cli/services/commands/version.ts rename to packages/cli/src/cli/services/commands/version/types.ts index 31eb2058fed..dee9479723a 100644 --- a/packages/cli/src/cli/services/commands/version.ts +++ b/packages/cli/src/cli/services/commands/version/types.ts @@ -1,14 +1,9 @@ -import {CLI_KIT_VERSION} from '@shopify/cli-kit/common/version' 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.string(), + schema: zod.object({version: zod.string()}), }) export type VersionResult = InferJsonOutputSchema - -export async function versionService(): Promise { - return CLI_KIT_VERSION -} From 31e12a5cb73c6ce718974e523347adf8eae0ace3 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Wed, 16 Sep 2026 13:19:42 +0200 Subject: [PATCH 4/4] Run version schema test through the CLI launcher --- packages/cli/src/cli/commands/version.test.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/cli/commands/version.test.ts b/packages/cli/src/cli/commands/version.test.ts index 962b9ab76bf..1de7d44d6ce 100644 --- a/packages/cli/src/cli/commands/version.test.ts +++ b/packages/cli/src/cli/commands/version.test.ts @@ -21,7 +21,18 @@ const sourceLoaderUrl = new URL('../../../../cli-kit/test/fixtures/cli-kit-sourc const runVersion = async (arguments_: string[]) => { const script = ` const {default: Version} = await import(${JSON.stringify(commandUrl)}) - await Version.run(${JSON.stringify(arguments_)}, ${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(