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 00000000000..b9a478c01a2 --- /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 | null}} + +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 00000000000..e5495a3fff4 --- /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 838f8b12d20..e13448952f6 100644 --- a/packages/app/src/cli/models/app/app.test-data.ts +++ b/packages/app/src/cli/models/app/app.test-data.ts @@ -1376,6 +1376,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.test.ts b/packages/app/src/cli/prompts/deploy-release.test.ts index e7286c555f6..a0121f6c176 100644 --- a/packages/app/src/cli/prompts/deploy-release.test.ts +++ b/packages/app/src/cli/prompts/deploy-release.test.ts @@ -206,6 +206,60 @@ describe('deployOrReleaseConfirmationPrompt', () => { expect(result).toBe(true) }) + test('and no force with deleted extensions and installCount should pass installCount to danger prompt', async () => { + // Given + const breakdownInfo = buildCompleteBreakdownInfo() + const renderDangerousConfirmationPromptSpyOn = vi + .spyOn(ui, 'renderDangerousConfirmationPrompt') + .mockResolvedValue(true) + vi.spyOn(metadata, 'addPublicMetadata').mockImplementation(async () => {}) + const appTitle = 'app title' + + // When + const result = await deployOrReleaseConfirmationPrompt({ + ...breakdownInfo, + appTitle, + release: true, + force: false, + installCount: 1243, + }) + + // Then + expect(renderDangerousConfirmationPromptSpyOn).toHaveBeenCalledWith( + expect.objectContaining({ + warningItem: expect.arrayContaining([{error: '1243'}]), + }), + ) + expect(result).toBe(true) + }) + + test('and no force with deleted extensions but installCount 0 should not pass warningItem to danger prompt', async () => { + // Given + const breakdownInfo = buildCompleteBreakdownInfo() + const renderDangerousConfirmationPromptSpyOn = vi + .spyOn(ui, 'renderDangerousConfirmationPrompt') + .mockResolvedValue(true) + vi.spyOn(metadata, 'addPublicMetadata').mockImplementation(async () => {}) + const appTitle = 'app title' + + // When + const result = await deployOrReleaseConfirmationPrompt({ + ...breakdownInfo, + appTitle, + release: true, + force: false, + installCount: 0, + }) + + // Then + expect(renderDangerousConfirmationPromptSpyOn).toHaveBeenCalledWith( + expect.not.objectContaining({ + warningItem: expect.anything(), + }), + ) + expect(result).toBe(true) + }) + test('and no force with deleted extensions but without app title should display the complete confirmation prompt', async () => { // Given const breakdownInfo = buildCompleteBreakdownInfo() diff --git a/packages/app/src/cli/prompts/deploy-release.ts b/packages/app/src/cli/prompts/deploy-release.ts index 5198b205e25..40b91d5b57a 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,21 @@ 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 + ? { + warningItem: [ + 'This release removes extensions and related data from', + {error: installCount.toString()}, + '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.test.ts b/packages/app/src/cli/services/context/identifiers.test.ts index 97e0351c489..b24fb50ccdf 100644 --- a/packages/app/src/cli/services/context/identifiers.test.ts +++ b/packages/app/src/cli/services/context/identifiers.test.ts @@ -1,4 +1,9 @@ -import {configExtensionsIdentifiersBreakdown, extensionsIdentifiersDeployBreakdown} from './breakdown-extensions.js' +import { + buildExtensionBreakdownInfo, + configExtensionsIdentifiersBreakdown, + ExtensionIdentifierBreakdownInfo, + extensionsIdentifiersDeployBreakdown, +} from './breakdown-extensions.js' import {ensureDeploymentIdsPresence} from './identifiers.js' import {deployConfirmed} from './identifiers-extensions.js' import {deployOrReleaseConfirmationPrompt} from '../../prompts/deploy-release.js' @@ -34,6 +39,152 @@ describe('ensureDeploymentIdsPresence', () => { await expect(ensureDeploymentIdsPresence(params)).rejects.toThrow(AbortSilentError) }) + test('when there are remote-only extensions and not forced, appInstallCount is called with remoteApp.id', async () => { + // Given + const breakdown = buildExtensionsBreakdown() + breakdown.extensionIdentifiersBreakdown.onlyRemote = [buildExtensionBreakdownInfo('removed', 'uuid-1')] + vi.mocked(extensionsIdentifiersDeployBreakdown).mockResolvedValue(breakdown) + vi.mocked(configExtensionsIdentifiersBreakdown).mockResolvedValue(buildConfigBreakdown()) + vi.mocked(deployOrReleaseConfirmationPrompt).mockResolvedValue(false) + + const remoteApp = testOrganizationApp({id: 'real-app-id', apiKey: 'api-key-different'}) + const client = testDeveloperPlatformClient({ + appInstallCount: vi.fn().mockResolvedValue(42), + }) + + const params = { + app: testApp(), + developerPlatformClient: client, + appId: 'api-key-different', + appName: 'appName', + remoteApp, + envIdentifiers: {}, + force: false, + release: true, + } + + // When + await expect(ensureDeploymentIdsPresence(params)).rejects.toThrow() + + // Then + expect(client.appInstallCount).toHaveBeenCalledWith({ + id: 'real-app-id', + apiKey: 'api-key-different', + organizationId: remoteApp.organizationId, + }) + }) + + test('when force is true, appInstallCount is not called even with remote-only extensions', async () => { + // Given + const breakdown = buildExtensionsBreakdown() + breakdown.extensionIdentifiersBreakdown.onlyRemote = [buildExtensionBreakdownInfo('removed', 'uuid-1')] + vi.mocked(extensionsIdentifiersDeployBreakdown).mockResolvedValue(breakdown) + vi.mocked(configExtensionsIdentifiersBreakdown).mockResolvedValue(buildConfigBreakdown()) + vi.mocked(deployOrReleaseConfirmationPrompt).mockResolvedValue(true) + vi.mocked(deployConfirmed).mockResolvedValue({ + extensions: {}, + extensionIds: {}, + extensionsNonUuidManaged: {}, + }) + + const client = testDeveloperPlatformClient({ + appInstallCount: vi.fn().mockResolvedValue(42), + }) + + const params = { + app: testApp(), + developerPlatformClient: client, + appId: 'appId', + appName: 'appName', + remoteApp: testOrganizationApp(), + envIdentifiers: {}, + force: true, + release: true, + } + + // When + await ensureDeploymentIdsPresence(params) + + // Then + expect(client.appInstallCount).not.toHaveBeenCalled() + }) + + test('when allowUpdates and allowDeletes are both true, appInstallCount is not called', async () => { + // Given + const breakdown = buildExtensionsBreakdown() + breakdown.extensionIdentifiersBreakdown.onlyRemote = [buildExtensionBreakdownInfo('removed', 'uuid-1')] + vi.mocked(extensionsIdentifiersDeployBreakdown).mockResolvedValue(breakdown) + vi.mocked(configExtensionsIdentifiersBreakdown).mockResolvedValue(buildConfigBreakdown()) + vi.mocked(deployOrReleaseConfirmationPrompt).mockResolvedValue(true) + vi.mocked(deployConfirmed).mockResolvedValue({ + extensions: {}, + extensionIds: {}, + extensionsNonUuidManaged: {}, + }) + + const client = testDeveloperPlatformClient({ + appInstallCount: vi.fn().mockResolvedValue(42), + }) + + const params = { + app: testApp(), + developerPlatformClient: client, + appId: 'appId', + appName: 'appName', + remoteApp: testOrganizationApp(), + envIdentifiers: {}, + force: false, + allowUpdates: true, + allowDeletes: true, + release: true, + } + + // When + await ensureDeploymentIdsPresence(params) + + // Then + expect(client.appInstallCount).not.toHaveBeenCalled() + }) + + test('when appInstallCount throws, installCount is undefined and deploy proceeds', async () => { + // Given + const breakdown = buildExtensionsBreakdown() + breakdown.extensionIdentifiersBreakdown.onlyRemote = [buildExtensionBreakdownInfo('removed', 'uuid-1')] + vi.mocked(extensionsIdentifiersDeployBreakdown).mockResolvedValue(breakdown) + vi.mocked(configExtensionsIdentifiersBreakdown).mockResolvedValue(buildConfigBreakdown()) + vi.mocked(deployOrReleaseConfirmationPrompt).mockResolvedValue(true) + vi.mocked(deployConfirmed).mockResolvedValue({ + extensions: {}, + extensionIds: {}, + extensionsNonUuidManaged: {}, + }) + + const client = testDeveloperPlatformClient({ + appInstallCount: vi.fn().mockRejectedValue(new Error('API error')), + }) + + const params = { + app: testApp(), + developerPlatformClient: client, + appId: 'appId', + appName: 'appName', + remoteApp: testOrganizationApp(), + envIdentifiers: {}, + force: false, + release: true, + } + + // When + await ensureDeploymentIdsPresence(params) + + // Then - installCount should be undefined in the prompt call + expect(deployOrReleaseConfirmationPrompt).toHaveBeenCalledWith( + expect.objectContaining({ + installCount: undefined, + }), + ) + }) + test('when the prompt is confirmed post-confirmation actions as run and the result is returned', async () => { // Given vi.mocked(extensionsIdentifiersDeployBreakdown).mockResolvedValue(buildExtensionsBreakdown()) @@ -75,7 +226,7 @@ describe('ensureDeploymentIdsPresence', () => { function buildExtensionsBreakdown() { return { extensionIdentifiersBreakdown: { - onlyRemote: [], + onlyRemote: [] as ExtensionIdentifierBreakdownInfo[], toCreate: [], toUpdate: [], fromDashboard: [], diff --git a/packages/app/src/cli/services/context/identifiers.ts b/packages/app/src/cli/services/context/identifiers.ts index 247ab71bfef..4584c66ed0b 100644 --- a/packages/app/src/cli/services/context/identifiers.ts +++ b/packages/app/src/cli/services/context/identifiers.ts @@ -58,6 +58,25 @@ export async function ensureDeploymentIdsPresence(options: EnsureDeploymentIdsPr activeAppVersion: options.activeAppVersion, }) + const shouldFetchInstallCount = + extensionIdentifiersBreakdown.onlyRemote.length > 0 && + !options.force && + !(options.allowUpdates && options.allowDeletes) + + let installCount: number | undefined + if (shouldFetchInstallCount) { + try { + installCount = await options.developerPlatformClient.appInstallCount({ + id: options.remoteApp.id, + 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 +85,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 8b27e3b501a..e8d792f0de1 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 fcf8c5232b8..1ab8d93dbef 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 @@ -114,6 +114,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' @@ -644,6 +645,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 ?? 0 + } + 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 9f54e23a50e..08c73410fa1 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) diff --git a/packages/cli-kit/src/private/node/ui/components/DangerousConfirmationPrompt.tsx b/packages/cli-kit/src/private/node/ui/components/DangerousConfirmationPrompt.tsx index 533b20ac8c3..2a31e5756ad 100644 --- a/packages/cli-kit/src/private/node/ui/components/DangerousConfirmationPrompt.tsx +++ b/packages/cli-kit/src/private/node/ui/components/DangerousConfirmationPrompt.tsx @@ -16,6 +16,7 @@ export interface DangerousConfirmationPromptProps { message: string confirmation: string infoTable?: InfoTableProps['table'] + warningItem?: TokenItem onSubmit: (value: boolean) => void abortSignal?: AbortSignal } @@ -24,6 +25,7 @@ const DangerousConfirmationPrompt: FunctionComponent { @@ -103,6 +105,12 @@ const DangerousConfirmationPrompt: FunctionComponent ) : null} + {warningItem ? ( + + {figures.warning} WARNING + + + ) : null}