From f4422e311f0e5d046b425db38e00be1ae9ef373c Mon Sep 17 00:00:00 2001 From: Ariel Caplan Date: Mon, 7 Sep 2026 19:45:26 +0300 Subject: [PATCH 1/2] Preserve generated functions when setup fails Assisted-By: devx/d54f9bfe-5e29-4147-aea5-071dbb6add71 --- .changeset/function-setup-recovery.md | 5 + .../cli/services/generate/extension.test.ts | 103 ++++++++++++++++++ .../src/cli/services/generate/extension.ts | 49 +++++++-- 3 files changed, 146 insertions(+), 11 deletions(-) create mode 100644 .changeset/function-setup-recovery.md diff --git a/.changeset/function-setup-recovery.md b/.changeset/function-setup-recovery.md new file mode 100644 index 00000000000..5e84b83ec90 --- /dev/null +++ b/.changeset/function-setup-recovery.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': patch +--- + +Preserve generated function files and provide recovery steps when dependency installation or type generation fails. diff --git a/packages/app/src/cli/services/generate/extension.test.ts b/packages/app/src/cli/services/generate/extension.test.ts index 8c97436e93d..c6111215ece 100644 --- a/packages/app/src/cli/services/generate/extension.test.ts +++ b/packages/app/src/cli/services/generate/extension.test.ts @@ -122,6 +122,109 @@ 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'}, + ])( + 'preserves the function 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) + } + + await expect( + createFromTemplate({ + 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"`, + ) + }, + }), + ).rejects.toMatchObject({ + message: failure.message, + cause: failure, + tryMessage: expect.stringContaining(extensionDirectory), + nextSteps: [ + ...(packageManager === 'pnpm' ? [expect.stringContaining(`pnpm approve-builds in ${tmpDir}`)] : []), + expect.stringContaining(`with your package manager in ${useWorkspaces ? extensionDirectory : tmpDir}`), + expect.stringContaining(`shopify app function typegen from ${extensionDirectory}`), + ], + }) + + await expect(file.readFile(joinPath(extensionDirectory, 'src', 'index.js'))).resolves.toBe( + 'export default {}', + ) + await expect(file.fileExists(joinPath(extensionDirectory, 'package.json'))).resolves.toBe(true) + await expect(file.fileExists(joinPath(extensionDirectory, 'shopify.extension.toml'))).resolves.toBe(true) + await expect(file.fileExists(joinPath(extensionDirectory, configurationFileNames.lockFile))).resolves.toBe( + false, + ) + if (failureStage !== 'type generation') expect(buildGraphqlTypes).not.toHaveBeenCalled() + }, + {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..0101e4d8c33 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,27 @@ interface ExtensionInitOptions { onGetTemplateRepository: (url: string, destination: string) => Promise } +class FunctionSetupError extends AbortError { + constructor(error: unknown, {directory, project}: ExtensionInitOptions) { + const dependencyDirectory = project.usesWorkspaces ? directory : project.directory + const nextSteps = [ + ...(project.packageManager === 'pnpm' + ? [ + `If pnpm blocked dependency build scripts, run pnpm approve-builds in ${project.directory} and approve the dependencies you trust.`, + ] + : []), + `Install @shopify/shopify_function@~${PREFERRED_FUNCTION_NPM_PACKAGE_MAJOR_VERSION}.0.0 with your package manager in ${dependencyDirectory}, then rerun its install command in ${project.directory}.`, + `Run shopify app function typegen from ${directory} to finish generating GraphQL types.`, + ] + super( + error instanceof Error ? error.message : String(error), + `Your function files were kept in ${directory}. Resolve the setup error, then finish setup manually.`, + nextSteps, + ) + this.cause = error + } +} + export async function generateExtensionTemplate( options: GenerateExtensionTemplateOptions, ): Promise { @@ -125,7 +147,11 @@ async function extensionInit(options: ExtensionInitOptions) { const lockFilePath = joinPath(options.directory, configurationFileNames.lockFile) await removeFile(lockFilePath) } catch (error) { - await removeFile(options.directory) + if (error instanceof FunctionSetupError) { + await removeFile(joinPath(options.directory, configurationFileNames.lockFile)) + } else { + await removeFile(options.directory) + } throw error } } @@ -149,17 +175,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 +203,7 @@ async function functionExtensionInit({ const srcFileExtension = getSrcFileExtension(extensionFlavor?.value ?? 'rust') await changeIndexFileExtension(directory, srcFileExtension, '!(*.graphql)') } + templateGenerated = true }, }) @@ -214,7 +235,13 @@ async function functionExtensionInit({ }) } - await renderTasks(taskList) + try { + await renderTasks(taskList) + } catch (error) { + // Keep a complete scaffold so dependency approvals and type generation can be retried manually. + if (templateGenerated) throw new FunctionSetupError(error, options) + throw error + } } async function uiExtensionInit({ From 134f1620f48c67173b23e072ff74401119359ac0 Mon Sep 17 00:00:00 2001 From: Ariel Caplan Date: Mon, 7 Sep 2026 19:58:43 +0300 Subject: [PATCH 2/2] Clean up failed functions and explain how to retry generation Assisted-By: devx/d54f9bfe-5e29-4147-aea5-071dbb6add71 --- .changeset/function-setup-recovery.md | 2 +- .../cli/services/generate/extension.test.ts | 54 ++++++++----------- .../src/cli/services/generate/extension.ts | 14 ++--- 3 files changed, 28 insertions(+), 42 deletions(-) diff --git a/.changeset/function-setup-recovery.md b/.changeset/function-setup-recovery.md index 5e84b83ec90..780d7fe0510 100644 --- a/.changeset/function-setup-recovery.md +++ b/.changeset/function-setup-recovery.md @@ -2,4 +2,4 @@ '@shopify/app': patch --- -Preserve generated function files and provide recovery steps when dependency installation or type generation fails. +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 c6111215ece..7d724130c86 100644 --- a/packages/app/src/cli/services/generate/extension.test.ts +++ b/packages/app/src/cli/services/generate/extension.test.ts @@ -158,7 +158,7 @@ describe('initialize a extension', async () => { {failureStage: 'type generation', useWorkspaces: true, packageManager: 'pnpm'}, {failureStage: 'runtime install', useWorkspaces: false, packageManager: 'npm'}, ])( - 'preserves the function when $failureStage fails ($packageManager, workspaces: $useWorkspaces)', + 'removes the function and allows retrying when $failureStage fails ($packageManager, workspaces: $useWorkspaces)', async ({failureStage, useWorkspaces, packageManager}) => { await withTemporaryApp( async (tmpDir) => { @@ -181,44 +181,36 @@ describe('initialize a extension', async () => { buildGraphqlTypes.mockImplementationOnce(failAfterPartialInstall) } - await expect( - createFromTemplate({ - 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"`, - ) - }, - }), - ).rejects.toMatchObject({ + 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: expect.stringContaining(extensionDirectory), + tryMessage: `The incomplete function directory at ${extensionDirectory} was removed.`, nextSteps: [ ...(packageManager === 'pnpm' ? [expect.stringContaining(`pnpm approve-builds in ${tmpDir}`)] : []), - expect.stringContaining(`with your package manager in ${useWorkspaces ? extensionDirectory : tmpDir}`), - expect.stringContaining(`shopify app function typegen from ${extensionDirectory}`), + expect.stringContaining(`shopify app generate extension from ${tmpDir}`), ], }) - await expect(file.readFile(joinPath(extensionDirectory, 'src', 'index.js'))).resolves.toBe( - 'export default {}', - ) - await expect(file.fileExists(joinPath(extensionDirectory, 'package.json'))).resolves.toBe(true) - await expect(file.fileExists(joinPath(extensionDirectory, 'shopify.extension.toml'))).resolves.toBe(true) - await expect(file.fileExists(joinPath(extensionDirectory, configurationFileNames.lockFile))).resolves.toBe( - false, - ) + await expect(file.fileExists(extensionDirectory)).resolves.toBe(false) if (failureStage !== 'type generation') expect(buildGraphqlTypes).not.toHaveBeenCalled() + await expect(createFromTemplate(options)).resolves.toBe(extensionDirectory) }, {useWorkspaces}, ) diff --git a/packages/app/src/cli/services/generate/extension.ts b/packages/app/src/cli/services/generate/extension.ts index 0101e4d8c33..ceb3ebd2110 100644 --- a/packages/app/src/cli/services/generate/extension.ts +++ b/packages/app/src/cli/services/generate/extension.ts @@ -83,19 +83,17 @@ interface ExtensionInitOptions { class FunctionSetupError extends AbortError { constructor(error: unknown, {directory, project}: ExtensionInitOptions) { - const dependencyDirectory = project.usesWorkspaces ? directory : project.directory const nextSteps = [ ...(project.packageManager === 'pnpm' ? [ `If pnpm blocked dependency build scripts, run pnpm approve-builds in ${project.directory} and approve the dependencies you trust.`, ] : []), - `Install @shopify/shopify_function@~${PREFERRED_FUNCTION_NPM_PACKAGE_MAJOR_VERSION}.0.0 with your package manager in ${dependencyDirectory}, then rerun its install command in ${project.directory}.`, - `Run shopify app function typegen from ${directory} to finish generating GraphQL types.`, + `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), - `Your function files were kept in ${directory}. Resolve the setup error, then finish setup manually.`, + `The incomplete function directory at ${directory} was removed.`, nextSteps, ) this.cause = error @@ -147,11 +145,7 @@ async function extensionInit(options: ExtensionInitOptions) { const lockFilePath = joinPath(options.directory, configurationFileNames.lockFile) await removeFile(lockFilePath) } catch (error) { - if (error instanceof FunctionSetupError) { - await removeFile(joinPath(options.directory, configurationFileNames.lockFile)) - } else { - await removeFile(options.directory) - } + await removeFile(options.directory) throw error } } @@ -238,7 +232,7 @@ async function functionExtensionInit(options: ExtensionInitOptions) { try { await renderTasks(taskList) } catch (error) { - // Keep a complete scaffold so dependency approvals and type generation can be retried manually. + // Explain how to retry setup failures after extensionInit removes the incomplete function. if (templateGenerated) throw new FunctionSetupError(error, options) throw error }