Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/template-based-extension-flavors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/app': patch
---

Accept all CLI-supported extension flavors and validate compatibility with the selected template.
4 changes: 2 additions & 2 deletions docs-shopify.dev/generated/generated_docs_data_v2.json
Original file line number Diff line number Diff line change
Expand Up @@ -2498,7 +2498,7 @@
"syntaxKind": "PropertySignature",
"name": "--flavor <value>",
"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"
},
Expand Down Expand Up @@ -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 <value>'?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id <value>'?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config <value>'?: 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 <value>'?: 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 <value>'?: 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 <value>'?: 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 <value>'?: 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 <value>'?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id <value>'?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config <value>'?: 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 <value>'?: 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 <value>'?: 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 <value>'?: 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 <value>'?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}"
}
},
"appgraphiql": {
Expand Down
49 changes: 49 additions & 0 deletions packages/app/src/cli/commands/app/generate/extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
5 changes: 3 additions & 2 deletions packages/app/src/cli/commands/app/generate/extension.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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',
}),
}
Expand Down
64 changes: 55 additions & 9 deletions packages/app/src/cli/services/generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,26 +248,72 @@ 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,
project: testProject(),
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[] = []) {
Expand Down
14 changes: 8 additions & 6 deletions packages/app/src/cli/services/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ async function buildPromptOptions(
options: GenerateOptions,
): Promise<GenerateExtensionPromptOptions> {
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)

Expand Down Expand Up @@ -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(', ')}.`,
)
}
}
Expand Down
23 changes: 13 additions & 10 deletions packages/app/src/cli/services/generate/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,19 @@ export interface GenerateExtensionTemplateOptions {
onGetTemplateRepository?: (url: string, destination: string) => Promise<void>
}

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 {
Expand Down
10 changes: 5 additions & 5 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3492,8 +3492,8 @@ Generate a new app Extension.
```
USAGE
$ shopify app generate extension [--auth-alias <value>] [--client-id <value> | -c <value>] [--flavor
vanilla-js|preact|react|typescript|typescript-react|wasm|rust] [-j] [--json-schema] [-n <value>] [--no-color]
[--no-input] [--path <value>] [--reset | ] [-t <value>] [--verbose]
vanilla-js|preact|react|typescript|typescript-react|rust|wasm|liquid|config-only] [-j] [--json-schema] [-n <value>]
[--no-color] [--no-input] [--path <value>] [--reset | ] [-t <value>] [--verbose]

FLAGS
-c, --config=<value>
Expand Down Expand Up @@ -3521,10 +3521,10 @@ FLAGS
[env: SHOPIFY_FLAG_CLIENT_ID]

--flavor=<option>
Choose a starting template for your extension, where applicable. Required if non interactive when the selected
extension template supports multiple flavors.
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]
<options: vanilla-js|preact|react|typescript|typescript-react|wasm|rust>
<options: vanilla-js|preact|react|typescript|typescript-react|rust|wasm|liquid|config-only>

--json-schema
Print the command's JSON schemas.
Expand Down
6 changes: 4 additions & 2 deletions packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2707,7 +2707,7 @@
"type": "option"
},
"flavor": {
"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.",
"env": "SHOPIFY_FLAG_FLAVOR",
"hasDynamicHelp": false,
"hidden": false,
Expand All @@ -2719,8 +2719,10 @@
"react",
"typescript",
"typescript-react",
"rust",
"wasm",
"rust"
"liquid",
"config-only"
],
"type": "option"
},
Expand Down
Loading