diff --git a/.changeset/function-setup-recovery.md b/.changeset/function-setup-recovery.md new file mode 100644 index 00000000000..780d7fe0510 --- /dev/null +++ b/.changeset/function-setup-recovery.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': patch +--- + +Explain how to retry function generation after cleaning up a failed dependency installation or type generation. diff --git a/packages/app/src/cli/services/generate/extension.test.ts b/packages/app/src/cli/services/generate/extension.test.ts index 8c97436e93d..7d724130c86 100644 --- a/packages/app/src/cli/services/generate/extension.test.ts +++ b/packages/app/src/cli/services/generate/extension.test.ts @@ -122,6 +122,101 @@ describe('initialize a extension', async () => { }) }) + test('removes a partially installed extension after dependency installation fails and allows retrying', async () => { + await withTemporaryApp( + async (tmpDir) => { + const name = 'failed-install' + const extensionDirectory = joinPath(tmpDir, 'extensions', name) + const installationError = new Error('ERR_PNPM_IGNORED_BUILDS: Run pnpm approve-builds') + vi.mocked(installNodeModules).mockImplementationOnce(async () => { + const dependencyDirectory = joinPath(extensionDirectory, 'node_modules', '.pnpm', 'dependency') + await file.mkdir(dependencyDirectory) + await file.writeFile(joinPath(dependencyDirectory, 'package.json'), '{}') + throw installationError + }) + const options = { + name, + extensionTemplate: checkoutUITemplate, + extensionFlavor: 'vanilla-js' as const, + appDirectory: tmpDir, + specifications, + onGetTemplateRepository, + } + + await expect(createFromTemplate(options)).rejects.toThrow(installationError) + + await expect(file.fileExists(extensionDirectory)).resolves.toBe(false) + await expect(createFromTemplate(options)).resolves.toBe(extensionDirectory) + }, + {useWorkspaces: true}, + ) + }) + + test.each([ + {failureStage: 'workspace install', useWorkspaces: true, packageManager: 'pnpm'}, + {failureStage: 'runtime install', useWorkspaces: true, packageManager: 'pnpm'}, + {failureStage: 'type generation', useWorkspaces: true, packageManager: 'pnpm'}, + {failureStage: 'runtime install', useWorkspaces: false, packageManager: 'npm'}, + ])( + 'removes the function and allows retrying when $failureStage fails ($packageManager, workspaces: $useWorkspaces)', + async ({failureStage, useWorkspaces, packageManager}) => { + await withTemporaryApp( + async (tmpDir) => { + const name = 'failed-function' + const extensionDirectory = joinPath(tmpDir, 'extensions', name) + const extensionTemplate = allFunctionTemplates.find((spec) => spec.identifier === 'order_discounts')! + const failure = new Error('Function setup failed') + await file.writeFile(joinPath(tmpDir, packageManager === 'pnpm' ? 'pnpm-lock.yaml' : 'package-lock.json'), '') + const failAfterPartialInstall = async () => { + await file.mkdir(joinPath(extensionDirectory, 'node_modules', '.pnpm')) + await file.writeFile(joinPath(extensionDirectory, 'node_modules', '.pnpm', 'lock.yaml'), '') + throw failure + } + const buildGraphqlTypes = vi.spyOn(functionBuild, 'buildGraphqlTypes').mockResolvedValue() + if (failureStage === 'workspace install') { + vi.mocked(installNodeModules).mockImplementationOnce(failAfterPartialInstall) + } else if (failureStage === 'runtime install') { + vi.mocked(addNPMDependenciesIfNeeded).mockImplementationOnce(failAfterPartialInstall) + } else { + buildGraphqlTypes.mockImplementationOnce(failAfterPartialInstall) + } + + const options: CreateFromTemplateOptions = { + name, + extensionTemplate, + extensionFlavor: 'vanilla-js', + appDirectory: tmpDir, + specifications, + onGetTemplateRepository: async (_url, destination) => { + const templateDirectory = joinPath(destination, 'discounts/javascript/order-discounts/default') + await file.mkdir(joinPath(templateDirectory, 'src')) + await file.writeFile(joinPath(templateDirectory, 'src', 'index'), 'export default {}') + await file.writeFile(joinPath(templateDirectory, 'package.json'), '{}') + await file.writeFile( + joinPath(templateDirectory, 'shopify.extension.toml'), + `name = "${name}"\ntype = "function"\napi_version = "2026-07"`, + ) + }, + } + await expect(createFromTemplate(options)).rejects.toMatchObject({ + message: failure.message, + cause: failure, + tryMessage: `The incomplete function directory at ${extensionDirectory} was removed.`, + nextSteps: [ + ...(packageManager === 'pnpm' ? [expect.stringContaining(`pnpm approve-builds in ${tmpDir}`)] : []), + expect.stringContaining(`shopify app generate extension from ${tmpDir}`), + ], + }) + + await expect(file.fileExists(extensionDirectory)).resolves.toBe(false) + if (failureStage !== 'type generation') expect(buildGraphqlTypes).not.toHaveBeenCalled() + await expect(createFromTemplate(options)).resolves.toBe(extensionDirectory) + }, + {useWorkspaces}, + ) + }, + ) + test('errors when trying to re-generate an existing extension', async () => { await withTemporaryApp(async (tmpDir: string) => { const name = 'my-ext-1' diff --git a/packages/app/src/cli/services/generate/extension.ts b/packages/app/src/cli/services/generate/extension.ts index ab50ff65835..ceb3ebd2110 100644 --- a/packages/app/src/cli/services/generate/extension.ts +++ b/packages/app/src/cli/services/generate/extension.ts @@ -21,6 +21,7 @@ import {fileExists, inTemporaryDirectory, mkdir, moveFile, removeFile, glob} fro import {joinPath, relativizePath} from '@shopify/cli-kit/node/path' import {slugify} from '@shopify/cli-kit/common/string' import {nonRandomUUID} from '@shopify/cli-kit/node/crypto' +import {AbortError} from '@shopify/cli-kit/node/error' export interface GenerateExtensionTemplateOptions { app: AppLinkedInterface @@ -80,6 +81,25 @@ interface ExtensionInitOptions { onGetTemplateRepository: (url: string, destination: string) => Promise } +class FunctionSetupError extends AbortError { + constructor(error: unknown, {directory, project}: ExtensionInitOptions) { + const nextSteps = [ + ...(project.packageManager === 'pnpm' + ? [ + `If pnpm blocked dependency build scripts, run pnpm approve-builds in ${project.directory} and approve the dependencies you trust.`, + ] + : []), + `Resolve the error above, then rerun shopify app generate extension from ${project.directory}. You can reuse the same extension name.`, + ] + super( + error instanceof Error ? error.message : String(error), + `The incomplete function directory at ${directory} was removed.`, + nextSteps, + ) + this.cause = error + } +} + export async function generateExtensionTemplate( options: GenerateExtensionTemplateOptions, ): Promise { @@ -149,17 +169,11 @@ async function themeExtensionInit({ }) } -async function functionExtensionInit({ - directory, - url, - app, - project, - name, - extensionFlavor, - onGetTemplateRepository, -}: ExtensionInitOptions) { +async function functionExtensionInit(options: ExtensionInitOptions) { + const {directory, url, app, project, name, extensionFlavor, onGetTemplateRepository} = options const templateLanguage = getTemplateLanguage(extensionFlavor?.value) const taskList = [] + let templateGenerated = false taskList.push({ title: `Generating function extension`, @@ -183,6 +197,7 @@ async function functionExtensionInit({ const srcFileExtension = getSrcFileExtension(extensionFlavor?.value ?? 'rust') await changeIndexFileExtension(directory, srcFileExtension, '!(*.graphql)') } + templateGenerated = true }, }) @@ -214,7 +229,13 @@ async function functionExtensionInit({ }) } - await renderTasks(taskList) + try { + await renderTasks(taskList) + } catch (error) { + // Explain how to retry setup failures after extensionInit removes the incomplete function. + if (templateGenerated) throw new FunctionSetupError(error, options) + throw error + } } async function uiExtensionInit({