From 9d690a944b52558e46180b7f615546d5f0cc4a63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Thu, 9 Jul 2026 19:45:41 +0200 Subject: [PATCH] Derive deploy identifiers from the active app version Replaces the interactive extension-matching flow used by `deploy` with a classification against the app's active version: each local extension is matched to a remote module by UID (with a legacy UUID fallback for un-migrated extensions), and created/updated/deleted/unchanged status is derived from that. `ensureDeployContext` now returns `DeployIdentifiers`, and `deploy` no longer writes identifiers back to the .env file. Release reuses the same config breakdown via `configExtensionsIdentifiersReleaseBreakdown`. The legacy matching modules remain in place but unused; they are removed in a follow-up commit. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/cli/models/app/identifiers.test.ts | 6 +- .../app/src/cli/models/app/identifiers.ts | 7 +- packages/app/src/cli/services/context.test.ts | 50 +- packages/app/src/cli/services/context.ts | 12 +- .../services/context/breakdown-extensions.ts | 57 +- .../context/deploy-app-version-migrations.ts | 100 ++ .../context/deploy-app-version.test.ts | 774 ++++++++++++ .../services/context/deploy-app-version.ts | 191 +++ .../context/identifiers-extensions.test.ts | 1099 ----------------- .../context/identifiers-extensions.ts | 2 +- .../src/cli/services/context/identifiers.ts | 4 +- packages/app/src/cli/services/deploy.test.ts | 45 +- packages/app/src/cli/services/deploy.ts | 141 +-- .../src/cli/services/deploy/upload.test.ts | 10 +- .../app/src/cli/services/deploy/upload.ts | 22 +- packages/app/src/cli/services/release.test.ts | 4 +- packages/app/src/cli/services/release.ts | 21 +- 17 files changed, 1252 insertions(+), 1293 deletions(-) create mode 100644 packages/app/src/cli/services/context/deploy-app-version-migrations.ts create mode 100644 packages/app/src/cli/services/context/deploy-app-version.test.ts create mode 100644 packages/app/src/cli/services/context/deploy-app-version.ts delete mode 100644 packages/app/src/cli/services/context/identifiers-extensions.test.ts diff --git a/packages/app/src/cli/models/app/identifiers.test.ts b/packages/app/src/cli/models/app/identifiers.test.ts index e0615febd4c..106c325f834 100644 --- a/packages/app/src/cli/models/app/identifiers.test.ts +++ b/packages/app/src/cli/models/app/identifiers.test.ts @@ -203,8 +203,7 @@ describe('getAppIdentifiers', () => { }) // Then - expect(got.app).toEqual('FOO') - expect((got.extensions ?? {})['test-ui-extension']).toEqual('BAR') + expect(got['test-ui-extension']).toEqual('BAR') }) }) @@ -229,8 +228,7 @@ describe('getAppIdentifiers', () => { ) // Then - expect(got.app).toEqual('FOO') - expect((got.extensions ?? {})['test-ui-extension']).toEqual('BAR') + expect(got['test-ui-extension']).toEqual('BAR') }) }) }) diff --git a/packages/app/src/cli/models/app/identifiers.ts b/packages/app/src/cli/models/app/identifiers.ts index ceb77f4c7cd..44c8334385e 100644 --- a/packages/app/src/cli/models/app/identifiers.ts +++ b/packages/app/src/cli/models/app/identifiers.ts @@ -100,7 +100,7 @@ interface GetAppIdentifiersOptions { export function getAppIdentifiers( {app}: GetAppIdentifiersOptions, systemEnvironment = process.env, -): Partial { +): ExtensionUuidsByLocalIdentifier { const envVariables = { ...app.dotenv?.variables, ...(systemEnvironment as {[variable: string]: string}), @@ -113,8 +113,5 @@ export function getAppIdentifiers( } app.allExtensions.forEach(processExtension) - return { - app: envVariables[app.idEnvironmentVariableName], - extensions: extensionsIdentifiers, - } + return extensionsIdentifiers } diff --git a/packages/app/src/cli/services/context.test.ts b/packages/app/src/cli/services/context.test.ts index dd8bf8e6488..d3cde5781c9 100644 --- a/packages/app/src/cli/services/context.test.ts +++ b/packages/app/src/cli/services/context.test.ts @@ -1,7 +1,7 @@ import {fetchOrganizations, fetchOrgFromId} from './dev/fetch.js' import {selectOrCreateApp} from './dev/select-app.js' import {selectStore} from './dev/select-store.js' -import {ensureDeploymentIdsPresence} from './context/identifiers.js' +import {ensureDeployIdentifiersFromAppVersion} from './context/deploy-app-version.js' import {appFromIdentifiers, ensureDeployContext} from './context.js' import {CachedAppInfo} from './local-storage.js' import link from './app/config/link.js' @@ -110,7 +110,7 @@ vi.mock('./dev/select-store') vi.mock('../prompts/dev') vi.mock('@shopify/organizations') vi.mock('../models/app/identifiers') -vi.mock('./context/identifiers') +vi.mock('./context/deploy-app-version') vi.mock('../models/app/loader.js') vi.mock('@shopify/cli-kit/node/node-package-manager.js') vi.mock('@shopify/cli-kit/node/ui') @@ -134,7 +134,7 @@ beforeAll(async () => { }) beforeEach(async () => { - vi.mocked(getAppIdentifiers).mockReturnValue({app: undefined}) + vi.mocked(getAppIdentifiers).mockReturnValue({}) vi.mocked(selectOrganizationPrompt).mockResolvedValue(ORG1) vi.mocked(selectOrCreateApp).mockResolvedValue(APP1) vi.mocked(selectStore).mockResolvedValue(STORE1) @@ -174,13 +174,7 @@ describe('ensureDeployContext', () => { test('does not abort when force is true and include_config_on_deploy is not set', async () => { // Given const app = testAppWithConfig({config: {client_id: APP2.apiKey}}) - const identifiers = { - app: APP2.apiKey, - extensions: {}, - extensionIds: {}, - extensionsNonUuidManaged: {}, - } - vi.mocked(ensureDeploymentIdsPresence).mockResolvedValue(identifiers) + vi.mocked(ensureDeployIdentifiersFromAppVersion).mockResolvedValue(emptyDeployIdentifiers()) vi.mocked(getAppConfigurationFileName).mockReturnValue('shopify.app.toml') const options = { @@ -196,13 +190,7 @@ describe('ensureDeployContext', () => { test('removes the include_config_on_deploy field when using app management API and the value is true', async () => { // Given const app = testAppWithConfig({config: {client_id: APP2.apiKey, build: {include_config_on_deploy: true}}}) - const identifiers = { - app: APP2.apiKey, - extensions: {}, - extensionIds: {}, - extensionsNonUuidManaged: {}, - } - vi.mocked(ensureDeploymentIdsPresence).mockResolvedValue(identifiers) + vi.mocked(ensureDeployIdentifiersFromAppVersion).mockResolvedValue(emptyDeployIdentifiers()) vi.mocked(getAppConfigurationFileName).mockReturnValue('shopify.app.toml') mockTomlFileRemove.mockResolvedValue(undefined) @@ -238,13 +226,7 @@ describe('ensureDeployContext', () => { test('removes the include_config_on_deploy field when using app management API and the value is false', async () => { // Given const app = testAppWithConfig({config: {client_id: APP2.apiKey, build: {include_config_on_deploy: false}}}) - const identifiers = { - app: APP2.apiKey, - extensions: {}, - extensionIds: {}, - extensionsNonUuidManaged: {}, - } - vi.mocked(ensureDeploymentIdsPresence).mockResolvedValue(identifiers) + vi.mocked(ensureDeployIdentifiersFromAppVersion).mockResolvedValue(emptyDeployIdentifiers()) vi.mocked(getAppConfigurationFileName).mockReturnValue('shopify.app.toml') mockTomlFileRemove.mockResolvedValue(undefined) @@ -280,13 +262,7 @@ describe('ensureDeployContext', () => { test('sets didMigrateExtensionsToDevDash to true when app modules are missing registration IDs', async () => { // Given const app = testAppWithConfig({config: {client_id: APP2.apiKey}}) - const identifiers = { - app: APP2.apiKey, - extensions: {}, - extensionIds: {}, - extensionsNonUuidManaged: {}, - } - vi.mocked(ensureDeploymentIdsPresence).mockResolvedValue(identifiers) + vi.mocked(ensureDeployIdentifiersFromAppVersion).mockResolvedValue(emptyDeployIdentifiers()) vi.mocked(getAppConfigurationFileName).mockReturnValue('shopify.app.toml') const activeAppVersion = { @@ -325,13 +301,7 @@ describe('ensureDeployContext', () => { test('sets didMigrateExtensionsToDevDash to false when all app modules have registration IDs', async () => { // Given const app = testAppWithConfig({config: {client_id: APP2.apiKey}}) - const identifiers = { - app: APP2.apiKey, - extensions: {}, - extensionIds: {}, - extensionsNonUuidManaged: {}, - } - vi.mocked(ensureDeploymentIdsPresence).mockResolvedValue(identifiers) + vi.mocked(ensureDeployIdentifiersFromAppVersion).mockResolvedValue(emptyDeployIdentifiers()) vi.mocked(getAppConfigurationFileName).mockReturnValue('shopify.app.toml') const activeAppVersion = { @@ -422,6 +392,10 @@ describe('appFromIdentifiers', () => { }) }) +function emptyDeployIdentifiers() { + return {appModuleUuids: {}, appModuleRegistrationIds: {}} +} + const renderTryMessage = (isOrg: boolean, identifier: string) => [ { list: { diff --git a/packages/app/src/cli/services/context.ts b/packages/app/src/cli/services/context.ts index d2d81730080..8f7373f83c5 100644 --- a/packages/app/src/cli/services/context.ts +++ b/packages/app/src/cli/services/context.ts @@ -1,11 +1,11 @@ import {selectOrCreateApp} from './dev/select-app.js' import {fetchOrganizations, fetchOrgFromId} from './dev/fetch.js' -import {ensureDeploymentIdsPresence} from './context/identifiers.js' +import {ensureDeployIdentifiersFromAppVersion} from './context/deploy-app-version.js' import {CachedAppInfo} from './local-storage.js' import {DeployOptions} from './deploy.js' import {formatConfigInfoBody} from './format-config-info-body.js' import {AppInterface, AppLinkedInterface} from '../models/app/app.js' -import {Identifiers, updateAppIdentifiers, getAppIdentifiers} from '../models/app/identifiers.js' +import {DeployIdentifiers, getAppIdentifiers} from '../models/app/identifiers.js' import {Organization, OrganizationApp, OrganizationSource, OrganizationStore} from '../models/organization.js' import metadata from '../metadata.js' import {getAppConfigurationFileName} from '../models/app/loader.js' @@ -101,7 +101,7 @@ export const appFromIdentifiers = async (options: AppFromIdOptions): Promise !version.registrationId) } - return {identifiers, didMigrateExtensionsToDevDash} + return {deployIdentifiers, didMigrateExtensionsToDevDash} } async function removeIncludeConfigOnDeployField(localApp: AppInterface) { diff --git a/packages/app/src/cli/services/context/breakdown-extensions.ts b/packages/app/src/cli/services/context/breakdown-extensions.ts index dfb5ea8c9f5..fb97cd52a18 100644 --- a/packages/app/src/cli/services/context/breakdown-extensions.ts +++ b/packages/app/src/cli/services/context/breakdown-extensions.ts @@ -15,7 +15,7 @@ import {ExtensionSpecification, isAppConfigSpecification} from '../../models/ext import {rewriteConfiguration} from '../app/write-app-configuration-file.js' import {AppConfigurationUsedByCli} from '../../models/extensions/specifications/types/app_config.js' import {removeTrailingSlash} from '../../models/extensions/specifications/validation/common.js' -import {deepCompare, deepDifference} from '@shopify/cli-kit/common/object' +import {deepCompare, deepCompareWithOrderInsensitiveArrays, deepDifference} from '@shopify/cli-kit/common/object' import {zod} from '@shopify/cli-kit/node/schema' export interface ConfigExtensionIdentifiersBreakdown { @@ -419,3 +419,58 @@ function loadDashboardIdentifiersBreakdown(currentRegistrations: RemoteSource[], unchanged: toUpdate, } } + +export function configExtensionsIdentifiersReleaseBreakdown({ + localApp, + versionAppModules, + activeAppVersion, +}: { + localApp: AppInterface + versionAppModules: AppModuleVersion[] + activeAppVersion?: AppVersion +}) { + if (localApp.allExtensions.filter((extension) => extension.isAppConfigExtension).length === 0) return + const versionConfig = remoteAppConfigurationExtensionContent( + versionAppModules, + localApp.specifications ?? [], + localApp.remoteFlags, + ) + const activeConfig = remoteAppConfigurationExtensionContent( + activeAppVersion?.appModuleVersions ?? [], + localApp.specifications ?? [], + localApp.remoteFlags, + ) + return buildConfigExtensionIdentifiersBreakdown(versionConfig, activeConfig) +} + +export function buildConfigExtensionIdentifiersBreakdown( + localConfig: {[key: string]: unknown}, + remoteConfig: {[key: string]: unknown}, +): ConfigExtensionIdentifiersBreakdown | undefined { + const fieldNames = new Set([...Object.keys(localConfig), ...Object.keys(remoteConfig)]) + if (fieldNames.size === 0) return undefined + + const breakdown: ConfigExtensionIdentifiersBreakdown = { + existingFieldNames: [], + existingUpdatedFieldNames: [], + newFieldNames: [], + deletedFieldNames: [], + } + + for (const fieldName of fieldNames) { + const localValue = localConfig[fieldName] + const remoteValue = remoteConfig[fieldName] + + if (localValue === undefined) { + breakdown.deletedFieldNames.push(fieldName) + } else if (remoteValue === undefined) { + breakdown.newFieldNames.push(fieldName) + } else if (deepCompareWithOrderInsensitiveArrays(localValue, remoteValue)) { + breakdown.existingFieldNames.push(fieldName) + } else { + breakdown.existingUpdatedFieldNames.push(fieldName) + } + } + + return breakdown +} diff --git a/packages/app/src/cli/services/context/deploy-app-version-migrations.ts b/packages/app/src/cli/services/context/deploy-app-version-migrations.ts new file mode 100644 index 00000000000..9e862120cd4 --- /dev/null +++ b/packages/app/src/cli/services/context/deploy-app-version-migrations.ts @@ -0,0 +1,100 @@ +import {EnsureDeploymentIdsPresenceOptions, RemoteSource} from './identifiers.js' +import {extensionMigrationPrompt} from './prompts.js' +import {migrateFlowExtensions} from '../dev/migrate-flow-extension.js' +import {migrateExtensionsToUIExtension} from '../dev/migrate-to-ui-extension.js' +import { + AdminLinkModulesMap, + FlowModulesMap, + getModulesToMigrate, + MarketingModulesMap, + migrateAppModules, + UIModulesMap, +} from '../dev/migrate-app-module.js' +import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' +import {PartnersClient} from '../../utilities/developer-platform-client/partners-client.js' +import {AbortSilentError} from '@shopify/cli-kit/node/error' + +/** Returns a fresh active app version when migrations changed remote modules. */ +export async function activeAppVersionAfterMigrations(options: EnsureDeploymentIdsPresenceOptions) { + const didMigrateExtensions = await migrateExtensionsIfNeeded(options) + return didMigrateExtensions + ? options.developerPlatformClient.activeAppVersion(options.remoteApp) + : options.activeAppVersion +} + +/** Runs supported legacy extension migrations before deploy classification. */ +async function migrateExtensionsIfNeeded(options: EnsureDeploymentIdsPresenceOptions) { + const {app, appId, developerPlatformClient, envIdentifiers, remoteApp} = options + const registrations = await developerPlatformClient.appExtensionRegistrations(remoteApp, options.activeAppVersion) + const localExtensions = app.allExtensions + const identifiers = envIdentifiers + const remoteExtensions = registrations.app.extensionRegistrations + const dashboardExtensions = registrations.app.dashboardManagedExtensionRegistrations + const migrationClient = PartnersClient.getInstance() + + let didMigrate = false + let migratedRemoteExtensions = remoteExtensions + + const uiExtensionsToMigrate = getModulesToMigrate( + localExtensions, + migratedRemoteExtensions, + identifiers, + UIModulesMap, + ) + if (uiExtensionsToMigrate.length > 0) { + const confirmedMigration = await extensionMigrationPrompt(uiExtensionsToMigrate) + if (!confirmedMigration) throw new AbortSilentError() + migratedRemoteExtensions = await migrateExtensionsToUIExtension({ + extensionsToMigrate: uiExtensionsToMigrate, + appId, + remoteExtensions: migratedRemoteExtensions, + migrationClient, + }) + didMigrate = true + } + + const dashboardMigrations = [ + {typesMap: FlowModulesMap, migrate: migrateFlowExtensions}, + migrationGroup(MarketingModulesMap, 'marketing_activity'), + migrationGroup(AdminLinkModulesMap, 'admin_link'), + ] + + for (const migration of dashboardMigrations) { + const extensionsToMigrate = getModulesToMigrate( + localExtensions, + dashboardExtensions, + identifiers, + migration.typesMap, + ) + if (extensionsToMigrate.length === 0) continue + + // eslint-disable-next-line no-await-in-loop + const confirmedMigration = await extensionMigrationPrompt(extensionsToMigrate, false) + if (!confirmedMigration) throw new AbortSilentError() + + // eslint-disable-next-line no-await-in-loop + const newRemoteExtensions = await migration.migrate({ + extensionsToMigrate, + appId, + remoteExtensions: dashboardExtensions, + migrationClient, + }) + migratedRemoteExtensions = migratedRemoteExtensions.concat(newRemoteExtensions) + didMigrate = true + } + + return didMigrate +} + +/** Adapts app-module migrations to the shared dashboard migration loop. */ +function migrationGroup(typesMap: {[key: string]: string[]}, type: string) { + return { + typesMap, + migrate: (options: { + extensionsToMigrate: Parameters[0]['extensionsToMigrate'] + appId: string + remoteExtensions: RemoteSource[] + migrationClient: DeveloperPlatformClient + }) => migrateAppModules({...options, type}), + } +} diff --git a/packages/app/src/cli/services/context/deploy-app-version.test.ts b/packages/app/src/cli/services/context/deploy-app-version.test.ts new file mode 100644 index 00000000000..90917396bb3 --- /dev/null +++ b/packages/app/src/cli/services/context/deploy-app-version.test.ts @@ -0,0 +1,774 @@ +/* eslint-disable @shopify/prefer-module-scope-constants */ +import {classifyDeployExtensionChanges, ensureDeployIdentifiersFromAppVersion} from './deploy-app-version.js' +import {extensionMigrationPrompt} from './prompts.js' +import {AppInterface} from '../../models/app/app.js' +import { + testApp, + testAppConfigExtensions, + testDeveloperPlatformClient, + testOrganizationApp, + testSingleWebhookSubscriptionExtension, + testUIExtension, +} from '../../models/app/app.test-data.js' +import {OrganizationApp} from '../../models/organization.js' +import {ExtensionInstance} from '../../models/extensions/extension-instance.js' +import {BaseConfigType} from '../../models/extensions/schemas.js' +import {createConfigExtensionSpecification} from '../../models/extensions/specification.js' +import {AppModuleVersion, DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' +import {deployOrReleaseConfirmationPrompt} from '../../prompts/deploy-release.js' +import {migrateExtensionsToUIExtension} from '../dev/migrate-to-ui-extension.js' +import {beforeAll, beforeEach, describe, expect, test, vi} from 'vitest' +import {AbortSilentError} from '@shopify/cli-kit/node/error' +import {zod} from '@shopify/cli-kit/node/schema' + +vi.mock('../../prompts/deploy-release.js') +vi.mock('./prompts.js') +vi.mock('../dev/migrate-to-ui-extension.js') + +const REMOTE_APP: OrganizationApp = testOrganizationApp({ + id: 'app-id', + apiKey: 'api-key', + organizationId: 'org-id', +}) + +let EXTENSION_A: ExtensionInstance +let EXTENSION_B: ExtensionInstance +let EXTENSION_TO_MIGRATE: ExtensionInstance +let CONFIG_EXTENSION: ExtensionInstance +let WEBHOOK_SUBSCRIPTION_EXTENSION: ExtensionInstance +let APP: AppInterface +let REMOTE_EXTENSION_A: AppModuleVersion +let REMOTE_EXTENSION_DELETED: AppModuleVersion +let REMOTE_CONFIG_EXTENSION: AppModuleVersion + +beforeAll(async () => { + EXTENSION_A = await testUIExtension({ + directory: '/extension-a', + configuration: { + name: 'Extension A', + type: 'checkout_post_purchase', + metafields: [], + capabilities: { + network_access: false, + block_progress: false, + api_access: false, + collect_buyer_consent: { + sms_marketing: false, + customer_privacy: false, + }, + iframe: { + sources: [], + }, + }, + }, + entrySourceFilePath: '', + devUUID: 'devUUID', + uid: 'uid-a', + }) + + EXTENSION_B = await testUIExtension({ + directory: '/extension-b', + configuration: { + name: 'Extension B', + type: 'checkout_post_purchase', + metafields: [], + capabilities: { + network_access: false, + block_progress: false, + api_access: false, + collect_buyer_consent: { + sms_marketing: false, + customer_privacy: false, + }, + iframe: { + sources: [], + }, + }, + }, + entrySourceFilePath: '', + devUUID: 'devUUID', + uid: 'uid-b', + }) + + EXTENSION_TO_MIGRATE = await testUIExtension({ + directory: '/extension-to-migrate', + configuration: { + name: 'Legacy UI', + type: 'ui_extension', + metafields: [], + capabilities: { + network_access: false, + block_progress: false, + api_access: false, + collect_buyer_consent: { + sms_marketing: false, + customer_privacy: false, + }, + iframe: { + sources: [], + }, + }, + }, + entrySourceFilePath: '', + devUUID: 'devUUID', + uid: 'uid-migration', + }) + + CONFIG_EXTENSION = await testAppConfigExtensions() + WEBHOOK_SUBSCRIPTION_EXTENSION = await testSingleWebhookSubscriptionExtension() + + APP = testApp({ + name: 'my-app', + directory: '/app', + configuration: { + client_id: REMOTE_APP.apiKey, + name: 'my-app', + application_url: 'https://example.com', + embedded: true, + access_scopes: { + scopes: 'read_products', + }, + }, + allExtensions: [EXTENSION_A, EXTENSION_B, CONFIG_EXTENSION], + }) + + REMOTE_EXTENSION_A = { + registrationId: EXTENSION_A.uid, + registrationUuid: 'remote-uuid-a', + registrationTitle: 'Remote Extension A', + type: 'checkout_post_purchase', + config: await EXTENSION_A.deployConfig({apiKey: REMOTE_APP.apiKey, appConfiguration: APP.configuration}), + specification: { + identifier: 'checkout_post_purchase', + name: 'Post purchase UI extension', + experience: 'extension', + options: {managementExperience: 'cli'}, + }, + } + + REMOTE_EXTENSION_DELETED = { + registrationId: 'deleted-uid', + registrationUuid: 'deleted-uuid', + registrationTitle: 'Deleted Extension', + type: 'checkout_post_purchase', + config: {}, + specification: { + identifier: 'checkout_post_purchase', + name: 'Post purchase UI extension', + experience: 'extension', + options: {managementExperience: 'cli'}, + }, + } + + REMOTE_CONFIG_EXTENSION = { + registrationId: CONFIG_EXTENSION.uid, + registrationUuid: 'remote-config-uuid', + registrationTitle: 'Point of Sale', + type: 'point_of_sale', + config: {}, + specification: { + identifier: 'point_of_sale', + name: 'Point of Sale', + experience: 'configuration', + options: {managementExperience: 'cli'}, + }, + } +}) + +beforeEach(() => { + vi.mocked(deployOrReleaseConfirmationPrompt).mockResolvedValue(true) + vi.mocked(extensionMigrationPrompt).mockResolvedValue(true) + vi.mocked(migrateExtensionsToUIExtension).mockResolvedValue([]) +}) + +function deployOptionsFor(app: AppInterface, envIdentifiers: {[localIdentifier: string]: string}) { + return { + app, + appId: REMOTE_APP.apiKey, + appName: REMOTE_APP.title, + release: true, + developerPlatformClient: testDeveloperPlatformClient(), + envIdentifiers, + remoteApp: REMOTE_APP, + } +} + +describe('classifyDeployExtensionChanges', () => { + test('classifies local and active app version extensions in one pass', async () => { + const changes = await classifyDeployExtensionChanges({ + options: deployOptionsFor(APP, {}), + activeAppVersion: {appModuleVersions: [REMOTE_EXTENSION_A, REMOTE_EXTENSION_DELETED, REMOTE_CONFIG_EXTENSION]}, + }) + + expect( + changes.map((change) => ({ + status: change.status, + local: change.local?.localIdentifier, + remote: change.remote?.registrationTitle, + })), + ).toEqual([ + {status: 'unchanged', local: EXTENSION_A.localIdentifier, remote: 'Remote Extension A'}, + {status: 'created', local: EXTENSION_B.localIdentifier, remote: undefined}, + {status: 'unchanged', local: CONFIG_EXTENSION.localIdentifier, remote: 'Point of Sale'}, + {status: 'deleted', local: undefined, remote: 'Deleted Extension'}, + ]) + }) + + test('classifies UUID fallback matches as updated', async () => { + const remoteWithoutMatchingUID = { + ...REMOTE_EXTENSION_A, + registrationId: '', + } + + const changes = await classifyDeployExtensionChanges({ + options: deployOptionsFor(testApp({...APP, allExtensions: [EXTENSION_A]}), { + [EXTENSION_A.localIdentifier]: REMOTE_EXTENSION_A.registrationUuid!, + }), + activeAppVersion: {appModuleVersions: [remoteWithoutMatchingUID]}, + }) + + expect(changes).toMatchObject([ + { + status: 'updated', + local: {localIdentifier: EXTENSION_A.localIdentifier}, + remote: {registrationUuid: REMOTE_EXTENSION_A.registrationUuid}, + }, + ]) + }) + + test('keeps same-UID non-config extensions unchanged even when config differs', async () => { + const changes = await classifyDeployExtensionChanges({ + options: deployOptionsFor(testApp({...APP, allExtensions: [EXTENSION_A]}), {}), + activeAppVersion: { + appModuleVersions: [ + { + ...REMOTE_EXTENSION_A, + config: {changed: true}, + }, + ], + }, + }) + + expect(changes).toMatchObject([ + { + status: 'unchanged', + local: {localIdentifier: EXTENSION_A.localIdentifier}, + remote: {registrationUuid: REMOTE_EXTENSION_A.registrationUuid}, + }, + ]) + }) + + test('groups remote-only webhook subscription changes under configuration webhooks', async () => { + const remoteWebhookSubscriptions = [ + { + registrationId: 'app/uninstalled:::https://example.com/webhooks/app/uninstalled', + registrationUuid: 'webhook-subscription-uuid-1', + registrationTitle: 'app/uninstalled:::https://example.com/webhooks/app/uninstalled', + type: 'webhook_subscription', + config: { + topic: 'app/uninstalled', + uri: 'https://example.com/webhooks/app/uninstalled', + }, + specification: { + identifier: 'webhook_subscription', + name: 'Webhook Subscription', + experience: 'configuration', + options: {managementExperience: 'cli'}, + }, + }, + { + registrationId: 'scopes/update:::https://example.com/webhooks/scopes/update', + registrationUuid: 'webhook-subscription-uuid-2', + registrationTitle: 'scopes/update:::https://example.com/webhooks/scopes/update', + type: 'webhook_subscription', + config: { + topic: 'scopes/update', + uri: 'https://example.com/webhooks/scopes/update', + }, + specification: { + identifier: 'webhook_subscription', + name: 'Webhook Subscription', + experience: 'configuration', + options: {managementExperience: 'cli'}, + }, + }, + ] as AppModuleVersion[] + const app = testApp({ + ...APP, + allExtensions: [], + specifications: [WEBHOOK_SUBSCRIPTION_EXTENSION.specification], + }) + + await ensureDeployIdentifiersFromAppVersion({ + app, + appId: REMOTE_APP.apiKey, + appName: REMOTE_APP.title, + release: true, + developerPlatformClient: testDeveloperPlatformClient(), + envIdentifiers: {}, + remoteApp: REMOTE_APP, + activeAppVersion: {appModuleVersions: remoteWebhookSubscriptions}, + allowDeletes: true, + }) + + expect(deployOrReleaseConfirmationPrompt).toHaveBeenCalledWith( + expect.objectContaining({ + extensionIdentifiersBreakdown: { + onlyRemote: [], + toCreate: [], + toUpdate: [], + unchanged: [], + }, + configExtensionIdentifiersBreakdown: { + existingFieldNames: [], + existingUpdatedFieldNames: [], + newFieldNames: [], + deletedFieldNames: ['webhooks'], + }, + }), + ) + }) + + test('trusts remote experience when deciding if remote-only modules are configuration', async () => { + const remoteConfigLikeExtension = { + registrationId: 'remote-config-id', + registrationUuid: 'remote-config-uuid', + registrationTitle: 'Remote Config-Like Extension', + type: 'unknown_remote_type', + config: {}, + specification: { + identifier: 'unknown_remote_type', + name: 'Unknown Remote Type', + experience: 'configuration', + options: {managementExperience: 'cli'}, + }, + } as AppModuleVersion + const app = testApp({...APP, allExtensions: [], specifications: []}) + + await ensureDeployIdentifiersFromAppVersion({ + app, + appId: REMOTE_APP.apiKey, + appName: REMOTE_APP.title, + release: true, + developerPlatformClient: testDeveloperPlatformClient(), + envIdentifiers: {}, + remoteApp: REMOTE_APP, + activeAppVersion: {appModuleVersions: [remoteConfigLikeExtension]}, + allowDeletes: true, + }) + + expect(deployOrReleaseConfirmationPrompt).toHaveBeenCalledWith( + expect.objectContaining({ + extensionIdentifiersBreakdown: { + onlyRemote: [], + toCreate: [], + toUpdate: [], + unchanged: [], + }, + configExtensionIdentifiersBreakdown: undefined, + }), + ) + }) + + test('shows each config field once and collapses mixed webhook changes to updated', async () => { + const remoteWebhookSubscription = { + registrationId: 'app/uninstalled:::https://example.com/webhooks/app/uninstalled', + registrationUuid: 'webhook-subscription-uuid-1', + registrationTitle: 'app/uninstalled:::https://example.com/webhooks/app/uninstalled', + type: 'webhook_subscription', + config: { + topic: 'app/uninstalled', + uri: 'https://example.com/webhooks/app/uninstalled', + }, + specification: { + identifier: 'webhook_subscription', + name: 'Webhook Subscription', + experience: 'configuration', + options: {managementExperience: 'cli'}, + }, + } as AppModuleVersion + const app = testApp({ + ...APP, + allExtensions: [WEBHOOK_SUBSCRIPTION_EXTENSION], + specifications: [WEBHOOK_SUBSCRIPTION_EXTENSION.specification], + }) + + await ensureDeployIdentifiersFromAppVersion({ + app, + appId: REMOTE_APP.apiKey, + appName: REMOTE_APP.title, + release: true, + developerPlatformClient: testDeveloperPlatformClient(), + envIdentifiers: {}, + remoteApp: REMOTE_APP, + activeAppVersion: {appModuleVersions: [remoteWebhookSubscription]}, + allowDeletes: true, + }) + + expect(deployOrReleaseConfirmationPrompt).toHaveBeenCalledWith( + expect.objectContaining({ + configExtensionIdentifiersBreakdown: { + existingFieldNames: [], + existingUpdatedFieldNames: ['webhooks'], + newFieldNames: [], + deletedFieldNames: [], + }, + }), + ) + }) + + test('only marks changed fields as updated when one config specification owns multiple fields', async () => { + type DataConfig = BaseConfigType & { + product?: {enabled: boolean} + metaobject?: {enabled: boolean} + } + const dataSpec = createConfigExtensionSpecification({ + identifier: 'data', + schema: zod.object({ + product: zod.object({enabled: zod.boolean()}).optional(), + metaobject: zod.object({enabled: zod.boolean()}).optional(), + }), + transformConfig: { + product: 'product', + metaobject: 'metaobject', + }, + }) + const dataExtension = new ExtensionInstance({ + configuration: { + product: {enabled: true}, + metaobject: {enabled: true}, + }, + configurationPath: 'shopify.app.toml', + directory: '/app', + specification: dataSpec, + }) + const remoteDataModule = { + registrationId: dataExtension.uid, + registrationUuid: 'data-uuid', + registrationTitle: 'Data', + type: 'data', + config: { + product: {enabled: false}, + metaobject: {enabled: true}, + }, + specification: { + identifier: 'data', + name: 'Data', + experience: 'configuration', + options: {managementExperience: 'cli'}, + }, + } as AppModuleVersion + + await ensureDeployIdentifiersFromAppVersion({ + app: testApp({...APP, allExtensions: [dataExtension], specifications: [dataSpec]}), + appId: REMOTE_APP.apiKey, + appName: REMOTE_APP.title, + release: true, + developerPlatformClient: testDeveloperPlatformClient(), + envIdentifiers: {}, + remoteApp: REMOTE_APP, + activeAppVersion: {appModuleVersions: [remoteDataModule]}, + allowDeletes: true, + }) + + expect(deployOrReleaseConfirmationPrompt).toHaveBeenCalledWith( + expect.objectContaining({ + configExtensionIdentifiersBreakdown: { + existingFieldNames: ['metaobject'], + existingUpdatedFieldNames: ['product'], + newFieldNames: [], + deletedFieldNames: [], + }, + }), + ) + }) + + test('compares webhook subscriptions by config content instead of matching by UID', async () => { + const localWebhookSubscription = await testSingleWebhookSubscriptionExtension({ + config: { + topic: 'orders/delete', + api_version: '2024-01', + uri: '/webhooks/orders/delete', + }, + }) + const remoteWebhookSubscription = { + registrationId: 'orders/delete::::https://example.com/webhooks/orders/delete', + registrationUuid: 'webhook-subscription-uuid', + registrationTitle: 'orders/delete::::https://example.com/webhooks/orders/delete', + type: 'webhook_subscription', + config: { + topic: 'orders/delete', + api_version: '2024-01', + uri: 'https://example.com/webhooks/orders/delete', + }, + specification: { + identifier: 'webhook_subscription', + name: 'Webhook Subscription', + experience: 'configuration', + options: {managementExperience: 'cli'}, + }, + } as AppModuleVersion + + await ensureDeployIdentifiersFromAppVersion({ + app: testApp({ + ...APP, + allExtensions: [localWebhookSubscription], + specifications: [WEBHOOK_SUBSCRIPTION_EXTENSION.specification], + }), + appId: REMOTE_APP.apiKey, + appName: REMOTE_APP.title, + release: true, + developerPlatformClient: testDeveloperPlatformClient(), + envIdentifiers: {}, + remoteApp: REMOTE_APP, + activeAppVersion: {appModuleVersions: [remoteWebhookSubscription]}, + allowDeletes: true, + }) + + expect(deployOrReleaseConfirmationPrompt).toHaveBeenCalledWith( + expect.objectContaining({ + extensionIdentifiersBreakdown: { + onlyRemote: [], + toCreate: [], + toUpdate: [], + unchanged: [], + }, + configExtensionIdentifiersBreakdown: { + existingFieldNames: ['webhooks'], + existingUpdatedFieldNames: [], + newFieldNames: [], + deletedFieldNames: [], + }, + }), + ) + }) + + test('does not mark webhooks as updated when subscriptions are in a different order', async () => { + const localWebhookSubscriptions = [ + await testSingleWebhookSubscriptionExtension({ + config: { + topic: 'orders/delete', + api_version: '2024-01', + uri: 'https://example.com/webhooks/orders/delete', + }, + }), + await testSingleWebhookSubscriptionExtension({ + config: { + topic: 'products/update', + api_version: '2024-01', + uri: 'https://example.com/webhooks/products/update', + }, + }), + ] + const remoteWebhookSubscriptions = [ + { + registrationId: 'products/update::::https://example.com/webhooks/products/update', + registrationUuid: 'webhook-subscription-uuid-1', + registrationTitle: 'products/update::::https://example.com/webhooks/products/update', + type: 'webhook_subscription', + config: { + topic: 'products/update', + api_version: '2024-01', + uri: 'https://example.com/webhooks/products/update', + }, + specification: { + identifier: 'webhook_subscription', + name: 'Webhook Subscription', + experience: 'configuration', + options: {managementExperience: 'cli'}, + }, + }, + { + registrationId: 'orders/delete::::https://example.com/webhooks/orders/delete', + registrationUuid: 'webhook-subscription-uuid-2', + registrationTitle: 'orders/delete::::https://example.com/webhooks/orders/delete', + type: 'webhook_subscription', + config: { + topic: 'orders/delete', + api_version: '2024-01', + uri: 'https://example.com/webhooks/orders/delete', + }, + specification: { + identifier: 'webhook_subscription', + name: 'Webhook Subscription', + experience: 'configuration', + options: {managementExperience: 'cli'}, + }, + }, + ] as AppModuleVersion[] + + await ensureDeployIdentifiersFromAppVersion({ + app: testApp({ + ...APP, + allExtensions: localWebhookSubscriptions, + specifications: [WEBHOOK_SUBSCRIPTION_EXTENSION.specification], + }), + appId: REMOTE_APP.apiKey, + appName: REMOTE_APP.title, + release: true, + developerPlatformClient: testDeveloperPlatformClient(), + envIdentifiers: {}, + remoteApp: REMOTE_APP, + activeAppVersion: {appModuleVersions: remoteWebhookSubscriptions}, + allowDeletes: true, + }) + + expect(deployOrReleaseConfirmationPrompt).toHaveBeenCalledWith( + expect.objectContaining({ + configExtensionIdentifiersBreakdown: { + existingFieldNames: ['webhooks'], + existingUpdatedFieldNames: [], + newFieldNames: [], + deletedFieldNames: [], + }, + }), + ) + }) +}) + +describe('ensureDeployIdentifiersFromAppVersion', () => { + test('prompts with the existing UI breakdown shape and returns deploy identifiers', async () => { + const developerPlatformClient: DeveloperPlatformClient = testDeveloperPlatformClient() + + const identifiers = await ensureDeployIdentifiersFromAppVersion({ + app: APP, + appId: REMOTE_APP.apiKey, + appName: REMOTE_APP.title, + release: true, + developerPlatformClient, + envIdentifiers: {}, + remoteApp: REMOTE_APP, + activeAppVersion: {appModuleVersions: [REMOTE_EXTENSION_A, REMOTE_EXTENSION_DELETED, REMOTE_CONFIG_EXTENSION]}, + allowDeletes: true, + }) + + expect(deployOrReleaseConfirmationPrompt).toHaveBeenCalledWith({ + extensionIdentifiersBreakdown: { + onlyRemote: [{title: 'Deleted Extension', uid: 'deleted-uid', experience: 'extension'}], + toCreate: [{title: EXTENSION_B.localIdentifier, uid: EXTENSION_B.uid, experience: 'extension'}], + toUpdate: [], + unchanged: [{title: EXTENSION_A.localIdentifier, uid: undefined, experience: 'extension'}], + }, + configExtensionIdentifiersBreakdown: { + existingFieldNames: [], + existingUpdatedFieldNames: [], + newFieldNames: ['pos'], + deletedFieldNames: [], + }, + appTitle: REMOTE_APP.title, + release: true, + allowUpdates: undefined, + allowDeletes: true, + installCount: undefined, + }) + expect(identifiers).toEqual({ + appModuleUuids: { + [EXTENSION_A.localIdentifier]: REMOTE_EXTENSION_A.registrationUuid, + [EXTENSION_B.localIdentifier]: EXTENSION_B.uid, + [CONFIG_EXTENSION.localIdentifier]: REMOTE_CONFIG_EXTENSION.registrationUuid, + }, + appModuleRegistrationIds: { + [EXTENSION_A.localIdentifier]: EXTENSION_A.uid, + [EXTENSION_B.localIdentifier]: EXTENSION_B.uid, + [CONFIG_EXTENSION.localIdentifier]: CONFIG_EXTENSION.uid, + }, + }) + }) + + test('runs extension migrations before classifying the app version', async () => { + const legacyRemoteExtension = { + uuid: 'legacy-uuid-a', + id: '', + title: EXTENSION_TO_MIGRATE.localIdentifier, + type: 'CHECKOUT_UI_EXTENSION', + } + const migratedModule = { + registrationId: EXTENSION_TO_MIGRATE.uid, + registrationUuid: legacyRemoteExtension.uuid, + registrationTitle: 'Legacy UI', + type: 'ui_extension', + config: await EXTENSION_TO_MIGRATE.deployConfig({apiKey: REMOTE_APP.apiKey, appConfiguration: APP.configuration}), + specification: { + identifier: 'ui_extension', + name: 'UI Extension', + experience: 'extension', + options: {managementExperience: 'cli'}, + }, + } as AppModuleVersion + const activeAppVersion = {appModuleVersions: [migratedModule]} + const developerPlatformClient: DeveloperPlatformClient = testDeveloperPlatformClient({ + appExtensionRegistrations: () => + Promise.resolve({ + app: { + extensionRegistrations: [legacyRemoteExtension], + dashboardManagedExtensionRegistrations: [], + configurationRegistrations: [], + }, + }), + activeAppVersion: () => Promise.resolve(activeAppVersion), + }) + + await ensureDeployIdentifiersFromAppVersion({ + app: testApp({...APP, allExtensions: [EXTENSION_TO_MIGRATE]}), + appId: REMOTE_APP.apiKey, + appName: REMOTE_APP.title, + release: true, + developerPlatformClient, + envIdentifiers: {}, + remoteApp: REMOTE_APP, + activeAppVersion: {appModuleVersions: []}, + allowUpdates: true, + }) + + expect(extensionMigrationPrompt).toHaveBeenCalledWith([ + {local: EXTENSION_TO_MIGRATE, remote: legacyRemoteExtension}, + ]) + expect(migrateExtensionsToUIExtension).toHaveBeenCalledWith({ + extensionsToMigrate: [{local: EXTENSION_TO_MIGRATE, remote: legacyRemoteExtension}], + appId: REMOTE_APP.apiKey, + remoteExtensions: [legacyRemoteExtension], + migrationClient: expect.objectContaining({clientName: 'partners'}), + }) + expect(deployOrReleaseConfirmationPrompt).toHaveBeenCalledWith( + expect.objectContaining({ + extensionIdentifiersBreakdown: expect.objectContaining({ + unchanged: [{title: EXTENSION_TO_MIGRATE.localIdentifier, uid: undefined, experience: 'extension'}], + }), + }), + ) + }) + + test('aborts when extension migration is declined', async () => { + const legacyRemoteExtension = { + uuid: 'legacy-uuid-a', + id: '', + title: EXTENSION_TO_MIGRATE.localIdentifier, + type: 'CHECKOUT_UI_EXTENSION', + } + const developerPlatformClient: DeveloperPlatformClient = testDeveloperPlatformClient({ + appExtensionRegistrations: () => + Promise.resolve({ + app: { + extensionRegistrations: [legacyRemoteExtension], + dashboardManagedExtensionRegistrations: [], + configurationRegistrations: [], + }, + }), + }) + vi.mocked(extensionMigrationPrompt).mockResolvedValue(false) + + await expect( + ensureDeployIdentifiersFromAppVersion({ + app: testApp({...APP, allExtensions: [EXTENSION_TO_MIGRATE]}), + appId: REMOTE_APP.apiKey, + appName: REMOTE_APP.title, + release: true, + developerPlatformClient, + envIdentifiers: {}, + remoteApp: REMOTE_APP, + activeAppVersion: {appModuleVersions: []}, + }), + ).rejects.toThrow(AbortSilentError) + expect(deployOrReleaseConfirmationPrompt).not.toHaveBeenCalled() + }) +}) diff --git a/packages/app/src/cli/services/context/deploy-app-version.ts b/packages/app/src/cli/services/context/deploy-app-version.ts new file mode 100644 index 00000000000..3c9bcacc315 --- /dev/null +++ b/packages/app/src/cli/services/context/deploy-app-version.ts @@ -0,0 +1,191 @@ +import { + buildDashboardBreakdownInfo, + buildConfigExtensionIdentifiersBreakdown, + buildExtensionBreakdownInfo, + ExtensionIdentifiersBreakdown, +} from './breakdown-extensions.js' +import {activeAppVersionAfterMigrations} from './deploy-app-version-migrations.js' +import {EnsureDeploymentIdsPresenceOptions} from './identifiers.js' +import {remoteAppConfigurationExtensionContent} from '../app/select-app.js' +import {AppInterface} from '../../models/app/app.js' +import {DeployIdentifiers, ExtensionUuidsByLocalIdentifier} from '../../models/app/identifiers.js' +import {MinimalOrganizationApp} from '../../models/organization.js' +import {ExtensionInstance} from '../../models/extensions/extension-instance.js' +import {deployOrReleaseConfirmationPrompt} from '../../prompts/deploy-release.js' +import {AppModuleVersion, AppVersion} from '../../utilities/developer-platform-client.js' +import {AbortSilentError} from '@shopify/cli-kit/node/error' +import {deepMergeObjects} from '@shopify/cli-kit/common/object' + +type DeployExtensionChangeStatus = 'created' | 'updated' | 'deleted' | 'unchanged' + +interface DeployExtensionChange { + status: DeployExtensionChangeStatus + experience: 'configuration' | 'extension' | 'deprecated' + local?: ExtensionInstance + remote?: AppModuleVersion +} + +interface ClassifyDeployExtensionChangesOptions { + options: EnsureDeploymentIdsPresenceOptions + activeAppVersion?: AppVersion +} + +/** Builds deploy identifiers after migration, classification, and confirmation. */ +export async function ensureDeployIdentifiersFromAppVersion( + options: EnsureDeploymentIdsPresenceOptions, +): Promise { + const activeAppVersion = await activeAppVersionAfterMigrations(options) + const changes = await classifyDeployExtensionChanges({options, activeAppVersion}) + const extensionIdentifiersBreakdown = buildExtensionIdentifiersBreakdown(changes) + const configExtensionIdentifiersBreakdown = await buildDeployConfigExtensionIdentifiersBreakdown( + options, + activeAppVersion, + ) + + const shouldFetchInstallCount = + options.release && !options.allowDeletes && extensionIdentifiersBreakdown.onlyRemote.length > 0 + const installCount = shouldFetchInstallCount ? await fetchInstallCount(options).catch(() => undefined) : undefined + + const confirmed = await deployOrReleaseConfirmationPrompt({ + extensionIdentifiersBreakdown, + configExtensionIdentifiersBreakdown, + appTitle: options.remoteApp?.title, + release: options.release, + allowUpdates: options.allowUpdates, + allowDeletes: options.allowDeletes, + installCount, + }) + if (!confirmed) throw new AbortSilentError() + + return buildDeployIdentifiersFromChanges(changes) +} + +/** Classifies local and active-version extensions by deploy outcome. */ +export async function classifyDeployExtensionChanges({ + options, + activeAppVersion, +}: ClassifyDeployExtensionChangesOptions): Promise { + const remoteModules = activeAppVersion?.appModuleVersions ?? [] + const app = options.app + const localChanges = await Promise.all( + app.allExtensions.map(async (local): Promise => { + const experience = local.specification.experience + const remoteMatchByUID = remoteModules.find((remote) => remote.registrationId === local.uid) + if (remoteMatchByUID) { + return {experience, status: 'unchanged', local, remote: remoteMatchByUID} + } + + // Legacy match by UUID, used only if extensions are not migrated and don't have a remote UID. + const localUUID = options.envIdentifiers[local.localIdentifier] + const remoteByUUID = localUUID ? remoteModules.find((remote) => remote.registrationUuid === localUUID) : undefined + if (remoteByUUID) { + return {experience, status: 'updated', local, remote: remoteByUUID} + } + + return {experience, status: 'created', local} + }), + ) + + const matchedRemoteModules = localChanges.map((change) => change.remote) + const deletedChanges = remoteModules + .filter((remote) => !matchedRemoteModules.includes(remote)) + .map( + (remote): DeployExtensionChange => ({ + experience: remote.specification?.experience ?? 'extension', + status: 'deleted', + remote, + }), + ) + + return [...localChanges, ...deletedChanges] +} + +/** Converts config deploy changes into the existing config prompt breakdown. */ +async function buildDeployConfigExtensionIdentifiersBreakdown( + options: EnsureDeploymentIdsPresenceOptions, + activeAppVersion: AppVersion | undefined, +): Promise> { + const {app, appId} = options + const localConfig = await localAppConfigurationExtensionContent(app, appId) + const remoteConfig = remoteAppConfigurationExtensionContent( + activeAppVersion?.appModuleVersions ?? [], + app.specifications ?? [], + app.remoteFlags, + ) + + return buildConfigExtensionIdentifiersBreakdown(localConfig, remoteConfig) +} + +/** Converts deploy changes into the existing extension prompt breakdown. */ +function buildExtensionIdentifiersBreakdown(changes: DeployExtensionChange[]): ExtensionIdentifiersBreakdown { + const visibleChanges = changes.filter((change) => change.experience === 'extension') + + return { + onlyRemote: visibleChanges + .filter((change) => change.status === 'deleted') + .map((change) => buildRemoteBreakdownInfo(change.remote!)), + toCreate: visibleChanges + .filter((change) => change.status === 'created') + .map((change) => buildExtensionBreakdownInfo(change.local!.localIdentifier, change.local!.uid)), + toUpdate: visibleChanges + .filter((change) => change.status === 'updated') + .map((change) => buildExtensionBreakdownInfo(change.local!.localIdentifier, undefined)), + unchanged: visibleChanges + .filter((change) => change.status === 'unchanged') + .map((change) => buildExtensionBreakdownInfo(change.local!.localIdentifier, undefined)), + } +} + +/** Converts deploy changes into the identifiers needed by upload. */ +function buildDeployIdentifiersFromChanges(changes: DeployExtensionChange[]) { + const appModuleUuids: ExtensionUuidsByLocalIdentifier = {} + const appModuleRegistrationIds: ExtensionUuidsByLocalIdentifier = {} + + for (const change of changes) { + if (!change.local) continue + + const localUID = change.local.uid + const uuid = change.remote?.registrationUuid ?? localUID + const registrationId = change.remote?.registrationId ?? localUID + appModuleUuids[change.local.localIdentifier] = uuid + appModuleRegistrationIds[change.local.localIdentifier] = registrationId + } + + return {appModuleUuids, appModuleRegistrationIds} +} + +async function localAppConfigurationExtensionContent(app: AppInterface, apiKey: string) { + let appConfig: {[key: string]: unknown} = {} + const configExtensions = app.allExtensions.filter((extension) => extension.isAppConfigExtension) + + for (const extension of configExtensions) { + // eslint-disable-next-line no-await-in-loop + const deployConfig = await extension.deployConfig({apiKey, appConfiguration: app.configuration}) + const localConfig = + extension.specification.transformRemoteToLocal?.(deployConfig ?? {}, {flags: app.remoteFlags}) ?? + extension.configuration + appConfig = deepMergeObjects(appConfig, localConfig) + } + + return appConfig +} + +/** Builds prompt metadata for a remote-only module. */ +function buildRemoteBreakdownInfo(remote: AppModuleVersion) { + if (remote.specification?.options.managementExperience === 'dashboard') { + return buildDashboardBreakdownInfo(remote.registrationTitle) + } + return buildExtensionBreakdownInfo(remote.registrationTitle, remote.registrationId) +} + +/** Fetches install count for delete warnings. */ +async function fetchInstallCount(options: { + developerPlatformClient: EnsureDeploymentIdsPresenceOptions['developerPlatformClient'] + remoteApp: MinimalOrganizationApp +}) { + return options.developerPlatformClient.appInstallCount({ + id: options.remoteApp.id, + apiKey: options.remoteApp.apiKey, + organizationId: options.remoteApp.organizationId, + }) +} diff --git a/packages/app/src/cli/services/context/identifiers-extensions.test.ts b/packages/app/src/cli/services/context/identifiers-extensions.test.ts deleted file mode 100644 index ec5c8b3f3de..00000000000 --- a/packages/app/src/cli/services/context/identifiers-extensions.test.ts +++ /dev/null @@ -1,1099 +0,0 @@ -/* eslint-disable @shopify/prefer-module-scope-constants */ -import {automaticMatchmaking} from './id-matching.js' -import { - deployConfirmed, - ensureExtensionsIds, - ensureNonUuidManagedExtensionsIds, - groupRegistrationByUidStrategy, -} from './identifiers-extensions.js' -import {extensionMigrationPrompt, matchConfirmationPrompt} from './prompts.js' -import {manualMatchIds} from './id-manual-matching.js' -import {EnsureDeploymentIdsPresenceOptions, LocalSource, RemoteSource} from './identifiers.js' -import {AppInterface} from '../../models/app/app.js' -import { - testApp, - testAppConfigExtensions, - testFunctionExtension, - testOrganizationApp, - testUIExtension, - testPaymentsAppExtension, - testDeveloperPlatformClient, - testSingleWebhookSubscriptionExtension, -} from '../../models/app/app.test-data.js' -import {migrateExtensionsToUIExtension} from '../dev/migrate-to-ui-extension.js' -import {OrganizationApp} from '../../models/organization.js' -import {ExtensionInstance} from '../../models/extensions/extension-instance.js' -import {DeveloperPlatformClient, Flag} from '../../utilities/developer-platform-client.js' -import appPOSSpec from '../../models/extensions/specifications/app_config_point_of_sale.js' -import appWebhookSubscriptionSpec from '../../models/extensions/specifications/app_config_webhook_subscription.js' -import {getModulesToMigrate} from '../dev/migrate-app-module.js' -import {ExtensionSpecification} from '../../models/extensions/specification.js' -import {PartnersClient} from '../../utilities/developer-platform-client/partners-client.js' -import {beforeEach, describe, expect, vi, test, beforeAll} from 'vitest' -import {AbortSilentError} from '@shopify/cli-kit/node/error' -import {setPathValue} from '@shopify/cli-kit/common/object' - -const REGISTRATION_A = { - uuid: 'UUID_A', - id: 'A', - title: 'A', - type: 'CHECKOUT_POST_PURCHASE', - contextValue: '', -} - -const REGISTRATION_A_2 = { - uuid: 'UUID_A_2', - id: 'A_2', - title: 'A_2', - type: 'CHECKOUT_POST_PURCHASE', - contextValue: '', -} - -const REGISTRATION_A_3 = { - uuid: 'UUID_A_3', - id: 'A_3', - title: 'A_3', - type: 'CHECKOUT_POST_PURCHASE', - contextValue: '', -} - -const REGISTRATION_B = { - uuid: 'UUID_B', - id: 'B', - title: 'B', - type: 'SUBSCRIPTION_MANAGEMENT', - contextValue: '', -} - -const FUNCTION_REGISTRATION_A = { - uuid: 'FUNCTION_A_UUID', - id: 'FUNCTION_A', - title: 'FUNCTION_A', - type: 'FUNCTION', - contextValue: '', -} - -const PAYMENTS_REGISTRATION_A = { - uuid: 'PAYMENTS_A_UUID', - id: 'PAYMENTS_A', - title: 'PAYMENTS_A', - type: 'PAYMENTS', - contextValue: 'payments.offsite.render', -} - -let EXTENSION_A: ExtensionInstance -let EXTENSION_A_2: ExtensionInstance -let EXTENSION_B: ExtensionInstance -let FUNCTION_A: ExtensionInstance -let PAYMENTS_A: ExtensionInstance - -const LOCAL_APP = ( - uiExtensions: ExtensionInstance[], - functionExtensions: ExtensionInstance[] = [], - includeDeployConfig = false, - configExtensions: ExtensionInstance[] = [], -): AppInterface => { - return testApp({ - name: 'my-app', - directory: '/app', - configuration: { - client_id: 'test-client-id', - name: 'my-app', - application_url: 'https://example.com', - embedded: true, - access_scopes: { - scopes: 'read_products', - }, - extension_directories: ['extensions/*'], - ...(includeDeployConfig ? {build: {include_config_on_deploy: true}} : {}), - }, - allExtensions: [...uiExtensions, ...functionExtensions, ...configExtensions], - }) -} - -const options = ( - uiExtensions: ExtensionInstance[], - functionExtensions: ExtensionInstance[] = [], - options: { - identifiers?: any - remoteApp?: OrganizationApp - release?: boolean - includeDeployConfig?: boolean - configExtensions?: ExtensionInstance[] - flags?: Flag[] - developerPlatformClient?: DeveloperPlatformClient - } = {}, -): EnsureDeploymentIdsPresenceOptions => { - const { - identifiers = {}, - remoteApp = testOrganizationApp(), - release = true, - includeDeployConfig = false, - configExtensions = [], - flags = [], - developerPlatformClient = testDeveloperPlatformClient(), - } = options - const localApp = { - app: LOCAL_APP(uiExtensions, functionExtensions, includeDeployConfig, configExtensions), - developerPlatformClient, - appId: 'appId', - appName: 'appName', - envIdentifiers: {extensions: identifiers}, - force: false, - remoteApp, - release, - } - setPathValue(localApp.app, 'remoteFlags', flags) - return localApp -} - -vi.mock('@shopify/cli-kit/node/session') - -vi.mock('./prompts', async () => { - const prompts: any = await vi.importActual('./prompts') - return { - ...prompts, - matchConfirmationPrompt: vi.fn(), - deployConfirmationPrompt: vi.fn(), - extensionMigrationPrompt: vi.fn(), - } -}) -vi.mock('./id-matching') -vi.mock('./id-manual-matching') -vi.mock('../dev/migrate-to-ui-extension') -vi.mock('../dev/migrate-app-module') -beforeAll(async () => { - EXTENSION_A = await testUIExtension({ - directory: 'EXTENSION_A', - type: 'checkout_post_purchase', - configuration: { - name: 'EXTENSION A', - type: 'checkout_post_purchase', - metafields: [], - capabilities: { - network_access: false, - block_progress: false, - api_access: false, - collect_buyer_consent: { - sms_marketing: false, - customer_privacy: false, - }, - iframe: { - sources: [], - }, - }, - }, - entrySourceFilePath: '', - }) - - EXTENSION_A_2 = await testUIExtension({ - directory: 'EXTENSION_A_2', - type: 'checkout_post_purchase', - configuration: { - name: 'EXTENSION A 2', - type: 'checkout_post_purchase', - metafields: [], - capabilities: { - network_access: false, - block_progress: false, - api_access: false, - collect_buyer_consent: { - sms_marketing: false, - customer_privacy: false, - }, - iframe: { - sources: [], - }, - }, - }, - entrySourceFilePath: '', - devUUID: 'devUUID', - }) - - EXTENSION_B = await testUIExtension({ - directory: 'EXTENSION_B', - type: 'checkout_post_purchase', - configuration: { - name: 'EXTENSION_B', - type: 'checkout_post_purchase', - metafields: [], - capabilities: { - network_access: false, - block_progress: false, - api_access: false, - collect_buyer_consent: { - sms_marketing: false, - customer_privacy: false, - }, - iframe: { - sources: [], - }, - }, - }, - entrySourceFilePath: '', - devUUID: 'devUUID', - }) - - FUNCTION_A = await testFunctionExtension({ - dir: '/function', - config: { - name: 'FUNCTION A', - type: 'product_discounts', - description: 'Function', - build: { - command: 'make build', - path: 'dist/index.wasm', - wasm_opt: true, - }, - configuration_ui: false, - api_version: '2022-07', - }, - }) - - PAYMENTS_A = await testPaymentsAppExtension({ - dir: '/payments', - config: { - name: 'Payments Extension', - type: 'payments_extension', - description: 'Payments App Extension', - api_version: '2022-07', - payment_session_url: 'https://example.com/payment', - supported_countries: ['US'], - supported_payment_methods: ['VISA'], - test_mode_available: true, - merchant_label: 'Merchant Label', - supports_installments: false, - supports_deferred_payments: false, - supports_3ds: false, - targeting: [{target: 'payments.offsite.render'}], - }, - }) -}) - -beforeEach(() => { - vi.mocked(getModulesToMigrate).mockReturnValue([]) -}) - -describe('matchmaking returns more remote sources than local', () => { - test('ensureExtensionsIds', async () => { - // Given - vi.mocked(matchConfirmationPrompt).mockResolvedValueOnce(true) - vi.mocked(automaticMatchmaking).mockResolvedValueOnce({ - identifiers: {EXTENSION_A: 'UUID_A'}, - toCreate: [], - toConfirm: [], - toManualMatch: { - local: [], - remote: [REGISTRATION_B], - }, - }) - - // When - const got = await ensureExtensionsIds(options([EXTENSION_A]), { - extensionRegistrations: [REGISTRATION_A, REGISTRATION_B], - dashboardManagedExtensionRegistrations: [], - }) - - // Then - expect(got).toEqual({ - dashboardOnlyExtensions: [], - extensionsToCreate: [], - validMatches: { - EXTENSION_A: 'UUID_A', - }, - didMigrateDashboardExtensions: false, - }) - }) - test('deployConfirmed', async () => { - // Given - const extensionsToCreate: LocalSource[] = [] - const validMatches = { - EXTENSION_A: 'UUID_A', - } - const remoteExtensions = [REGISTRATION_A, REGISTRATION_B] - - // When - const got = await deployConfirmed(options([EXTENSION_A]), remoteExtensions, [], {extensionsToCreate, validMatches}) - - // Then - expect(got).toEqual({ - extensions: { - EXTENSION_A: 'UUID_A', - }, - extensionIds: {EXTENSION_A: 'A'}, - extensionsNonUuidManaged: {}, - }) - }) -}) - -describe('matchmaking returns ok with pending manual matches', () => { - test('ensureExtensionsIds: will call manualMatch and merge automatic and manual matches and create missing extensions', async () => { - // Given - vi.mocked(automaticMatchmaking).mockResolvedValueOnce({ - identifiers: {}, - toCreate: [], - toConfirm: [], - toManualMatch: { - local: [EXTENSION_A, EXTENSION_A_2, EXTENSION_B], - remote: [REGISTRATION_A, REGISTRATION_A_2], - }, - }) - - vi.mocked(manualMatchIds).mockResolvedValueOnce({ - identifiers: {EXTENSION_A: 'UUID_A', EXTENSION_A_2: 'UUID_A_2'}, - toCreate: [EXTENSION_B], - onlyRemote: [], - }) - - // When - const got = await ensureExtensionsIds(options([EXTENSION_A, EXTENSION_A_2]), { - extensionRegistrations: [REGISTRATION_A, REGISTRATION_A_2], - dashboardManagedExtensionRegistrations: [], - }) - - // Then - expect(manualMatchIds).toHaveBeenCalledWith({ - local: [EXTENSION_A, EXTENSION_A_2, EXTENSION_B], - remote: [REGISTRATION_A, REGISTRATION_A_2], - }) - expect(got).toEqual({ - dashboardOnlyExtensions: [], - extensionsToCreate: [EXTENSION_B], - validMatches: { - EXTENSION_A: 'UUID_A', - EXTENSION_A_2: 'UUID_A_2', - }, - didMigrateDashboardExtensions: false, - }) - }) - - test('deployConfirmed: create missing extensions', async () => { - // Given - const extensionsToCreate: LocalSource[] = [EXTENSION_B] - const validMatches = { - EXTENSION_A: 'UUID_A', - EXTENSION_A_2: 'UUID_A_2', - } - const remoteExtensions = [REGISTRATION_A, REGISTRATION_A_2] - const developerPlatformClient = testDeveloperPlatformClient() - - // When - const got = await deployConfirmed( - options([EXTENSION_A, EXTENSION_A_2], [], {developerPlatformClient}), - remoteExtensions, - [], - { - extensionsToCreate, - validMatches, - }, - ) - - // Then - expect(got).toEqual({ - extensions: { - EXTENSION_A: 'UUID_A', - EXTENSION_A_2: 'UUID_A_2', - 'extension-b': EXTENSION_B.uid, - }, - extensionIds: {EXTENSION_A: 'A', EXTENSION_A_2: 'A_2', 'extension-b': EXTENSION_B.uid}, - extensionsNonUuidManaged: {}, - }) - }) -}) - -describe('matchmaking returns ok with pending manual matches and manual match fails', () => { - test('ensureExtensionsIds', async () => { - // Given - vi.mocked(matchConfirmationPrompt).mockResolvedValueOnce(true) - vi.mocked(automaticMatchmaking).mockResolvedValueOnce({ - identifiers: {}, - toCreate: [], - toConfirm: [], - toManualMatch: { - local: [EXTENSION_A, EXTENSION_A_2], - remote: [REGISTRATION_A, REGISTRATION_A_2], - }, - }) - vi.mocked(manualMatchIds).mockResolvedValueOnce({ - identifiers: {EXTENSION_A: 'UUID_A'}, - toCreate: [EXTENSION_A_2], - onlyRemote: [REGISTRATION_A_2], - }) - - // When - const got = await ensureExtensionsIds(options([EXTENSION_A, EXTENSION_A_2]), { - extensionRegistrations: [REGISTRATION_A, REGISTRATION_A_2], - dashboardManagedExtensionRegistrations: [], - }) - - // Then - expect(got).toEqual({ - dashboardOnlyExtensions: [], - extensionsToCreate: [EXTENSION_A_2], - validMatches: { - EXTENSION_A: 'UUID_A', - }, - didMigrateDashboardExtensions: false, - }) - expect(manualMatchIds).toBeCalledWith({ - local: [EXTENSION_A, EXTENSION_A_2], - remote: [REGISTRATION_A, REGISTRATION_A_2], - }) - }) - test('deployConfirmed', async () => { - // Given - const extensionsToCreate: LocalSource[] = [EXTENSION_A_2] - const validMatches = { - EXTENSION_A: 'UUID_A', - } - const remoteExtensions = [REGISTRATION_A, REGISTRATION_A_2] - const developerPlatformClient = testDeveloperPlatformClient() - - // When - const got = await deployConfirmed( - options([EXTENSION_A, EXTENSION_A_2], [], {developerPlatformClient}), - remoteExtensions, - [], - { - extensionsToCreate, - validMatches, - }, - ) - - // Then - expect(got).toEqual({ - extensions: { - EXTENSION_A: 'UUID_A', - 'extension-a-2': EXTENSION_A_2.uid, - }, - extensionIds: {EXTENSION_A: 'A', 'extension-a-2': EXTENSION_A_2.uid}, - extensionsNonUuidManaged: {}, - }) - }) -}) - -describe('matchmaking returns ok with pending some pending to create', () => { - test('ensureExtensionsIds', async () => { - // Given - vi.mocked(automaticMatchmaking).mockResolvedValueOnce({ - identifiers: {}, - toConfirm: [], - toCreate: [EXTENSION_A, EXTENSION_A_2], - toManualMatch: { - local: [], - remote: [], - }, - }) - - // When - const got = await ensureExtensionsIds(options([EXTENSION_A, EXTENSION_A_2]), { - extensionRegistrations: [REGISTRATION_A, REGISTRATION_A_2], - dashboardManagedExtensionRegistrations: [], - }) - - // Then - expect(got).toEqual({ - dashboardOnlyExtensions: [], - extensionsToCreate: [EXTENSION_A, EXTENSION_A_2], - validMatches: {}, - didMigrateDashboardExtensions: false, - }) - }) - test('deployConfirmed: Create the pending extensions and succeeds', async () => { - // Given - const extensionsToCreate: LocalSource[] = [EXTENSION_A, EXTENSION_A_2] - const validMatches = {} - const remoteExtensions = [REGISTRATION_A, REGISTRATION_A_2] - const developerPlatformClient = testDeveloperPlatformClient() - - // When - const got = await deployConfirmed( - options([EXTENSION_A, EXTENSION_A_2], [], {developerPlatformClient}), - remoteExtensions, - [], - { - extensionsToCreate, - validMatches, - }, - ) - - // Then - expect(got).toEqual({ - extensions: {'extension-a': EXTENSION_A.uid, 'extension-a-2': EXTENSION_A_2.uid}, - extensionIds: {'extension-a': EXTENSION_A.uid, 'extension-a-2': EXTENSION_A_2.uid}, - extensionsNonUuidManaged: {}, - }) - }) -}) - -describe('matchmaking returns ok with some pending confirmation', () => { - test('ensureExtensionsIds: confirms the pending ones and succeeds', async () => { - // Given - vi.mocked(matchConfirmationPrompt).mockResolvedValueOnce(true) - vi.mocked(automaticMatchmaking).mockResolvedValueOnce({ - identifiers: {}, - toConfirm: [{local: EXTENSION_B, remote: REGISTRATION_B}], - toCreate: [], - toManualMatch: { - local: [], - remote: [], - }, - }) - - // When - const got = await ensureExtensionsIds(options([EXTENSION_B]), { - extensionRegistrations: [REGISTRATION_B], - dashboardManagedExtensionRegistrations: [], - }) - - // Then - expect(got).toEqual({ - dashboardOnlyExtensions: [], - extensionsToCreate: [], - validMatches: { - 'extension-b': 'UUID_B', - }, - didMigrateDashboardExtensions: false, - }) - }) - test('deployConfirmed', async () => { - // Given - const extensionsToCreate: LocalSource[] = [] - const validMatches = {'extension-b': 'UUID_B'} - const remoteExtensions = [REGISTRATION_B] - const developerPlatformClient = testDeveloperPlatformClient() - - // When - const got = await deployConfirmed(options([EXTENSION_B], [], {developerPlatformClient}), remoteExtensions, [], { - extensionsToCreate, - validMatches, - }) - - // Then - expect(got).toEqual({ - extensions: {'extension-b': 'UUID_B'}, - extensionIds: {'extension-b': 'B'}, - extensionsNonUuidManaged: {}, - }) - }) -}) - -describe('matchmaking returns ok with some pending confirmation', () => { - test('ensureExtensionsIds: does not confirm the pending ones', async () => { - // Given - vi.mocked(matchConfirmationPrompt).mockResolvedValueOnce(false) - vi.mocked(automaticMatchmaking).mockResolvedValueOnce({ - identifiers: {}, - toConfirm: [{local: EXTENSION_B, remote: REGISTRATION_B}], - toCreate: [], - toManualMatch: { - local: [], - remote: [], - }, - }) - - // When - const got = await ensureExtensionsIds(options([EXTENSION_B]), { - extensionRegistrations: [REGISTRATION_B], - dashboardManagedExtensionRegistrations: [], - }) - - // Then - expect(got).toEqual({ - dashboardOnlyExtensions: [], - extensionsToCreate: [EXTENSION_B], - validMatches: {}, - didMigrateDashboardExtensions: false, - }) - }) - test('deployConfirmed: creates non confirmed as new extensions', async () => { - // Given - const extensionsToCreate: LocalSource[] = [EXTENSION_B] - const validMatches = {} - const remoteExtensions = [REGISTRATION_B] - const developerPlatformClient = testDeveloperPlatformClient() - - // When - const got = await deployConfirmed(options([EXTENSION_B], [], {developerPlatformClient}), remoteExtensions, [], { - extensionsToCreate, - validMatches, - }) - - // Then - expect(got).toEqual({ - extensions: {'extension-b': EXTENSION_B.uid}, - extensionIds: {'extension-b': EXTENSION_B.uid}, - extensionsNonUuidManaged: {}, - }) - }) -}) - -describe('matchmaking returns ok with nothing pending', () => { - test('ensureExtensionsIds: succeeds and returns all identifiers', async () => { - // Given - vi.mocked(automaticMatchmaking).mockResolvedValueOnce({ - identifiers: {EXTENSION_A: 'UUID_A', EXTENSION_A_2: 'UUID_A_2'}, - toCreate: [], - toConfirm: [], - toManualMatch: { - local: [], - remote: [], - }, - }) - - // When - const got = await ensureExtensionsIds(options([EXTENSION_A, EXTENSION_A_2]), { - extensionRegistrations: [REGISTRATION_A, REGISTRATION_A_2], - dashboardManagedExtensionRegistrations: [], - }) - - // Then - expect(got).toEqual({ - dashboardOnlyExtensions: [], - extensionsToCreate: [], - validMatches: {EXTENSION_A: 'UUID_A', EXTENSION_A_2: 'UUID_A_2'}, - didMigrateDashboardExtensions: false, - }) - }) - test('deployConfirmed: does not create any extension', async () => { - // Given - const extensionsToCreate: LocalSource[] = [] - const validMatches = {EXTENSION_A: 'UUID_A', EXTENSION_A_2: 'UUID_A_2'} - const remoteExtensions = [REGISTRATION_A, REGISTRATION_A_2] - const developerPlatformClient = testDeveloperPlatformClient() - - // When - const got = await deployConfirmed( - options([EXTENSION_A, EXTENSION_A_2], [], {developerPlatformClient}), - remoteExtensions, - [], - { - extensionsToCreate, - validMatches, - }, - ) - - // Then - expect(got).toEqual({ - extensions: {EXTENSION_A: 'UUID_A', EXTENSION_A_2: 'UUID_A_2'}, - extensionIds: {EXTENSION_A: 'A', EXTENSION_A_2: 'A_2'}, - extensionsNonUuidManaged: {}, - }) - }) -}) - -describe('includes functions', () => { - test('ensureExtensionsIds: succeeds and returns all identifiers', async () => { - // Given - vi.mocked(automaticMatchmaking).mockResolvedValueOnce({ - identifiers: {EXTENSION_A: 'UUID_A', FUNCTION_A: 'FUNCTION_A_UUID'}, - toCreate: [], - toConfirm: [], - toManualMatch: { - local: [], - remote: [], - }, - }) - - // When - const ensureExtensionsIdsOptions = options([EXTENSION_A], [FUNCTION_A]) - const got = await ensureExtensionsIds(ensureExtensionsIdsOptions, { - extensionRegistrations: [REGISTRATION_A, FUNCTION_REGISTRATION_A], - dashboardManagedExtensionRegistrations: [], - }) - - // Then - expect(automaticMatchmaking).toHaveBeenCalledWith( - [EXTENSION_A, FUNCTION_A], - [REGISTRATION_A, FUNCTION_REGISTRATION_A], - {}, - ) - expect(got).toEqual({ - dashboardOnlyExtensions: [], - extensionsToCreate: [], - validMatches: {EXTENSION_A: 'UUID_A', FUNCTION_A: 'FUNCTION_A_UUID'}, - didMigrateDashboardExtensions: false, - }) - }) - test('deployConfirmed: does not create any extension', async () => { - // Given - const extensionsToCreate: LocalSource[] = [] - const validMatches = {EXTENSION_A: 'UUID_A', FUNCTION_A: 'FUNCTION_A_UUID'} - const remoteExtensions = [REGISTRATION_A, FUNCTION_REGISTRATION_A] - const developerPlatformClient = testDeveloperPlatformClient() - - // When - const got = await deployConfirmed( - options([EXTENSION_A], [FUNCTION_A], {developerPlatformClient}), - remoteExtensions, - [], - { - extensionsToCreate, - validMatches, - }, - ) - - // Then - expect(got).toEqual({ - extensions: {EXTENSION_A: 'UUID_A', FUNCTION_A: 'FUNCTION_A_UUID'}, - extensionIds: {EXTENSION_A: 'A', FUNCTION_A: 'FUNCTION_A'}, - extensionsNonUuidManaged: {}, - }) - }) -}) - -describe('excludes non uuid managed extensions', () => { - test("ensureExtensionsIds: automatic matching logic doesn't receive the non uuid managed extensions", async () => { - // Given - vi.mocked(automaticMatchmaking).mockResolvedValueOnce({ - identifiers: {EXTENSION_A: 'UUID_A', FUNCTION_A: 'FUNCTION_A_UUID'}, - toCreate: [], - toConfirm: [], - toManualMatch: { - local: [], - remote: [], - }, - }) - - // When - const CONFIG_A = await testAppConfigExtensions() - const ensureExtensionsIdsOptions = options([EXTENSION_A], [], {configExtensions: [CONFIG_A]}) - await ensureExtensionsIds(ensureExtensionsIdsOptions, { - extensionRegistrations: [REGISTRATION_A], - dashboardManagedExtensionRegistrations: [], - }) - - // Then - expect(automaticMatchmaking).toHaveBeenCalledWith([EXTENSION_A], [REGISTRATION_A], {}) - }) -}) - -describe('ensureExtensionsIds: Migrates extension', () => { - test('shows confirmation prompt', async () => { - // Given - vi.mocked(automaticMatchmaking).mockResolvedValueOnce({ - identifiers: {EXTENSION_A: 'UUID_A', EXTENSION_A_2: 'UUID_A_2'}, - toCreate: [], - toConfirm: [], - toManualMatch: { - local: [], - remote: [], - }, - }) - const extensionsToMigrate = [ - {local: EXTENSION_A, remote: REGISTRATION_A}, - {local: EXTENSION_A_2, remote: REGISTRATION_A_2}, - ] - vi.mocked(getModulesToMigrate).mockReturnValueOnce(extensionsToMigrate) - - // When / Then - await expect(() => - ensureExtensionsIds(options([EXTENSION_A, EXTENSION_A_2]), { - extensionRegistrations: [REGISTRATION_A, REGISTRATION_A_2], - dashboardManagedExtensionRegistrations: [], - }), - ).rejects.toThrowError(AbortSilentError) - - expect(extensionMigrationPrompt).toBeCalledWith(extensionsToMigrate) - }) - - test('migrates extensions', async () => { - // Given - vi.mocked(automaticMatchmaking).mockResolvedValueOnce({ - identifiers: {EXTENSION_A: 'UUID_A', EXTENSION_A_2: 'UUID_A_2'}, - toCreate: [], - toConfirm: [], - toManualMatch: { - local: [], - remote: [], - }, - }) - const extensionsToMigrate = [ - {local: EXTENSION_A, remote: REGISTRATION_A}, - {local: EXTENSION_A_2, remote: REGISTRATION_A_2}, - ] - vi.mocked(getModulesToMigrate).mockReturnValueOnce(extensionsToMigrate) - vi.mocked(extensionMigrationPrompt).mockResolvedValueOnce(true) - const opts = options([EXTENSION_A, EXTENSION_A_2]) - const remoteExtensions = [REGISTRATION_A, REGISTRATION_A_2] - - // When - const result = await ensureExtensionsIds(opts, { - extensionRegistrations: remoteExtensions, - dashboardManagedExtensionRegistrations: [], - }) - - // Then - expect(result).toEqual({ - didMigrateDashboardExtensions: true, - dashboardOnlyExtensions: [], - extensionsToCreate: [], - validMatches: { - EXTENSION_A: 'UUID_A', - EXTENSION_A_2: 'UUID_A_2', - }, - }) - - expect(migrateExtensionsToUIExtension).toBeCalledWith({ - extensionsToMigrate, - appId: opts.appId, - remoteExtensions, - migrationClient: expect.any(PartnersClient), - }) - }) -}) - -describe('deployConfirmed: handle non existent uuid managed extensions', () => { - test('when include config on deploy flag is enabled configuration extensions are created', async () => { - // Given - const extensionsToCreate: LocalSource[] = [] - const validMatches = {} - const REGISTRATION_CONFIG_A = { - uuid: 'UUID_C_A', - id: 'C_A', - title: 'C_A', - type: 'POINT_OF_SALE', - } - const developerPlatformClient = testDeveloperPlatformClient() - - // When - const CONFIG_A = await testAppConfigExtensions() - const ensureExtensionsIdsOptions = options([], [], { - includeDeployConfig: true, - configExtensions: [CONFIG_A], - developerPlatformClient, - }) - const got = await deployConfirmed(ensureExtensionsIdsOptions, [], [REGISTRATION_CONFIG_A], { - extensionsToCreate, - validMatches, - }) - - // Then - expect(got).toEqual({ - extensions: {}, - extensionIds: {point_of_sale: 'C_A'}, - extensionsNonUuidManaged: {point_of_sale: 'UUID_C_A'}, - }) - }) - test('when the include config on deploy flag is disabled configuration extensions are not created', async () => { - // Given - const extensionsToCreate: LocalSource[] = [] - const validMatches = {} - const developerPlatformClient = testDeveloperPlatformClient() - - // When - const CONFIG_A = await testAppConfigExtensions() - // Don't pass config extensions when includeConfigOnDeploy is false - they won't be in allExtensions - const ensureExtensionsIdsOptions = options([], [], {developerPlatformClient}) - const got = await deployConfirmed(ensureExtensionsIdsOptions, [], [], { - extensionsToCreate, - validMatches, - }) - - // Then - expect(got).toEqual({ - extensions: {}, - extensionIds: {}, - extensionsNonUuidManaged: {}, - }) - }) -}) -describe('deployConfirmed: handle existent uuid managed extensions', () => { - test('when the include config on deploy flag is enabled configuration extensions are not created but the uuids are returned', async () => { - // Given - const extensionsToCreate: LocalSource[] = [] - const validMatches = {} - const REGISTRATION_CONFIG_A = { - uuid: 'UUID_C_A', - id: 'C_A', - title: 'C_A', - type: 'POINT_OF_SALE', - } - const developerPlatformClient = testDeveloperPlatformClient() - - // When - const CONFIG_A = await testAppConfigExtensions() - - const ensureExtensionsIdsOptions = options([], [], { - includeDeployConfig: true, - configExtensions: [CONFIG_A], - developerPlatformClient, - }) - const got = await deployConfirmed(ensureExtensionsIdsOptions, [], [REGISTRATION_CONFIG_A], { - extensionsToCreate, - validMatches, - }) - - // Then - expect(got).toEqual({ - extensions: {}, - extensionIds: {point_of_sale: 'C_A'}, - extensionsNonUuidManaged: {point_of_sale: 'UUID_C_A'}, - }) - }) -}) - -describe('deployConfirmed: extensions that should be managed in the TOML', () => { - test('are also passed to the `ensureNonUuidManagedExtensionsIds` function', async () => { - // Given - const extensionsToCreate: LocalSource[] = [] - const validMatches = {} - const REGISTRATION_CONFIG_A = { - uuid: 'UUID_C_A', - id: 'C_A', - title: 'C_A', - type: 'POINT_OF_SALE', - } - const developerPlatformClient = testDeveloperPlatformClient() - - // When - const CONFIG_A = await testAppConfigExtensions() - - const ensureExtensionsIdsOptions = options([], [], { - includeDeployConfig: true, - configExtensions: [CONFIG_A], - developerPlatformClient, - }) - const got = await deployConfirmed(ensureExtensionsIdsOptions, [], [REGISTRATION_CONFIG_A], { - extensionsToCreate, - validMatches, - }) - - // Then - expect(got).toEqual({ - extensions: {}, - extensionIds: {point_of_sale: 'C_A'}, - extensionsNonUuidManaged: {point_of_sale: 'UUID_C_A'}, - }) - }) -}) - -describe('groupRegistrationByUidStrategy', () => { - test('extension registrations have dynamic UID strategy should be returned in the same array as configuration registrations', () => { - // Given - const dynamicUidStrategyExtension = { - uuid: 'webhook-subscription-uuid', - id: 'webhook-subscription-id', - title: 'Webhook Subscription', - type: 'WEBHOOK_SUBSCRIPTION', - } - - const extensionRegistrations = [REGISTRATION_B, dynamicUidStrategyExtension] - const configurationRegistrations = [ - { - uuid: 'UUID_C_A', - id: 'C_A', - title: 'C_A', - type: 'POINT_OF_SALE', - }, - ] - const specifications = [appPOSSpec, appWebhookSubscriptionSpec] as ExtensionSpecification[] - - const dynamicUidStrategySpec = specifications.find( - (spec) => spec.identifier === dynamicUidStrategyExtension.type.toLowerCase(), - ) - expect(dynamicUidStrategySpec?.experience).toEqual('configuration') - expect(dynamicUidStrategySpec?.uidStrategy).toEqual('dynamic') - - // When - const {uuidUidStrategyExtensions, singleAndDynamicStrategyExtensions} = groupRegistrationByUidStrategy( - extensionRegistrations, - configurationRegistrations, - specifications, - ) - - // Then - expect(uuidUidStrategyExtensions).toEqual([REGISTRATION_B]) - expect(singleAndDynamicStrategyExtensions).toEqual([configurationRegistrations[0], dynamicUidStrategyExtension]) - }) -}) - -describe('ensureNonUuidManagedExtensionsIds: for extensions managed in the TOML', () => { - test('creates the extension if no possible matches', async () => { - // Given - const remoteSources: RemoteSource[] = [] - const localSources = [ - await testSingleWebhookSubscriptionExtension(), - await testSingleWebhookSubscriptionExtension({topic: 'products/create'}), - await testSingleWebhookSubscriptionExtension({topic: 'products/update'}), - await testSingleWebhookSubscriptionExtension({topic: 'products/delete'}), - ] - const app = options(localSources, [], { - includeDeployConfig: true, - flags: [], - }).app - const developerPlatformClient = testDeveloperPlatformClient() - - // When - - const {extensionsNonUuidManaged, extensionsIdsNonUuidManaged} = await ensureNonUuidManagedExtensionsIds( - remoteSources, - app, - developerPlatformClient, - ) - - // Then - expect(Object.values(extensionsNonUuidManaged)).toEqual(localSources.map((source) => source.uid)) - expect(Object.values(extensionsIdsNonUuidManaged)).toEqual(localSources.map((source) => source.uid)) - }) - - test('if there are possible matches, creates the extension for those that dont match', async () => { - // Given - const config = { - topic: 'orders/delete', - api_version: '2024-01', - uri: '/webhooks', - include_fields: ['id'], - filter: 'id:*', - } - const sameTopicConfig = { - topic: 'orders/delete', - api_version: '2024-01', - uri: 'https://my-app.com/webhooks', - include_fields: ['id'], - filter: 'id:1234', - } - - const webhookSubscriptionExtension = { - uuid: 'webhook-subscription-uuid', - id: 'webhook-subscription-id', - title: 'Webhook Subscription', - type: 'WEBHOOK_SUBSCRIPTION', - activeVersion: { - // use absolute path here to test that it matches both absolute and relative paths from the local config - config: JSON.stringify({...config, uri: 'https://my-app.com/webhooks'}), - }, - } - - const localSources = [ - await testSingleWebhookSubscriptionExtension({config}), - await testSingleWebhookSubscriptionExtension({config: sameTopicConfig}), - await testSingleWebhookSubscriptionExtension({topic: 'products/create'}), - await testSingleWebhookSubscriptionExtension({topic: 'products/update'}), - await testSingleWebhookSubscriptionExtension({topic: 'products/delete'}), - ] - const remoteSources = [webhookSubscriptionExtension] - const app = options(localSources, [], { - includeDeployConfig: true, - flags: [], - }).app - const developerPlatformClient = testDeveloperPlatformClient() - - // When - const {extensionsNonUuidManaged, extensionsIdsNonUuidManaged} = await ensureNonUuidManagedExtensionsIds( - remoteSources, - app, - developerPlatformClient, - ) - - // Then - expect(Object.values(extensionsNonUuidManaged)).toEqual([ - 'webhook-subscription-uuid', - ...localSources.slice(1).map((source) => source.uid), - ]) - expect(Object.values(extensionsIdsNonUuidManaged)).toEqual([ - 'webhook-subscription-id', - ...localSources.slice(1).map((source) => source.uid), - ]) - }) -}) diff --git a/packages/app/src/cli/services/context/identifiers-extensions.ts b/packages/app/src/cli/services/context/identifiers-extensions.ts index 6f96b48e7ab..c9e4f1bfe16 100644 --- a/packages/app/src/cli/services/context/identifiers-extensions.ts +++ b/packages/app/src/cli/services/context/identifiers-extensions.ts @@ -37,7 +37,7 @@ export async function ensureExtensionsIds( }: AppWithExtensions, ) { let remoteExtensions = initialRemoteExtensions - const identifiers = options.envIdentifiers.extensions ?? {} + const identifiers = options.envIdentifiers ?? {} const localExtensions = options.app.allExtensions.filter((ext) => !ext.isAppConfigExtension) const uiExtensionsToMigrate = getModulesToMigrate(localExtensions, remoteExtensions, identifiers, UIModulesMap) diff --git a/packages/app/src/cli/services/context/identifiers.ts b/packages/app/src/cli/services/context/identifiers.ts index 1f2b7eeefcf..bbd44c30f62 100644 --- a/packages/app/src/cli/services/context/identifiers.ts +++ b/packages/app/src/cli/services/context/identifiers.ts @@ -1,7 +1,7 @@ import {deployConfirmed} from './identifiers-extensions.js' import {configExtensionsIdentifiersBreakdown, extensionsIdentifiersDeployBreakdown} from './breakdown-extensions.js' import {AppInterface} from '../../models/app/app.js' -import {Identifiers} from '../../models/app/identifiers.js' +import {ExtensionUuidsByLocalIdentifier} from '../../models/app/identifiers.js' import {MinimalOrganizationApp} from '../../models/organization.js' import {deployOrReleaseConfirmationPrompt} from '../../prompts/deploy-release.js' import {AppVersion, DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' @@ -14,7 +14,7 @@ export interface EnsureDeploymentIdsPresenceOptions { developerPlatformClient: DeveloperPlatformClient appId: string appName: string - envIdentifiers: Partial + envIdentifiers: ExtensionUuidsByLocalIdentifier /** If true, allow adding and updating extensions and configuration without user confirmation */ allowUpdates?: boolean /** If true, allow removing extensions and configuration without user confirmation */ diff --git a/packages/app/src/cli/services/deploy.test.ts b/packages/app/src/cli/services/deploy.test.ts index 0af16d422c3..2a06642566e 100644 --- a/packages/app/src/cli/services/deploy.test.ts +++ b/packages/app/src/cli/services/deploy.test.ts @@ -17,7 +17,6 @@ import { testOrganization, testProject, } from '../models/app/app.test-data.js' -import {updateAppIdentifiers} from '../models/app/identifiers.js' import {AppInterface, AppLinkedInterface} from '../models/app/app.js' import {OrganizationApp} from '../models/organization.js' import {DeveloperPlatformClient} from '../utilities/developer-platform-client.js' @@ -52,7 +51,6 @@ vi.mock('./context.js') vi.mock('./deploy/upload.js') vi.mock('./deploy/bundle.js') vi.mock('./dev/fetch.js') -vi.mock('../models/app/identifiers.js') vi.mock('@shopify/cli-kit/node/context/local') vi.mock('@shopify/cli-kit/node/ui') vi.mock('@shopify/cli-kit/node/crypto') @@ -116,7 +114,7 @@ describe('deploy', () => { organizationId: 'org-id', appModules: [], developerPlatformClient, - extensionIds: {}, + appModuleRegistrationIds: {}, release: true, }) }) @@ -186,11 +184,10 @@ describe('deploy', () => { organizationId: 'org-id', appModules: [], developerPlatformClient, - extensionIds: {}, + appModuleRegistrationIds: {}, release: true, }) expect(bundleAndBuildExtensions).toHaveBeenCalledOnce() - expect(updateAppIdentifiers).toHaveBeenCalledOnce() }) test('uploads the extension bundle with 1 UI extension', async () => { @@ -234,11 +231,10 @@ describe('deploy', () => { }, ], developerPlatformClient, - extensionIds: {}, + appModuleRegistrationIds: {}, release: true, }) expect(bundleAndBuildExtensions).toHaveBeenCalledOnce() - expect(updateAppIdentifiers).toHaveBeenCalledOnce() }) test('uploads the extension bundle with 1 theme extension', async () => { @@ -282,11 +278,10 @@ describe('deploy', () => { }, ], developerPlatformClient, - extensionIds: {}, + appModuleRegistrationIds: {}, release: true, }) expect(bundleAndBuildExtensions).toHaveBeenCalledOnce() - expect(updateAppIdentifiers).toHaveBeenCalledOnce() }) test('uploads the extension bundle with 1 function extension', async () => { @@ -347,12 +342,11 @@ describe('deploy', () => { }, ], developerPlatformClient, - extensionIds: {}, + appModuleRegistrationIds: {}, bundlePath: expect.stringMatching(/bundle.zip$/), release: true, }) expect(bundleAndBuildExtensions).toHaveBeenCalledOnce() - expect(updateAppIdentifiers).toHaveBeenCalledOnce() }) test('uploads the extension bundle with 1 UI and 1 theme extension', async () => { @@ -415,12 +409,11 @@ describe('deploy', () => { }, ], developerPlatformClient, - extensionIds: {}, + appModuleRegistrationIds: {}, release: true, commitReference, }) expect(bundleAndBuildExtensions).toHaveBeenCalledOnce() - expect(updateAppIdentifiers).toHaveBeenCalledOnce() }) test('pushes the configuration extension if include config on deploy ', async () => { @@ -468,12 +461,11 @@ describe('deploy', () => { }, ], developerPlatformClient, - extensionIds: {}, + appModuleRegistrationIds: {}, release: true, commitReference, }) expect(bundleAndBuildExtensions).toHaveBeenCalledOnce() - expect(updateAppIdentifiers).toHaveBeenCalledOnce() }) test('doesnt push the configuration extension if include config on deploy is disabled', async () => { @@ -512,12 +504,11 @@ describe('deploy', () => { organizationId: 'org-id', appModules: [], developerPlatformClient, - extensionIds: {}, + appModuleRegistrationIds: {}, release: true, commitReference, }) expect(bundleAndBuildExtensions).toHaveBeenCalledOnce() - expect(updateAppIdentifiers).toHaveBeenCalledOnce() }) test('shows a success message', async () => { @@ -712,22 +703,13 @@ async function testDeployBundle({ didMigrateExtensionsToDevDash = false, }: TestDeployBundleInput) { // Given - const extensionsPayload: {[key: string]: string} = {} - for (const extension of app.allExtensions.filter((ext) => ext.isUUIDStrategyExtension)) { - extensionsPayload[extension.localIdentifier] = extension.localIdentifier - } - const extensionsNonUuidPayload: {[key: string]: string} = {} - for (const extension of app.allExtensions.filter((ext) => !ext.isUUIDStrategyExtension)) { - extensionsNonUuidPayload[extension.localIdentifier] = extension.localIdentifier - } - const identifiers = { - app: 'app-id', - extensions: extensionsPayload, - extensionIds: {}, - extensionsNonUuidManaged: extensionsNonUuidPayload, + const appModuleUuids: {[key: string]: string} = {} + for (const extension of app.allExtensions) { + appModuleUuids[extension.localIdentifier] = extension.localIdentifier } + const deployIdentifiers = {appModuleUuids, appModuleRegistrationIds: {}} - vi.mocked(ensureDeployContext).mockResolvedValue({identifiers, didMigrateExtensionsToDevDash}) + vi.mocked(ensureDeployContext).mockResolvedValue({deployIdentifiers, didMigrateExtensionsToDevDash}) vi.mocked(uploadExtensionsBundle).mockResolvedValue({ validationErrors: [], @@ -736,7 +718,6 @@ async function testDeployBundle({ ...(!released && {deployError: 'no release error'}), location: 'https://partners.shopify.com/0/apps/0/versions/1', }) - vi.mocked(updateAppIdentifiers).mockResolvedValue(app) await deploy({ app, diff --git a/packages/app/src/cli/services/deploy.ts b/packages/app/src/cli/services/deploy.ts index 758fdb83a8f..02bc9fb7d35 100644 --- a/packages/app/src/cli/services/deploy.ts +++ b/packages/app/src/cli/services/deploy.ts @@ -6,7 +6,6 @@ import {allExtensionTypes, filterOutImportedExtensions, importAllExtensions} fro import {getExtensions} from './fetch-extensions.js' import {AppLinkedInterface} from '../models/app/app.js' import {Project} from '../models/project/project.js' -import {updateAppIdentifiers} from '../models/app/identifiers.js' import {DeveloperPlatformClient} from '../utilities/developer-platform-client.js' import {Organization, OrganizationApp} from '../models/organization.js' import {reloadApp} from '../models/app/loader.js' @@ -148,7 +147,7 @@ export async function deploy(options: DeployOptions) { allowDeletes, }) - const {identifiers, didMigrateExtensionsToDevDash} = await ensureDeployContext({ + const {deployIdentifiers, didMigrateExtensionsToDevDash} = await ensureDeployContext({ ...options, app, developerPlatformClient, @@ -156,8 +155,6 @@ export async function deploy(options: DeployOptions) { allowDeletes, }) - const appModuleUuids = {...identifiers.extensions, ...identifiers.extensionsNonUuidManaged} - const release = !noRelease const apiKey = remoteApp.apiKey @@ -172,87 +169,79 @@ export async function deploy(options: DeployOptions) { let uploadExtensionsBundleResult!: UploadExtensionsBundleOutput - try { - const candidateBundlePath = joinPath( - options.app.directory, - '.shopify', - `deploy-bundle.${developerPlatformClient.bundleFormat}`, - ) - await mkdir(dirname(candidateBundlePath)) + const candidateBundlePath = joinPath( + options.app.directory, + '.shopify', + `deploy-bundle.${developerPlatformClient.bundleFormat}`, + ) + await mkdir(dirname(candidateBundlePath)) - const appManifest = await app.manifest(appManifestUuids(app, appModuleUuids)) + const appManifest = await app.manifest(appManifestUuids(app, deployIdentifiers.appModuleUuids)) - const bundlePath = await bundleAndBuildExtensions({ - app, - appManifest, - bundlePath: candidateBundlePath, - skipBuild: options.skipBuild, - }) + const bundlePath = await bundleAndBuildExtensions({ + app, + appManifest, + bundlePath: candidateBundlePath, + skipBuild: options.skipBuild, + }) - let uploadTaskTitle + let uploadTaskTitle - if (release) { - uploadTaskTitle = 'Releasing an app version' - } else { - uploadTaskTitle = 'Creating an app version' - } + if (release) { + uploadTaskTitle = 'Releasing an app version' + } else { + uploadTaskTitle = 'Creating an app version' + } - const tasks: Task[] = [ - { - title: 'Running validation', - task: async () => { - await app.preDeployValidation() - }, + const tasks: Task[] = [ + { + title: 'Running validation', + task: async () => { + await app.preDeployValidation() }, - { - title: uploadTaskTitle, - task: async () => { - const appModules = await Promise.all( - app.allExtensions.flatMap((ext) => - ext.bundleConfig({appModuleUuids, developerPlatformClient, apiKey, appConfiguration: app.configuration}), - ), - ) - - uploadExtensionsBundleResult = await uploadExtensionsBundle({ - appManifest, - appId: remoteApp.id, - apiKey, - name: app.name, - organizationId: remoteApp.organizationId, - bundlePath, - appModules: getArrayRejectingUndefined(appModules), - release, - developerPlatformClient, - extensionIds: identifiers.extensionIds, - message: options.message, - version: options.version, - commitReference: options.commitReference, - }) - - await updateAppIdentifiers({app, identifiers, command: 'deploy'}) - }, + }, + { + title: uploadTaskTitle, + task: async () => { + const appModules = await Promise.all( + app.allExtensions.flatMap((ext) => + ext.bundleConfig({ + appModuleUuids: deployIdentifiers.appModuleUuids, + developerPlatformClient, + apiKey, + appConfiguration: app.configuration, + }), + ), + ) + + uploadExtensionsBundleResult = await uploadExtensionsBundle({ + appManifest, + appId: remoteApp.id, + apiKey, + name: app.name, + organizationId: remoteApp.organizationId, + bundlePath, + appModules: getArrayRejectingUndefined(appModules), + release, + developerPlatformClient, + appModuleRegistrationIds: deployIdentifiers.appModuleRegistrationIds, + message: options.message, + version: options.version, + commitReference: options.commitReference, + }) }, - ] - - await renderTasks(tasks) + }, + ] - await outputCompletionMessage({ - app, - project: options.project, - release, - uploadExtensionsBundleResult, - didMigrateExtensionsToDevDash, - }) + await renderTasks(tasks) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (error: any) { - /** - * If deployment fails when uploading we want the identifiers to be persisted - * for the next run. - */ - await updateAppIdentifiers({app, identifiers, command: 'deploy'}) - throw error - } + await outputCompletionMessage({ + app, + project: options.project, + release, + uploadExtensionsBundleResult, + didMigrateExtensionsToDevDash, + }) return {app} } diff --git a/packages/app/src/cli/services/deploy/upload.test.ts b/packages/app/src/cli/services/deploy/upload.test.ts index fab9ca04a84..c01e3154c8f 100644 --- a/packages/app/src/cli/services/deploy/upload.test.ts +++ b/packages/app/src/cli/services/deploy/upload.test.ts @@ -39,7 +39,7 @@ describe('uploadExtensionsBundle', () => { {uuid: '123', config: '{}', context: '', handle: 'handle', specificationIdentifier: 'ui_extension'}, ], developerPlatformClient, - extensionIds: {}, + appModuleRegistrationIds: {}, release: true, }) @@ -85,7 +85,7 @@ describe('uploadExtensionsBundle', () => { {uuid: '123', config: '{}', context: '', handle: 'handle', specificationIdentifier: 'ui_extension'}, ], developerPlatformClient, - extensionIds: {}, + appModuleRegistrationIds: {}, release: true, message: 'test', version: '1.0.0', @@ -129,7 +129,7 @@ describe('uploadExtensionsBundle', () => { bundlePath: undefined, appModules: [], developerPlatformClient, - extensionIds: {}, + appModuleRegistrationIds: {}, release: true, }) @@ -247,7 +247,7 @@ describe('uploadExtensionsBundle', () => { {uuid: '456', config: '{}', context: '', handle: 'handle', specificationIdentifier: 'ui_extension'}, ], developerPlatformClient, - extensionIds: { + appModuleRegistrationIds: { 'amortizable-marketplace-ext': '123', 'amortizable-marketplace-ext-2': '456', }, @@ -350,7 +350,7 @@ describe('uploadExtensionsBundle', () => { {uuid: '456', config: '{}', context: '', handle: 'handle', specificationIdentifier: 'ui_extension'}, ], developerPlatformClient, - extensionIds: { + appModuleRegistrationIds: { 'amortizable-marketplace-ext': '123', 'amortizable-marketplace-ext-2': '456', }, diff --git a/packages/app/src/cli/services/deploy/upload.ts b/packages/app/src/cli/services/deploy/upload.ts index 807d353c733..b0c22decfc2 100644 --- a/packages/app/src/cli/services/deploy/upload.ts +++ b/packages/app/src/cli/services/deploy/upload.ts @@ -1,4 +1,4 @@ -import {IdentifiersExtensions} from '../../models/app/identifiers.js' +import {ExtensionUuidsByLocalIdentifier} from '../../models/app/identifiers.js' import {AppDeploySchema, AppModuleSettings} from '../../api/graphql/app_deploy.js' import {AppDeployOptions, DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' @@ -32,8 +32,7 @@ interface UploadExtensionsBundleOptions { /** App Modules extra data */ appModules: AppModuleSettings[] - /** The extensions' numeric identifiers (expressed as a string). */ - extensionIds: IdentifiersExtensions + appModuleRegistrationIds: ExtensionUuidsByLocalIdentifier /** Wether or not to release the version */ release: boolean @@ -114,7 +113,7 @@ export async function uploadExtensionsBundle( if (!result.appDeploy.appVersion) { const customSections: AlertCustomSection[] = deploymentErrorsToCustomSections( result.appDeploy.userErrors ?? [], - options.extensionIds, + options.appModuleRegistrationIds, options.appModules, { version: options.version, @@ -147,7 +146,7 @@ const GENERIC_ERRORS_TITLE = '\n' export function deploymentErrorsToCustomSections( errors: AppDeploySchema['appDeploy']['userErrors'], - extensionIds: IdentifiersExtensions, + appModuleRegistrationIds: ExtensionUuidsByLocalIdentifier, appModules: AppModuleSettings[], flags: { version?: string @@ -157,11 +156,11 @@ export function deploymentErrorsToCustomSections( return error.details?.some((detail) => detail.extension_id) ?? false } - const isCliError = (error: (typeof errors)[0], extensionIds: IdentifiersExtensions) => { + const isCliError = (error: (typeof errors)[0], appModuleRegistrationIds: ExtensionUuidsByLocalIdentifier) => { const errorExtensionId = error.details?.find((detail) => typeof detail.extension_id !== 'undefined')?.extension_id ?? '' - return Object.values(extensionIds).includes(errorExtensionId.toString()) + return Object.values(appModuleRegistrationIds).includes(errorExtensionId.toString()) } const isAppManagementValidationError = (error: (typeof errors)[0]) => { @@ -173,11 +172,11 @@ export function deploymentErrorsToCustomSections( const [extensionErrors, nonExtensionErrors] = partition(nonAppManagementErrors, (error) => isExtensionError(error)) - const [cliErrors, partnersErrors] = partition(extensionErrors, (error) => isCliError(error, extensionIds)) + const [cliErrors, partnersErrors] = partition(extensionErrors, (error) => isCliError(error, appModuleRegistrationIds)) const customSections = [ ...generalErrorsSection(nonExtensionErrors, {version: flags.version}), - ...cliErrorsSections(cliErrors, extensionIds), + ...cliErrorsSections(cliErrors, appModuleRegistrationIds), ...partnersErrorsSections(partnersErrors), ...appManagementErrorsSection(appManagementErrors, appModules), ] @@ -228,7 +227,10 @@ function generalErrorsSection( } } -function cliErrorsSections(errors: AppDeploySchema['appDeploy']['userErrors'], identifiers: IdentifiersExtensions) { +function cliErrorsSections( + errors: AppDeploySchema['appDeploy']['userErrors'], + identifiers: ExtensionUuidsByLocalIdentifier, +) { return errors.reduce((sections, error) => { const field = (error.field ?? ['unknown']).join('.').replace('extension_points', 'extensions.targeting') const errorMessage = field === 'base' ? error.message : `${field}: ${error.message}` diff --git a/packages/app/src/cli/services/release.test.ts b/packages/app/src/cli/services/release.test.ts index 3f77c0c2dee..fb00a65db6d 100644 --- a/packages/app/src/cli/services/release.test.ts +++ b/packages/app/src/cli/services/release.test.ts @@ -1,6 +1,6 @@ import {release} from './release.js' import { - configExtensionsIdentifiersBreakdown, + configExtensionsIdentifiersReleaseBreakdown, extensionsIdentifiersReleaseBreakdown, } from './context/breakdown-extensions.js' import {testAppLinked, testDeveloperPlatformClient} from '../models/app/app.test-data.js' @@ -148,7 +148,7 @@ async function testRelease( ) { // Given vi.mocked(extensionsIdentifiersReleaseBreakdown).mockResolvedValue(buildExtensionsBreakdown()) - vi.mocked(configExtensionsIdentifiersBreakdown).mockResolvedValue(buildConfigExtensionsBreakdown()) + vi.mocked(configExtensionsIdentifiersReleaseBreakdown).mockReturnValue(buildConfigExtensionsBreakdown()) await release({ app, diff --git a/packages/app/src/cli/services/release.ts b/packages/app/src/cli/services/release.ts index 4d3101fe85e..8f5c2b2b1f0 100644 --- a/packages/app/src/cli/services/release.ts +++ b/packages/app/src/cli/services/release.ts @@ -1,5 +1,5 @@ import { - configExtensionsIdentifiersBreakdown, + configExtensionsIdentifiersReleaseBreakdown, extensionsIdentifiersReleaseBreakdown, } from './context/breakdown-extensions.js' import {AppLinkedInterface} from '../models/app/app.js' @@ -41,16 +41,15 @@ export async function release(options: ReleaseOptions) { remoteApp, options.version, ) - const configExtensionIdentifiersBreakdown = await configExtensionsIdentifiersBreakdown({ - developerPlatformClient, - apiKey: remoteApp.apiKey, - localApp: app, - remoteApp, - versionAppModules: versionDetails.appModuleVersions.map((appModuleVersion) => ({ - ...appModuleVersion, - })), - release: true, - }) + const configExtensionIdentifiersBreakdown = app.allExtensions.some((extension) => extension.isAppConfigExtension) + ? configExtensionsIdentifiersReleaseBreakdown({ + localApp: app, + versionAppModules: versionDetails.appModuleVersions.map((appModuleVersion) => ({ + ...appModuleVersion, + })), + activeAppVersion: await developerPlatformClient.activeAppVersion(remoteApp), + }) + : undefined const confirmed = await deployOrReleaseConfirmationPrompt({ configExtensionIdentifiersBreakdown, extensionIdentifiersBreakdown,