diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index a63653fc9f0..5d276795093 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -6770,6 +6770,75 @@ "strict": true, "summary": "Create a preview Shopify store." }, + "store:delete": { + "aliases": [ + ], + "args": { + }, + "customPluginName": "@shopify/store", + "description": "Deletes a development store from your organization.", + "descriptionWithMarkdown": "Deletes a development store from your organization.", + "enableJsonFlag": false, + "examples": [ + "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --organization-id 1234567", + "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --organization-id 1234567 --json" + ], + "flags": { + "json": { + "allowNo": false, + "char": "j", + "description": "Output the result as JSON. Automatically disables color output.", + "env": "SHOPIFY_FLAG_JSON", + "hidden": false, + "name": "json", + "type": "boolean" + }, + "no-color": { + "allowNo": false, + "description": "Disable color output.", + "env": "SHOPIFY_FLAG_NO_COLOR", + "hidden": false, + "name": "no-color", + "type": "boolean" + }, + "organization-id": { + "description": "The numeric organization ID. Auto-selects if you belong to a single organization.", + "env": "SHOPIFY_FLAG_ORGANIZATION_ID", + "hasDynamicHelp": false, + "multiple": false, + "name": "organization-id", + "type": "option" + }, + "store": { + "char": "s", + "description": "The myshopify.com domain of the store.", + "env": "SHOPIFY_FLAG_STORE", + "hasDynamicHelp": false, + "multiple": false, + "name": "store", + "required": true, + "type": "option" + }, + "verbose": { + "allowNo": false, + "description": "Increase the verbosity of the output.", + "env": "SHOPIFY_FLAG_VERBOSE", + "hidden": false, + "name": "verbose", + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hidden": true, + "hiddenAliases": [ + ], + "id": "store:delete", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true, + "summary": "Delete a development store." + }, "store:execute": { "aliases": [ ], diff --git a/packages/store/src/cli/api/graphql/business-platform-organizations/generated/delete_app_development_store.ts b/packages/store/src/cli/api/graphql/business-platform-organizations/generated/delete_app_development_store.ts new file mode 100644 index 00000000000..f1d0c553aa1 --- /dev/null +++ b/packages/store/src/cli/api/graphql/business-platform-organizations/generated/delete_app_development_store.ts @@ -0,0 +1,69 @@ +/* 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 DeleteAppDevelopmentStoreMutationVariables = Types.Exact<{ + storeFqdn: Types.Scalars['String']['input'] +}> + +export type DeleteAppDevelopmentStoreMutation = { + deleteAppDevelopmentStore: { + success: boolean + userErrors: {code?: string | null; field: string[]; message: string}[] + } +} + +export const DeleteAppDevelopmentStore = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: {kind: 'Name', value: 'DeleteAppDevelopmentStore'}, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'storeFqdn'}}, + type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'deleteAppDevelopmentStore'}, + arguments: [ + { + kind: 'Argument', + name: {kind: 'Name', value: 'storeFqdn'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'storeFqdn'}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'success'}}, + { + kind: 'Field', + name: {kind: 'Name', value: 'userErrors'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'code'}}, + {kind: 'Field', name: {kind: 'Name', value: 'field'}}, + {kind: 'Field', name: {kind: 'Name', value: 'message'}}, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode diff --git a/packages/store/src/cli/api/graphql/business-platform-organizations/generated/organization_accessible_shop.ts b/packages/store/src/cli/api/graphql/business-platform-organizations/generated/organization_accessible_shop.ts new file mode 100644 index 00000000000..bc39fc355fd --- /dev/null +++ b/packages/store/src/cli/api/graphql/business-platform-organizations/generated/organization_accessible_shop.ts @@ -0,0 +1,67 @@ +/* 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 OrganizationAccessibleShopQueryVariables = Types.Exact<{ + id: Types.Scalars['ShopifyShopID']['input'] +}> + +export type OrganizationAccessibleShopQuery = { + organization?: { + accessibleShop?: {shopifyShopId?: string | null; planName?: string | null; storeType?: Types.Store | null} | null + } | null +} + +export const OrganizationAccessibleShop = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: {kind: 'Name', value: 'OrganizationAccessibleShop'}, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'id'}}, + type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'ShopifyShopID'}}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'organization'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'accessibleShop'}, + arguments: [ + { + kind: 'Argument', + name: {kind: 'Name', value: 'id'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'id'}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'shopifyShopId'}}, + {kind: 'Field', name: {kind: 'Name', value: 'planName'}}, + {kind: 'Field', name: {kind: 'Name', value: 'storeType'}}, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode diff --git a/packages/store/src/cli/api/graphql/business-platform-organizations/mutations/delete_app_development_store.graphql b/packages/store/src/cli/api/graphql/business-platform-organizations/mutations/delete_app_development_store.graphql new file mode 100644 index 00000000000..e8761418b26 --- /dev/null +++ b/packages/store/src/cli/api/graphql/business-platform-organizations/mutations/delete_app_development_store.graphql @@ -0,0 +1,10 @@ +mutation DeleteAppDevelopmentStore($storeFqdn: String!) { + deleteAppDevelopmentStore(storeFqdn: $storeFqdn) { + success + userErrors { + code + field + message + } + } +} diff --git a/packages/store/src/cli/api/graphql/business-platform-organizations/queries/organization_accessible_shop.graphql b/packages/store/src/cli/api/graphql/business-platform-organizations/queries/organization_accessible_shop.graphql new file mode 100644 index 00000000000..7be5d7182a1 --- /dev/null +++ b/packages/store/src/cli/api/graphql/business-platform-organizations/queries/organization_accessible_shop.graphql @@ -0,0 +1,9 @@ +query OrganizationAccessibleShop($id: ShopifyShopID!) { + organization { + accessibleShop(id: $id) { + shopifyShopId + planName + storeType + } + } +} diff --git a/packages/store/src/cli/commands/store/delete.test.ts b/packages/store/src/cli/commands/store/delete.test.ts new file mode 100644 index 00000000000..9de093cf1da --- /dev/null +++ b/packages/store/src/cli/commands/store/delete.test.ts @@ -0,0 +1,149 @@ +import StoreDelete from './delete.js' +import {deleteDevStore} from '../../services/store/delete/dev.js' +import {findStoreOwningOrganization} from '../../utilities/store-lookup/organization.js' +import {selectOrg} from '@shopify/organizations' +import {AbortError} from '@shopify/cli-kit/node/error' +import {outputResult} from '@shopify/cli-kit/node/output' +import {terminalSupportsPrompting} from '@shopify/cli-kit/node/system' +import {describe, expect, test, vi, beforeEach} from 'vitest' + +vi.mock('../../services/store/delete/dev.js') +vi.mock('../../utilities/store-lookup/organization.js') +vi.mock('@shopify/cli-kit/node/system') + +vi.mock('@shopify/organizations', () => ({ + selectOrg: vi.fn(), +})) + +vi.mock('@shopify/cli-kit/node/output', async (importOriginal) => { + const actual: Record = await importOriginal() + return { + ...actual, + outputResult: vi.fn(), + } +}) + +const defaultOrg = {id: '12345', businessName: 'Test Org'} +const inferredOrg = {id: '67890', businessName: 'Inferred Org'} + +beforeEach(() => { + vi.mocked(selectOrg).mockResolvedValue(defaultOrg) + vi.mocked(findStoreOwningOrganization).mockResolvedValue(inferredOrg) + vi.mocked(terminalSupportsPrompting).mockReturnValue(true) +}) + +describe('store delete command', () => { + test('resolves the organization and passes parsed flags through to the service', async () => { + await StoreDelete.run(['--store', 'my-store.myshopify.com', '--organization-id', '12345']) + + expect(selectOrg).toHaveBeenCalledWith('12345') + expect(deleteDevStore).toHaveBeenCalledWith({ + store: 'my-store.myshopify.com', + organization: defaultOrg, + json: false, + }) + }) + + test('normalizes the store flag before passing it to the service', async () => { + await StoreDelete.run(['--store', 'my-store', '--organization-id', '12345']) + + expect(deleteDevStore).toHaveBeenCalledWith(expect.objectContaining({store: 'my-store.myshopify.com'})) + }) + + test('passes json flag through to the service', async () => { + await StoreDelete.run(['--store', 'my-store.myshopify.com', '--json', '--organization-id', '12345']) + + expect(deleteDevStore).toHaveBeenCalledWith({ + store: 'my-store.myshopify.com', + organization: defaultOrg, + json: true, + }) + }) + + test('infers the organization from the store when --organization-id is omitted', async () => { + await StoreDelete.run(['--store', 'my-store.myshopify.com']) + + expect(findStoreOwningOrganization).toHaveBeenCalledWith({store: 'my-store.myshopify.com', noPrompt: false}) + expect(selectOrg).not.toHaveBeenCalled() + expect(deleteDevStore).toHaveBeenCalledWith(expect.objectContaining({organization: inferredOrg})) + }) + + test('falls back to prompting for the organization when store ownership cannot be inferred interactively', async () => { + vi.mocked(findStoreOwningOrganization).mockResolvedValueOnce(undefined) + + await StoreDelete.run(['--store', 'my-store.myshopify.com']) + + expect(selectOrg).toHaveBeenCalledWith() + expect(deleteDevStore).toHaveBeenCalledWith(expect.objectContaining({organization: defaultOrg})) + }) + + test('does not require --organization-id non-interactively when store ownership can be inferred', async () => { + vi.mocked(terminalSupportsPrompting).mockReturnValue(false) + + await StoreDelete.run(['--store', 'my-store.myshopify.com']) + + expect(findStoreOwningOrganization).toHaveBeenCalledWith({store: 'my-store.myshopify.com', noPrompt: true}) + expect(selectOrg).not.toHaveBeenCalled() + expect(deleteDevStore).toHaveBeenCalledWith(expect.objectContaining({organization: inferredOrg})) + }) + + test('fails in a non-interactive environment when store ownership cannot be inferred and --organization-id is missing', async () => { + vi.mocked(terminalSupportsPrompting).mockReturnValue(false) + vi.mocked(findStoreOwningOrganization).mockResolvedValueOnce(undefined) + const mockExit = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit') + }) as never) + + await expect(StoreDelete.run(['--store', 'my-store.myshopify.com'])).rejects.toThrow('process.exit') + expect(selectOrg).not.toHaveBeenCalled() + expect(deleteDevStore).not.toHaveBeenCalled() + expect(mockExit).toHaveBeenCalledWith(1) + + mockExit.mockRestore() + }) + + test('defines the expected flags', () => { + expect(StoreDelete.flags.store).toBeDefined() + expect(StoreDelete.flags['organization-id']).toBeDefined() + expect(StoreDelete.flags.json).toBeDefined() + }) + + test('outputs structured JSON error when --json is active and service throws AbortError', async () => { + vi.mocked(deleteDevStore).mockRejectedValueOnce(new AbortError('Something went wrong')) + const mockExit = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit') + }) as never) + + await expect( + StoreDelete.run(['--store', 'my-store.myshopify.com', '--organization-id', '12345', '--json']), + ).rejects.toThrow('process.exit') + + const call = vi.mocked(outputResult).mock.calls[0]![0] as string + const parsed = JSON.parse(call) + expect(parsed).toEqual({ + error: true, + message: 'Something went wrong', + nextSteps: [], + exitCode: 1, + }) + expect(mockExit).toHaveBeenCalledWith(1) + + mockExit.mockRestore() + }) + + test('does not output JSON for non-AbortError even when --json is active', async () => { + vi.mocked(deleteDevStore).mockRejectedValueOnce(new Error('unexpected')) + + await expect( + StoreDelete.run(['--store', 'my-store.myshopify.com', '--organization-id', '12345', '--json']), + ).rejects.toThrow() + expect(vi.mocked(outputResult)).not.toHaveBeenCalled() + }) + + test('does not output JSON for AbortError when --json is not active', async () => { + vi.mocked(deleteDevStore).mockRejectedValueOnce(new AbortError('Something went wrong')) + + await expect(StoreDelete.run(['--store', 'my-store.myshopify.com', '--organization-id', '12345'])).rejects.toThrow() + expect(vi.mocked(outputResult)).not.toHaveBeenCalled() + }) +}) diff --git a/packages/store/src/cli/commands/store/delete.ts b/packages/store/src/cli/commands/store/delete.ts new file mode 100644 index 00000000000..50eb348ee3e --- /dev/null +++ b/packages/store/src/cli/commands/store/delete.ts @@ -0,0 +1,84 @@ +import {deleteDevStore} from '../../services/store/delete/dev.js' +import {storeFlags} from '../../flags.js' +import {findStoreOwningOrganization} from '../../utilities/store-lookup/organization.js' +import {selectOrg, type Organization} from '@shopify/organizations' +import Command from '@shopify/cli-kit/node/base-command' +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' +import {AbortError} from '@shopify/cli-kit/node/error' +import {outputResult} from '@shopify/cli-kit/node/output' +import {terminalSupportsPrompting} from '@shopify/cli-kit/node/system' + +export default class StoreDelete extends Command { + static hidden = true + + static summary = 'Delete a development store.' + + static descriptionWithMarkdown = 'Deletes a development store from your organization.' + + static description = this.descriptionWithoutMarkdown() + + static examples = [ + '<%= config.bin %> <%= command.id %> --store shop.myshopify.com --organization-id 1234567', + '<%= config.bin %> <%= command.id %> --store shop.myshopify.com --organization-id 1234567 --json', + ] + + static flags = { + ...globalFlags, + ...jsonFlag, + store: storeFlags.store, + 'organization-id': storeFlags['organization-id'], + } + + async run(): Promise { + const {flags} = await this.parse(StoreDelete) + this.failMissingNonTTYFlags(flags, ['store']) + + const organization = await organizationForStoreDeletion(flags.store, flags['organization-id']?.toString()) + + try { + await deleteDevStore({ + store: flags.store, + organization, + json: flags.json, + }) + } catch (error) { + if (flags.json && error instanceof AbortError) { + outputResult( + JSON.stringify( + { + error: true, + message: error.message, + nextSteps: error.nextSteps ?? [], + exitCode: 1, + }, + null, + 2, + ), + ) + process.exit(1) + } + throw error + } + } +} + +async function organizationForStoreDeletion(store: string, organizationId?: string): Promise { + if (organizationId) { + return selectOrg(organizationId) + } + + const canPrompt = terminalSupportsPrompting() + const owningOrganization = await findStoreOwningOrganization({store, noPrompt: !canPrompt}) + if (owningOrganization) { + return owningOrganization + } + + if (!canPrompt) { + throw new AbortError( + `Could not determine which organization owns ${store}.`, + 'Provide `--organization-id`, for example `--organization-id 1234567`. Run `shopify organization list` to find IDs.', + ) + } + + return selectOrg() +} diff --git a/packages/store/src/cli/services/store/create/dev.ts b/packages/store/src/cli/services/store/create/dev.ts index 9929f0fcd9e..b0b73093ec1 100644 --- a/packages/store/src/cli/services/store/create/dev.ts +++ b/packages/store/src/cli/services/store/create/dev.ts @@ -32,11 +32,11 @@ type StoreCreationStatus = NonNullable< function friendlyStatus(status: StoreCreationStatus): string { switch (status) { case 'CALLING_CORE': - return 'Initiating store creation...' + return 'Initiating store creation' case 'AWAITING_CORE_STORE_READY': - return 'Waiting for store to be ready...' + return 'Waiting for store to be ready' case 'FINALIZING': - return 'Finalizing store setup...' + return 'Finalizing store setup' case 'COMPLETE': return 'Store creation complete!' case 'FAILED': @@ -84,7 +84,7 @@ export async function createDevStore(options: CreateDevStoreOptions): Promise { const startTime = Date.now() while (true) { diff --git a/packages/store/src/cli/services/store/delete/dev.test.ts b/packages/store/src/cli/services/store/delete/dev.test.ts new file mode 100644 index 00000000000..48ba0352df8 --- /dev/null +++ b/packages/store/src/cli/services/store/delete/dev.test.ts @@ -0,0 +1,300 @@ +import {deleteDevStore, toOrganizationsShopifyShopId} from './dev.js' +import {businessPlatformOrganizationsRequestDoc} from '@shopify/cli-kit/node/api/business-platform' +import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session' +import {renderSingleTask, renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui' +import {outputResult} from '@shopify/cli-kit/node/output' +import {sleep} from '@shopify/cli-kit/node/system' +import {beforeEach, describe, expect, test, vi} from 'vitest' + +vi.mock('@shopify/cli-kit/node/api/business-platform', () => ({ + businessPlatformOrganizationsRequestDoc: vi.fn(), +})) + +vi.mock('@shopify/cli-kit/node/session', () => ({ + ensureAuthenticatedBusinessPlatform: vi.fn(), +})) + +vi.mock('@shopify/cli-kit/node/ui', () => ({ + renderSingleTask: vi.fn(), + renderSuccess: vi.fn(), + renderWarning: vi.fn(), +})) + +vi.mock('@shopify/cli-kit/node/output', async (importOriginal) => { + const actual: Record = await importOriginal() + return { + ...actual, + outputResult: vi.fn(), + } +}) + +vi.mock('@shopify/cli-kit/node/system', () => ({ + sleep: vi.fn(), +})) + +const defaultOrg = {id: '123', businessName: 'Test Org'} +const defaultOptions = {store: 'test-store.myshopify.com', organization: defaultOrg, json: false} +const defaultMutationResult = { + deleteAppDevelopmentStore: { + success: true, + userErrors: [], + }, +} +const defaultShopLookupResult = { + organization: { + accessibleShops: { + edges: [ + { + node: { + shopifyShopId: '72193245184', + name: 'Test Store', + primaryDomain: 'https://test-store.myshopify.com', + storeType: 'APP_DEVELOPMENT', + developerPreviewHandle: null, + planName: 'professional', + ownerDetails: null, + }, + }, + ], + }, + }, +} + +const encodedDefaultShopId = 'Z2lkOi8vb3JnYW5pemF0aW9uL1Nob3BpZnlTaG9wLzcyMTkzMjQ1MTg0' + +type PollShop = {shopifyShopId?: string | null; planName?: string | null; storeType?: string | null} | null + +beforeEach(() => { + vi.mocked(ensureAuthenticatedBusinessPlatform).mockResolvedValue('test-token') + vi.mocked(renderSingleTask).mockImplementation(async ({task}) => task(() => {})) + vi.mocked(sleep).mockResolvedValue(undefined) + mockBusinessPlatformRequests() +}) + +describe('toOrganizationsShopifyShopId', () => { + test('converts a raw numeric Core shop ID to an Organizations ShopifyShopID', () => { + expect(toOrganizationsShopifyShopId('18')).toBe('Z2lkOi8vb3JnYW5pemF0aW9uL1Nob3BpZnlTaG9wLzE4') + expect(toOrganizationsShopifyShopId(18)).toBe('Z2lkOi8vb3JnYW5pemF0aW9uL1Nob3BpZnlTaG9wLzE4') + }) + + test('converts a Core Admin shop GID to an Organizations ShopifyShopID', () => { + expect(toOrganizationsShopifyShopId('gid://shopify/Shop/18')).toBe('Z2lkOi8vb3JnYW5pemF0aW9uL1Nob3BpZnlTaG9wLzE4') + }) +}) + +describe('deleteDevStore', () => { + test('requests deletion and renders success after polling sees the plan become cancelled', async () => { + mockBusinessPlatformRequests({pollShops: [pollShop({planName: 'professional'}), pollShop({planName: 'cancelled'})]}) + + await deleteDevStore(defaultOptions) + + expect(ensureAuthenticatedBusinessPlatform).toHaveBeenCalled() + expect(businessPlatformOrganizationsRequestDoc).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.anything(), + token: 'test-token', + organizationId: '123', + variables: {storeFqdn: 'test-store.myshopify.com'}, + unauthorizedHandler: expect.objectContaining({type: 'token_refresh'}), + }), + ) + expect(mutationRequests()).toHaveLength(1) + expect(pollRequests()).toHaveLength(2) + expect(pollRequests()[0]?.variables).toEqual({id: encodedDefaultShopId}) + expect(sleep).toHaveBeenCalledWith(5) + expect(renderSuccess).toHaveBeenCalledWith( + expect.objectContaining({ + headline: expect.stringContaining('deleted successfully'), + body: expect.arrayContaining([expect.stringContaining('confirmed the store was deleted')]), + }), + ) + expect(renderWarning).not.toHaveBeenCalled() + }) + + test('outputs JSON when --json flag is set', async () => { + await deleteDevStore({...defaultOptions, json: true}) + + const call = vi.mocked(outputResult).mock.calls[0]![0] as string + expect(JSON.parse(call)).toEqual({ + store: { + domain: 'test-store.myshopify.com', + deletionRequested: true, + deletionConfirmed: true, + }, + organization: { + id: '123', + name: 'Test Org', + }, + }) + expect(renderSuccess).not.toHaveBeenCalled() + }) + + test('throws AbortError when mutation returns null deleteAppDevelopmentStore', async () => { + mockBusinessPlatformRequests({mutationResult: {deleteAppDevelopmentStore: null}}) + + await expect(deleteDevStore(defaultOptions)).rejects.toThrow('Store deletion failed: unexpected empty response.') + }) + + test('throws AbortError and does not poll when mutation returns userErrors', async () => { + mockBusinessPlatformRequests({ + mutationResult: { + deleteAppDevelopmentStore: { + success: false, + userErrors: [{code: 'NOT_FOUND', field: ['storeFqdn'], message: 'Store not found'}], + }, + }, + }) + + await expect(deleteDevStore(defaultOptions)).rejects.toThrow('Failed to delete development store: Store not found') + + expect(renderSingleTask).not.toHaveBeenCalled() + expect(mutationRequests()).toHaveLength(1) + expect(pollRequests()).toHaveLength(0) + }) + + test('rewrites the backend CLI store creation gate as a deletion-specific error', async () => { + mockBusinessPlatformRequests({ + mutationResult: { + deleteAppDevelopmentStore: { + success: false, + userErrors: [ + { + code: null, + field: ['base'], + message: 'Crear una tienda desde la CLI de Shopify aún no está habilitado para tu organización.', + }, + ], + }, + }, + }) + + await expect(deleteDevStore(defaultOptions)).rejects.toThrow( + 'Failed to delete development store: Deleting a development store from the Shopify CLI is not yet enabled for your organization.', + ) + + expect(renderSingleTask).not.toHaveBeenCalled() + expect(mutationRequests()).toHaveLength(1) + expect(pollRequests()).toHaveLength(0) + }) + + test('throws AbortError when mutation reports failure without userErrors', async () => { + mockBusinessPlatformRequests({ + mutationResult: { + deleteAppDevelopmentStore: { + success: false, + userErrors: [], + }, + }, + }) + + await expect(deleteDevStore(defaultOptions)).rejects.toThrow('Store deletion failed.') + }) + + test('passes the selected organization ID to the GraphQL request', async () => { + await deleteDevStore({...defaultOptions, organization: {id: '456', businessName: 'Another Org'}}) + + expect(businessPlatformOrganizationsRequestDoc).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: '456', + }), + ) + }) + + test('reports requested but not confirmed when polling times out before the plan becomes cancelled', async () => { + let dateNowCallCount = 0 + vi.spyOn(Date, 'now').mockImplementation(() => { + dateNowCallCount++ + if (dateNowCallCount <= 2) return 0 + return 6 * 60 * 1000 + }) + mockBusinessPlatformRequests({pollShops: [pollShop({planName: 'professional'})]}) + + await expect(deleteDevStore(defaultOptions)).resolves.toBeUndefined() + + expect(mutationRequests()).toHaveLength(1) + expect(renderWarning).toHaveBeenCalledWith( + expect.objectContaining({ + headline: expect.stringContaining('deletion was requested, but not confirmed'), + body: expect.arrayContaining([expect.stringContaining('still finish deleting asynchronously')]), + }), + ) + expect(renderSuccess).not.toHaveBeenCalled() + }) + + test('continues polling until planName matches cancelled after normalization', async () => { + mockBusinessPlatformRequests({ + pollShops: [pollShop({planName: 'deleted'}), pollShop({planName: 'Cancelled'})], + }) + + await deleteDevStore(defaultOptions) + + expect(pollRequests()).toHaveLength(2) + expect(sleep).toHaveBeenCalledTimes(1) + expect(renderSuccess).toHaveBeenCalledWith( + expect.objectContaining({headline: expect.stringContaining('deleted successfully')}), + ) + }) + + test('does not treat a null accessibleShop response as confirmed deletion', async () => { + mockBusinessPlatformRequests({pollShops: [null, pollShop({planName: 'cancelled'})]}) + + await deleteDevStore(defaultOptions) + + expect(pollRequests()).toHaveLength(2) + expect(sleep).toHaveBeenCalledWith(5) + expect(renderSuccess).toHaveBeenCalledWith( + expect.objectContaining({headline: expect.stringContaining('deleted successfully')}), + ) + expect(renderWarning).not.toHaveBeenCalled() + }) +}) + +function mockBusinessPlatformRequests(options: {mutationResult?: unknown; pollShops?: PollShop[]} = {}) { + const mutationResult = options.mutationResult ?? defaultMutationResult + const pollShops = options.pollShops ?? [pollShop({planName: 'cancelled'})] + let pollIndex = 0 + + vi.mocked(businessPlatformOrganizationsRequestDoc).mockImplementation(async (request) => { + const variables = request.variables as Record + + if ('search' in variables) { + return defaultShopLookupResult as never + } + + if ('storeFqdn' in variables) { + return mutationResult as never + } + + if ('id' in variables) { + const shop = pollShops[Math.min(pollIndex, pollShops.length - 1)] + pollIndex++ + return {organization: {accessibleShop: shop}} as never + } + + throw new Error(`Unexpected request variables: ${JSON.stringify(variables)}`) + }) +} + +function pollShop(overrides: NonNullable = {}): NonNullable { + return { + shopifyShopId: '72193245184', + planName: 'cancelled', + storeType: 'APP_DEVELOPMENT', + ...overrides, + } +} + +function mutationRequests() { + return requestsWithVariable('storeFqdn') +} + +function pollRequests() { + return requestsWithVariable('id') +} + +function requestsWithVariable(variableName: string) { + return vi + .mocked(businessPlatformOrganizationsRequestDoc) + .mock.calls.map(([request]) => request) + .filter((request) => variableName in (request.variables as Record)) +} diff --git a/packages/store/src/cli/services/store/delete/dev.ts b/packages/store/src/cli/services/store/delete/dev.ts new file mode 100644 index 00000000000..c500780bb6c --- /dev/null +++ b/packages/store/src/cli/services/store/delete/dev.ts @@ -0,0 +1,195 @@ +import {businessPlatformTokenRefreshHandler} from '../business-platform.js' +import {fetchOptionalOrganizationShop} from '../../../utilities/store-lookup/organization-shop.js' +import {DeleteAppDevelopmentStore} from '../../../api/graphql/business-platform-organizations/generated/delete_app_development_store.js' +import { + OrganizationAccessibleShop, + type OrganizationAccessibleShopQuery, + type OrganizationAccessibleShopQueryVariables, +} from '../../../api/graphql/business-platform-organizations/generated/organization_accessible_shop.js' +import {type Organization} from '@shopify/organizations' +import {businessPlatformOrganizationsRequestDoc} from '@shopify/cli-kit/node/api/business-platform' +import {type UnauthorizedHandler} from '@shopify/cli-kit/node/api/graphql' +import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session' +import {renderSingleTask, renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui' +import {outputContent, outputResult} from '@shopify/cli-kit/node/output' +import {AbortError} from '@shopify/cli-kit/node/error' +import {sleep} from '@shopify/cli-kit/node/system' + +const CANCELLED_PLAN_NAME = 'cancelled' +const POLL_INTERVAL_SECONDS = 5 +const POLL_TIMEOUT_MS = 5 * 60 * 1000 + +interface DeleteDevStoreOptions { + store: string + organization: Organization + json: boolean +} + +interface StoreDeletionConfirmationOptions { + shopifyShopId: string + organizationId: string + token: string + unauthorizedHandler: UnauthorizedHandler +} + +export async function deleteDevStore(options: DeleteDevStoreOptions): Promise { + const {organization: org, store} = options + const token = await ensureAuthenticatedBusinessPlatform() + const unauthorizedHandler = businessPlatformTokenRefreshHandler() + let shopifyShopId = await fetchShopifyShopId({store, organizationId: org.id, token}) + + const mutationResult = await businessPlatformOrganizationsRequestDoc({ + query: DeleteAppDevelopmentStore, + token, + organizationId: org.id, + variables: {storeFqdn: store}, + unauthorizedHandler, + }) + + const deleteAppDevelopmentStore = mutationResult.deleteAppDevelopmentStore + if (!deleteAppDevelopmentStore) { + throw new AbortError('Store deletion failed: unexpected empty response.') + } + + const userErrors = deleteAppDevelopmentStore.userErrors + if (userErrors && userErrors.length > 0) { + const messages = userErrors.map((error) => deleteDevelopmentStoreUserErrorMessage(error.message)).join(', ') + throw new AbortError(`Failed to delete development store: ${messages}`) + } + if (deleteAppDevelopmentStore.success === false) { + throw new AbortError('Store deletion failed.') + } + + shopifyShopId ??= await fetchShopifyShopId({store, organizationId: org.id, token}) + + const deletionConfirmed = shopifyShopId + ? await waitForStoreDeletionConfirmation({ + shopifyShopId, + organizationId: org.id, + token, + unauthorizedHandler, + }) + : false + + if (options.json) { + outputResult(deletionResultJson({store, organization: org, deletionConfirmed})) + } else if (deletionConfirmed) { + renderSuccess({ + headline: `Development store "${store}" deleted successfully.`, + body: ['Shopify accepted the deletion request and confirmed the store was deleted.'], + }) + } else { + renderWarning({ + headline: `Development store "${store}" deletion was requested, but not confirmed.`, + body: [ + 'Shopify accepted the deletion request, but deletion was not confirmed before the CLI stopped waiting.', + 'The store may still finish deleting asynchronously.', + ], + }) + } +} + +async function fetchShopifyShopId(options: { + store: string + organizationId: string + token: string +}): Promise { + const shop = await fetchOptionalOrganizationShop(options) + return shop?.shopifyShopId +} + +export function toOrganizationsShopifyShopId(shopifyShopId: string | number): string { + const raw = String(shopifyShopId) + const numericId = raw.match(/^(?:gid:\/\/shopify\/Shop\/)?(\d+)$/)?.[1] + + if (!numericId) { + throw new Error(`Invalid Shopify shop ID: ${raw}`) + } + + return Buffer.from(`gid://organization/ShopifyShop/${numericId}`).toString('base64url') +} + +async function waitForStoreDeletionConfirmation(options: StoreDeletionConfirmationOptions): Promise { + const organizationsShopifyShopId = toOrganizationsShopifyShopId(options.shopifyShopId) + + return renderSingleTask({ + title: outputContent`Development store deletion requested. Waiting for deletion confirmation`, + task: async (updateStatus) => { + const startTime = Date.now() + while (true) { + if (Date.now() - startTime > POLL_TIMEOUT_MS) { + return false + } + + // eslint-disable-next-line no-await-in-loop + const pollResult = await businessPlatformOrganizationsRequestDoc< + OrganizationAccessibleShopQuery, + OrganizationAccessibleShopQueryVariables + >({ + query: OrganizationAccessibleShop, + token: options.token, + organizationId: options.organizationId, + variables: {id: organizationsShopifyShopId}, + unauthorizedHandler: options.unauthorizedHandler, + }) + + const accessibleShop = pollResult.organization?.accessibleShop + if (accessibleShop?.planName?.toLowerCase() === CANCELLED_PLAN_NAME.toLowerCase()) { + return true + } + + updateStatus(outputContent`${deletionPollStatus()}`) + + // eslint-disable-next-line no-await-in-loop + await sleep(POLL_INTERVAL_SECONDS) + } + }, + renderOptions: {stdout: process.stderr}, + }) +} + +function deletionPollStatus(): string { + return 'Waiting for deletion confirmation' +} + +function deleteDevelopmentStoreUserErrorMessage(message: string): string { + // The backend delete operation currently reuses the store-create CLI gate and localized + // message. Keep the CLI output delete-specific so users understand which operation failed. + if (isCliStoreCreateGateMessage(message)) { + return 'Deleting a development store from the Shopify CLI is not yet enabled for your organization.' + } + + return message +} + +function isCliStoreCreateGateMessage(message: string): boolean { + return [ + 'Creating a store from the Shopify CLI is not yet enabled for your organization.', + 'Crear una tienda desde la CLI de Shopify aún no está habilitado para tu organización.', + ].includes(message) +} + +function deletionResultJson(options: {store: string; organization: Organization; deletionConfirmed: boolean}): string { + const {store, organization, deletionConfirmed} = options + return JSON.stringify( + { + store: { + domain: store, + deletionRequested: true, + deletionConfirmed, + }, + organization: { + id: organization.id, + name: organization.businessName, + }, + ...(deletionConfirmed + ? {} + : { + message: + 'Deletion was requested, but has not been confirmed yet. The store may still finish deleting asynchronously.', + }), + }, + null, + 2, + ) +} diff --git a/packages/store/src/cli/services/store/info/index.test.ts b/packages/store/src/cli/services/store/info/index.test.ts index c91c7f2c76b..31ee079108f 100644 --- a/packages/store/src/cli/services/store/info/index.test.ts +++ b/packages/store/src/cli/services/store/info/index.test.ts @@ -1,6 +1,6 @@ import {getStoreInfo} from './index.js' -import {StoreInfoBusinessPlatformStoreNotFoundError, fetchDestinationsContext} from './destinations.js' -import {fetchOrganizationShop} from './organization-shop.js' +import {StoreLookupStoreNotFoundError, fetchDestinationsContext} from '../../../utilities/store-lookup/destinations.js' +import {fetchOrganizationShop} from '../../../utilities/store-lookup/organization-shop.js' import {STORE_AUTH_APP_CLIENT_ID} from '../auth/config.js' import {loadStoredStoreSession} from '../auth/session-lifecycle.js' import {recordStoreFqdnMetadata} from '../attribution.js' @@ -11,17 +11,19 @@ import {adminUrl} from '@shopify/cli-kit/node/api/admin' import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' import {setLastSeenUserId} from '@shopify/cli-kit/node/session' import {beforeEach, describe, test, expect, vi} from 'vitest' -import type {OrganizationShopFields} from './types.js' +import type {OrganizationShopFields} from '../../../utilities/store-lookup/types.js' import type {Store} from '../../../api/graphql/business-platform-organizations/generated/types.js' -vi.mock('./destinations.js', async () => { - const actual = await vi.importActual('./destinations.js') +vi.mock('../../../utilities/store-lookup/destinations.js', async () => { + const actual = await vi.importActual( + '../../../utilities/store-lookup/destinations.js', + ) return { ...actual, fetchDestinationsContext: vi.fn(), } }) -vi.mock('./organization-shop.js') +vi.mock('../../../utilities/store-lookup/organization-shop.js') vi.mock('../auth/session-lifecycle.js') vi.mock('@shopify/cli-kit/node/store-auth-session') vi.mock('../attribution.js') @@ -96,7 +98,7 @@ function mockStoredStoreAuth(): void { } function mockBusinessPlatformUnavailable( - error: Error = new StoreInfoBusinessPlatformStoreNotFoundError( + error: Error = new StoreLookupStoreNotFoundError( `Couldn't find a store with domain ${SHOP} for the current account.`, ), ): void { diff --git a/packages/store/src/cli/services/store/info/index.ts b/packages/store/src/cli/services/store/info/index.ts index 8edf47be6c4..974b7d4a619 100644 --- a/packages/store/src/cli/services/store/info/index.ts +++ b/packages/store/src/cli/services/store/info/index.ts @@ -1,5 +1,3 @@ -import {StoreInfoBusinessPlatformStoreNotFoundError, fetchDestinationsContext} from './destinations.js' -import {fetchOrganizationShop} from './organization-shop.js' import {mapPlanToPublicHandle} from './plan.js' import {classifyAdminApiError, throwIfStoredStoreAuthIsInvalid} from '../admin-errors.js' import {recordStoreFqdnMetadata} from '../attribution.js' @@ -7,6 +5,8 @@ import {throwStoredAuthInvalidError} from '../auth/recovery.js' import {loadStoredStoreSession} from '../auth/session-lifecycle.js' import {getPreviewStore, PreviewStoreRequestError} from '../create/preview/client.js' import {storeTypeHandle} from '../store-type.js' +import {StoreLookupStoreNotFoundError, fetchDestinationsContext} from '../../../utilities/store-lookup/destinations.js' +import {fetchOrganizationShop} from '../../../utilities/store-lookup/organization-shop.js' import {getCurrentStoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session' import {AbortError} from '@shopify/cli-kit/node/error' import {adminUrl} from '@shopify/cli-kit/node/api/admin' @@ -15,7 +15,8 @@ import {compact} from '@shopify/cli-kit/common/object' import {extractMyshopifyHandle} from '@shopify/cli-kit/common/url' import {setLastSeenUserId} from '@shopify/cli-kit/node/session' import {outputDebug} from '@shopify/cli-kit/node/output' -import type {DestinationsContext, OrganizationShopFields, StoreInfoResult, StoreInfoStoreOwner} from './types.js' +import type {DestinationsContext, OrganizationShopFields} from '../../../utilities/store-lookup/types.js' +import type {StoreInfoResult, StoreInfoStoreOwner} from './types.js' import type {StoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session' interface GetStoreInfoOptions { @@ -193,7 +194,7 @@ async function safeFetchOrganizationShop( } function isBusinessPlatformFallbackError(error: unknown): boolean { - return error instanceof StoreInfoBusinessPlatformStoreNotFoundError || isNoPromptAuthenticationError(error) + return error instanceof StoreLookupStoreNotFoundError || isNoPromptAuthenticationError(error) } function isNoPromptAuthenticationError(error: unknown): boolean { diff --git a/packages/store/src/cli/services/store/info/types.ts b/packages/store/src/cli/services/store/info/types.ts index cb142ad1c8a..ef037f371f2 100644 --- a/packages/store/src/cli/services/store/info/types.ts +++ b/packages/store/src/cli/services/store/info/types.ts @@ -1,19 +1,8 @@ -import type {Store} from '../../../api/graphql/business-platform-organizations/generated/types.js' - export interface StoreInfoStoreOwner { name?: string email?: string } -/** - * Internal-only org reference used to drive the BP Organizations request and to - * populate `organizationId` / `organizationName`. - */ -export interface OwningOrgInternal { - name: string - id?: string -} - export interface StoreInfoResult { id?: string displayName?: string @@ -33,22 +22,3 @@ export interface StoreInfoResult { // there's no way to grant additional scopes later. authScopes?: string[] } - -/** - * Result of the BP Destinations lookup. The destination itself carries no fields we surface; - * its only job is to prove the store exists/is accessible and to resolve the owning org. - */ -export interface DestinationsContext { - owningOrg?: OwningOrgInternal -} - -export interface OrganizationShopFields { - shopifyShopId?: string - name?: string - primaryDomain?: string - storeType?: Store - developerPreviewHandle?: string - planName?: string - ownerName?: string - ownerEmail?: string -} diff --git a/packages/store/src/cli/services/store/info/destinations.test.ts b/packages/store/src/cli/utilities/store-lookup/destinations.test.ts similarity index 100% rename from packages/store/src/cli/services/store/info/destinations.test.ts rename to packages/store/src/cli/utilities/store-lookup/destinations.test.ts diff --git a/packages/store/src/cli/services/store/info/destinations.ts b/packages/store/src/cli/utilities/store-lookup/destinations.ts similarity index 84% rename from packages/store/src/cli/services/store/info/destinations.ts rename to packages/store/src/cli/utilities/store-lookup/destinations.ts index 9e6ade13968..87731b2af9e 100644 --- a/packages/store/src/cli/services/store/info/destinations.ts +++ b/packages/store/src/cli/utilities/store-lookup/destinations.ts @@ -1,6 +1,6 @@ -import {businessPlatformTokenRefreshHandler} from '../business-platform.js' -import {StoreInfoDestinations} from '../../../api/graphql/business-platform-destinations/generated/store-info-destinations.js' -import {StoreInfoOwningOrg} from '../../../api/graphql/business-platform-destinations/generated/store-info-owning-org.js' +import {businessPlatformTokenRefreshHandler} from '../../services/store/business-platform.js' +import {StoreInfoDestinations} from '../../api/graphql/business-platform-destinations/generated/store-info-destinations.js' +import {StoreInfoOwningOrg} from '../../api/graphql/business-platform-destinations/generated/store-info-owning-org.js' import {AbortError} from '@shopify/cli-kit/node/error' import {businessPlatformRequestDoc} from '@shopify/cli-kit/node/api/business-platform' import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session' @@ -10,12 +10,12 @@ import {outputDebug} from '@shopify/cli-kit/node/output' import type { StoreInfoDestinationsQuery, StoreInfoDestinationsQueryVariables, -} from '../../../api/graphql/business-platform-destinations/generated/store-info-destinations.js' +} from '../../api/graphql/business-platform-destinations/generated/store-info-destinations.js' import type { StoreInfoOwningOrgQuery, StoreInfoOwningOrgQueryVariables, -} from '../../../api/graphql/business-platform-destinations/generated/store-info-owning-org.js' -import type {DestinationsContext, OwningOrgInternal} from './types.js' +} from '../../api/graphql/business-platform-destinations/generated/store-info-owning-org.js' +import type {DestinationsContext, StoreLookupOrganization} from './types.js' import type {UnauthorizedHandler} from '@shopify/cli-kit/node/api/graphql' type DestinationNodeFromQuery = NonNullable< @@ -28,7 +28,7 @@ interface FetchDestinationsContextOptions { noPrompt?: boolean } -export class StoreInfoBusinessPlatformStoreNotFoundError extends AbortError {} +export class StoreLookupStoreNotFoundError extends AbortError {} export async function fetchDestinationsContext(options: FetchDestinationsContextOptions): Promise { const token = options.token ?? (await ensureAuthenticatedBusinessPlatform([], {noPrompt: options.noPrompt})) @@ -55,7 +55,7 @@ export async function fetchDestinationsContext(options: FetchDestinationsContext const matchedNode = nodes.find((node) => matchesStore(node, targetHost)) if (!matchedNode) { - throw new StoreInfoBusinessPlatformStoreNotFoundError( + throw new StoreLookupStoreNotFoundError( `Couldn't find a store with domain ${options.store} for the current account.`, 'Verify the domain (must be the canonical `myshopify.com` FQDN) and that you are signed in to an account with access to the store. Inactive shops are not searchable.', ) @@ -70,7 +70,7 @@ async function fetchOwningOrg( destinationPublicId: string, token: string, unauthorizedHandler: UnauthorizedHandler, -): Promise { +): Promise { try { const orgResponse = await businessPlatformRequestDoc({ query: StoreInfoOwningOrg, diff --git a/packages/store/src/cli/services/store/info/organization-shop.test.ts b/packages/store/src/cli/utilities/store-lookup/organization-shop.test.ts similarity index 93% rename from packages/store/src/cli/services/store/info/organization-shop.test.ts rename to packages/store/src/cli/utilities/store-lookup/organization-shop.test.ts index 1b2ea4c22e6..401d350ac8c 100644 --- a/packages/store/src/cli/services/store/info/organization-shop.test.ts +++ b/packages/store/src/cli/utilities/store-lookup/organization-shop.test.ts @@ -1,7 +1,7 @@ import {fetchOrganizationShop} from './organization-shop.js' import {businessPlatformOrganizationsRequestDoc} from '@shopify/cli-kit/node/api/business-platform' import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session' -import {BugError} from '@shopify/cli-kit/node/error' +import {AbortError} from '@shopify/cli-kit/node/error' import {describe, test, expect, vi, beforeEach} from 'vitest' vi.mock('@shopify/cli-kit/node/api/business-platform') @@ -48,7 +48,7 @@ describe('fetchOrganizationShop', () => { expect(shop.ownerEmail).toBe('jane@acme.com') }) - test('throws BugError when no shop matches the domain', async () => { + test('throws AbortError when no shop matches the domain', async () => { vi.mocked(businessPlatformOrganizationsRequestDoc).mockResolvedValueOnce({ organization: { id: 'gid', @@ -57,7 +57,7 @@ describe('fetchOrganizationShop', () => { }, } as never) - await expect(fetchOrganizationShop({store: SHOP, organizationId: ORG_ID})).rejects.toBeInstanceOf(BugError) + await expect(fetchOrganizationShop({store: SHOP, organizationId: ORG_ID})).rejects.toBeInstanceOf(AbortError) }) test('passes organizationId and search variable to the request', async () => { @@ -86,6 +86,6 @@ describe('fetchOrganizationShop', () => { test('throws when organization is missing', async () => { vi.mocked(businessPlatformOrganizationsRequestDoc).mockResolvedValueOnce({organization: null} as never) - await expect(fetchOrganizationShop({store: SHOP, organizationId: ORG_ID})).rejects.toBeInstanceOf(BugError) + await expect(fetchOrganizationShop({store: SHOP, organizationId: ORG_ID})).rejects.toBeInstanceOf(AbortError) }) }) diff --git a/packages/store/src/cli/services/store/info/organization-shop.ts b/packages/store/src/cli/utilities/store-lookup/organization-shop.ts similarity index 75% rename from packages/store/src/cli/services/store/info/organization-shop.ts rename to packages/store/src/cli/utilities/store-lookup/organization-shop.ts index 680b98e8e3d..37c48ddf703 100644 --- a/packages/store/src/cli/services/store/info/organization-shop.ts +++ b/packages/store/src/cli/utilities/store-lookup/organization-shop.ts @@ -1,13 +1,13 @@ -import {businessPlatformTokenRefreshHandler} from '../business-platform.js' -import {StoreInfoShop} from '../../../api/graphql/business-platform-organizations/generated/store-info-shop.js' -import {BugError} from '@shopify/cli-kit/node/error' +import {businessPlatformTokenRefreshHandler} from '../../services/store/business-platform.js' +import {StoreInfoShop} from '../../api/graphql/business-platform-organizations/generated/store-info-shop.js' +import {AbortError} from '@shopify/cli-kit/node/error' import {businessPlatformOrganizationsRequestDoc} from '@shopify/cli-kit/node/api/business-platform' import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session' import {extractHost} from '@shopify/cli-kit/common/url' import type { StoreInfoShopQuery, StoreInfoShopQueryVariables, -} from '../../../api/graphql/business-platform-organizations/generated/store-info-shop.js' +} from '../../api/graphql/business-platform-organizations/generated/store-info-shop.js' import type {OrganizationShopFields} from './types.js' interface FetchOrganizationShopOptions { @@ -18,6 +18,21 @@ interface FetchOrganizationShopOptions { } export async function fetchOrganizationShop(options: FetchOrganizationShopOptions): Promise { + const shop = await fetchOptionalOrganizationShop(options) + + if (!shop) { + throw new AbortError( + `Couldn't find shop ${options.store} inside organization ${options.organizationId}.`, + 'The shop matched a global lookup but is not listed under its parent organization. This usually means the search index is stale; try again in a moment.', + ) + } + + return shop +} + +export async function fetchOptionalOrganizationShop( + options: FetchOrganizationShopOptions, +): Promise { const token = options.token ?? (await ensureAuthenticatedBusinessPlatform([], {noPrompt: options.noPrompt})) const unauthorizedHandler = businessPlatformTokenRefreshHandler({noPrompt: options.noPrompt}) @@ -33,12 +48,7 @@ export async function fetchOrganizationShop(options: FetchOrganizationShopOption const lowerStore = options.store.toLowerCase() const matched = edges.map((edge) => edge.node).find((node) => extractHost(node.primaryDomain) === lowerStore) - if (!matched) { - throw new BugError( - `Couldn't find shop ${options.store} inside organization ${options.organizationId}.`, - 'The shop matched a global lookup but is not listed under its parent organization. This usually means the search index is stale; try again in a moment.', - ) - } + if (!matched) return undefined return { shopifyShopId: matched.shopifyShopId ?? undefined, diff --git a/packages/store/src/cli/utilities/store-lookup/organization.test.ts b/packages/store/src/cli/utilities/store-lookup/organization.test.ts new file mode 100644 index 00000000000..60305bcdf38 --- /dev/null +++ b/packages/store/src/cli/utilities/store-lookup/organization.test.ts @@ -0,0 +1,30 @@ +import {findStoreOwningOrganization} from './organization.js' +import {fetchDestinationsContext} from './destinations.js' +import {describe, expect, test, vi} from 'vitest' + +vi.mock('./destinations.js') + +const STORE = 'shop.myshopify.com' + +describe('findStoreOwningOrganization', () => { + test('maps the destination owning org into an organizations package Organization', async () => { + vi.mocked(fetchDestinationsContext).mockResolvedValue({owningOrg: {id: '1234', name: 'Acme'}}) + + const organization = await findStoreOwningOrganization({store: STORE, noPrompt: true}) + + expect(fetchDestinationsContext).toHaveBeenCalledWith({store: STORE, noPrompt: true}) + expect(organization).toEqual({id: '1234', businessName: 'Acme'}) + }) + + test('returns undefined when the destination lookup cannot resolve an org id', async () => { + vi.mocked(fetchDestinationsContext).mockResolvedValue({owningOrg: {name: 'Acme'}}) + + await expect(findStoreOwningOrganization({store: STORE})).resolves.toBeUndefined() + }) + + test('returns undefined when the destination lookup fails', async () => { + vi.mocked(fetchDestinationsContext).mockRejectedValue(new Error('not found')) + + await expect(findStoreOwningOrganization({store: STORE})).resolves.toBeUndefined() + }) +}) diff --git a/packages/store/src/cli/utilities/store-lookup/organization.ts b/packages/store/src/cli/utilities/store-lookup/organization.ts new file mode 100644 index 00000000000..f6ea0411476 --- /dev/null +++ b/packages/store/src/cli/utilities/store-lookup/organization.ts @@ -0,0 +1,36 @@ +import {fetchDestinationsContext} from './destinations.js' +import {outputDebug} from '@shopify/cli-kit/node/output' +import type {Organization} from '@shopify/organizations' + +interface FindStoreOwningOrganizationOptions { + store: string + token?: string + noPrompt?: boolean +} + +export async function findStoreOwningOrganization( + options: FindStoreOwningOrganizationOptions, +): Promise { + const {store} = options + + try { + const destinationsContext = await fetchDestinationsContext(options) + const owningOrganization = destinationsContext.owningOrg + + if (!owningOrganization?.id) { + outputDebug(`Could not infer an owning organization ID for ${store}.`) + return undefined + } + + return {id: owningOrganization.id, businessName: owningOrganization.name} + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (error) { + outputDebug(`Could not infer the owning organization for ${store}: ${errorMessage(error)}`) + return undefined + } +} + +function errorMessage(error: unknown): string { + if (error instanceof Error) return error.message + return String(error) +} diff --git a/packages/store/src/cli/utilities/store-lookup/types.ts b/packages/store/src/cli/utilities/store-lookup/types.ts new file mode 100644 index 00000000000..807ff556080 --- /dev/null +++ b/packages/store/src/cli/utilities/store-lookup/types.ts @@ -0,0 +1,29 @@ +import type {Store} from '../../api/graphql/business-platform-organizations/generated/types.js' + +/** + * Organization reference resolved from a store destination. This is intentionally minimal: + * callers use it to address the BP Organizations API and to display the owning org. + */ +export interface StoreLookupOrganization { + name: string + id?: string +} + +/** + * Result of the BP Destinations lookup. The destination itself carries no fields we surface; + * its job is to prove the store exists/is accessible and to resolve the owning org. + */ +export interface DestinationsContext { + owningOrg?: StoreLookupOrganization +} + +export interface OrganizationShopFields { + shopifyShopId?: string + name?: string + primaryDomain?: string + storeType?: Store + developerPreviewHandle?: string + planName?: string + ownerName?: string + ownerEmail?: string +} diff --git a/packages/store/src/index.ts b/packages/store/src/index.ts index 602df8de513..58787c254f7 100644 --- a/packages/store/src/index.ts +++ b/packages/store/src/index.ts @@ -6,6 +6,7 @@ import StoreBulkStatus from './cli/commands/store/bulk/status.js' import StoreStripeAuth from './cli/commands/store/stripe-auth.js' import StoreCreateDev from './cli/commands/store/create/dev.js' import StoreCreatePreview from './cli/commands/store/create/preview.js' +import StoreDelete from './cli/commands/store/delete.js' import StoreExecute from './cli/commands/store/execute.js' import StoreGraphiQL from './cli/commands/store/graphiql.js' import StoreInfo from './cli/commands/store/info.js' @@ -23,6 +24,7 @@ const COMMANDS = { 'store:stripe-auth': StoreStripeAuth, 'store:create:dev': StoreCreateDev, 'store:create:preview': StoreCreatePreview, + 'store:delete': StoreDelete, 'store:execute': StoreExecute, 'store:graphiql': StoreGraphiQL, 'store:info': StoreInfo,