diff --git a/packages/app/src/cli/api/graphql/app-management/generated/app-install-count.ts b/packages/app/src/cli/api/graphql/app-management/generated/app-install-count.ts new file mode 100644 index 0000000000..3f05483d1e --- /dev/null +++ b/packages/app/src/cli/api/graphql/app-management/generated/app-install-count.ts @@ -0,0 +1,51 @@ +/* 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 AppInstallCountQueryVariables = Types.Exact<{ + appId: Types.Scalars['ID']['input'] +}> + +export type AppInstallCountQuery = {app: {installCount: number}} + +export const AppInstallCount = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: {kind: 'Name', value: 'AppInstallCount'}, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'appId'}}, + type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'ID'}}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'app'}, + arguments: [ + { + kind: 'Argument', + name: {kind: 'Name', value: 'id'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'appId'}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'installCount'}}, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode diff --git a/packages/app/src/cli/api/graphql/app-management/queries/app-install-count.graphql b/packages/app/src/cli/api/graphql/app-management/queries/app-install-count.graphql new file mode 100644 index 0000000000..e5495a3fff --- /dev/null +++ b/packages/app/src/cli/api/graphql/app-management/queries/app-install-count.graphql @@ -0,0 +1,5 @@ +query AppInstallCount($appId: ID!) { + app(id: $appId) { + installCount + } +} diff --git a/packages/app/src/cli/models/app/app.test-data.ts b/packages/app/src/cli/models/app/app.test-data.ts index 578c83bd17..1f53856ced 100644 --- a/packages/app/src/cli/models/app/app.test-data.ts +++ b/packages/app/src/cli/models/app/app.test-data.ts @@ -1458,6 +1458,7 @@ export function testDeveloperPlatformClient(stubs: Partial Promise.resolve(testOrganization()), appsForOrg: (_organizationId: string) => Promise.resolve({apps: [testOrganizationApp()], hasMorePages: false}), specifications: (_app: MinimalAppIdentifiers) => Promise.resolve(testRemoteSpecifications), + appInstallCount: (_app: MinimalAppIdentifiers) => Promise.resolve(0), templateSpecifications: (_app: MinimalAppIdentifiers) => Promise.resolve({templates: testRemoteExtensionTemplates, groupOrder: []}), orgAndApps: (_orgId: string) => diff --git a/packages/app/src/cli/prompts/deploy-release.ts b/packages/app/src/cli/prompts/deploy-release.ts index 5198b205e2..d91a00b446 100644 --- a/packages/app/src/cli/prompts/deploy-release.ts +++ b/packages/app/src/cli/prompts/deploy-release.ts @@ -24,6 +24,7 @@ interface DeployOrReleaseConfirmationPromptOptions { /** If true, allow removing extensions and configuration without user confirmation */ allowDeletes?: boolean showConfig?: boolean + installCount?: number } interface DeployConfirmationPromptOptions { @@ -36,6 +37,7 @@ interface DeployConfirmationPromptOptions { configInfoTable: InfoTableSection } release: boolean + installCount?: number } /** @@ -97,6 +99,7 @@ export async function deployOrReleaseConfirmationPrompt({ configExtensionIdentifiersBreakdown, appTitle, release, + installCount, }: DeployOrReleaseConfirmationPromptOptions): Promise { await metadata.addPublicMetadata(() => buildConfigurationBreakdownMetadata(configExtensionIdentifiersBreakdown)) @@ -117,6 +120,7 @@ export async function deployOrReleaseConfirmationPrompt({ extensionsContentPrompt, configContentPrompt, release, + installCount, }) } @@ -125,6 +129,7 @@ async function deployConfirmationPrompt({ extensionsContentPrompt: {extensionsInfoTable, hasDeletedExtensions}, configContentPrompt, release, + installCount, }: DeployConfirmationPromptOptions): Promise { const timeBeforeConfirmationMs = new Date().valueOf() let confirmationResponse = true @@ -149,11 +154,17 @@ async function deployConfirmationPrompt({ } const question = `${release ? 'Release' : 'Create'} a new version${appTitle ? ` of ${appTitle}` : ''}?` + const showInstallCountWarning = hasDeletedExtensions && installCount !== undefined && installCount > 0 if (isDangerous) { confirmationResponse = await renderDangerousConfirmationPrompt({ message: question, infoTable, confirmation: appTitle, + ...(showInstallCountWarning + ? { + body: `This release removes extensions and related data from ${installCount} app installations.\nUse caution as this may include production data on live stores.`, + } + : {}), }) } else { confirmationResponse = await renderConfirmationPrompt({ diff --git a/packages/app/src/cli/services/context/identifiers.ts b/packages/app/src/cli/services/context/identifiers.ts index 247ab71bfe..6aa9dba461 100644 --- a/packages/app/src/cli/services/context/identifiers.ts +++ b/packages/app/src/cli/services/context/identifiers.ts @@ -58,6 +58,20 @@ export async function ensureDeploymentIdsPresence(options: EnsureDeploymentIdsPr activeAppVersion: options.activeAppVersion, }) + let installCount: number | undefined + if (extensionIdentifiersBreakdown.onlyRemote.length > 0) { + try { + installCount = await options.developerPlatformClient.appInstallCount({ + id: options.appId, + apiKey: options.remoteApp.apiKey, + organizationId: options.remoteApp.organizationId, + }) + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (_error) { + installCount = undefined + } + } + const confirmed = await deployOrReleaseConfirmationPrompt({ extensionIdentifiersBreakdown, configExtensionIdentifiersBreakdown, @@ -66,6 +80,7 @@ export async function ensureDeploymentIdsPresence(options: EnsureDeploymentIdsPr force: options.force, allowUpdates: options.allowUpdates, allowDeletes: options.allowDeletes, + installCount, }) if (!confirmed) throw new AbortSilentError() diff --git a/packages/app/src/cli/utilities/developer-platform-client.ts b/packages/app/src/cli/utilities/developer-platform-client.ts index 8b27e3b501..e8d792f0de 100644 --- a/packages/app/src/cli/utilities/developer-platform-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client.ts @@ -260,6 +260,7 @@ export interface DeveloperPlatformClient { activeAppVersion?: AppVersion, ) => Promise appVersions: (app: OrganizationApp) => Promise + appInstallCount: (app: MinimalAppIdentifiers) => Promise activeAppVersion: (app: MinimalAppIdentifiers) => Promise appVersionByTag: (app: MinimalOrganizationApp, tag: string) => Promise appVersionsDiff: (app: MinimalOrganizationApp, version: AppVersionIdentifiers) => Promise diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts index bce3901bd7..82ba958f11 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts @@ -117,6 +117,7 @@ import { import {CreateAssetUrl} from '../../api/graphql/app-management/generated/create-asset-url.js' import {AppVersionById} from '../../api/graphql/app-management/generated/app-version-by-id.js' import {AppVersions} from '../../api/graphql/app-management/generated/app-versions.js' +import {AppInstallCount} from '../../api/graphql/app-management/generated/app-install-count.js' import {CreateApp, CreateAppMutationVariables} from '../../api/graphql/app-management/generated/create-app.js' import {FetchSpecifications} from '../../api/graphql/app-management/generated/specifications.js' import {ListApps} from '../../api/graphql/app-management/generated/apps.js' @@ -650,6 +651,13 @@ export class AppManagementClient implements DeveloperPlatformClient { } } + async appInstallCount({id}: MinimalAppIdentifiers): Promise { + const query = AppInstallCount + const variables = {appId: id} + const result = await this.appManagementRequest({query, variables}) + return result.app.installCount + } + async appVersionByTag( {id: appId, organizationId}: MinimalOrganizationApp, versionTag: string, 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 index 1c45d86a6c..e140a97b49 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/partners-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/partners-client.ts @@ -428,6 +428,11 @@ export class PartnersClient implements DeveloperPlatformClient { 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)