diff --git a/docs-shopify.dev/commands/interfaces/app-validate.interface.ts b/docs-shopify.dev/commands/interfaces/app-validate.interface.ts index 0eca35e3e8..40fedf27f3 100644 --- a/docs-shopify.dev/commands/interfaces/app-validate.interface.ts +++ b/docs-shopify.dev/commands/interfaces/app-validate.interface.ts @@ -12,6 +12,12 @@ export interface appvalidate { */ '-c, --config '?: string + /** + * Output the result as JSON. Automatically disables color output. + * @environment SHOPIFY_FLAG_JSON + */ + '-j, --json'?: '' + /** * Disable color output. * @environment SHOPIFY_FLAG_NO_COLOR diff --git a/docs-shopify.dev/generated/generated_docs_data.json b/docs-shopify.dev/generated/generated_docs_data.json index 71b1c2d763..132c099a3b 100644 --- a/docs-shopify.dev/generated/generated_docs_data.json +++ b/docs-shopify.dev/generated/generated_docs_data.json @@ -3166,9 +3166,18 @@ "description": "The name of the app configuration.", "isOptional": true, "environmentValue": "SHOPIFY_FLAG_APP_CONFIG" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-validate.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 appvalidate {\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + "value": "export interface appvalidate {\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } } } diff --git a/packages/app/src/cli/commands/app/validate.test.ts b/packages/app/src/cli/commands/app/validate.test.ts new file mode 100644 index 0000000000..bcbe7a2661 --- /dev/null +++ b/packages/app/src/cli/commands/app/validate.test.ts @@ -0,0 +1,49 @@ +import Validate from './validate.js' +import {linkedAppContext} from '../../services/app-context.js' +import {validateApp} from '../../services/validate.js' +import {testAppLinked} from '../../models/app/app.test-data.js' +import {describe, expect, test, vi} from 'vitest' + +vi.mock('../../services/app-context.js') +vi.mock('../../services/validate.js') + +describe('app validate command', () => { + test('calls validateApp with json: false by default', async () => { + // Given + const app = testAppLinked() + vi.mocked(linkedAppContext).mockResolvedValue({app} as Awaited>) + vi.mocked(validateApp).mockResolvedValue() + + // When + await Validate.run([], import.meta.url) + + // Then + expect(validateApp).toHaveBeenCalledWith(app, {json: false}) + }) + + test('calls validateApp with json: true when --json flag is passed', async () => { + // Given + const app = testAppLinked() + vi.mocked(linkedAppContext).mockResolvedValue({app} as Awaited>) + vi.mocked(validateApp).mockResolvedValue() + + // When + await Validate.run(['--json'], import.meta.url) + + // Then + expect(validateApp).toHaveBeenCalledWith(app, {json: true}) + }) + + test('calls validateApp with json: true when -j flag is passed', async () => { + // Given + const app = testAppLinked() + vi.mocked(linkedAppContext).mockResolvedValue({app} as Awaited>) + vi.mocked(validateApp).mockResolvedValue() + + // When + await Validate.run(['-j'], import.meta.url) + + // Then + expect(validateApp).toHaveBeenCalledWith(app, {json: true}) + }) +}) diff --git a/packages/app/src/cli/commands/app/validate.ts b/packages/app/src/cli/commands/app/validate.ts index 1564859ac6..3e426a2140 100644 --- a/packages/app/src/cli/commands/app/validate.ts +++ b/packages/app/src/cli/commands/app/validate.ts @@ -2,7 +2,7 @@ import {appFlags} from '../../flags.js' import {validateApp} from '../../services/validate.js' import AppLinkedCommand, {AppLinkedCommandOutput} from '../../utilities/app-linked-command.js' import {linkedAppContext} from '../../services/app-context.js' -import {globalFlags} from '@shopify/cli-kit/node/cli' +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' export default class Validate extends AppLinkedCommand { static summary = 'Validate your app configuration and extensions.' @@ -14,6 +14,7 @@ export default class Validate extends AppLinkedCommand { static flags = { ...globalFlags, ...appFlags, + ...jsonFlag, } public async run(): Promise { @@ -27,7 +28,7 @@ export default class Validate extends AppLinkedCommand { unsafeReportMode: true, }) - await validateApp(app) + await validateApp(app, {json: flags.json}) return {app} } diff --git a/packages/app/src/cli/services/validate.test.ts b/packages/app/src/cli/services/validate.test.ts index e29950ee7b..c475c4399d 100644 --- a/packages/app/src/cli/services/validate.test.ts +++ b/packages/app/src/cli/services/validate.test.ts @@ -2,9 +2,17 @@ import {validateApp} from './validate.js' import {testAppLinked} from '../models/app/app.test-data.js' import {AppErrors} from '../models/app/loader.js' import {describe, expect, test, vi} from 'vitest' +import {outputResult} from '@shopify/cli-kit/node/output' import {renderError, renderSuccess} from '@shopify/cli-kit/node/ui' import {AbortSilentError} from '@shopify/cli-kit/node/error' +vi.mock('@shopify/cli-kit/node/output', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + outputResult: vi.fn(), + } +}) vi.mock('@shopify/cli-kit/node/ui') describe('validateApp', () => { @@ -18,6 +26,20 @@ describe('validateApp', () => { // Then expect(renderSuccess).toHaveBeenCalledWith({headline: 'App configuration is valid.'}) expect(renderError).not.toHaveBeenCalled() + expect(outputResult).not.toHaveBeenCalled() + }) + + test('outputs json success when --json is enabled and there are no errors', async () => { + // Given + const app = testAppLinked() + + // When + await validateApp(app, {json: true}) + + // Then + expect(outputResult).toHaveBeenCalledWith(JSON.stringify({valid: true, errors: []}, null, 2)) + expect(renderSuccess).not.toHaveBeenCalled() + expect(renderError).not.toHaveBeenCalled() }) test('renders errors and throws when there are validation errors', async () => { @@ -35,6 +57,31 @@ describe('validateApp', () => { body: expect.stringContaining('client_id is required'), }) expect(renderSuccess).not.toHaveBeenCalled() + expect(outputResult).not.toHaveBeenCalled() + }) + + test('outputs json errors and throws when --json is enabled and there are validation errors', async () => { + // Given + const errors = new AppErrors() + errors.addError('/path/to/shopify.app.toml', 'client_id is required') + errors.addError('/path/to/extensions/my-ext/shopify.extension.toml', 'invalid type "unknown"') + const app = testAppLinked() + app.errors = errors + + // When / Then + await expect(validateApp(app, {json: true})).rejects.toThrow(AbortSilentError) + expect(outputResult).toHaveBeenCalledWith( + JSON.stringify( + { + valid: false, + errors: ['client_id is required', 'invalid type "unknown"'], + }, + null, + 2, + ), + ) + expect(renderError).not.toHaveBeenCalled() + expect(renderSuccess).not.toHaveBeenCalled() }) test('renders success when errors object exists but is empty', async () => { @@ -48,5 +95,6 @@ describe('validateApp', () => { // Then expect(renderSuccess).toHaveBeenCalledWith({headline: 'App configuration is valid.'}) + expect(outputResult).not.toHaveBeenCalled() }) }) diff --git a/packages/app/src/cli/services/validate.ts b/packages/app/src/cli/services/validate.ts index a08469d9f4..d27efebeea 100644 --- a/packages/app/src/cli/services/validate.ts +++ b/packages/app/src/cli/services/validate.ts @@ -1,18 +1,32 @@ import {AppLinkedInterface} from '../models/app/app.js' -import {stringifyMessage} from '@shopify/cli-kit/node/output' +import {outputResult, stringifyMessage} from '@shopify/cli-kit/node/output' import {renderError, renderSuccess} from '@shopify/cli-kit/node/ui' import {AbortSilentError} from '@shopify/cli-kit/node/error' -export async function validateApp(app: AppLinkedInterface): Promise { +interface ValidateAppOptions { + json: boolean +} + +export async function validateApp(app: AppLinkedInterface, options: ValidateAppOptions = {json: false}): Promise { const errors = app.errors if (!errors || errors.isEmpty()) { + if (options.json) { + outputResult(JSON.stringify({valid: true, errors: []}, null, 2)) + return + } + renderSuccess({headline: 'App configuration is valid.'}) return } const errorMessages = errors.toJSON().map((error) => stringifyMessage(error).trim()) + if (options.json) { + outputResult(JSON.stringify({valid: false, errors: errorMessages}, null, 2)) + throw new AbortSilentError() + } + renderError({ headline: 'Validation errors found.', body: errorMessages.join('\n\n'), diff --git a/packages/cli/README.md b/packages/cli/README.md index f1d3faa3d0..e3316b4788 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -927,10 +927,12 @@ Validate your app configuration and extensions. ``` USAGE - $ shopify app validate [--client-id | -c ] [--no-color] [--path ] [--reset | ] [--verbose] + $ shopify app validate [--client-id | -c ] [-j] [--no-color] [--path ] [--reset | ] + [--verbose] FLAGS -c, --config= [env: SHOPIFY_FLAG_APP_CONFIG] The name of the app configuration. + -j, --json [env: SHOPIFY_FLAG_JSON] Output the result as JSON. Automatically disables color output. --client-id= [env: SHOPIFY_FLAG_CLIENT_ID] The Client ID of your app. --no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output. --path= [env: SHOPIFY_FLAG_PATH] The path to your app directory. diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index f4bf2b5aa6..d19a77322b 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -2883,6 +2883,15 @@ "name": "config", "type": "option" }, + "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" + }, "no-color": { "allowNo": false, "description": "Disable color output.",