diff --git a/packages/app/src/cli/prompts/deploy-release.test.ts b/packages/app/src/cli/prompts/deploy-release.test.ts index 4eff2440102..04e2ccd4a64 100644 --- a/packages/app/src/cli/prompts/deploy-release.test.ts +++ b/packages/app/src/cli/prompts/deploy-release.test.ts @@ -350,6 +350,75 @@ describe('deployOrReleaseConfirmationPrompt', () => { ) expect(result).toBe(true) }) + + test('and no force with removed targets should display the dangerous confirmation prompt with target change details', async () => { + // Given + const breakdownInfo = buildEmptyBreakdownInfo() + breakdownInfo.extensionIdentifiersBreakdown.toUpdate.push( + buildExtensionBreakdownInfo('checkout-ui', undefined, { + removedTargets: ['purchase.checkout.block.render'], + }), + ) + breakdownInfo.extensionIdentifiersBreakdown.unchanged.push( + buildExtensionBreakdownInfo('unchanged extension', undefined), + ) + + const renderDangerousConfirmationPromptSpyOn = vi + .spyOn(ui, 'renderDangerousConfirmationPrompt') + .mockResolvedValue(true) + vi.spyOn(metadata, 'addPublicMetadata').mockImplementation(async () => {}) + const appTitle = 'app title' + + // When + const result = await deployOrReleaseConfirmationPrompt({ + ...breakdownInfo, + appTitle, + release: true, + force: false, + }) + + // Then + expect(renderDangerousConfirmationPromptSpyOn).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Release a new version of app title?', + confirmation: appTitle, + infoTable: expect.arrayContaining([ + expect.objectContaining({ + header: 'Extensions:', + helperText: + 'Removing extension targets can break the experience for merchants who have this extension activated at those targets. Consider removing the extension and creating a new one instead.', + }), + ]), + }), + ) + expect(result).toBe(true) + }) + + test('and no force with added targets should show updated extensions without dangerous prompt', async () => { + // Given + const breakdownInfo = buildEmptyBreakdownInfo() + breakdownInfo.extensionIdentifiersBreakdown.toUpdate.push( + buildExtensionBreakdownInfo('checkout-ui', undefined, { + addedTargets: ['purchase.checkout.footer.render'], + }), + ) + + const renderConfirmationPromptSpyOn = vi.spyOn(ui, 'renderConfirmationPrompt').mockResolvedValue(true) + vi.spyOn(metadata, 'addPublicMetadata').mockImplementation(async () => {}) + const appTitle = 'app title' + + // When + const result = await deployOrReleaseConfirmationPrompt({ + ...breakdownInfo, + appTitle, + release: true, + force: false, + }) + + // Then + expect(renderConfirmationPromptSpyOn).toHaveBeenCalled() + expect(result).toBe(true) + }) }) describe('when no release', () => { diff --git a/packages/app/src/cli/prompts/deploy-release.ts b/packages/app/src/cli/prompts/deploy-release.ts index 11379ce7591..04f7e97dc46 100644 --- a/packages/app/src/cli/prompts/deploy-release.ts +++ b/packages/app/src/cli/prompts/deploy-release.ts @@ -21,6 +21,7 @@ interface DeployConfirmationPromptOptions { extensionsContentPrompt: { extensionsInfoTable?: InfoTableSection hasDeletedExtensions: boolean + hasRemovedTargets: boolean } configContentPrompt?: { configInfoTable: InfoTableSection @@ -50,7 +51,7 @@ export async function deployOrReleaseConfirmationPrompt({ async function deployConfirmationPrompt({ appTitle, - extensionsContentPrompt: {extensionsInfoTable, hasDeletedExtensions}, + extensionsContentPrompt: {extensionsInfoTable, hasDeletedExtensions, hasRemovedTargets}, configContentPrompt, release, }: DeployConfirmationPromptOptions): Promise { @@ -65,13 +66,13 @@ async function deployConfirmationPrompt({ : configContentPrompt.configInfoTable, ) } - const isDangerous = appTitle !== undefined && hasDeletedExtensions + const isDangerous = appTitle !== undefined && (hasDeletedExtensions || hasRemovedTargets) if (extensionsInfoTable) { - infoTable.push( - isDangerous - ? {...extensionsInfoTable, helperText: 'Removing extensions can permanently delete app user data'} - : extensionsInfoTable, - ) + if (isDangerous && hasDeletedExtensions) { + infoTable.push({...extensionsInfoTable, helperText: 'Removing extensions can permanently delete app user data'}) + } else { + infoTable.push(extensionsInfoTable) + } } else { infoTable.push({header: 'Extensions:', emptyItemsText: 'None', items: []}) } @@ -109,12 +110,17 @@ async function buildExtensionsContentPrompt(extensionsContentBreakdown: Extensio switch (extension.experience) { case 'dashboard': return [extension.title, {subdued: `(${preffix}from Partner Dashboard)`}] - case 'extension': - if (extension.uid && extension.uid.length > 0) { - return `${extension.title} (uid: ${extension.uid})` - } else { - return extension.title + case 'extension': { + const label = + extension.uid && extension.uid.length > 0 ? `${extension.title} (uid: ${extension.uid})` : extension.title + + // Append target change details if present + const targetDetails = buildTargetChangeDetails(extension) + if (targetDetails) { + return [label, {subdued: targetDetails}] } + return label + } } } let extensionsInfoTable @@ -127,10 +133,17 @@ async function buildExtensionsContentPrompt(extensionsContentBreakdown: Extensio const extensionsInfo = buildDeployReleaseInfoTableSection(section) const hasDeletedExtensions = onlyRemote.length > 0 + const hasRemovedTargets = toUpdate.some((ext) => ext.removedTargets && ext.removedTargets.length > 0) if (extensionsInfo.length > 0) { extensionsInfoTable = { header: 'Extensions:', items: extensionsInfo, + ...(hasRemovedTargets + ? { + helperText: + 'Removing extension targets can break the experience for merchants who have this extension activated at those targets. Consider removing the extension and creating a new one instead.', + } + : {}), } } @@ -140,7 +153,18 @@ async function buildExtensionsContentPrompt(extensionsContentBreakdown: Extensio cmd_deploy_confirm_removed_registrations: onlyRemote.length, })) - return {extensionsInfoTable, hasDeletedExtensions} + return {extensionsInfoTable, hasDeletedExtensions, hasRemovedTargets} +} + +function buildTargetChangeDetails(extension: ExtensionIdentifierBreakdownInfo): string | undefined { + const parts: string[] = [] + if (extension.removedTargets && extension.removedTargets.length > 0) { + parts.push(`removing: ${extension.removedTargets.join(', ')}`) + } + if (extension.addedTargets && extension.addedTargets.length > 0) { + parts.push(`adding: ${extension.addedTargets.join(', ')}`) + } + return parts.length > 0 ? `(${parts.join('; ')})` : undefined } async function buildConfigContentPrompt( diff --git a/packages/app/src/cli/services/context/breakdown-extensions.test.ts b/packages/app/src/cli/services/context/breakdown-extensions.test.ts index e74ab310183..6c1bc73c50e 100644 --- a/packages/app/src/cli/services/context/breakdown-extensions.test.ts +++ b/packages/app/src/cli/services/context/breakdown-extensions.test.ts @@ -727,6 +727,77 @@ describe('extensionsIdentifiersDeployBreakdown', () => { remoteExtensionsRegistrations: remoteExtensionRegistrations.app, }) }) + + test('and there is an active version with target changes, extension should be returned as toUpdate with target change details', async () => { + // Given + const MODULE_CLI_A_WITH_TARGETS: AppModuleVersion = { + registrationId: 'A', + registrationUuid: 'UUID_A', + registrationTitle: 'Checkout post purchase', + type: 'checkout_post_purchase', + config: { + extension_points: ['purchase.checkout.block.render', 'purchase.checkout.header.render'], + }, + specification: { + identifier: 'checkout_post_purchase', + name: 'Post purchase UI extension', + experience: 'extension', + options: { + managementExperience: 'cli', + }, + }, + } + + // Create a local extension with only one target (removing purchase.checkout.header.render) + const localExtWithTargetChange = await testUIExtension({ + directory: '/EXTENSION_A_TARGETS', + configuration: { + name: 'EXTENSION A', + type: 'checkout_ui_extension', + extension_points: ['purchase.checkout.block.render'], + metafields: [], + }, + entrySourceFilePath: '', + devUUID: 'devUUID', + }) + const localExtensions = [localExtWithTargetChange, EXTENSION_A_2] + + const extensionsToConfirm = { + validMatches: {[localExtWithTargetChange.localIdentifier]: 'UUID_A'}, + dashboardOnlyExtensions: [] as RemoteSource[], + extensionsToCreate: [EXTENSION_A_2], + didMigrateDashboardExtensions: false, + } + vi.mocked(ensureExtensionsIds).mockResolvedValue(extensionsToConfirm) + const remoteExtensionRegistrations = { + app: { + extensionRegistrations: [REGISTRATION_A], + configurationRegistrations: [], + dashboardManagedExtensionRegistrations: [], + }, + } + const activeAppVersion = { + appModuleVersions: [MODULE_CONFIG_A, MODULE_CLI_A_WITH_TARGETS], + } + const developerPlatformClient: DeveloperPlatformClient = testDeveloperPlatformClient({ + appExtensionRegistrations: (_app: MinimalAppIdentifiers) => Promise.resolve(remoteExtensionRegistrations), + activeAppVersion: (_app: MinimalAppIdentifiers) => Promise.resolve(activeAppVersion), + }) + + // When + const result = await extensionsIdentifiersDeployBreakdown( + await options({uiExtensions: localExtensions, developerPlatformClient, activeAppVersion}), + ) + + // Then + const updatedExtensions = result.extensionIdentifiersBreakdown.toUpdate + expect(updatedExtensions.length).toBeGreaterThanOrEqual(1) + const extensionWithTargetChange = updatedExtensions.find( + (ext) => ext.removedTargets && ext.removedTargets.length > 0, + ) + expect(extensionWithTargetChange).toBeDefined() + expect(extensionWithTargetChange!.removedTargets).toEqual(['purchase.checkout.header.render']) + }) }) }) diff --git a/packages/app/src/cli/services/context/breakdown-extensions.ts b/packages/app/src/cli/services/context/breakdown-extensions.ts index e685a07cd8c..0b13d714ca9 100644 --- a/packages/app/src/cli/services/context/breakdown-extensions.ts +++ b/packages/app/src/cli/services/context/breakdown-extensions.ts @@ -36,10 +36,22 @@ export interface ExtensionIdentifierBreakdownInfo { title: string uid: string | undefined experience: 'extension' | 'dashboard' + removedTargets?: string[] + addedTargets?: string[] } -export function buildExtensionBreakdownInfo(title: string, uid: string | undefined): ExtensionIdentifierBreakdownInfo { - return {title, uid, experience: 'extension'} +export function buildExtensionBreakdownInfo( + title: string, + uid: string | undefined, + targetChanges?: {removedTargets?: string[]; addedTargets?: string[]}, +): ExtensionIdentifierBreakdownInfo { + return { + title, + uid, + experience: 'extension', + ...(targetChanges?.removedTargets?.length ? {removedTargets: targetChanges.removedTargets} : {}), + ...(targetChanges?.addedTargets?.length ? {addedTargets: targetChanges.addedTargets} : {}), + } } export function buildDashboardBreakdownInfo(title: string): ExtensionIdentifierBreakdownInfo { @@ -95,6 +107,7 @@ export async function extensionsIdentifiersDeployBreakdown(options: EnsureDeploy extensionsToConfirm.dashboardOnlyExtensions, options.app.specifications ?? [], options.activeAppVersion, + options.app.allExtensions, )) ?? extensionIdentifiersBreakdown } return { @@ -368,6 +381,7 @@ async function resolveRemoteExtensionIdentifiersBreakdown( dashboardOnly: RemoteSource[], specs: ExtensionSpecification[], activeAppVersion?: AppVersion, + localExtensions?: {localIdentifier: string; configuration?: object}[], ): Promise { const version = activeAppVersion || (await developerPlatformClient.activeAppVersion(remoteApp)) if (!version) return @@ -378,6 +392,7 @@ async function resolveRemoteExtensionIdentifiersBreakdown( toCreate, specs, developerPlatformClient, + localExtensions, ) const dashboardOnlyFinal = dashboardOnly.filter( @@ -401,6 +416,7 @@ function loadExtensionsIdentifiersBreakdown( toCreate: LocalSource[], specs: ExtensionSpecification[], developerPlatformClient: DeveloperPlatformClient, + localExtensions?: {localIdentifier: string; configuration?: object}[], ) { const extensionModules = activeAppVersion?.appModuleVersions.filter( (ext) => extensionTypeStrategy(specs, ext.specification?.identifier) === 'uuid', @@ -435,6 +451,23 @@ function loadExtensionsIdentifiersBreakdown( !extensionsBeingMigratedToDevDash.some((module) => module.registrationUuid === validMatches[identifier]), ) + // Compute target changes for existing extensions + const targetChangesMap = computeExtensionTargetChanges( + allExistingExtensions, + validMatches, + extensionModules, + localExtensions ?? [], + moduleHasUUIDorUID, + ) + + // Move extensions with target changes from unchanged to toUpdate + const extensionsWithTargetChanges = unchangedExtensions.filter( + (identifier) => targetChangesMap.has(identifier), + ) + const trulyUnchangedExtensions = unchangedExtensions.filter( + (identifier) => !targetChangesMap.has(identifier), + ) + const extensionsToCreate = Object.entries(validMatches) .filter(([_identifier, uuid]) => !extensionModules.some((module) => moduleHasUUIDorUID(module, uuid))) .map(([identifier, uuid]) => ({title: identifier, uid: uuid})) @@ -456,11 +489,90 @@ function loadExtensionsIdentifiersBreakdown( return { onlyRemote: extensionsOnlyRemote.map(({title, uid}) => buildExtensionBreakdownInfo(title, uid)), toCreate: extensionsToCreate.map(({title, uid}) => buildExtensionBreakdownInfo(title, uid)), - toUpdate: extensionsToUpdate.map((title) => buildExtensionBreakdownInfo(title, undefined)), - unchanged: unchangedExtensions.map((title) => buildExtensionBreakdownInfo(title, undefined)), + toUpdate: [ + ...extensionsToUpdate.map((title) => buildExtensionBreakdownInfo(title, undefined, targetChangesMap.get(title))), + ...extensionsWithTargetChanges.map((title) => + buildExtensionBreakdownInfo(title, undefined, targetChangesMap.get(title)), + ), + ], + unchanged: trulyUnchangedExtensions.map((title) => buildExtensionBreakdownInfo(title, undefined)), } } +/** + * Extracts extension point targets from an extension's configuration. + * Handles both `extension_points` (array of strings or objects with target) and `targeting` (array of objects). + */ +function extractTargetsFromConfig(config?: object): string[] { + if (!config || typeof config !== 'object') return [] + + const configRecord = config as Record + + // Handle extension_points: can be array of strings (checkout_ui_extension) or array of objects with target + const extensionPoints = configRecord.extension_points as unknown[] | undefined + if (extensionPoints && Array.isArray(extensionPoints)) { + return extensionPoints + .map((ep) => { + if (typeof ep === 'string') return ep + if (ep && typeof ep === 'object' && 'target' in ep) return (ep as {target: string}).target + return undefined + }) + .filter((t): t is string => t !== undefined) + .sort() + } + + // Handle targeting: array of objects with target (e.g., payments extensions) + const targeting = configRecord.targeting as {target: string}[] | undefined + if (targeting && Array.isArray(targeting)) { + return targeting.map((t) => t.target).filter(Boolean).sort() + } + + return [] +} + +/** + * Computes target changes between local extensions and their remote counterparts. + * Returns a map of extension identifier -> {removedTargets, addedTargets} for extensions that have target changes. + */ +function computeExtensionTargetChanges( + existingExtensions: string[], + validMatches: IdentifiersExtensions, + remoteModules: AppModuleVersion[], + localExtensions: {localIdentifier: string; configuration?: object}[], + moduleHasUUIDorUID: (module: AppModuleVersion, identifier: string) => boolean, +): Map { + const targetChanges = new Map() + + for (const identifier of existingExtensions) { + const localExtension = localExtensions.find((ext) => ext.localIdentifier === identifier) + if (!localExtension) continue + + const remoteUuid = validMatches[identifier] + if (!remoteUuid) continue + + const remoteModule = remoteModules.find((module) => moduleHasUUIDorUID(module, remoteUuid)) + if (!remoteModule) continue + + const localTargets = extractTargetsFromConfig(localExtension.configuration) + const remoteTargets = extractTargetsFromConfig(remoteModule.config as object | undefined) + + // Skip if neither side has targets (not a targeted extension) + if (localTargets.length === 0 && remoteTargets.length === 0) continue + + const removedTargets = remoteTargets.filter((target) => !localTargets.includes(target)) + const addedTargets = localTargets.filter((target) => !remoteTargets.includes(target)) + + if (removedTargets.length > 0 || addedTargets.length > 0) { + targetChanges.set(identifier, { + ...(removedTargets.length > 0 ? {removedTargets} : {}), + ...(addedTargets.length > 0 ? {addedTargets} : {}), + }) + } + } + + return targetChanges +} + function loadDashboardIdentifiersBreakdown(currentRegistrations: RemoteSource[], activeAppVersion: AppVersion) { const currentVersions = activeAppVersion?.appModuleVersions.filter(