From 06db440a8f370c20ceb37ba988da7db876bf368a Mon Sep 17 00:00:00 2001 From: Durga Date: Tue, 27 Oct 2026 14:28:20 -0400 Subject: [PATCH 1/4] Validate extension flavors against template metadata --- .../template-based-extension-flavors.md | 5 ++ .../generated/generated_docs_data_v2.json | 4 +- .../commands/app/generate/extension.test.ts | 20 +++++++ .../cli/commands/app/generate/extension.ts | 3 +- .../app/src/cli/services/generate.test.ts | 58 ++++++++++++++++--- packages/app/src/cli/services/generate.ts | 14 +++-- packages/cli/README.md | 12 ++-- packages/cli/oclif.manifest.json | 11 +--- 8 files changed, 91 insertions(+), 36 deletions(-) create mode 100644 .changeset/template-based-extension-flavors.md diff --git a/.changeset/template-based-extension-flavors.md b/.changeset/template-based-extension-flavors.md new file mode 100644 index 00000000000..45cdccef003 --- /dev/null +++ b/.changeset/template-based-extension-flavors.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': patch +--- + +Validate extension flavors against template metadata instead of a fixed flag allowlist. diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 968c240a37f..03e43be317b 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -2498,7 +2498,7 @@ "syntaxKind": "PropertySignature", "name": "--flavor ", "value": "string", - "description": "Choose a starting template for your extension, where applicable. Required if non interactive when the selected extension template supports multiple flavors.", + "description": "Choose a starting template for your extension, where applicable. Supported flavors depend on the selected extension template. Required if non interactive when the selected extension template supports multiple flavors.", "isOptional": true, "environmentValue": "SHOPIFY_FLAG_FLAVOR" }, @@ -2593,7 +2593,7 @@ "environmentValue": "SHOPIFY_FLAG_EXTENSION_TEMPLATE" } ], - "value": "export interface appgenerateextension {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\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 * Choose a starting template for your extension, where applicable. Required if non interactive when the selected extension template supports multiple flavors.\n * @environment SHOPIFY_FLAG_FLAVOR\n */\n '--flavor '?: 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 * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * name of your Extension. Required if non interactive.\n * @environment SHOPIFY_FLAG_NAME\n */\n '-n, --name '?: string\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\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 * Extension template. Required if non interactive.\n * @environment SHOPIFY_FLAG_EXTENSION_TEMPLATE\n */\n '-t, --template '?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + "value": "export interface appgenerateextension {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\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 * Choose a starting template for your extension, where applicable. Supported flavors depend on the selected extension template. Required if non interactive when the selected extension template supports multiple flavors.\n * @environment SHOPIFY_FLAG_FLAVOR\n */\n '--flavor '?: 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 * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * name of your Extension. Required if non interactive.\n * @environment SHOPIFY_FLAG_NAME\n */\n '-n, --name '?: string\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\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 * Extension template. Required if non interactive.\n * @environment SHOPIFY_FLAG_EXTENSION_TEMPLATE\n */\n '-t, --template '?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, "appgraphiql": { diff --git a/packages/app/src/cli/commands/app/generate/extension.test.ts b/packages/app/src/cli/commands/app/generate/extension.test.ts index 1bfb558bbc2..1bfccb6e8ac 100644 --- a/packages/app/src/cli/commands/app/generate/extension.test.ts +++ b/packages/app/src/cli/commands/app/generate/extension.test.ts @@ -93,6 +93,26 @@ test.each([{flags: ['--flavor', 'preact']}, {flags: ['--flavor=preact']}])( }, ) +test.each(['liquid', 'config-only', 'future-flavor'])('delegates validation of %s to the generator', async (flavor) => { + await withApp(async (directory) => { + await withCapturedStandardStreams(async () => { + await runCommand(directory, ['--no-input', '--flavor', flavor]) + await runCommand(directory, ['--no-input', `--flavor=${flavor}`]) + }) + expect(generate).toHaveBeenCalledWith(expect.objectContaining({flavor})) + }) +}) + +test('delegates validation of a flavor from the environment to the generator', async () => { + vi.stubEnv('SHOPIFY_FLAG_FLAVOR', 'future-flavor') + await withApp(async (directory) => { + await withCapturedStandardStreams(async () => { + await runCommand(directory, ['--no-input']) + }) + expect(generate).toHaveBeenCalledWith(expect.objectContaining({flavor: 'future-flavor'})) + }) +}) + test.each([ {flags: ['--json'], environment: false}, {flags: ['-j'], environment: false}, diff --git a/packages/app/src/cli/commands/app/generate/extension.ts b/packages/app/src/cli/commands/app/generate/extension.ts index 59a78ba0cd5..c1d32afe717 100644 --- a/packages/app/src/cli/commands/app/generate/extension.ts +++ b/packages/app/src/cli/commands/app/generate/extension.ts @@ -53,8 +53,7 @@ export default class AppGenerateExtension extends AppLinkedCommand { flavor: Flags.string({ hidden: false, description: - 'Choose a starting template for your extension, where applicable. Required if non interactive when the selected extension template supports multiple flavors.', - options: ['vanilla-js', 'preact', 'react', 'typescript', 'typescript-react', 'wasm', 'rust'], + 'Choose a starting template for your extension, where applicable. Supported flavors depend on the selected extension template. Required if non interactive when the selected extension template supports multiple flavors.', env: 'SHOPIFY_FLAG_FLAVOR', }), } diff --git a/packages/app/src/cli/services/generate.test.ts b/packages/app/src/cli/services/generate.test.ts index 8f8251f9310..6dcdf9eec9a 100644 --- a/packages/app/src/cli/services/generate.test.ts +++ b/packages/app/src/cli/services/generate.test.ts @@ -248,12 +248,14 @@ describe('generate', () => { await expect(got).rejects.toThrow(/Invalid extension type/) }) - test('throws error if trying to generate with an unsupported flavor', async () => { - // Given - await mockSuccessfulCommandExecution('cart_checkout_validation') + test.each([ + {template: 'theme_app_extension', flavor: 'liquid'}, + {template: 'cart_transform', flavor: 'wasm'}, + {template: undefined, flavor: 'liquid'}, + ])('accepts $flavor from template metadata with template=$template', async ({template, flavor}) => { + await mockSuccessfulCommandExecution(template ?? 'theme_app_extension') - // When - const got = generate({ + await generate({ directory: '/', reset: false, app, @@ -261,13 +263,51 @@ describe('generate', () => { remoteApp, specifications, developerPlatformClient, - template: 'cart_checkout_validation', - flavor: 'unknown', + template, + flavor, }) - // Then - await expect(got).rejects.toThrow(/Invalid template for extension type/) + expect(generateExtensionPrompts).toHaveBeenCalledWith( + expect.objectContaining({templateType: template, extensionFlavor: flavor}), + ) }) + + test.each([ + {template: 'cart_checkout_validation', flavor: 'unknown', possibleFlavors: 'rust'}, + {template: 'cart_checkout_validation', flavor: 'react', possibleFlavors: 'rust'}, + { + template: undefined, + flavor: 'unknown', + possibleFlavors: 'rust, wasm, vanilla-js, react, typescript-react, typescript, liquid', + }, + ])( + 'rejects $flavor with template=$template before prompting or generating', + async ({template, flavor, possibleFlavors}) => { + // Given + await mockSuccessfulCommandExecution('cart_checkout_validation') + + // When + const got = generate({ + directory: '/', + reset: false, + app, + project: testProject(), + remoteApp, + specifications, + developerPlatformClient, + template, + flavor, + }) + + // Then + await expect(got).rejects.toMatchObject({ + message: 'Invalid extension flavor', + tryMessage: `Expected flavor to be one of the following: ${possibleFlavors}.`, + }) + expect(generateExtensionPrompts).not.toHaveBeenCalled() + expect(generateExtensionTemplate).not.toHaveBeenCalled() + }, + ) }) async function mockSuccessfulCommandExecution(identifier: string, existingExtensions: ExtensionInstance[] = []) { diff --git a/packages/app/src/cli/services/generate.ts b/packages/app/src/cli/services/generate.ts index cacf6066764..803f6d96a99 100644 --- a/packages/app/src/cli/services/generate.ts +++ b/packages/app/src/cli/services/generate.ts @@ -74,7 +74,7 @@ async function buildPromptOptions( options: GenerateOptions, ): Promise { const extensionTemplate = await handleTypeParameter(options.template, app, extensionTemplates, specifications) - validateExtensionFlavor(extensionTemplate, options.flavor) + validateExtensionFlavor(extensionTemplate ? [extensionTemplate] : extensionTemplates, options.flavor) const {validTemplates, templatesOverlimit} = checkLimits(extensionTemplates, specifications, app) @@ -135,15 +135,17 @@ function buildGenerateOptions( } } -function validateExtensionFlavor(extensionTemplate?: ExtensionTemplate, flavor?: string) { - if (!flavor || !extensionTemplate) return +function validateExtensionFlavor(extensionTemplates: ExtensionTemplate[], flavor?: string) { + if (!flavor) return - const possibleFlavors: string[] = extensionTemplate.supportedFlavors.map((flavor) => flavor.value as string) + const possibleFlavors: string[] = [ + ...new Set(extensionTemplates.flatMap((template) => template.supportedFlavors.map((flavor) => flavor.value))), + ] if (!possibleFlavors.includes(flavor)) { throw new AbortError( - 'Invalid template for extension type', - `Expected template to be one of the following: ${possibleFlavors.join(', ')}.`, + 'Invalid extension flavor', + `Expected flavor to be one of the following: ${possibleFlavors.join(', ')}.`, ) } } diff --git a/packages/cli/README.md b/packages/cli/README.md index 9dfdafe56c8..71f182c8081 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -3491,9 +3491,8 @@ Generate a new app Extension. ``` USAGE - $ shopify app generate extension [--auth-alias ] [--client-id | -c ] [--flavor - vanilla-js|preact|react|typescript|typescript-react|wasm|rust] [-j] [--json-schema] [-n ] [--no-color] - [--no-input] [--path ] [--reset | ] [-t ] [--verbose] + $ shopify app generate extension [--auth-alias ] [--client-id | -c ] [--flavor ] [-j] + [--json-schema] [-n ] [--no-color] [--no-input] [--path ] [--reset | ] [-t ] [--verbose] FLAGS -c, --config= @@ -3520,11 +3519,10 @@ FLAGS The Client ID of your app. [env: SHOPIFY_FLAG_CLIENT_ID] - --flavor=