Skip to content
Closed
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/function-setup-recovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/app': patch
---

Explain how to retry function generation after cleaning up a failed dependency installation or type generation.
95 changes: 95 additions & 0 deletions packages/app/src/cli/services/generate/extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
41 changes: 31 additions & 10 deletions packages/app/src/cli/services/generate/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -80,6 +81,25 @@ interface ExtensionInitOptions {
onGetTemplateRepository: (url: string, destination: string) => Promise<void>
}

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<GeneratedExtension> {
Expand Down Expand Up @@ -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`,
Expand All @@ -183,6 +197,7 @@ async function functionExtensionInit({
const srcFileExtension = getSrcFileExtension(extensionFlavor?.value ?? 'rust')
await changeIndexFileExtension(directory, srcFileExtension, '!(*.graphql)')
}
templateGenerated = true
},
})

Expand Down Expand Up @@ -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({
Expand Down
Loading