From a750ad0ce812c0749dc89a8e527fa057464c17ce Mon Sep 17 00:00:00 2001 From: Jamie Guerrero Date: Mon, 11 May 2026 11:58:03 -0400 Subject: [PATCH] Show extension target changes in deploy/release confirmation prompt When an extension's targets are being added or removed compared to the active remote version, the CLI now: 1. Detects target changes by comparing local extension configuration (extension_points/targeting) against the remote AppModuleVersion config 2. Shows extensions with target changes as 'updated' with details like '(removing: purchase.checkout.block.render)' 3. Triggers a dangerous confirmation prompt when targets are being removed, similar to when extensions are fully deleted 4. Shows a helper text warning: 'Removing extension targets can break the experience for merchants who have this extension activated at those targets.' This gives partners visibility into target changes before the version is created, rather than only seeing a server-side validation error after the fact. --- .../src/cli/prompts/deploy-release.test.ts | 69 ++++++++++ .../app/src/cli/prompts/deploy-release.ts | 50 ++++++-- .../context/breakdown-extensions.test.ts | 71 +++++++++++ .../services/context/breakdown-extensions.ts | 120 +++++++++++++++++- 4 files changed, 293 insertions(+), 17 deletions(-) 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(