diff --git a/.changeset/remove-partners-developer-platform-client.md b/.changeset/remove-partners-developer-platform-client.md new file mode 100644 index 00000000000..74d68a14416 --- /dev/null +++ b/.changeset/remove-partners-developer-platform-client.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': patch +--- + +Remove the Partners API developer platform client from app commands. App Management is now the only developer platform client used by `app dev`, `deploy`, and related commands, so local development no longer needs the Partners dev service (`SHOPIFY_CLI_NEVER_USE_PARTNERS_API` is no longer required). The Partners-era legacy extension migrations that ran during `deploy` are also removed, since they only ever worked against the Partners API. diff --git a/packages/app/src/cli/api/graphql/partners/generated/all-orgs.ts b/packages/app/src/cli/api/graphql/partners/generated/all-orgs.ts deleted file mode 100644 index a0e4c8c245e..00000000000 --- a/packages/app/src/cli/api/graphql/partners/generated/all-orgs.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* eslint-disable @typescript-eslint/consistent-type-definitions */ -import * as Types from './types.js' - -import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/core' - -export type AllOrgsQueryVariables = Types.Exact<{[key: string]: never}> - -export type AllOrgsQuery = {organizations: {nodes?: ({id: string; businessName: string} | null)[] | null}} - -export const AllOrgs = { - kind: 'Document', - definitions: [ - { - kind: 'OperationDefinition', - operation: 'query', - name: {kind: 'Name', value: 'AllOrgs'}, - selectionSet: { - kind: 'SelectionSet', - selections: [ - { - kind: 'Field', - name: {kind: 'Name', value: 'organizations'}, - arguments: [ - {kind: 'Argument', name: {kind: 'Name', value: 'first'}, value: {kind: 'IntValue', value: '200'}}, - ], - selectionSet: { - kind: 'SelectionSet', - selections: [ - { - kind: 'Field', - name: {kind: 'Name', value: 'nodes'}, - selectionSet: { - kind: 'SelectionSet', - selections: [ - {kind: 'Field', name: {kind: 'Name', value: 'id'}}, - {kind: 'Field', name: {kind: 'Name', value: 'businessName'}}, - {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, - ], - }, - }, - {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, - ], - }, - }, - ], - }, - }, - ], -} as unknown as DocumentNode diff --git a/packages/app/src/cli/api/graphql/partners/generated/current-account-info.ts b/packages/app/src/cli/api/graphql/partners/generated/current-account-info.ts deleted file mode 100644 index ebbce6a1785..00000000000 --- a/packages/app/src/cli/api/graphql/partners/generated/current-account-info.ts +++ /dev/null @@ -1,58 +0,0 @@ -/* eslint-disable @typescript-eslint/consistent-type-definitions */ -import * as Types from './types.js' - -import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/core' - -export type CurrentAccountInfoQueryVariables = Types.Exact<{[key: string]: never}> - -export type CurrentAccountInfoQuery = { - currentAccountInfo: {__typename: 'ServiceAccount'; orgName: string} | {__typename: 'UserAccount'; email: string} -} - -export const CurrentAccountInfo = { - kind: 'Document', - definitions: [ - { - kind: 'OperationDefinition', - operation: 'query', - name: {kind: 'Name', value: 'currentAccountInfo'}, - selectionSet: { - kind: 'SelectionSet', - selections: [ - { - kind: 'Field', - name: {kind: 'Name', value: 'currentAccountInfo'}, - selectionSet: { - kind: 'SelectionSet', - selections: [ - {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, - { - kind: 'InlineFragment', - typeCondition: {kind: 'NamedType', name: {kind: 'Name', value: 'ServiceAccount'}}, - selectionSet: { - kind: 'SelectionSet', - selections: [ - {kind: 'Field', name: {kind: 'Name', value: 'orgName'}}, - {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, - ], - }, - }, - { - kind: 'InlineFragment', - typeCondition: {kind: 'NamedType', name: {kind: 'Name', value: 'UserAccount'}}, - selectionSet: { - kind: 'SelectionSet', - selections: [ - {kind: 'Field', name: {kind: 'Name', value: 'email'}}, - {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, - ], - }, - }, - ], - }, - }, - ], - }, - }, - ], -} as unknown as DocumentNode diff --git a/packages/app/src/cli/api/graphql/partners/generated/dev-stores-by-org.ts b/packages/app/src/cli/api/graphql/partners/generated/dev-stores-by-org.ts deleted file mode 100644 index 12381011f62..00000000000 --- a/packages/app/src/cli/api/graphql/partners/generated/dev-stores-by-org.ts +++ /dev/null @@ -1,132 +0,0 @@ -/* eslint-disable @typescript-eslint/consistent-type-definitions */ -import * as Types from './types.js' - -import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/core' - -export type DevStoresByOrgQueryVariables = Types.Exact<{ - id: Types.Scalars['ID']['input'] -}> - -export type DevStoresByOrgQuery = { - organizations: { - nodes?: - | ({ - id: string - stores: { - nodes?: - | ({ - shopId?: string | null - link: unknown - shopDomain: string - shopName: string - transferDisabled: boolean - convertableToPartnerTest: boolean - } | null)[] - | null - } - } | null)[] - | null - } -} - -export const DevStoresByOrg = { - kind: 'Document', - definitions: [ - { - kind: 'OperationDefinition', - operation: 'query', - name: {kind: 'Name', value: 'DevStoresByOrg'}, - variableDefinitions: [ - { - kind: 'VariableDefinition', - variable: {kind: 'Variable', name: {kind: 'Name', value: 'id'}}, - type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'ID'}}}, - }, - ], - selectionSet: { - kind: 'SelectionSet', - selections: [ - { - kind: 'Field', - name: {kind: 'Name', value: 'organizations'}, - arguments: [ - { - kind: 'Argument', - name: {kind: 'Name', value: 'id'}, - value: {kind: 'Variable', name: {kind: 'Name', value: 'id'}}, - }, - {kind: 'Argument', name: {kind: 'Name', value: 'first'}, value: {kind: 'IntValue', value: '1'}}, - ], - selectionSet: { - kind: 'SelectionSet', - selections: [ - { - kind: 'Field', - name: {kind: 'Name', value: 'nodes'}, - selectionSet: { - kind: 'SelectionSet', - selections: [ - {kind: 'Field', name: {kind: 'Name', value: 'id'}}, - { - kind: 'Field', - name: {kind: 'Name', value: 'stores'}, - arguments: [ - { - kind: 'Argument', - name: {kind: 'Name', value: 'first'}, - value: {kind: 'IntValue', value: '500'}, - }, - { - kind: 'Argument', - name: {kind: 'Name', value: 'archived'}, - value: {kind: 'BooleanValue', value: false}, - }, - { - kind: 'Argument', - name: {kind: 'Name', value: 'type'}, - value: { - kind: 'ListValue', - values: [ - {kind: 'EnumValue', value: 'DEVELOPMENT'}, - {kind: 'EnumValue', value: 'MANAGED'}, - {kind: 'EnumValue', value: 'PLUS_SANDBOX'}, - ], - }, - }, - ], - selectionSet: { - kind: 'SelectionSet', - selections: [ - { - kind: 'Field', - name: {kind: 'Name', value: 'nodes'}, - selectionSet: { - kind: 'SelectionSet', - selections: [ - {kind: 'Field', name: {kind: 'Name', value: 'shopId'}}, - {kind: 'Field', name: {kind: 'Name', value: 'link'}}, - {kind: 'Field', name: {kind: 'Name', value: 'shopDomain'}}, - {kind: 'Field', name: {kind: 'Name', value: 'shopName'}}, - {kind: 'Field', name: {kind: 'Name', value: 'transferDisabled'}}, - {kind: 'Field', name: {kind: 'Name', value: 'convertableToPartnerTest'}}, - {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, - ], - }, - }, - {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, - ], - }, - }, - {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, - ], - }, - }, - {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, - ], - }, - }, - ], - }, - }, - ], -} as unknown as DocumentNode diff --git a/packages/app/src/cli/api/graphql/partners/generated/types.d.ts b/packages/app/src/cli/api/graphql/partners/generated/types.d.ts deleted file mode 100644 index 1ffd775f795..00000000000 --- a/packages/app/src/cli/api/graphql/partners/generated/types.d.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* eslint-disable @typescript-eslint/consistent-type-definitions, @typescript-eslint/naming-convention, @typescript-eslint/no-explicit-any, tsdoc/syntax, @typescript-eslint/no-duplicate-type-constituents, @typescript-eslint/no-redundant-type-constituents, @nx/enforce-module-boundaries */ -import {JsonMapType} from '@shopify/cli-kit/node/toml' -export type Maybe = T | null; -export type InputMaybe = Maybe; -export type Exact = { [K in keyof T]: T[K] }; -export type MakeOptional = Omit & { [SubKey in K]?: Maybe }; -export type MakeMaybe = Omit & { [SubKey in K]: Maybe }; -export type MakeEmpty = { [_ in K]?: never }; -export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; -/** All built-in and custom scalars, mapped to their actual values */ -export type Scalars = { - ID: { input: string; output: string; } - String: { input: string; output: string; } - Boolean: { input: boolean; output: boolean; } - Int: { input: number; output: number; } - Float: { input: number; output: number; } - /** - * An [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601) encoded UTC date time - * string. Example value: `"2019-07-03T20:47:55.123456Z"`. - */ - DateTime: { input: any; output: any; } - /** - * Deprecated: Supported extension types. - * This used to include a list of extension types, we mantain it to keep compatibility with the old schema - */ - ExtensionType: { input: any; output: any; } - /** Represents untyped JSON */ - JSON: { input: JsonMapType | string; output: JsonMapType; } - /** A valid URL, transported as a string. */ - Url: { input: any; output: any; } - /** A valid webhook subscription endpoint string. */ - WebhookSubscriptionEndpoint: { input: any; output: any; } -}; diff --git a/packages/app/src/cli/api/graphql/partners/queries/all-orgs.graphql b/packages/app/src/cli/api/graphql/partners/queries/all-orgs.graphql deleted file mode 100644 index 30eb916c46d..00000000000 --- a/packages/app/src/cli/api/graphql/partners/queries/all-orgs.graphql +++ /dev/null @@ -1,8 +0,0 @@ -query AllOrgs { - organizations(first: 200) { - nodes { - id - businessName - } - } -} diff --git a/packages/app/src/cli/api/graphql/partners/queries/current-account-info.graphql b/packages/app/src/cli/api/graphql/partners/queries/current-account-info.graphql deleted file mode 100644 index 05d05fee2e2..00000000000 --- a/packages/app/src/cli/api/graphql/partners/queries/current-account-info.graphql +++ /dev/null @@ -1,11 +0,0 @@ -query currentAccountInfo { - currentAccountInfo { - __typename - ... on ServiceAccount { - orgName - } - ... on UserAccount { - email - } - } -} diff --git a/packages/app/src/cli/api/graphql/partners/queries/dev-stores-by-org.graphql b/packages/app/src/cli/api/graphql/partners/queries/dev-stores-by-org.graphql deleted file mode 100644 index a4f0dd56a46..00000000000 --- a/packages/app/src/cli/api/graphql/partners/queries/dev-stores-by-org.graphql +++ /dev/null @@ -1,17 +0,0 @@ -query DevStoresByOrg($id: ID!) { - organizations(id: $id, first: 1) { - nodes { - id - stores(first: 500, archived: false, type: [DEVELOPMENT, MANAGED, PLUS_SANDBOX]) { - nodes { - shopId - link - shopDomain - shopName - transferDisabled - convertableToPartnerTest - } - } - } - } -} 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 deleted file mode 100644 index 9e862120cd4..00000000000 --- a/packages/app/src/cli/services/context/deploy-app-version-migrations.ts +++ /dev/null @@ -1,100 +0,0 @@ -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-identifier-matching.test.ts b/packages/app/src/cli/services/context/deploy-identifier-matching.test.ts index 1ae7b2fc8d3..c5f6c638058 100644 --- a/packages/app/src/cli/services/context/deploy-identifier-matching.test.ts +++ b/packages/app/src/cli/services/context/deploy-identifier-matching.test.ts @@ -1,6 +1,5 @@ /* eslint-disable @shopify/prefer-module-scope-constants */ import {classifyDeployExtensionChanges, ensureDeployIdentifiersFromAppVersion} from './deploy-identifier-matching.js' -import {extensionMigrationPrompt} from './prompts.js' import {EnsureDeploymentIdsPresenceOptions} from './identifiers.js' import {AppInterface} from '../../models/app/app.js' import { @@ -15,16 +14,12 @@ 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 {AppModuleVersion} 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', @@ -36,7 +31,6 @@ const EMPTY_EXTENSION_BREAKDOWN = {onlyRemote: [], toCreate: [], toUpdate: [], u 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 @@ -121,13 +115,6 @@ beforeAll(async () => { type: 'checkout_post_purchase', uid: 'uid-b', }) - EXTENSION_TO_MIGRATE = await buildUiExtension({ - directory: '/extension-to-migrate', - name: 'Legacy UI', - type: 'ui_extension', - uid: 'uid-migration', - }) - CONFIG_EXTENSION = await testAppConfigExtensions() WEBHOOK_SUBSCRIPTION_EXTENSION = await testSingleWebhookSubscriptionExtension() @@ -191,8 +178,6 @@ beforeAll(async () => { beforeEach(() => { vi.mocked(deployOrReleaseConfirmationPrompt).mockResolvedValue(true) - vi.mocked(extensionMigrationPrompt).mockResolvedValue(true) - vi.mocked(migrateExtensionsToUIExtension).mockResolvedValue([]) }) describe('classifyDeployExtensionChanges', () => { @@ -628,95 +613,4 @@ describe('ensureDeployIdentifiersFromAppVersion', () => { }, }) }) - - 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( - deployOptions({ - app: testApp({...APP, allExtensions: [EXTENSION_TO_MIGRATE]}), - developerPlatformClient, - 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( - deployOptions({ - app: testApp({...APP, allExtensions: [EXTENSION_TO_MIGRATE]}), - developerPlatformClient, - activeAppVersion: {appModuleVersions: []}, - }), - ), - ).rejects.toThrow(AbortSilentError) - expect(deployOrReleaseConfirmationPrompt).not.toHaveBeenCalled() - }) }) diff --git a/packages/app/src/cli/services/context/deploy-identifier-matching.ts b/packages/app/src/cli/services/context/deploy-identifier-matching.ts index 8728e26ae50..d2955b69397 100644 --- a/packages/app/src/cli/services/context/deploy-identifier-matching.ts +++ b/packages/app/src/cli/services/context/deploy-identifier-matching.ts @@ -4,7 +4,6 @@ import { 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' @@ -30,11 +29,11 @@ interface ClassifyDeployExtensionChangesOptions { activeAppVersion?: AppVersion } -/** Builds deploy identifiers after migration, classification, and confirmation. */ +/** Builds deploy identifiers after classification and confirmation. */ export async function ensureDeployIdentifiersFromAppVersion( options: EnsureDeploymentIdsPresenceOptions, ): Promise { - const activeAppVersion = await activeAppVersionAfterMigrations(options) + const {activeAppVersion} = options const changes = await classifyDeployExtensionChanges({options, activeAppVersion}) const extensionIdentifiersBreakdown = buildExtensionIdentifiersBreakdown(changes) const configExtensionIdentifiersBreakdown = await buildDeployConfigExtensionIdentifiersBreakdown( diff --git a/packages/app/src/cli/services/context/prompts.ts b/packages/app/src/cli/services/context/prompts.ts deleted file mode 100644 index 1364864b787..00000000000 --- a/packages/app/src/cli/services/context/prompts.ts +++ /dev/null @@ -1,31 +0,0 @@ -import {LocalRemoteSource} from '../dev/migrate-app-module.js' -import {renderConfirmationPrompt, renderInfo} from '@shopify/cli-kit/node/ui' - -export async function extensionMigrationPrompt( - toMigrate: LocalRemoteSource[], - includeRemoteType = true, -): Promise { - const migrationNames = toMigrate.map(({local}) => `"${local.handle}"`).join(', ') - const allMigrationTypes = toMigrate.map(({remote}) => remote.type.toLocaleLowerCase()) - const uniqueMigrationTypes = allMigrationTypes - .filter((type, i) => allMigrationTypes.indexOf(type) === i) - .map((name) => `"${name}"`) - .join(', ') - - const migrationEndType = toMigrate.map(({local}) => `"${local.type}"`).join(', ') - - renderInfo({ - headline: "Extension migrations can't be undone.", - body: `Your ${migrationNames} configuration has been updated. Migrating gives you access to new features and won't impact the end user experience. All previous extension versions will reflect this change.`, - }) - - const confirmMessage = includeRemoteType - ? `Yes, confirm migration from ${uniqueMigrationTypes} to ${migrationEndType}` - : 'Yes, confirm migration' - - return renderConfirmationPrompt({ - message: `Migrate ${migrationNames}?`, - confirmationMessage: confirmMessage, - cancellationMessage: 'No, cancel', - }) -} diff --git a/packages/app/src/cli/services/dev/fetch.test.ts b/packages/app/src/cli/services/dev/fetch.test.ts index 26e0d0870e8..1da638dde24 100644 --- a/packages/app/src/cli/services/dev/fetch.test.ts +++ b/packages/app/src/cli/services/dev/fetch.test.ts @@ -6,13 +6,11 @@ import { testDeveloperPlatformClient, } from '../../models/app/app.test-data.js' import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' -import {PartnersClient} from '../../utilities/developer-platform-client/partners-client.js' import {AppManagementClient} from '../../utilities/developer-platform-client/app-management-client.js' import {afterEach, describe, expect, test, vi} from 'vitest' import {renderFatalError} from '@shopify/cli-kit/node/ui' import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' import {AbortError} from '@shopify/cli-kit/node/error' -import {blockPartnersAccess} from '@shopify/cli-kit/node/environment' const ORG1: Organization = { id: '1', @@ -34,10 +32,7 @@ const STORE1: OrganizationStore = { provisionable: true, } -vi.mock('@shopify/cli-kit/node/api/partners') -vi.mock('../../utilities/developer-platform-client/partners-client.js') vi.mock('../../utilities/developer-platform-client/app-management-client.js') -vi.mock('@shopify/cli-kit/node/environment') afterEach(() => { mockAndCaptureOutput().clear() @@ -45,30 +40,8 @@ afterEach(() => { }) describe('fetchOrganizations', async () => { - test('returns fetched organizations from available developer platform clients', async () => { + test('returns fetched organizations from App Management', async () => { // Given - vi.mocked(blockPartnersAccess).mockReturnValue(false) - const partnersClient: PartnersClient = testDeveloperPlatformClient({ - organizations: () => Promise.resolve([ORG1]), - }) as PartnersClient - const appManagementClient: AppManagementClient = testDeveloperPlatformClient({ - organizations: () => Promise.resolve([ORG2]), - }) as AppManagementClient - vi.mocked(PartnersClient.getInstance).mockReturnValue(partnersClient) - vi.mocked(AppManagementClient.getInstance).mockReturnValue(appManagementClient) - - // When - const got = await fetchOrganizations() - - // Then - expect(got).toEqual([ORG2, ORG1]) - expect(partnersClient.organizations).toHaveBeenCalled() - expect(appManagementClient.organizations).toHaveBeenCalled() - }) - - test('returns fetched organizations from App Management for 3P development', async () => { - // Given - vi.mocked(blockPartnersAccess).mockReturnValue(true) const appManagementClient: AppManagementClient = testDeveloperPlatformClient({ organizations: () => Promise.resolve([ORG2]), }) as AppManagementClient @@ -79,13 +52,11 @@ describe('fetchOrganizations', async () => { // Then expect(got).toEqual([ORG2]) - expect(PartnersClient.getInstance).not.toHaveBeenCalled() expect(appManagementClient.organizations).toHaveBeenCalled() }) test('throws if there are no organizations', async () => { // Given - vi.mocked(blockPartnersAccess).mockReturnValue(true) const appManagementClient: AppManagementClient = testDeveloperPlatformClient({ organizations: () => Promise.resolve([]), }) as AppManagementClient diff --git a/packages/app/src/cli/services/dev/migrate-app-module.test.ts b/packages/app/src/cli/services/dev/migrate-app-module.test.ts deleted file mode 100644 index c19f5d706df..00000000000 --- a/packages/app/src/cli/services/dev/migrate-app-module.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -import {getModulesToMigrate, migrateAppModules} from './migrate-app-module.js' -import {LocalSource, RemoteSource} from '../context/identifiers.js' -import {testDeveloperPlatformClient} from '../../models/app/app.test-data.js' -import {ClientName} from '../../utilities/developer-platform-client.js' -import {describe, expect, test} from 'vitest' - -function getLocalExtension(attributes: Partial = {}) { - return { - localIdentifier: 'my-action', - configuration: { - name: 'my-action', - }, - ...attributes, - } as unknown as LocalSource -} - -function getRemoteExtension(attributes: Partial = {}) { - return { - uuid: '1234', - title: 'a-different-extension', - ...attributes, - } as unknown as RemoteSource -} -const defaultMap = { - payments_extension: [ - 'payments_app', - 'payments_app_credit_card', - 'payments_app_custom_credit_card', - 'payments_app_custom_onsite', - 'payments_app_redeemable', - ], - marketing_activity: ['marketing_activity_extension'], - subscription_link_extension: ['subscription_link'], -} - -const defaultIdentifiers = { - 'module-A': '1234', - 'module-B': '', -} - -describe('getModulesToMigrate()', () => { - test('returns an empty array if no extensions are provided', () => { - const toMigrate = getModulesToMigrate([], [], {}, defaultMap) - expect(toMigrate).toStrictEqual([]) - }) - - test('matching by remote title and localIdentifier, without defaultIdentifiers', () => { - // Given - const localExtension = getLocalExtension({type: 'payments_extension', localIdentifier: 'module-A'}) - const localExtensionB = getLocalExtension({type: 'marketing_activity', localIdentifier: 'module-B'}) - const localExtensionC = getLocalExtension({type: 'subscription_link_extension', localIdentifier: 'module-C'}) - const remoteExtension = getRemoteExtension({type: 'payments_app_credit_card', title: 'module-A', uuid: 'yy'}) - const remoteExtensionB = getRemoteExtension({type: 'marketing_activity_extension', title: 'module-B', uuid: 'xx'}) - const remoteExtensionC = getRemoteExtension({type: 'subscription_link', title: 'module-C', uuid: 'zz'}) - - // When - const toMigrate = getModulesToMigrate( - [localExtension, localExtensionB, localExtensionC], - [remoteExtension, remoteExtensionB, remoteExtensionC], - {}, - defaultMap, - ) - - // Then - expect(toMigrate).toStrictEqual([ - {local: localExtension, remote: remoteExtension}, - {local: localExtensionB, remote: remoteExtensionB}, - {local: localExtensionC, remote: remoteExtensionC}, - ]) - }) - - test('matching by truncated remote title and localIdentifier, without defaultIdentifiers', () => { - // Given - const localExtension = getLocalExtension({ - type: 'payments_extension', - localIdentifier: 'ten-chars-ten-chars-ten-chars-ten-chars-ten-123456', - }) - const remoteExtension = getRemoteExtension({ - type: 'payments_app_credit_card', - title: 'Ten Chars Ten Chars Ten Chars Ten Chars Ten 123456789', - }) - - // When - const toMigrate = getModulesToMigrate([localExtension], [remoteExtension], {}, defaultMap) - - // Then - expect(toMigrate).toStrictEqual([{local: localExtension, remote: remoteExtension}]) - }) - - test('matching different title and localIdentifier, using uuid', () => { - // Given - const localExtension = getLocalExtension({type: 'payments_extension', localIdentifier: 'module-A'}) - const remoteExtension = getRemoteExtension({ - type: 'payments_app_credit_card', - title: 'random-title', - uuid: defaultIdentifiers['module-A'], - }) - - // When - const toMigrate = getModulesToMigrate([localExtension], [remoteExtension], defaultIdentifiers, defaultMap) - - // Then - expect(toMigrate).toStrictEqual([{local: localExtension, remote: remoteExtension}]) - }) - - test('does not return modules where local.type is not included in defaultMap', () => { - // Given - const localExtension = getLocalExtension({type: 'checkout_ui_extension'}) - const remoteExtension = getRemoteExtension({type: 'payments_app_credit_card'}) - - // When - const toMigrate = getModulesToMigrate([localExtension], [remoteExtension], defaultIdentifiers, defaultMap) - - // Then - expect(toMigrate).toStrictEqual([]) - }) - - test('does not return modules where remote.type is not included in defaultMap', () => { - // Given - const localExtension = getLocalExtension({type: 'payments_extension'}) - const remoteExtension = getRemoteExtension({type: 'marketing_activity_extension'}) - - // When - const toMigrate = getModulesToMigrate([localExtension], [remoteExtension], defaultIdentifiers, defaultMap) - - // Then - expect(toMigrate).toStrictEqual([]) - }) - - test('if neither title/name or ids match, does not return any extensions', () => { - // Given - const localExtension = getLocalExtension({type: 'payments_extension', localIdentifier: 'module-A'}) - const remoteExtension = getRemoteExtension({type: 'payments_app_credit_card', title: 'doesnt-match', uuid: '0000'}) - - // When - const toMigrate = getModulesToMigrate([localExtension], [remoteExtension], defaultIdentifiers, defaultMap) - - // Then - expect(toMigrate).toStrictEqual([]) - }) -}) - -describe('migrateAppModules()', () => { - test('uses registrationUuid for AppManagement client', async () => { - const extensionsToMigrate = [ - {local: getLocalExtension(), remote: getRemoteExtension({id: 'id1', uuid: 'uuid1'})}, - {local: getLocalExtension(), remote: getRemoteExtension({id: 'id2', uuid: 'uuid2'})}, - ] - const appId = '123abc' - const type = 'payments_extension' - const remoteExtensions = extensionsToMigrate.map(({remote}) => remote) - const appManagementClient = testDeveloperPlatformClient({clientName: ClientName.AppManagement}) - const migrationClient = testDeveloperPlatformClient() - - await migrateAppModules({ - extensionsToMigrate, - appId, - type, - remoteExtensions, - migrationClient, - }) - - expect(migrationClient.migrateAppModule).toHaveBeenCalledTimes(extensionsToMigrate.length) - expect(migrationClient.migrateAppModule).toHaveBeenCalledWith({ - apiKey: appId, - registrationId: undefined, - registrationUuid: extensionsToMigrate[0]!.remote.uuid, - type, - }) - expect(migrationClient.migrateAppModule).toHaveBeenCalledWith({ - apiKey: appId, - registrationId: undefined, - registrationUuid: extensionsToMigrate[1]!.remote.uuid, - type, - }) - }) - - test('Returns updated remoteExensions', async () => { - const extensionsToMigrate = [ - {local: getLocalExtension(), remote: getRemoteExtension()}, - {local: getLocalExtension(), remote: getRemoteExtension()}, - ] - const appId = '123abc' - const type = 'payments_extension' - const remoteExtensions = extensionsToMigrate.map(({remote}) => remote) - - const result = await migrateAppModules({ - extensionsToMigrate, - appId, - type, - remoteExtensions, - migrationClient: testDeveloperPlatformClient(), - }) - - expect(result).toStrictEqual(remoteExtensions.map((remote) => ({...remote, type: 'PAYMENTS_EXTENSION'}))) - }) -}) diff --git a/packages/app/src/cli/services/dev/migrate-app-module.ts b/packages/app/src/cli/services/dev/migrate-app-module.ts deleted file mode 100644 index c626040a0c7..00000000000 --- a/packages/app/src/cli/services/dev/migrate-app-module.ts +++ /dev/null @@ -1,149 +0,0 @@ -import {LocalSource, RemoteSource} from '../context/identifiers.js' -import {ExtensionUuidsByLocalIdentifier} from '../../models/app/identifiers.js' -import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' -import {MigrateAppModuleSchema, MigrateAppModuleVariables} from '../../api/graphql/extension_migrate_app_module.js' -import {MAX_EXTENSION_HANDLE_LENGTH} from '../../models/extensions/schemas.js' -import {AbortError} from '@shopify/cli-kit/node/error' -import {slugify} from '@shopify/cli-kit/common/string' - -/** - * All ***ModulesMap define the migration mapping between local and remote extension types. - * - * When adding new mappings, follow this rule in the mapping object: - * - The key is the NEW type - * - The value is an array of OLD types that can be migrated to the new type - */ -export const MarketingModulesMap = { - marketing_activity: ['marketing_activity_extension'], -} - -export const FlowModulesMap = { - flow_action: ['flow_action_definition'], - flow_trigger: ['flow_trigger_definition'], - flow_trigger_lifecycle_callback: ['flow_trigger_discovery_webhook'], -} - -export const UIModulesMap = { - ui_extension: ['CHECKOUT_UI_EXTENSION', 'POS_UI_EXTENSION'], -} - -export const AdminLinkModulesMap = { - admin_link: ['app_link', 'bulk_action'], -} - -export interface LocalRemoteSource { - local: LocalSource - remote: RemoteSource -} - -function getExtensionIds( - localSources: LocalSource[], - identifiers: ExtensionUuidsByLocalIdentifier, -): ExtensionUuidsByLocalIdentifier { - const localSourceIds = new Set(localSources.map((source) => source.localIdentifier)) - return Object.fromEntries(Object.entries(identifiers).filter(([identifier]) => localSourceIds.has(identifier))) -} - -/** - * Returns a list of local and remote extensions that need to be migrated. - * - * @param localSources - The local extensions to migrate. - * @param remoteSources - The remote extensions to migrate. - * @param identifiers - The identifiers for the extensions. - * @param typesMap - A map of extension types to migrate. - * @returns A list of local and remote extensions that need to be migrated. - */ -export function getModulesToMigrate( - localSources: LocalSource[], - remoteSources: RemoteSource[], - identifiers: ExtensionUuidsByLocalIdentifier, - typesMap: {[key: string]: string[]}, -) { - const ids = getExtensionIds(localSources, identifiers) - const localExtensionTypesToMigrate = Object.keys(typesMap) - const remoteExtensionTypesToMigrate = Object.values(typesMap).flat() - - const local = localSources.filter((source) => localExtensionTypesToMigrate.includes(source.type)) - const remote = remoteSources.filter((source) => remoteExtensionTypesToMigrate.includes(source.type)) - - // Map remote sources by uuid and slugified title (the slugified title is used for matching with local folder) - const remoteSourcesMap = new Map() - remote.forEach((remoteSource) => { - remoteSourcesMap.set(remoteSource.uuid, remoteSource) - remoteSourcesMap.set(slugify(remoteSource.title.substring(0, MAX_EXTENSION_HANDLE_LENGTH)), remoteSource) - }) - - return local.reduce((accumulator, localSource) => { - const localSourceId = ids[localSource.localIdentifier] ?? 'unknown' - const remoteSource = - remoteSourcesMap.get(localSourceId) ?? remoteSourcesMap.get(localSource.localIdentifier.toLowerCase()) - const typeMatch = typesMap[localSource.type]?.includes(remoteSource?.type ?? 'undefined') - - if (remoteSource && typeMatch) { - accumulator.push({local: localSource, remote: remoteSource}) - } - return accumulator - }, []) -} - -export async function migrateAppModules(options: { - extensionsToMigrate: LocalRemoteSource[] - appId: string - type: string - remoteExtensions: RemoteSource[] - migrationClient: DeveloperPlatformClient -}) { - const {extensionsToMigrate, appId, type, remoteExtensions, migrationClient} = options - - await Promise.all( - extensionsToMigrate.map(({remote}) => - migrateAppModule({ - apiKey: appId, - registrationId: undefined, - registrationUuid: remote.uuid, - type, - migrationClient, - }), - ), - ) - - const migratedUUIDs = extensionsToMigrate.map(({remote}) => remote.uuid) - return remoteExtensions - .filter((extension) => migratedUUIDs.includes(extension.uuid)) - .map((extension) => { - return { - ...extension, - type: type.toUpperCase(), - } - }) -} - -async function migrateAppModule(options: { - apiKey: MigrateAppModuleVariables['apiKey'] - registrationId: MigrateAppModuleVariables['registrationId'] - registrationUuid: MigrateAppModuleVariables['registrationUuid'] - type: MigrateAppModuleVariables['type'] - migrationClient: DeveloperPlatformClient -}) { - const {apiKey, registrationId, registrationUuid, type, migrationClient} = options - - const variables: MigrateAppModuleVariables = { - apiKey, - registrationId, - registrationUuid, - type, - } - - const result: MigrateAppModuleSchema = await migrationClient.migrateAppModule(variables) - - if (result?.migrateAppModule?.userErrors?.length > 0) { - const errors = result.migrateAppModule.userErrors.map((error) => error.message).join(', ') - throw new AbortError(errors) - } - - if (!result?.migrateAppModule?.migratedAppModule) { - throw new AbortError(`Couldn't migrate to app module ${type}`) - } - - return registrationId -} diff --git a/packages/app/src/cli/services/dev/migrate-flow-extension.test.ts b/packages/app/src/cli/services/dev/migrate-flow-extension.test.ts deleted file mode 100644 index ffd57bbd9c5..00000000000 --- a/packages/app/src/cli/services/dev/migrate-flow-extension.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import {migrateFlowExtensions} from './migrate-flow-extension.js' -import {LocalSource, RemoteSource} from '../context/identifiers.js' -import {testDeveloperPlatformClient} from '../../models/app/app.test-data.js' -import {describe, expect, test} from 'vitest' - -function getLocalExtension(attributes: Partial = {}) { - return { - type: 'flow_action', - localIdentifier: 'my-action', - configuration: { - name: 'my-action', - }, - ...attributes, - } as unknown as LocalSource -} - -function getRemoteExtension(attributes: Partial = {}) { - return { - uuid: '1234', - type: 'flow_action_definition', - title: 'a-different-extension', - ...attributes, - } as unknown as RemoteSource -} - -describe('migrateExtensions()', () => { - test('uses registrationUuid for AppManagement client', async () => { - const extensionsToMigrate = [ - {local: getLocalExtension(), remote: getRemoteExtension({id: 'id1', uuid: 'uuid1'})}, - {local: getLocalExtension(), remote: getRemoteExtension({id: 'id2', uuid: 'uuid2'})}, - ] - const appId = '123abc' - const remoteExtensions = extensionsToMigrate.map(({remote}) => remote) - const migrationClient = testDeveloperPlatformClient() - - await migrateFlowExtensions({ - extensionsToMigrate, - appId, - remoteExtensions, - migrationClient, - }) - - expect(migrationClient.migrateFlowExtension).toHaveBeenCalledTimes(extensionsToMigrate.length) - expect(migrationClient.migrateFlowExtension).toHaveBeenCalledWith({ - apiKey: appId, - registrationId: undefined, - registrationUuid: extensionsToMigrate[0]!.remote.uuid, - }) - expect(migrationClient.migrateFlowExtension).toHaveBeenCalledWith({ - apiKey: appId, - registrationId: undefined, - registrationUuid: extensionsToMigrate[1]!.remote.uuid, - }) - }) - - test('Returns updated remoteExensions', async () => { - const extensionsToMigrate = [ - {local: getLocalExtension(), remote: getRemoteExtension()}, - {local: getLocalExtension(), remote: getRemoteExtension()}, - ] - const appId = '123abc' - const remoteExtensions = extensionsToMigrate.map(({remote}) => remote) - - const result = await migrateFlowExtensions({ - extensionsToMigrate, - appId, - remoteExtensions, - migrationClient: testDeveloperPlatformClient(), - }) - - expect(result).toStrictEqual(remoteExtensions.map((remote) => ({...remote, type: 'FLOW_ACTION'}))) - }) -}) diff --git a/packages/app/src/cli/services/dev/migrate-flow-extension.ts b/packages/app/src/cli/services/dev/migrate-flow-extension.ts deleted file mode 100644 index b5241e8f48f..00000000000 --- a/packages/app/src/cli/services/dev/migrate-flow-extension.ts +++ /dev/null @@ -1,71 +0,0 @@ -import {LocalRemoteSource} from './migrate-app-module.js' -import {RemoteSource} from '../context/identifiers.js' -import { - MigrateFlowExtensionSchema, - MigrateFlowExtensionVariables, -} from '../../api/graphql/extension_migrate_flow_extension.js' -import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' -import {AbortError} from '@shopify/cli-kit/node/error' - -export async function migrateFlowExtensions(options: { - extensionsToMigrate: LocalRemoteSource[] - appId: string - remoteExtensions: RemoteSource[] - migrationClient: DeveloperPlatformClient -}) { - const {extensionsToMigrate, appId, remoteExtensions, migrationClient} = options - - await Promise.all( - extensionsToMigrate.map(({remote}) => - migrateFlowExtension({ - apiKey: appId, - registrationId: undefined, - registrationUuid: remote.uuid, - migrationClient, - }), - ), - ) - - const typesMap = new Map([ - ['flow_action_definition', 'FLOW_ACTION'], - ['flow_trigger_definition', 'FLOW_TRIGGER'], - ]) - - const migratedUUIDs = extensionsToMigrate.map(({remote}) => remote.uuid) - return remoteExtensions - .filter((extension) => migratedUUIDs.includes(extension.uuid)) - .map((extension) => { - return { - ...extension, - type: typesMap.get(extension.type) ?? extension.type, - } - }) -} - -async function migrateFlowExtension(options: { - apiKey: MigrateFlowExtensionVariables['apiKey'] - registrationId: MigrateFlowExtensionVariables['registrationId'] - registrationUuid: MigrateFlowExtensionVariables['registrationUuid'] - migrationClient: DeveloperPlatformClient -}) { - const {apiKey, registrationId, registrationUuid, migrationClient} = options - - const variables: MigrateFlowExtensionVariables = { - apiKey, - registrationId, - registrationUuid, - } - - const result: MigrateFlowExtensionSchema = await migrationClient.migrateFlowExtension(variables) - - if (result?.migrateFlowExtension?.userErrors?.length > 0) { - const errors = result.migrateFlowExtension.userErrors.map((error) => error.message).join(', ') - throw new AbortError(errors) - } - - if (!result?.migrateFlowExtension?.migratedFlowExtension) { - throw new AbortError("Couldn't migrate to Flow extension") - } - - return registrationId -} diff --git a/packages/app/src/cli/services/dev/migrate-to-ui-extension.test.ts b/packages/app/src/cli/services/dev/migrate-to-ui-extension.test.ts deleted file mode 100644 index cb5de06225d..00000000000 --- a/packages/app/src/cli/services/dev/migrate-to-ui-extension.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import {migrateExtensionsToUIExtension} from './migrate-to-ui-extension.js' -import {LocalSource, RemoteSource} from '../context/identifiers.js' -import {testDeveloperPlatformClient} from '../../models/app/app.test-data.js' -import {describe, expect, test} from 'vitest' - -function getLocalExtension(attributes: Partial = {}) { - return { - type: 'ui_extension', - localIdentifier: 'my-extension', - handle: 'my-extension', - configuration: { - name: 'my-extension', - }, - ...attributes, - } as unknown as LocalSource -} - -function getRemoteExtension(attributes: Partial = {}) { - return { - uuid: '1234', - type: 'CHECKOUT_UI_EXTENSION', - title: 'a-different-extension', - ...attributes, - } as unknown as RemoteSource -} - -describe('migrateExtensions()', () => { - test('uses registrationUuid for AppManagement client', async () => { - const extensionsToMigrate = [ - {local: getLocalExtension(), remote: getRemoteExtension({id: 'id1', uuid: 'uuid1'})}, - {local: getLocalExtension(), remote: getRemoteExtension({id: 'id2', uuid: 'uuid2'})}, - ] - const appId = '123abc' - const remoteExtensions = extensionsToMigrate.map(({remote}) => ({...remote, type: 'CHECKOUT_UI_EXTENSION'})) - const migrationClient = testDeveloperPlatformClient() - - await migrateExtensionsToUIExtension({ - extensionsToMigrate, - appId, - remoteExtensions, - migrationClient, - }) - - expect(migrationClient.migrateToUiExtension).toHaveBeenCalledTimes(extensionsToMigrate.length) - expect(migrationClient.migrateToUiExtension).toHaveBeenCalledWith({ - apiKey: appId, - registrationId: undefined, - registrationUuid: extensionsToMigrate[0]!.remote.uuid, - }) - expect(migrationClient.migrateToUiExtension).toHaveBeenCalledWith({ - apiKey: appId, - registrationId: undefined, - registrationUuid: extensionsToMigrate[1]!.remote.uuid, - }) - }) - - test('Returns updated remoteExensions', async () => { - const extensionsToMigrate = [ - {local: getLocalExtension(), remote: getRemoteExtension()}, - {local: getLocalExtension(), remote: getRemoteExtension()}, - ] - const appId = '123abc' - const remoteExtensions = extensionsToMigrate.map(({remote}) => ({...remote, type: 'CHECKOUT_UI_EXTENSION'})) - - const result = await migrateExtensionsToUIExtension({ - extensionsToMigrate, - appId, - remoteExtensions, - migrationClient: testDeveloperPlatformClient(), - }) - - expect(result).toStrictEqual(remoteExtensions.map((remote) => ({...remote, type: 'UI_EXTENSION'}))) - }) -}) diff --git a/packages/app/src/cli/services/dev/migrate-to-ui-extension.ts b/packages/app/src/cli/services/dev/migrate-to-ui-extension.ts deleted file mode 100644 index 92596967f88..00000000000 --- a/packages/app/src/cli/services/dev/migrate-to-ui-extension.ts +++ /dev/null @@ -1,64 +0,0 @@ -import {LocalRemoteSource} from './migrate-app-module.js' -import { - MigrateToUiExtensionSchema, - MigrateToUiExtensionVariables, -} from '../../api/graphql/extension_migrate_to_ui_extension.js' -import {RemoteSource} from '../context/identifiers.js' -import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' -import {AbortError} from '@shopify/cli-kit/node/error' - -export async function migrateExtensionsToUIExtension(options: { - extensionsToMigrate: LocalRemoteSource[] - appId: string - remoteExtensions: RemoteSource[] - migrationClient: DeveloperPlatformClient -}) { - const {extensionsToMigrate, appId, remoteExtensions, migrationClient} = options - - await Promise.all( - extensionsToMigrate.map(({remote}) => - migrateExtensionToUIExtension({ - apiKey: appId, - registrationId: undefined, - registrationUuid: remote.uuid, - migrationClient, - }), - ), - ) - - return remoteExtensions.map((extension) => { - if (extensionsToMigrate.some(({remote}) => remote.uuid === extension.uuid)) { - return { - ...extension, - type: 'UI_EXTENSION', - } - } - return extension - }) -} - -async function migrateExtensionToUIExtension(options: { - apiKey: MigrateToUiExtensionVariables['apiKey'] - registrationId: MigrateToUiExtensionVariables['registrationId'] - registrationUuid: MigrateToUiExtensionVariables['registrationUuid'] - migrationClient: DeveloperPlatformClient -}) { - const {apiKey, registrationId, registrationUuid, migrationClient} = options - - const variables: MigrateToUiExtensionVariables = { - apiKey, - registrationId, - registrationUuid, - } - - const result: MigrateToUiExtensionSchema = await migrationClient.migrateToUiExtension(variables) - - if (result?.migrateToUiExtension?.userErrors?.length > 0) { - const errors = result.migrateToUiExtension.userErrors.map((error) => error.message).join(', ') - throw new AbortError(errors) - } - - if (!result?.migrateToUiExtension?.migratedToUiExtension) { - throw new AbortError("Couldn't migrate to UI extension") - } -} diff --git a/packages/app/src/cli/utilities/developer-platform-client.ts b/packages/app/src/cli/utilities/developer-platform-client.ts index f092e6d1d1b..1f52b09e135 100644 --- a/packages/app/src/cli/utilities/developer-platform-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client.ts @@ -1,4 +1,3 @@ -import {PartnersClient} from './developer-platform-client/partners-client.js' import {AppManagementClient} from './developer-platform-client/app-management-client.js' import { MinimalAppIdentifiers, @@ -49,7 +48,6 @@ import { import {Store} from '../api/graphql/business-platform-organizations/generated/types.js' import {Session} from '@shopify/cli-kit/node/session' import {TokenItem} from '@shopify/cli-kit/node/ui' -import {blockPartnersAccess} from '@shopify/cli-kit/node/environment' import {UnauthorizedHandler} from '@shopify/cli-kit/node/api/graphql' import {JsonMapType} from '@shopify/cli-kit/node/toml' @@ -57,6 +55,7 @@ export type {Store} from '../api/graphql/business-platform-organizations/generat export enum ClientName { AppManagement = 'app-management', + // Retained as a label for legacy fixtures; no client implements it anymore. Partners = 'partners', } @@ -74,30 +73,14 @@ export interface AppVersionIdentifiers { } export function allDeveloperPlatformClients(): DeveloperPlatformClient[] { - const clients: DeveloperPlatformClient[] = [] - - clients.push(AppManagementClient.getInstance()) - - if (!blockPartnersAccess()) { - clients.push(PartnersClient.getInstance()) - } - - return clients -} - -export function selectDeveloperPlatformClient({ - organization, -}: SelectDeveloperPlatformClientOptions = {}): DeveloperPlatformClient { - if (organization) return selectDeveloperPlatformClientByOrg(organization) - return defaultDeveloperPlatformClient() -} - -function selectDeveloperPlatformClientByOrg(organization: Organization): DeveloperPlatformClient { - if (organization.source === OrganizationSource.BusinessPlatform) return AppManagementClient.getInstance() - return PartnersClient.getInstance() + return [AppManagementClient.getInstance()] } -function defaultDeveloperPlatformClient(): DeveloperPlatformClient { +// All organizations are on the App Management platform, so App Management is the only +// client. The `organization` option is retained for call-site compatibility. +export function selectDeveloperPlatformClient( + _options: SelectDeveloperPlatformClientOptions = {}, +): DeveloperPlatformClient { return AppManagementClient.getInstance() } diff --git a/packages/app/src/cli/utilities/developer-platform-client/partners-client.test.ts b/packages/app/src/cli/utilities/developer-platform-client/partners-client.test.ts deleted file mode 100644 index e2cce11bf64..00000000000 --- a/packages/app/src/cli/utilities/developer-platform-client/partners-client.test.ts +++ /dev/null @@ -1,239 +0,0 @@ -import {PartnersClient} from './partners-client.js' -import {CreateAppQuery} from '../../api/graphql/create_app.js' -import {AppInterface, WebType} from '../../models/app/app.js' -import {Organization, OrganizationSource, OrganizationStore} from '../../models/organization.js' -import { - testPartnersUserSession, - testApp, - testAppWithConfig, - testOrganizationApp, -} from '../../models/app/app.test-data.js' -import {appNamePrompt} from '../../prompts/dev.js' -import {FindOrganizationQuery} from '../../api/graphql/find_org.js' -import {partnersRequest} from '@shopify/cli-kit/node/api/partners' -import {describe, expect, vi, test, beforeEach} from 'vitest' - -vi.mock('../../prompts/dev.js') -vi.mock('@shopify/cli-kit/node/api/partners') - -beforeEach(() => { - // Reset the singleton instance before each test - PartnersClient.resetInstance() -}) - -const LOCAL_APP: AppInterface = testApp({ - directory: '', - configuration: { - client_id: 'test-client-id', - name: 'my-app', - application_url: 'https://example.com', - embedded: true, - access_scopes: {scopes: 'read_products'}, - extension_directories: ['extensions/*'], - }, - webs: [ - { - directory: '', - configuration: { - roles: [WebType.Backend], - commands: {dev: ''}, - }, - }, - ], - name: 'my-app', -}) - -type OrganizationInPartnersResponse = Omit - -const ORG1: OrganizationInPartnersResponse = { - id: '1', - businessName: 'org1', -} -const ORG2: OrganizationInPartnersResponse = { - id: '2', - businessName: 'org2', -} - -const APP1 = testOrganizationApp({apiKey: 'key1'}) -const APP2 = testOrganizationApp({ - id: '2', - title: 'app2', - apiKey: 'key2', - apiSecretKeys: [{secret: 'secret2'}], -}) - -const STORE1: OrganizationStore = { - shopId: '1', - link: 'link1', - shopDomain: 'domain1', - shopName: 'store1', - transferDisabled: false, - convertableToPartnerTest: false, - provisionable: true, -} - -const FETCH_ORG_RESPONSE_VALUE = { - organizations: { - nodes: [ - { - id: ORG1.id, - businessName: ORG1.businessName, - apps: {nodes: [APP1, APP2], pageInfo: {hasNextPage: false}}, - stores: {nodes: [STORE1]}, - }, - ], - }, -} - -describe('createApp', () => { - test('sends request to create app with launchable defaults and returns it', async () => { - // Given - const partnersClient = PartnersClient.getInstance(testPartnersUserSession) - const localApp = testAppWithConfig({config: {access_scopes: {scopes: 'write_products'}}}) - vi.mocked(appNamePrompt).mockResolvedValue('app-name') - vi.mocked(partnersRequest).mockResolvedValueOnce({appCreate: {app: APP1, userErrors: []}}) - const variables = { - org: 1, - title: localApp.name, - appUrl: 'https://example.com', - redir: ['https://example.com/api/auth'], - requestedAccessScopes: ['write_products'], - type: 'undecided', - } - - // When - const got = await partnersClient.createApp( - {...ORG1, source: OrganizationSource.Partners}, - { - name: localApp.name, - scopesArray: ['write_products'], - isLaunchable: true, - directory: '', - }, - ) - - // Then - expect(got).toEqual({...APP1, newApp: true, developerPlatformClient: partnersClient}) - expect(partnersRequest).toHaveBeenCalledWith(CreateAppQuery, 'token', variables, undefined, undefined, { - type: 'token_refresh', - handler: expect.any(Function), - }) - }) - - test('creates an app with non-launchable defaults', async () => { - // Given - const partnersClient = PartnersClient.getInstance(testPartnersUserSession) - vi.mocked(appNamePrompt).mockResolvedValue('app-name') - vi.mocked(partnersRequest).mockResolvedValueOnce({appCreate: {app: APP1, userErrors: []}}) - const variables = { - org: 1, - title: LOCAL_APP.name, - appUrl: 'https://shopify.dev/apps/default-app-home', - redir: ['https://shopify.dev/apps/default-app-home/api/auth'], - requestedAccessScopes: ['write_products'], - type: 'undecided', - } - - // When - const got = await partnersClient.createApp( - {...ORG1, source: OrganizationSource.Partners}, - { - name: LOCAL_APP.name, - isLaunchable: false, - scopesArray: ['write_products'], - }, - ) - - // Then - expect(got).toEqual({...APP1, newApp: true, developerPlatformClient: partnersClient}) - expect(partnersRequest).toHaveBeenCalledWith(CreateAppQuery, 'token', variables, undefined, undefined, { - type: 'token_refresh', - handler: expect.any(Function), - }) - }) - - test('throws error if requests has a user error', async () => { - // Given - const partnersClient = PartnersClient.getInstance(testPartnersUserSession) - vi.mocked(appNamePrompt).mockResolvedValue('app-name') - vi.mocked(partnersRequest).mockResolvedValueOnce({ - appCreate: {app: {}, userErrors: [{message: 'some-error'}]}, - }) - - // When - const got = partnersClient.createApp({...ORG2, source: OrganizationSource.Partners}, {name: LOCAL_APP.name}) - - // Then - await expect(got).rejects.toThrow(`some-error`) - }) -}) - -describe('fetchApp', async () => { - test('returns fetched apps', async () => { - // Given - const partnersClient = PartnersClient.getInstance(testPartnersUserSession) - vi.mocked(partnersRequest).mockResolvedValue(FETCH_ORG_RESPONSE_VALUE) - const partnerMarkedOrg = {...ORG1, source: 'Partners'} - - // When - const got = await partnersClient.orgAndApps(ORG1.id) - - // Then - expect(got).toEqual({organization: partnerMarkedOrg, apps: [APP1, APP2], hasMorePages: false}) - expect(partnersRequest).toHaveBeenCalledWith(FindOrganizationQuery, 'token', {id: ORG1.id}, undefined, undefined, { - type: 'token_refresh', - handler: expect.any(Function), - }) - }) - - test('throws if there are no organizations', async () => { - // Given - const partnersClient = PartnersClient.getInstance(testPartnersUserSession) - vi.mocked(partnersRequest).mockResolvedValue({organizations: {nodes: []}}) - - // When - const got = () => partnersClient.orgAndApps(ORG1.id) - - // Then - await expect(got).rejects.toThrow('No Organization found') - expect(partnersRequest).toHaveBeenCalledWith(FindOrganizationQuery, 'token', {id: ORG1.id}, undefined, undefined, { - type: 'token_refresh', - handler: expect.any(Function), - }) - }) -}) - -describe('PartnersClient', () => { - describe('bundleFormat', () => { - test('uses zip format', () => { - // Given - const client = PartnersClient.getInstance() - - // Then - expect(client.bundleFormat).toBe('zip') - }) - }) -}) - -describe('singleton pattern', () => { - test('getInstance returns the same instance', () => { - // Given/When - const instance1 = PartnersClient.getInstance() - const instance2 = PartnersClient.getInstance() - - // Then - expect(instance1).toBe(instance2) - }) - - test('resetInstance allows creating a new instance', () => { - // Given - const instance1 = PartnersClient.getInstance() - - // When - PartnersClient.resetInstance() - const instance2 = PartnersClient.getInstance() - - // Then - expect(instance1).not.toBe(instance2) - }) -}) diff --git a/packages/app/src/cli/utilities/developer-platform-client/partners-client.ts b/packages/app/src/cli/utilities/developer-platform-client/partners-client.ts deleted file mode 100644 index fa2dafdc36e..00000000000 --- a/packages/app/src/cli/utilities/developer-platform-client/partners-client.ts +++ /dev/null @@ -1,670 +0,0 @@ -import {CreateAppQuery, CreateAppQuerySchema, CreateAppQueryVariables} from '../../api/graphql/create_app.js' -import { - AppVersion, - AppDeployOptions, - AssetUrlSchema, - AppVersionIdentifiers, - DeveloperPlatformClient, - TemplateSpecificationsOptions, - Paginateable, - filterDisabledFlags, - ClientName, - AppVersionWithContext, - CreateAppOptions, - AppLogsResponse, - createUnauthorizedHandler, -} from '../developer-platform-client.js' -import {fetchCurrentAccountInformation} from '../../services/context/partner-account-info.js' -import { - MinimalAppIdentifiers, - MinimalOrganizationApp, - Organization, - OrganizationApp, - OrganizationSource, - OrganizationStore, -} from '../../models/organization.js' -import { - AllAppExtensionRegistrationsQuery, - AllAppExtensionRegistrationsQueryVariables, - AllAppExtensionRegistrationsQuerySchema, -} from '../../api/graphql/all_app_extension_registrations.js' -import { - ActiveAppVersionQuery, - ActiveAppVersionQuerySchema, - ActiveAppVersionQueryVariables, -} from '../../api/graphql/app_active_version.js' -import {AppDeploy, AppDeploySchema, AppDeployVariables} from '../../api/graphql/app_deploy.js' -import { - GenerateSignedUploadUrl, - GenerateSignedUploadUrlSchema, - GenerateSignedUploadUrlVariables, -} from '../../api/graphql/generate_signed_upload_url.js' -import { - ConvertDevToTransferDisabledStoreQuery, - ConvertDevToTransferDisabledSchema, - ConvertDevToTransferDisabledStoreVariables, -} from '../../api/graphql/convert_dev_to_transfer_disabled_store.js' -import { - FindStoreByDomainQuery, - FindStoreByDomainQueryVariables, - FindStoreByDomainSchema, -} from '../../api/graphql/find_store_by_domain.js' -import {Store} from '../../api/graphql/business-platform-organizations/generated/types.js' -import { - AppVersionsQuery, - AppVersionsQueryVariables, - AppVersionsQuerySchema, -} from '../../api/graphql/get_versions_list.js' -import { - AppVersionsDiffQuery, - AppVersionsDiffSchema, - AppVersionsDiffVariables, -} from '../../api/graphql/app_versions_diff.js' -import {AppRelease, AppReleaseSchema, AppReleaseVariables} from '../../api/graphql/app_release.js' -import { - AppVersionByTagQuery, - AppVersionByTagSchema, - AppVersionByTagVariables, -} from '../../api/graphql/app_version_by_tag.js' -import { - SendSampleWebhookSchema, - SendSampleWebhookVariables, - sendSampleWebhookMutation, -} from '../../services/webhook/request-sample.js' -import {PublicApiVersionsSchema, GetApiVersionsQuery} from '../../services/webhook/request-api-versions.js' -import {WebhookTopicsSchema, WebhookTopicsVariables, getTopicsQuery} from '../../services/webhook/request-topics.js' -import { - MigrateFlowExtensionVariables, - MigrateFlowExtensionSchema, - MigrateFlowExtensionMutation, -} from '../../api/graphql/extension_migrate_flow_extension.js' -import {UpdateURLsVariables, UpdateURLsSchema, UpdateURLsQuery} from '../../api/graphql/update_urls.js' -import {CurrentAccountInfo, CurrentAccountInfoQuery} from '../../api/graphql/partners/generated/current-account-info.js' -import { - RemoteTemplateSpecificationsQuery, - RemoteTemplateSpecificationsSchema, - RemoteTemplateSpecificationsVariables, -} from '../../api/graphql/template_specifications.js' -import {ExtensionTemplatesResult} from '../../models/app/template.js' -import { - TargetSchemaDefinitionQuerySchema, - TargetSchemaDefinitionQuery, -} from '../../api/graphql/functions/target_schema_definition.js' -import { - ApiSchemaDefinitionQuerySchema, - ApiSchemaDefinitionQuery, -} from '../../api/graphql/functions/api_schema_definition.js' -import { - MigrateToUiExtensionVariables, - MigrateToUiExtensionSchema, - MigrateToUiExtensionQuery, -} from '../../api/graphql/extension_migrate_to_ui_extension.js' -import { - ExtensionSpecificationsQuery, - ExtensionSpecificationsQuerySchema, - ExtensionSpecificationsQueryVariables, - RemoteSpecification, -} from '../../api/graphql/extension_specifications.js' -import { - FindOrganizationBasicQuery, - FindOrganizationBasicQuerySchema, - FindOrganizationBasicVariables, -} from '../../api/graphql/find_org_basic.js' -import { - MigrateAppModuleMutation, - MigrateAppModuleSchema, - MigrateAppModuleVariables, -} from '../../api/graphql/extension_migrate_app_module.js' -import {AppLogsSubscribeMutation, AppLogsSubscribeResponse} from '../../api/graphql/subscribe_to_app_logs.js' - -import {AllOrgs} from '../../api/graphql/partners/generated/all-orgs.js' -import {FindAppQuery, FindAppQuerySchema, FindAppQueryVariables} from '../../api/graphql/find_app.js' -import { - FindOrganizationQuery, - FindOrganizationQuerySchema, - FindOrganizationQueryVariables, -} from '../../api/graphql/find_org.js' -import {NoOrgError} from '../../services/dev/fetch.js' -import { - DevStoresByOrg, - DevStoresByOrgQuery, - DevStoresByOrgQueryVariables, -} from '../../api/graphql/partners/generated/dev-stores-by-org.js' -import {SchemaDefinitionByTargetQueryVariables} from '../../api/graphql/functions/generated/schema-definition-by-target.js' -import {SchemaDefinitionByApiTypeQueryVariables} from '../../api/graphql/functions/generated/schema-definition-by-api-type.js' -import {AppLogData} from '../../services/app-logs/types.js' -import {AppLogsOptions} from '../../services/app-logs/utils.js' -import {AppLogsSubscribeMutationVariables} from '../../api/graphql/app-management/generated/app-logs-subscribe.js' -import {TypedDocumentNode} from '@graphql-typed-document-node/core' -import {isUnitTest} from '@shopify/cli-kit/node/context/local' -import {AbortError} from '@shopify/cli-kit/node/error' -import {generateFetchAppLogUrl, partnersRequest, partnersRequestDoc} from '@shopify/cli-kit/node/api/partners' -import {CacheOptions, GraphQLVariables, UnauthorizedHandler} from '@shopify/cli-kit/node/api/graphql' -import {ensureAuthenticatedPartners, Session} from '@shopify/cli-kit/node/session' -import {partnersFqdn} from '@shopify/cli-kit/node/context/fqdn' -import {TokenItem} from '@shopify/cli-kit/node/ui' -import {RequestModeInput, Response, shopifyFetch} from '@shopify/cli-kit/node/http' -import {CLI_KIT_VERSION} from '@shopify/cli-kit/common/version' - -// this is a temporary solution for editions to support https://vault.shopify.io/gsd/projects/31406 -// read more here: https://vault.shopify.io/gsd/projects/31406 -const MAGIC_URL = 'https://shopify.dev/apps/default-app-home' -const MAGIC_REDIRECT_URL = 'https://shopify.dev/apps/default-app-home/api/auth' - -function getAppVars(org: Organization, options: CreateAppOptions): CreateAppQueryVariables { - const {name, isLaunchable = true, scopesArray} = options - const defaultAppUrl = isLaunchable ? 'https://example.com' : MAGIC_URL - const defaultRedirectUrl = isLaunchable ? 'https://example.com/api/auth' : MAGIC_REDIRECT_URL - - return { - org: parseInt(org.id, 10), - title: name, - appUrl: defaultAppUrl, - redir: [defaultRedirectUrl], - requestedAccessScopes: scopesArray ?? [], - type: 'undecided', - } -} - -interface OrganizationAppsResponse { - pageInfo: { - hasNextPage: boolean - } - nodes: MinimalOrganizationApp[] -} - -interface OrgAndAppsResponse { - organization: Organization - apps: OrganizationAppsResponse - stores: OrganizationStore[] -} - -export class PartnersClient implements DeveloperPlatformClient { - private static instance: PartnersClient | undefined - - static getInstance(session?: Session): PartnersClient { - PartnersClient.instance ??= new PartnersClient(session) - return PartnersClient.instance - } - - static resetInstance(): void { - PartnersClient.instance = undefined - } - - public readonly clientName = ClientName.Partners - public readonly webUiName = 'Partner Dashboard' - public readonly organizationSource = OrganizationSource.Partners - public readonly bundleFormat = 'zip' - private _session: Session | undefined - - private constructor(session?: Session) { - this._session = session - } - - async session(): Promise { - if (!this._session) { - if (isUnitTest()) { - throw new Error('PartnersClient.session() should not be invoked dynamically in a unit test') - } - const {token, userId} = await ensureAuthenticatedPartners() - this._session = { - token, - businessPlatformToken: '', - accountInfo: {type: 'UnknownAccount'}, - userId, - } - const accountInfo = await fetchCurrentAccountInformation(this, userId) - this._session = {token, businessPlatformToken: '', accountInfo, userId} - } - return this._session - } - - async request( - query: string, - variables: GraphQLVariables | undefined = undefined, - cacheOptions?: CacheOptions, - preferredBehaviour?: RequestModeInput, - ): Promise { - return partnersRequest( - query, - await this.token(), - variables, - cacheOptions, - preferredBehaviour, - this.createUnauthorizedHandler(), - ) - } - - async requestDoc( - document: TypedDocumentNode, - variables?: TVariables, - ): Promise { - return partnersRequestDoc(document, await this.token(), variables, undefined, this.createUnauthorizedHandler()) - } - - async token(): Promise { - return (await this.session()).token - } - - async unsafeRefreshToken(): Promise { - const {token} = await ensureAuthenticatedPartners([], process.env, {noPrompt: true, forceRefresh: true}) - const session = await this.session() - if (token) { - session.token = token - } - return session.token - } - - async accountInfo(): Promise { - return (await this.session()).accountInfo - } - - async appFromIdentifiers(apiKey: string): Promise { - const variables: FindAppQueryVariables = {apiKey} - const res: FindAppQuerySchema = await this.request(FindAppQuery, variables) - const app = res.app - if (app) { - const flags = filterDisabledFlags(app.disabledFlags) - return { - ...app, - flags, - developerPlatformClient: this, - } - } - } - - async organizations(): Promise { - try { - const result = await this.requestDoc(AllOrgs) - return result.organizations.nodes!.map((org) => ({ - id: org!.id, - businessName: `${org!.businessName} (Partner Dashboard)`, - source: this.organizationSource, - })) - } catch (error: unknown) { - if ((error as {statusCode?: number}).statusCode === 404) { - return [] - } else { - throw error - } - } - } - - async orgFromId(orgId: string): Promise { - const variables: FindOrganizationBasicVariables = {id: orgId} - const result: FindOrganizationBasicQuerySchema = await this.request(FindOrganizationBasicQuery, variables, { - cacheTTL: {hours: 6}, - }) - const org: Omit | undefined = result.organizations.nodes[0] - return org ? {...org, source: this.organizationSource} : undefined - } - - async orgAndApps(orgId: string): Promise> { - const result = await this.fetchOrgAndApps(orgId) - return { - organization: result.organization, - apps: result.apps.nodes, - hasMorePages: result.apps.pageInfo.hasNextPage, - } - } - - async appsForOrg(organizationId: string, term?: string): Promise> { - const result = await this.fetchOrgAndApps(organizationId, term) - return { - apps: result.apps.nodes, - hasMorePages: result.apps.pageInfo.hasNextPage, - } - } - - async specifications({apiKey}: MinimalAppIdentifiers): Promise { - const variables: ExtensionSpecificationsQueryVariables = {apiKey} - const result: ExtensionSpecificationsQuerySchema = await this.request(ExtensionSpecificationsQuery, variables) - // Partners API doesn't provide uidStrategy; derive it from experience. - return result.extensionSpecifications.map(({options, features, ...spec}) => ({ - ...spec, - uidStrategy: spec.experience === 'extension' ? 'uuid' : 'single', - registrationLimit: options.registrationLimit, - managementExperience: options.managementExperience, - surface: features?.argo?.surface, - })) - } - - async templateSpecifications( - {apiKey}: MinimalAppIdentifiers, - _options: TemplateSpecificationsOptions = {}, - ): Promise { - const variables: RemoteTemplateSpecificationsVariables = {apiKey} - const result: RemoteTemplateSpecificationsSchema = await this.request(RemoteTemplateSpecificationsQuery, variables) - const templates = result.templateSpecifications.map((template) => { - const {types, ...rest} = template - return { - ...rest, - ...types[0], - } - }) - - let counter = 0 - const templatesWithPriority = templates.map((template) => ({ - ...template, - sortPriority: template.sortPriority ?? counter++, - })) - - const groupOrder: string[] = [] - for (const template of templatesWithPriority) { - if (template.group && !groupOrder.includes(template.group)) { - groupOrder.push(template.group) - } - } - - return { - templates: templatesWithPriority, - groupOrder, - } - } - - async createApp(org: Organization, options: CreateAppOptions): Promise { - const variables: CreateAppQueryVariables = getAppVars(org, options) - const result: CreateAppQuerySchema = await this.request(CreateAppQuery, variables) - if (result.appCreate.userErrors.length > 0) { - const errors = result.appCreate.userErrors.map((error) => error.message).join(', ') - throw new AbortError(errors) - } - - const flags = filterDisabledFlags(result.appCreate.app.disabledFlags) - return {...result.appCreate.app, organizationId: org.id, newApp: true, flags, developerPlatformClient: this} - } - - async devStoresForOrg(orgId: string): Promise> { - const variables: DevStoresByOrgQueryVariables = {id: orgId} - const result: DevStoresByOrgQuery = await this.requestDoc(DevStoresByOrg, variables) - return { - stores: result.organizations.nodes![0]!.stores.nodes as OrganizationStore[], - hasMorePages: false, - } - } - - async appExtensionRegistrations( - {apiKey}: MinimalAppIdentifiers, - _activeAppVersion?: AppVersion, - ): Promise { - const variables: AllAppExtensionRegistrationsQueryVariables = {apiKey} - return this.request(AllAppExtensionRegistrationsQuery, variables) - } - - async appVersions({apiKey}: OrganizationApp): Promise { - const variables: AppVersionsQueryVariables = {apiKey} - return this.request(AppVersionsQuery, variables) - } - - async appInstallCount(_app: MinimalAppIdentifiers): Promise { - // Install count is not supported in partners client. - throw new Error('Unsupported operation') - } - - async appVersionByTag({apiKey}: MinimalOrganizationApp, versionTag: string): Promise { - const input: AppVersionByTagVariables = {apiKey, versionTag} - const result: AppVersionByTagSchema = await this.request(AppVersionByTagQuery, input) - const appVersion = result.app.appVersion - return { - ...appVersion, - appModuleVersions: appVersion.appModuleVersions.map((appModuleVersion) => ({ - ...appModuleVersion, - config: appModuleVersion.config ? JSON.parse(appModuleVersion.config) : undefined, - })), - } - } - - async appVersionsDiff( - {apiKey}: MinimalOrganizationApp, - {appVersionId}: AppVersionIdentifiers, - ): Promise { - const variables: AppVersionsDiffVariables = {apiKey, versionId: appVersionId} - return this.request(AppVersionsDiffQuery, variables) - } - - async activeAppVersion({apiKey}: MinimalAppIdentifiers): Promise { - const variables: ActiveAppVersionQueryVariables = {apiKey} - const result = await this.request(ActiveAppVersionQuery, variables) - const version = result.app.activeAppVersion - if (!version) return - return { - ...version, - appModuleVersions: version.appModuleVersions.map((mod) => { - return { - ...mod, - config: mod.config ? (JSON.parse(mod.config) as object) : {}, - } - }), - } - } - - async deploy(deployInput: AppDeployOptions): Promise { - const {organizationId, ...deployOptions} = deployInput - // Enforce the type - const variables: AppDeployVariables = deployOptions - // Exclude uid - variables.appModules = variables.appModules?.map((element) => { - const {uid, ...otherFields} = element - return otherFields - }) - return this.request(AppDeploy, variables, undefined, 'slow-request') - } - - async release({ - app: {apiKey}, - version: {appVersionId}, - }: { - app: MinimalOrganizationApp - version: AppVersionIdentifiers - }): Promise { - const input: AppReleaseVariables = {apiKey, appVersionId} - return this.request(AppRelease, input) - } - - async generateSignedUploadUrl(app: MinimalAppIdentifiers): Promise { - const variables: GenerateSignedUploadUrlVariables = {apiKey: app.apiKey, bundleFormat: 1} - const result = await this.request(GenerateSignedUploadUrl, variables) - return { - assetUrl: result.appVersionGenerateSignedUploadUrl.signedUploadUrl, - userErrors: result.appVersionGenerateSignedUploadUrl.userErrors, - } - } - - async convertToTransferDisabledStore( - input: ConvertDevToTransferDisabledStoreVariables, - ): Promise { - return this.request(ConvertDevToTransferDisabledStoreQuery, input) - } - - async storeByDomain(orgId: string, shopDomain: string, _storeTypes: Store[]): Promise { - // Note: storeTypes filtering not implemented for PartnersClient - const variables: FindStoreByDomainQueryVariables = {orgId, shopDomain} - const result: FindStoreByDomainSchema = await this.request(FindStoreByDomainQuery, variables) - - const node = result.organizations.nodes[0]?.stores.nodes[0] - if (!node) { - return undefined - } - return { - ...node, - provisionable: false, - } - } - - async ensureUserAccessToStore(_orgId: string, _store: OrganizationStore): Promise { - // This is a no-op for partners - } - - async sendSampleWebhook( - input: SendSampleWebhookVariables, - _organizationId: string, - ): Promise { - return this.request(sendSampleWebhookMutation, input) - } - - async apiVersions(_organizationId: string): Promise { - return this.request(GetApiVersionsQuery) - } - - async topics(input: WebhookTopicsVariables, _organizationId: string): Promise { - return this.request(getTopicsQuery, input) - } - - async migrateFlowExtension(input: MigrateFlowExtensionVariables): Promise { - return this.request(MigrateFlowExtensionMutation, input) - } - - async migrateAppModule(input: MigrateAppModuleVariables): Promise { - return this.request(MigrateAppModuleMutation, input) - } - - async updateURLs(input: UpdateURLsVariables): Promise { - return this.request(UpdateURLsQuery, input) - } - - async currentAccountInfo(): Promise { - return this.requestDoc(CurrentAccountInfo) - } - - async targetSchemaDefinition( - input: SchemaDefinitionByTargetQueryVariables, - apiKey: string, - _organizationId: string, - ): Promise { - // Ensures compatibility with existing partners requests - // Can remove once migrated to AMF - const transformedInput = { - target: input.handle, - version: input.version, - apiKey, - } - - const response: TargetSchemaDefinitionQuerySchema = await this.request( - TargetSchemaDefinitionQuery, - transformedInput, - ) - return response.definition - } - - async apiSchemaDefinition( - input: SchemaDefinitionByApiTypeQueryVariables & {apiKey?: string}, - apiKey: string, - _organizationId: string, - _appId?: string, - ): Promise { - const response: ApiSchemaDefinitionQuerySchema = await this.request(ApiSchemaDefinitionQuery, { - ...input, - apiKey, - }) - return response.definition - } - - async migrateToUiExtension(input: MigrateToUiExtensionVariables): Promise { - return this.request(MigrateToUiExtensionQuery, input) - } - - toExtensionGraphQLType(input: string) { - return input.toUpperCase() - } - - async subscribeToAppLogs( - input: AppLogsSubscribeMutationVariables, - _organizationId: string, - ): Promise { - return this.request(AppLogsSubscribeMutation, input) - } - - async appDeepLink({id, organizationId}: MinimalAppIdentifiers): Promise { - return `https://${await partnersFqdn()}/${organizationId}/apps/${id}` - } - - async appLogs(options: AppLogsOptions, _organizationId: string): Promise { - const response = await fetchAppLogs(options) - - try { - const data = (await response.json()) as { - app_logs?: AppLogData[] - cursor?: string - errors?: string[] - } - - if (!response.ok) { - return { - errors: data.errors ?? [`Request failed with status ${response.status}`], - status: response.status, - } - } - - return { - app_logs: data.app_logs ?? [], - cursor: data.cursor, - status: response.status, - } - // eslint-disable-next-line no-catch-all/no-catch-all - } catch (error) { - return { - errors: [`Failed to parse response: ${error}`], - status: response.status, - } - } - } - - async devSessionCreate(_input: unknown): Promise { - // Dev Sessions are not supported in partners client. - throw new Error('Unsupported operation') - } - - async devSessionUpdate(_input: unknown): Promise { - // Dev Sessions are not supported in partners client. - throw new Error('Unsupported operation') - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - async devSessionDelete(_input: unknown): Promise { - // Dev Sessions are not supported in partners client. - return Promise.resolve() - } - - async getCreateDevStoreLink(org: Organization): Promise { - const url = `https://${await partnersFqdn()}/${org.id}/stores` - return [ - `Looks like you don't have any dev stores associated with ${org.businessName}'s Partner Dashboard.`, - {link: {url, label: 'Create a store in Partner Dashboard'}}, - ] - } - - private async fetchOrgAndApps(orgId: string, title?: string): Promise { - const params: FindOrganizationQueryVariables = {id: orgId} - if (title) params.title = title - const result: FindOrganizationQuerySchema = await this.request(FindOrganizationQuery, params) - const org = result.organizations.nodes[0] - if (!org) { - const partnersSession = await this.session() - throw new NoOrgError(partnersSession.accountInfo, orgId) - } - const parsedOrg = {id: org.id, businessName: org.businessName, source: this.organizationSource} - const appsWithOrg = org.apps.nodes.map((app) => ({...app, organizationId: org.id})) - return {organization: parsedOrg, apps: {...org.apps, nodes: appsWithOrg}, stores: []} - } - - private createUnauthorizedHandler(): UnauthorizedHandler { - return createUnauthorizedHandler(this) - } -} - -const fetchAppLogs = async ({jwtToken, cursor, filters}: AppLogsOptions): Promise => { - const url = await generateFetchAppLogUrl(cursor, filters) - const userAgent = `Shopify CLI; v=${CLI_KIT_VERSION}` - const headers = { - Authorization: `Bearer ${jwtToken}`, - 'User-Agent': userAgent, - } - return shopifyFetch( - url, - { - method: 'GET', - headers, - }, - 'non-blocking', - ) -}