diff --git a/.changeset/template-based-extension-flavors.md b/.changeset/template-based-extension-flavors.md new file mode 100644 index 00000000000..86126fcda6a --- /dev/null +++ b/.changeset/template-based-extension-flavors.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': patch +--- + +Accept all CLI-supported extension flavors and validate compatibility with the selected template. 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..349152a8c55 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,55 @@ test.each([{flags: ['--flavor', 'preact']}, {flags: ['--flavor=preact']}])( }, ) +test.each(['vanilla-js', 'react', 'typescript', 'typescript-react', 'rust', 'wasm', 'liquid', 'config-only'])( + 'accepts the known flavor %s', + 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).toHaveBeenNthCalledWith(1, expect.objectContaining({flavor})) + expect(generate).toHaveBeenNthCalledWith(2, expect.objectContaining({flavor})) + }) + }, +) + +test.each(['', 'future-flavor', 'precat'])('rejects flavor %j before loading the app or generating', async (flavor) => { + await withApp(async (directory) => { + await withCapturedStandardStreams(async () => { + await expect(runCommand(directory, ['--no-input', '--flavor', flavor])).rejects.toThrow('to be one of:') + await expect(runCommand(directory, ['--no-input', `--flavor=${flavor}`])).rejects.toThrow('to be one of:') + }) + expect(linkedAppContext).not.toHaveBeenCalled() + expect(generate).not.toHaveBeenCalled() + }) +}) + +test('accepts a known flavor from the environment', async () => { + vi.stubEnv('SHOPIFY_FLAG_FLAVOR', 'liquid') + await withApp(async (directory) => { + await withCapturedStandardStreams(async () => { + await runCommand(directory, ['--no-input']) + }) + expect(generate).toHaveBeenCalledWith(expect.objectContaining({flavor: 'liquid'})) + }) +}) + +test.each(['future-flavor', 'precat'])( + 'rejects unknown environment flavor %j before loading the app', + async (flavor) => { + vi.stubEnv('SHOPIFY_FLAG_FLAVOR', flavor) + await withApp(async (directory) => { + await withCapturedStandardStreams(async () => { + await expect(runCommand(directory, ['--no-input'])).rejects.toThrow('to be one of:') + }) + expect(linkedAppContext).not.toHaveBeenCalled() + expect(generate).not.toHaveBeenCalled() + }) + }, +) + 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..f4503f3adb0 100644 --- a/packages/app/src/cli/commands/app/generate/extension.ts +++ b/packages/app/src/cli/commands/app/generate/extension.ts @@ -1,6 +1,7 @@ import {appFlags} from '../../../flags.js' import metadata from '../../../metadata.js' import generate from '../../../services/generate.js' +import {extensionFlavorValues} from '../../../services/generate/extension.js' import {appGenerateExtensionJsonOutputSchema} from '../../../services/generate/extension/types.js' import {renderGenerateExtensionResult} from '../../../services/generate/extension/result.js' import {checkFolderIsValidApp} from '../../../models/app/loader.js' @@ -53,8 +54,8 @@ 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.', + options: [...extensionFlavorValues], 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..4a07d155320 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,57 @@ 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: '', possibleFlavors: 'rust'}, + {template: 'cart_checkout_validation', flavor: 'unknown', possibleFlavors: 'rust'}, + {template: 'cart_checkout_validation', flavor: 'react', possibleFlavors: 'rust'}, + { + template: undefined, + flavor: '', + possibleFlavors: 'rust, wasm, vanilla-js, react, typescript-react, typescript, liquid', + }, + { + 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..438aa682f91 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 === undefined) 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/app/src/cli/services/generate/extension.ts b/packages/app/src/cli/services/generate/extension.ts index 9b58f107a01..aa900228d8b 100644 --- a/packages/app/src/cli/services/generate/extension.ts +++ b/packages/app/src/cli/services/generate/extension.ts @@ -37,16 +37,19 @@ export interface GenerateExtensionTemplateOptions { onGetTemplateRepository?: (url: string, destination: string) => Promise } -export type ExtensionFlavorValue = - | 'vanilla-js' - | 'preact' - | 'react' - | 'typescript' - | 'typescript-react' - | 'rust' - | 'wasm' - | 'liquid' - | 'config-only' +export const extensionFlavorValues = [ + 'vanilla-js', + 'preact', + 'react', + 'typescript', + 'typescript-react', + 'rust', + 'wasm', + 'liquid', + 'config-only', +] as const + +export type ExtensionFlavorValue = (typeof extensionFlavorValues)[number] export type TemplateLanguage = 'javascript' | 'rust' | 'wasm' | undefined function getTemplateLanguage(flavor: ExtensionFlavorValue | undefined): TemplateLanguage { diff --git a/packages/cli/README.md b/packages/cli/README.md index 9dfdafe56c8..241894e1831 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -3492,8 +3492,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] + vanilla-js|preact|react|typescript|typescript-react|rust|wasm|liquid|config-only] [-j] [--json-schema] [-n ] + [--no-color] [--no-input] [--path ] [--reset | ] [-t ] [--verbose] FLAGS -c, --config= @@ -3521,10 +3521,10 @@ FLAGS [env: SHOPIFY_FLAG_CLIENT_ID] --flavor=