diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index d6882fba5b4..e7253391dbc 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -6467,6 +6467,78 @@ "strict": true, "summary": "Create a preview Shopify store." }, + "store:delete": { + "aliases": [ + ], + "args": { + }, + "customPluginName": "@shopify/store", + "description": "Deletes an app development store from your organization.", + "descriptionWithMarkdown": "Deletes an app 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": { + "aliases": [ + "name" + ], + "char": "s", + "description": "The myshopify.com domain of the development store to delete.", + "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..c2fca9a9c3b --- /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 | null + userErrors?: {code?: string | null; field: string[]; message: string}[] | null + } | null +} + +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/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/commands/store/delete.test.ts b/packages/store/src/cli/commands/store/delete.test.ts new file mode 100644 index 00000000000..58ec6f29a40 --- /dev/null +++ b/packages/store/src/cli/commands/store/delete.test.ts @@ -0,0 +1,129 @@ +import StoreDelete from './delete.js' +import {deleteDevStore} from '../../services/store/delete/dev.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('@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'} + +beforeEach(() => { + vi.mocked(selectOrg).mockResolvedValue(defaultOrg) + 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('accepts --name as an alias for --store', async () => { + await StoreDelete.run(['--name', 'my-store.myshopify.com', '--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('prompts for the organization when --organization-id is omitted in an interactive environment', async () => { + await StoreDelete.run(['--store', 'my-store.myshopify.com']) + + expect(selectOrg).toHaveBeenCalledWith(undefined) + expect(deleteDevStore).toHaveBeenCalledWith(expect.objectContaining({organization: defaultOrg})) + }) + + test('fails in a non-interactive environment when --organization-id is missing', async () => { + vi.mocked(terminalSupportsPrompting).mockReturnValue(false) + 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() + expect(deleteDevStore).not.toHaveBeenCalled() + + mockExit.mockRestore() + }) + + test('defines the expected flags', () => { + expect(StoreDelete.flags.store).toBeDefined() + expect(StoreDelete.flags.store.aliases).toContain('name') + 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..9c560e0e17c --- /dev/null +++ b/packages/store/src/cli/commands/store/delete.ts @@ -0,0 +1,70 @@ +import {deleteDevStore} from '../../services/store/delete/dev.js' +import {storeFlags} from '../../flags.js' +import {selectOrg} from '@shopify/organizations' +import Command from '@shopify/cli-kit/node/base-command' +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' +import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' +import {AbortError} from '@shopify/cli-kit/node/error' +import {outputResult} from '@shopify/cli-kit/node/output' +import {Flags} from '@oclif/core' + +export default class StoreDelete extends Command { + static hidden = true + + static summary = 'Delete a development store.' + + static descriptionWithMarkdown = 'Deletes an app 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: Flags.string({ + char: 's', + description: 'The myshopify.com domain of the development store to delete.', + env: 'SHOPIFY_FLAG_STORE', + parse: async (input) => normalizeStoreFqdn(input), + required: true, + aliases: ['name'], + }), + 'organization-id': storeFlags['organization-id'], + } + + async run(): Promise { + const {flags} = await this.parse(StoreDelete) + this.failMissingNonTTYFlags(flags, ['store', 'organization-id']) + + const organization = await selectOrg(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 + } + } +} 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..4be8bd95513 --- /dev/null +++ b/packages/store/src/cli/services/store/delete/dev.test.ts @@ -0,0 +1,119 @@ +import {deleteDevStore} from './dev.js' +import {businessPlatformOrganizationsRequestDoc} from '@shopify/cli-kit/node/api/business-platform' +import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session' +import {renderSuccess} from '@shopify/cli-kit/node/ui' +import {outputResult} from '@shopify/cli-kit/node/output' +import {describe, expect, test, vi, beforeEach} 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', () => ({ + renderSuccess: vi.fn(), +})) + +vi.mock('@shopify/cli-kit/node/output', async (importOriginal) => { + const actual: Record = await importOriginal() + return { + ...actual, + outputResult: 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: [], + }, +} + +beforeEach(() => { + vi.mocked(ensureAuthenticatedBusinessPlatform).mockResolvedValue('test-token') + vi.mocked(businessPlatformOrganizationsRequestDoc).mockResolvedValue(defaultMutationResult) +}) + +describe('deleteDevStore', () => { + test('deletes a development store and renders success', async () => { + 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(renderSuccess).toHaveBeenCalledWith( + expect.objectContaining({ + headline: expect.stringContaining('test-store.myshopify.com'), + }), + ) + }) + + 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', + deleted: true, + }, + organization: { + id: '123', + name: 'Test Org', + }, + }) + expect(renderSuccess).not.toHaveBeenCalled() + }) + + test('throws AbortError when mutation returns null deleteAppDevelopmentStore', async () => { + vi.mocked(businessPlatformOrganizationsRequestDoc).mockResolvedValueOnce({ + deleteAppDevelopmentStore: null, + }) + + await expect(deleteDevStore(defaultOptions)).rejects.toThrow('Store deletion failed: unexpected empty response.') + }) + + test('throws AbortError when mutation returns userErrors', async () => { + vi.mocked(businessPlatformOrganizationsRequestDoc).mockResolvedValueOnce({ + 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') + }) + + test('throws AbortError when mutation reports failure without userErrors', async () => { + vi.mocked(businessPlatformOrganizationsRequestDoc).mockResolvedValueOnce({ + 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', + }), + ) + }) +}) 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..bd8e3da9dda --- /dev/null +++ b/packages/store/src/cli/services/store/delete/dev.ts @@ -0,0 +1,65 @@ +import {businessPlatformTokenRefreshHandler} from '../business-platform.js' +import {DeleteAppDevelopmentStore} from '../../../api/graphql/business-platform-organizations/generated/delete_app_development_store.js' +import {Organization} from '@shopify/organizations' +import {businessPlatformOrganizationsRequestDoc} from '@shopify/cli-kit/node/api/business-platform' +import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session' +import {renderSuccess} from '@shopify/cli-kit/node/ui' +import {outputResult} from '@shopify/cli-kit/node/output' +import {AbortError} from '@shopify/cli-kit/node/error' + +interface DeleteDevStoreOptions { + store: string + organization: Organization + json: boolean +} + +export async function deleteDevStore(options: DeleteDevStoreOptions): Promise { + const {organization: org, store} = options + const token = await ensureAuthenticatedBusinessPlatform() + const unauthorizedHandler = businessPlatformTokenRefreshHandler() + + 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) => error.message).join(', ') + throw new AbortError(`Failed to delete development store: ${messages}`) + } + if (deleteAppDevelopmentStore.success === false) { + throw new AbortError('Store deletion failed.') + } + + if (options.json) { + outputResult( + JSON.stringify( + { + store: { + domain: store, + deleted: true, + }, + organization: { + id: org.id, + name: org.businessName, + }, + }, + null, + 2, + ), + ) + } else { + renderSuccess({ + headline: `Development store "${store}" deleted successfully.`, + }) + } +} 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,