From a078db7f087cab0dfbecf1faf12db705c59e767c Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Fri, 20 Mar 2026 12:18:39 -0400 Subject: [PATCH] Add --json to validate First pass, not final agent shape --- .../app/src/cli/commands/app/validate.test.ts | 49 +++++++++++++++++++ packages/app/src/cli/commands/app/validate.ts | 5 +- .../app/src/cli/services/validate.test.ts | 48 ++++++++++++++++++ packages/app/src/cli/services/validate.ts | 18 ++++++- packages/cli/README.md | 4 +- packages/cli/oclif.manifest.json | 9 ++++ 6 files changed, 128 insertions(+), 5 deletions(-) create mode 100644 packages/app/src/cli/commands/app/validate.test.ts 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 4c967d354d..3ab1857905 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. --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 906abac5f3..d20c765d23 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.", + "env": "SHOPIFY_FLAG_JSON", + "hidden": false, + "name": "json", + "type": "boolean" + }, "no-color": { "allowNo": false, "description": "Disable color output.",