Skip to content

Commit b2a3d9a

Browse files
Merge pull request #6326 from Shopify/idempotent-import-extension
Better handling on import-extensions with existing folder
2 parents a8f8e83 + 42c6bcf commit b2a3d9a

File tree

3 files changed

+201
-12
lines changed

3 files changed

+201
-12
lines changed

.changeset/rotten-ties-flash.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@shopify/app': patch
3+
---
4+
5+
Better handling on import-extensions with existing folder

packages/app/src/cli/services/import-extensions.test.ts

Lines changed: 137 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ import {testAppLinked, testDeveloperPlatformClient, testUIExtension} from '../mo
44
import {OrganizationApp} from '../models/organization.js'
55
import {ExtensionRegistration} from '../api/graphql/all_app_extension_registrations.js'
66
import {describe, expect, test, vi, beforeEach} from 'vitest'
7-
import {fileExistsSync, inTemporaryDirectory} from '@shopify/cli-kit/node/fs'
7+
import {fileExistsSync, inTemporaryDirectory, mkdir} from '@shopify/cli-kit/node/fs'
88
import {renderSelectPrompt, renderSuccess} from '@shopify/cli-kit/node/ui'
99
import {joinPath} from '@shopify/cli-kit/node/path'
10+
import {AbortSilentError} from '@shopify/cli-kit/node/error'
1011

1112
vi.mock('@shopify/cli-kit/node/ui')
1213
vi.mock('./context.js')
@@ -135,6 +136,141 @@ describe('import-extensions', () => {
135136
})
136137
})
137138

139+
test('handles existing directory with user prompt - skip', async () => {
140+
// Given
141+
const extensions = [flowExtensionA]
142+
143+
// When
144+
await inTemporaryDirectory(async (tmpDir) => {
145+
const app = testAppLinked({directory: tmpDir})
146+
147+
// Create the extensions directory
148+
const extensionsDir = joinPath(tmpDir, 'extensions')
149+
await mkdir(extensionsDir)
150+
151+
// Create the specific extension directory
152+
const extensionDir = joinPath(extensionsDir, 'title-a')
153+
await mkdir(extensionDir)
154+
155+
// Mock prompts:
156+
// 1. First prompt: select which extension to migrate (select flowExtensionA by its UUID)
157+
// 2. Second prompt: what to do with existing directory (select skip)
158+
vi.mocked(renderSelectPrompt)
159+
// Select flowExtensionA
160+
.mockResolvedValueOnce('uuidA')
161+
// Skip existing directory
162+
.mockResolvedValueOnce('skip')
163+
164+
await importExtensions({
165+
app,
166+
remoteApp: organizationApp,
167+
developerPlatformClient: testDeveloperPlatformClient(),
168+
extensionTypes: ['flow_action_definition'],
169+
extensions,
170+
buildTomlObject,
171+
})
172+
173+
// Then - expect the success message to be shown (even for skipped extensions)
174+
expect(renderSuccess).toHaveBeenCalledWith({
175+
headline: ['Imported the following extensions from the dashboard:'],
176+
body: '• "titleA" at: extensions/title-a',
177+
})
178+
179+
// The toml file should not be created since we skipped
180+
const tomlPathA = joinPath(tmpDir, 'extensions', 'title-a', 'shopify.extension.toml')
181+
expect(fileExistsSync(tomlPathA)).toBe(false)
182+
})
183+
})
184+
185+
test('handles existing directory with write option', async () => {
186+
// Given
187+
const extensions = [flowExtensionA]
188+
189+
// When
190+
await inTemporaryDirectory(async (tmpDir) => {
191+
const app = testAppLinked({directory: tmpDir})
192+
193+
// Create the extensions directory
194+
const extensionsDir = joinPath(tmpDir, 'extensions')
195+
await mkdir(extensionsDir)
196+
197+
// Create the specific extension directory
198+
const extensionDir = joinPath(extensionsDir, 'title-a')
199+
await mkdir(extensionDir)
200+
201+
// Mock prompts:
202+
// 1. First prompt: select which extension to migrate (select flowExtensionA by its UUID)
203+
// 2. Second prompt: what to do with existing directory (select write)
204+
vi.mocked(renderSelectPrompt)
205+
// Select flowExtensionA
206+
.mockResolvedValueOnce('uuidA')
207+
// Write/overwrite existing directory
208+
.mockResolvedValueOnce('write')
209+
210+
await importExtensions({
211+
app,
212+
remoteApp: organizationApp,
213+
developerPlatformClient: testDeveloperPlatformClient(),
214+
extensionTypes: ['flow_action_definition'],
215+
extensions,
216+
buildTomlObject,
217+
})
218+
219+
// Then - expect the success message to be shown
220+
expect(renderSuccess).toHaveBeenCalledWith({
221+
headline: ['Imported the following extensions from the dashboard:'],
222+
body: '• "titleA" at: extensions/title-a',
223+
})
224+
225+
// The toml file should be created since we wrote/overwrote
226+
const tomlPathA = joinPath(tmpDir, 'extensions', 'title-a', 'shopify.extension.toml')
227+
expect(fileExistsSync(tomlPathA)).toBe(true)
228+
})
229+
})
230+
231+
test('handles existing directory with cancel option', async () => {
232+
// Given
233+
const extensions = [flowExtensionA]
234+
235+
// When
236+
await inTemporaryDirectory(async (tmpDir) => {
237+
const app = testAppLinked({directory: tmpDir})
238+
239+
// Create the extensions directory
240+
const extensionsDir = joinPath(tmpDir, 'extensions')
241+
await mkdir(extensionsDir)
242+
243+
// Create the specific extension directory
244+
const extensionDir = joinPath(extensionsDir, 'title-a')
245+
await mkdir(extensionDir)
246+
247+
// Mock prompts:
248+
// 1. First prompt: select which extension to migrate (select flowExtensionA by its UUID)
249+
// 2. Second prompt: what to do with existing directory (select cancel)
250+
vi.mocked(renderSelectPrompt)
251+
// Select flowExtensionA
252+
.mockResolvedValueOnce('uuidA')
253+
// Cancel the operation
254+
.mockResolvedValueOnce('cancel')
255+
256+
// Then - expect the function to throw an AbortSilentError
257+
await expect(
258+
importExtensions({
259+
app,
260+
remoteApp: organizationApp,
261+
developerPlatformClient: testDeveloperPlatformClient(),
262+
extensionTypes: ['flow_action_definition'],
263+
extensions,
264+
buildTomlObject,
265+
}),
266+
).rejects.toThrow(AbortSilentError)
267+
268+
// The toml file should not be created since we cancelled
269+
const tomlPathA = joinPath(tmpDir, 'extensions', 'title-a', 'shopify.extension.toml')
270+
expect(fileExistsSync(tomlPathA)).toBe(false)
271+
})
272+
})
273+
138274
test('selecting All imports all extensions', async () => {
139275
// Given
140276
const extensions = [

packages/app/src/cli/services/import-extensions.ts

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,17 @@
1-
import {ensureExtensionDirectoryExists} from './extensions/common.js'
21
import {AppLinkedInterface, CurrentAppConfiguration} from '../models/app/app.js'
32
import {updateAppIdentifiers, IdentifiersExtensions} from '../models/app/identifiers.js'
43
import {ExtensionRegistration} from '../api/graphql/all_app_extension_registrations.js'
54
import {DeveloperPlatformClient} from '../utilities/developer-platform-client.js'
65
import {MAX_EXTENSION_HANDLE_LENGTH} from '../models/extensions/schemas.js'
76
import {OrganizationApp} from '../models/organization.js'
87
import {allMigrationChoices, getMigrationChoices} from '../prompts/import-extensions.js'
9-
import {configurationFileNames} from '../constants.js'
8+
import {configurationFileNames, blocks} from '../constants.js'
109
import {renderSelectPrompt, renderSuccess} from '@shopify/cli-kit/node/ui'
1110
import {basename, joinPath} from '@shopify/cli-kit/node/path'
12-
import {removeFile, writeFile} from '@shopify/cli-kit/node/fs'
11+
import {removeFile, writeFile, fileExists, mkdir, touchFile} from '@shopify/cli-kit/node/fs'
1312
import {outputContent} from '@shopify/cli-kit/node/output'
14-
import {slugify} from '@shopify/cli-kit/common/string'
15-
import {AbortError} from '@shopify/cli-kit/node/error'
13+
import {slugify, hyphenate} from '@shopify/cli-kit/common/string'
14+
import {AbortError, AbortSilentError} from '@shopify/cli-kit/node/error'
1615

1716
export const allExtensionTypes = allMigrationChoices.flatMap((choice) => choice.extensionTypes)
1817

@@ -33,6 +32,50 @@ interface ImportOptions extends ImportAllOptions {
3332
all?: boolean
3433
}
3534

35+
enum DirectoryAction {
36+
Write = 'write',
37+
Skip = 'skip',
38+
Cancel = 'cancel',
39+
}
40+
41+
/**
42+
* Handles extension directory creation during import with user prompts for existing directories
43+
*/
44+
async function handleExtensionDirectory({
45+
name,
46+
app,
47+
}: {
48+
name: string
49+
app: AppLinkedInterface
50+
}): Promise<{directory: string; action: DirectoryAction}> {
51+
const hyphenizedName = hyphenate(name)
52+
const extensionDirectory = joinPath(app.directory, blocks.extensions.directoryName, hyphenizedName)
53+
54+
if (await fileExists(extensionDirectory)) {
55+
const choices = [
56+
{label: 'Overwrite local TOML with remote configuration', value: DirectoryAction.Write},
57+
{label: 'Keep local TOML', value: DirectoryAction.Skip},
58+
{label: 'Cancel', value: DirectoryAction.Cancel},
59+
]
60+
61+
const action = await renderSelectPrompt({
62+
message: `Directory "${hyphenizedName}" already exists. What would you like to do?`,
63+
choices,
64+
})
65+
66+
if (action === DirectoryAction.Cancel) {
67+
throw new AbortSilentError()
68+
}
69+
70+
return {directory: extensionDirectory, action}
71+
}
72+
73+
// Directory doesn't exist, create it
74+
await mkdir(extensionDirectory)
75+
await touchFile(joinPath(extensionDirectory, configurationFileNames.lockFile))
76+
return {directory: extensionDirectory, action: DirectoryAction.Write}
77+
}
78+
3679
export async function importExtensions(options: ImportOptions) {
3780
const {app, remoteApp, developerPlatformClient, extensionTypes, extensions, buildTomlObject, all} = options
3881

@@ -61,14 +104,19 @@ export async function importExtensions(options: ImportOptions) {
61104

62105
const extensionUuids: IdentifiersExtensions = {}
63106
const importPromises = extensionsToMigrate.map(async (ext) => {
64-
const directory = await ensureExtensionDirectoryExists({app, name: ext.title})
65-
const tomlObject = buildTomlObject(ext, extensions, app.configuration)
66-
const path = joinPath(directory, 'shopify.extension.toml')
67-
await writeFile(path, tomlObject)
107+
const {directory, action} = await handleExtensionDirectory({app, name: ext.title})
108+
68109
const handle = slugify(ext.title.substring(0, MAX_EXTENSION_HANDLE_LENGTH))
69110
extensionUuids[handle] = ext.uuid
70-
const lockFilePath = joinPath(directory, configurationFileNames.lockFile)
71-
await removeFile(lockFilePath)
111+
112+
if (action === DirectoryAction.Write) {
113+
const tomlObject = buildTomlObject(ext, extensions, app.configuration)
114+
const path = joinPath(directory, 'shopify.extension.toml')
115+
await writeFile(path, tomlObject)
116+
const lockFilePath = joinPath(directory, configurationFileNames.lockFile)
117+
await removeFile(lockFilePath)
118+
}
119+
72120
return {extension: ext, directory: joinPath('extensions', basename(directory))}
73121
})
74122

0 commit comments

Comments
 (0)