From 4518b8fee54005e98cb44da455e2270c2c39a7f7 Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Tue, 3 Mar 2026 16:21:58 -0700 Subject: [PATCH] add validate command --- .../generated/generated_docs_data.json | 92 +++++++++++++++++++ packages/app/src/cli/commands/app/validate.ts | 34 +++++++ packages/app/src/cli/index.ts | 2 + .../app/src/cli/services/validate.test.ts | 52 +++++++++++ packages/app/src/cli/services/validate.ts | 22 +++++ packages/cli/README.md | 24 +++++ packages/cli/oclif.manifest.json | 78 ++++++++++++++++ packages/e2e/data/snapshots/commands.txt | 1 + 8 files changed, 305 insertions(+) create mode 100644 packages/app/src/cli/commands/app/validate.ts create mode 100644 packages/app/src/cli/services/validate.test.ts create mode 100644 packages/app/src/cli/services/validate.ts diff --git a/docs-shopify.dev/generated/generated_docs_data.json b/docs-shopify.dev/generated/generated_docs_data.json index 7df09d98aea..8dd1835d1d1 100644 --- a/docs-shopify.dev/generated/generated_docs_data.json +++ b/docs-shopify.dev/generated/generated_docs_data.json @@ -3084,6 +3084,98 @@ "category": "app", "related": [] }, + { + "name": "app validate", + "description": "Validates the selected app configuration file and all extension configurations against their schemas and reports any errors found.", + "overviewPreviewDescription": "Validate your app configuration and extensions.", + "type": "command", + "isVisualComponent": false, + "defaultExample": { + "codeblock": { + "tabs": [ + { + "title": "app validate", + "code": "shopify app validate [flags]", + "language": "bash" + } + ], + "title": "app validate" + } + }, + "definitions": [ + { + "title": "Flags", + "description": "The following flags are available for the `app validate` command:", + "type": "appvalidate", + "typeDefinitions": { + "appvalidate": { + "filePath": "docs-shopify.dev/commands/interfaces/app-validate.interface.ts", + "name": "appvalidate", + "description": "", + "members": [ + { + "filePath": "docs-shopify.dev/commands/interfaces/app-validate.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--client-id ", + "value": "string", + "description": "The Client ID of your app.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_CLIENT_ID" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-validate.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/app-validate.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--path ", + "value": "string", + "description": "The path to your app directory.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_PATH" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-validate.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--reset", + "value": "\"\"", + "description": "Reset all your settings.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_RESET" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-validate.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--verbose", + "value": "\"\"", + "description": "Increase the verbosity of the output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_VERBOSE" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-validate.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-c, --config ", + "value": "string", + "description": "The name of the app configuration.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_APP_CONFIG" + } + ], + "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}" + } + } + } + ], + "category": "app", + "related": [] + }, { "name": "app versions list", "description": "Lists the deployed app versions. An app version is a snapshot of your app extensions.", diff --git a/packages/app/src/cli/commands/app/validate.ts b/packages/app/src/cli/commands/app/validate.ts new file mode 100644 index 00000000000..1564859ac67 --- /dev/null +++ b/packages/app/src/cli/commands/app/validate.ts @@ -0,0 +1,34 @@ +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' + +export default class Validate extends AppLinkedCommand { + static summary = 'Validate your app configuration and extensions.' + + static descriptionWithMarkdown = `Validates the selected app configuration file and all extension configurations against their schemas and reports any errors found.` + + static description = this.descriptionWithoutMarkdown() + + static flags = { + ...globalFlags, + ...appFlags, + } + + public async run(): Promise { + const {flags} = await this.parse(Validate) + + const {app} = await linkedAppContext({ + directory: flags.path, + clientId: flags['client-id'], + forceRelink: flags.reset, + userProvidedConfigName: flags.config, + unsafeReportMode: true, + }) + + await validateApp(app) + + return {app} + } +} diff --git a/packages/app/src/cli/index.ts b/packages/app/src/cli/index.ts index c1dbcfb4a06..77fcd5076c5 100644 --- a/packages/app/src/cli/index.ts +++ b/packages/app/src/cli/index.ts @@ -23,6 +23,7 @@ import GenerateSchema from './commands/app/generate/schema.js' import ImportExtensions from './commands/app/import-extensions.js' import AppInfo from './commands/app/info.js' import Init from './commands/app/init.js' +import Validate from './commands/app/validate.js' import Release from './commands/app/release.js' import VersionsList from './commands/app/versions/list.js' import WebhookTrigger from './commands/app/webhook/trigger.js' @@ -55,6 +56,7 @@ export const commands: {[key: string]: typeof AppLinkedCommand | typeof AppUnlin 'app:import-extensions': ImportExtensions, 'app:info': AppInfo, 'app:init': Init, + 'app:validate': Validate, 'app:release': Release, 'app:config:link': ConfigLink, 'app:config:use': ConfigUse, diff --git a/packages/app/src/cli/services/validate.test.ts b/packages/app/src/cli/services/validate.test.ts new file mode 100644 index 00000000000..e29950ee7bc --- /dev/null +++ b/packages/app/src/cli/services/validate.test.ts @@ -0,0 +1,52 @@ +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 {renderError, renderSuccess} from '@shopify/cli-kit/node/ui' +import {AbortSilentError} from '@shopify/cli-kit/node/error' + +vi.mock('@shopify/cli-kit/node/ui') + +describe('validateApp', () => { + test('renders success when there are no errors', async () => { + // Given + const app = testAppLinked() + + // When + await validateApp(app) + + // Then + expect(renderSuccess).toHaveBeenCalledWith({headline: 'App configuration is valid.'}) + expect(renderError).not.toHaveBeenCalled() + }) + + test('renders errors and throws when 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)).rejects.toThrow(AbortSilentError) + expect(renderError).toHaveBeenCalledWith({ + headline: 'Validation errors found.', + body: expect.stringContaining('client_id is required'), + }) + expect(renderSuccess).not.toHaveBeenCalled() + }) + + test('renders success when errors object exists but is empty', async () => { + // Given + const errors = new AppErrors() + const app = testAppLinked() + app.errors = errors + + // When + await validateApp(app) + + // Then + expect(renderSuccess).toHaveBeenCalledWith({headline: 'App configuration is valid.'}) + }) +}) diff --git a/packages/app/src/cli/services/validate.ts b/packages/app/src/cli/services/validate.ts new file mode 100644 index 00000000000..a08469d9f4a --- /dev/null +++ b/packages/app/src/cli/services/validate.ts @@ -0,0 +1,22 @@ +import {AppLinkedInterface} from '../models/app/app.js' +import {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 { + const errors = app.errors + + if (!errors || errors.isEmpty()) { + renderSuccess({headline: 'App configuration is valid.'}) + return + } + + const errorMessages = errors.toJSON().map((error) => stringifyMessage(error).trim()) + + renderError({ + headline: 'Validation errors found.', + body: errorMessages.join('\n\n'), + }) + + throw new AbortSilentError() +} diff --git a/packages/cli/README.md b/packages/cli/README.md index 87d516fa0a3..62c2a39eaa9 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -27,6 +27,7 @@ * [`shopify app logs`](#shopify-app-logs) * [`shopify app logs sources`](#shopify-app-logs-sources) * [`shopify app release --version `](#shopify-app-release---version-version) +* [`shopify app validate`](#shopify-app-validate) * [`shopify app versions list`](#shopify-app-versions-list) * [`shopify app webhook trigger`](#shopify-app-webhook-trigger) * [`shopify auth login`](#shopify-auth-login) @@ -919,6 +920,29 @@ DESCRIPTION Releases an existing app version. Pass the name of the version that you want to release using the `--version` flag. ``` +## `shopify app validate` + +Validate your app configuration and extensions. + +``` +USAGE + $ shopify app validate [--client-id | -c ] [--no-color] [--path ] [--reset | ] [--verbose] + +FLAGS + -c, --config= [env: SHOPIFY_FLAG_APP_CONFIG] The name of the app configuration. + --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. + --reset [env: SHOPIFY_FLAG_RESET] Reset all your settings. + --verbose [env: SHOPIFY_FLAG_VERBOSE] Increase the verbosity of the output. + +DESCRIPTION + Validate your app configuration and extensions. + + Validates the selected app configuration file and all extension configurations against their schemas and reports any + errors found. +``` + ## `shopify app versions list` List deployed versions of your app. diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index c29a6bd7a1e..a06a8696db5 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -2852,6 +2852,84 @@ "summary": "Release an app version.", "usage": "app release --version " }, + "app:validate": { + "aliases": [ + ], + "args": { + }, + "customPluginName": "@shopify/app", + "description": "Validates the selected app configuration file and all extension configurations against their schemas and reports any errors found.", + "descriptionWithMarkdown": "Validates the selected app configuration file and all extension configurations against their schemas and reports any errors found.", + "flags": { + "client-id": { + "description": "The Client ID of your app.", + "env": "SHOPIFY_FLAG_CLIENT_ID", + "exclusive": [ + "config" + ], + "hasDynamicHelp": false, + "hidden": false, + "multiple": false, + "name": "client-id", + "type": "option" + }, + "config": { + "char": "c", + "description": "The name of the app configuration.", + "env": "SHOPIFY_FLAG_APP_CONFIG", + "hasDynamicHelp": false, + "hidden": false, + "multiple": false, + "name": "config", + "type": "option" + }, + "no-color": { + "allowNo": false, + "description": "Disable color output.", + "env": "SHOPIFY_FLAG_NO_COLOR", + "hidden": false, + "name": "no-color", + "type": "boolean" + }, + "path": { + "description": "The path to your app directory.", + "env": "SHOPIFY_FLAG_PATH", + "hasDynamicHelp": false, + "multiple": false, + "name": "path", + "noCacheDefault": true, + "type": "option" + }, + "reset": { + "allowNo": false, + "description": "Reset all your settings.", + "env": "SHOPIFY_FLAG_RESET", + "exclusive": [ + "config" + ], + "hidden": false, + "name": "reset", + "type": "boolean" + }, + "verbose": { + "allowNo": false, + "description": "Increase the verbosity of the output.", + "env": "SHOPIFY_FLAG_VERBOSE", + "hidden": false, + "name": "verbose", + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [ + ], + "id": "app:validate", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true, + "summary": "Validate your app configuration and extensions." + }, "app:versions:list": { "aliases": [ ], diff --git a/packages/e2e/data/snapshots/commands.txt b/packages/e2e/data/snapshots/commands.txt index bfd6e031fcb..f2d5d916181 100644 --- a/packages/e2e/data/snapshots/commands.txt +++ b/packages/e2e/data/snapshots/commands.txt @@ -31,6 +31,7 @@ │ ├─ logs │ │ └─ sources │ ├─ release +│ ├─ validate │ ├─ versions │ │ └─ list │ └─ webhook