diff --git a/packages/cli/README.md b/packages/cli/README.md index 824e1e724cc..36c3769ac07 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -78,6 +78,7 @@ * [`shopify search [query]`](#shopify-search-query) * [`shopify store auth`](#shopify-store-auth) * [`shopify store create dev`](#shopify-store-create-dev) +* [`shopify store delete`](#shopify-store-delete) * [`shopify store execute`](#shopify-store-execute) * [`shopify theme check`](#shopify-theme-check) * [`shopify theme console`](#shopify-theme-console) @@ -2162,6 +2163,29 @@ DESCRIPTION Creates a new app development store in your organization. ``` +## `shopify store delete` + +Delete a development store. + +``` +USAGE + $ shopify store delete --store [-j] [--no-color] [--organization ] [--verbose] + +FLAGS + -j, --json [env: SHOPIFY_FLAG_JSON] Output the result as JSON. Automatically disables color output. + --no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output. + --organization= [env: SHOPIFY_FLAG_ORGANIZATION] The organization that owns the store (numeric ID). + Auto-selects if you belong to a single org. + --store= (required) [env: SHOPIFY_FLAG_STORE] The domain of the development store to delete (e.g. + my-store.myshopify.com). + --verbose [env: SHOPIFY_FLAG_VERBOSE] Increase the verbosity of the output. + +DESCRIPTION + Delete a development store. + + Deletes an app development store from your organization. +``` + ## `shopify store execute` Execute GraphQL queries and mutations on a store. diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 1068185a1d5..01a0f4a5379 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -5925,6 +5925,75 @@ "strict": true, "summary": "Create a new development 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, + "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": { + "aliases": [ + "organization-id" + ], + "description": "The organization that owns the store (numeric ID). Auto-selects if you belong to a single org.", + "env": "SHOPIFY_FLAG_ORGANIZATION", + "hasDynamicHelp": false, + "multiple": false, + "name": "organization", + "type": "option" + }, + "store": { + "aliases": [ + "name" + ], + "description": "The domain of the development store to delete (e.g. my-store.myshopify.com).", + "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, + "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..c730ef0a17b --- /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}[] + } | 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/api/graphql/business-platform-organizations/organizations_schema.graphql b/packages/store/src/cli/api/graphql/business-platform-organizations/organizations_schema.graphql index b334fce217c..306a937b41e 100644 --- a/packages/store/src/cli/api/graphql/business-platform-organizations/organizations_schema.graphql +++ b/packages/store/src/cli/api/graphql/business-platform-organizations/organizations_schema.graphql @@ -2403,6 +2403,18 @@ type CreateAppDevelopmentStoreResult { userErrors: [UserError!] } +type DeleteAppDevelopmentStorePayload { + """ + Whether the store deletion was successfully enqueued. + """ + success: Boolean! + + """ + The collection of errors. + """ + userErrors: [UserError!]! +} + input CreateCliTokenOrganizationUserInput { """ Organization-wide access conditions to apply to the user. @@ -10429,6 +10441,16 @@ type Mutation { """ convertUsersToSaml(convertUsersToSamlInput: ConvertUsersToSamlInput!): ConvertUsersToSamlResult! + """ + Delete an App Development Store. + """ + deleteAppDevelopmentStore( + """ + The fully-qualified domain name of the store to delete. + """ + storeFqdn: String! + ): DeleteAppDevelopmentStorePayload + """ Create an App Development Store. """ 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..bd7307bc4e2 --- /dev/null +++ b/packages/store/src/cli/commands/store/delete.test.ts @@ -0,0 +1,90 @@ +import StoreDelete from './delete.js' +import {deleteDevStore} from '../../services/store/delete/dev.js' +import {AbortError} from '@shopify/cli-kit/node/error' +import {outputResult} from '@shopify/cli-kit/node/output' +import {describe, expect, test, vi} from 'vitest' + +vi.mock('../../services/store/delete/dev.js') + +vi.mock('@shopify/cli-kit/node/output', async (importOriginal) => { + const actual: Record = await importOriginal() + return { + ...actual, + outputResult: vi.fn(), + } +}) + +describe('store delete command', () => { + test('passes parsed flags through to the service', async () => { + await StoreDelete.run(['--store', 'my-store.myshopify.com']) + + expect(deleteDevStore).toHaveBeenCalledWith({ + store: 'my-store.myshopify.com', + organization: undefined, + json: false, + }) + }) + + test('passes organization flag through to the service', async () => { + await StoreDelete.run(['--store', 'my-store.myshopify.com', '--organization', '12345']) + + expect(deleteDevStore).toHaveBeenCalledWith({ + store: 'my-store.myshopify.com', + organization: '12345', + json: false, + }) + }) + + test('passes json flag through to the service', async () => { + await StoreDelete.run(['--store', 'my-store.myshopify.com', '--json']) + + expect(deleteDevStore).toHaveBeenCalledWith({ + store: 'my-store.myshopify.com', + organization: undefined, + json: true, + }) + }) + + test('defines the expected flags', () => { + expect(StoreDelete.flags.store).toBeDefined() + expect(StoreDelete.flags.organization).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', '--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', '--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'])).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..8a6992d3748 --- /dev/null +++ b/packages/store/src/cli/commands/store/delete.ts @@ -0,0 +1,59 @@ +import {deleteDevStore} from '../../services/store/delete/dev.js' +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 {Flags} from '@oclif/core' + +export default class StoreDelete extends Command { + static summary = 'Delete a development store.' + + static descriptionWithMarkdown = 'Deletes an app development store from your organization.' + + static description = this.descriptionWithoutMarkdown() + + static flags = { + ...globalFlags, + ...jsonFlag, + store: Flags.string({ + description: 'The domain of the development store to delete (e.g. my-store.myshopify.com).', + required: true, + aliases: ['name'], + env: 'SHOPIFY_FLAG_STORE', + }), + organization: Flags.string({ + description: + 'The organization that owns the store (numeric ID). Auto-selects if you belong to a single org.', + aliases: ['organization-id'], + env: 'SHOPIFY_FLAG_ORGANIZATION', + }), + } + + async run(): Promise { + const {flags} = await this.parse(StoreDelete) + try { + await deleteDevStore({ + store: flags.store, + organization: flags.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..87dcc698c54 --- /dev/null +++ b/packages/store/src/cli/services/store/delete/dev.test.ts @@ -0,0 +1,134 @@ +import {deleteDevStore} from './dev.js' +import {describe, expect, test, vi, beforeEach} from 'vitest' + +vi.mock('@shopify/organizations', () => ({ + selectOrg: vi.fn(), +})) + +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(), + } +}) + +import {selectOrg} 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' + +const defaultOrg = {id: '123', businessName: 'Test Org'} +const defaultMutationResult = { + deleteAppDevelopmentStore: { + success: true, + userErrors: [], + }, +} + +beforeEach(() => { + vi.mocked(selectOrg).mockResolvedValue(defaultOrg) + vi.mocked(ensureAuthenticatedBusinessPlatform).mockResolvedValue('test-token') + vi.mocked(businessPlatformOrganizationsRequestDoc).mockResolvedValue(defaultMutationResult) +}) + +describe('deleteDevStore', () => { + test('deletes a development store and renders success', async () => { + await deleteDevStore({store: 'test-store.myshopify.com', json: false}) + + expect(selectOrg).toHaveBeenCalledWith(undefined) + expect(ensureAuthenticatedBusinessPlatform).toHaveBeenCalled() + expect(businessPlatformOrganizationsRequestDoc).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.anything(), + token: 'test-token', + organizationId: '123', + variables: {storeFqdn: 'test-store.myshopify.com'}, + }), + ) + expect(renderSuccess).toHaveBeenCalledWith( + expect.objectContaining({ + headline: expect.stringContaining('test-store.myshopify.com'), + }), + ) + }) + + test('outputs JSON when --json flag is set', async () => { + await deleteDevStore({store: 'test-store.myshopify.com', json: true}) + + expect(outputResult).toHaveBeenCalledWith( + expect.stringContaining('"status": "deleted"'), + ) + expect(outputResult).toHaveBeenCalledWith( + expect.stringContaining('"store": "test-store.myshopify.com"'), + ) + expect(renderSuccess).not.toHaveBeenCalled() + }) + + test('JSON output includes organization id and name', async () => { + await deleteDevStore({store: 'test-store.myshopify.com', json: true}) + + const call = vi.mocked(outputResult).mock.calls[0]![0] as string + const parsed = JSON.parse(call) + expect(parsed).toEqual({ + status: 'deleted', + store: 'test-store.myshopify.com', + organization: { + id: '123', + name: 'Test Org', + }, + }) + }) + + test('throws AbortError when mutation returns null deleteAppDevelopmentStore', async () => { + vi.mocked(businessPlatformOrganizationsRequestDoc).mockResolvedValueOnce({ + deleteAppDevelopmentStore: null, + }) + + await expect(deleteDevStore({store: 'test-store.myshopify.com', json: false})).rejects.toThrow( + 'Store deletion failed: unexpected 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({store: 'test-store.myshopify.com', json: false})).rejects.toThrow( + 'Store not found', + ) + }) + + test('passes organization flag to selectOrg', async () => { + await deleteDevStore({store: 'test-store.myshopify.com', organization: '456', json: false}) + + expect(selectOrg).toHaveBeenCalledWith('456') + }) + + test('maps --store flag to storeFqdn GraphQL variable', async () => { + await deleteDevStore({store: 'custom-store.myshopify.com', json: false}) + + expect(businessPlatformOrganizationsRequestDoc).toHaveBeenCalledWith( + expect.objectContaining({ + variables: {storeFqdn: 'custom-store.myshopify.com'}, + }), + ) + }) +}) 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..c707ebd3517 --- /dev/null +++ b/packages/store/src/cli/services/store/delete/dev.ts @@ -0,0 +1,65 @@ +import {DeleteAppDevelopmentStore} from '../../../api/graphql/business-platform-organizations/generated/delete_app_development_store.js' +import {selectOrg} 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?: string + json: boolean +} + +export async function deleteDevStore(options: DeleteDevStoreOptions): Promise { + const org = await selectOrg(options.organization) + const token = await ensureAuthenticatedBusinessPlatform() + const unauthorizedHandler = { + type: 'token_refresh' as const, + handler: async () => { + const newToken = await ensureAuthenticatedBusinessPlatform() + return {token: newToken} + }, + } + + const mutationResult = await businessPlatformOrganizationsRequestDoc({ + query: DeleteAppDevelopmentStore, + token, + organizationId: org.id, + variables: {storeFqdn: options.store}, + unauthorizedHandler, + }) + + const deleteAppDevelopmentStore = mutationResult.deleteAppDevelopmentStore + if (!deleteAppDevelopmentStore) { + throw new AbortError('Store deletion failed: unexpected response') + } + + const userErrors = deleteAppDevelopmentStore.userErrors + if (userErrors && userErrors.length > 0) { + const messages = userErrors.map((e) => e.message).join(', ') + throw new AbortError(`Failed to delete development store: ${messages}`) + } + + if (options.json) { + outputResult( + JSON.stringify( + { + status: 'deleted', + store: options.store, + organization: { + id: org.id, + name: org.businessName, + }, + }, + null, + 2, + ), + ) + } else { + renderSuccess({ + headline: `Development store "${options.store}" deleted successfully.`, + }) + } +} diff --git a/packages/store/src/index.ts b/packages/store/src/index.ts index ef0fadc5413..4c207bee509 100644 --- a/packages/store/src/index.ts +++ b/packages/store/src/index.ts @@ -1,10 +1,12 @@ import StoreAuth from './cli/commands/store/auth.js' import StoreCreateDev from './cli/commands/store/create/dev.js' +import StoreDelete from './cli/commands/store/delete.js' import StoreExecute from './cli/commands/store/execute.js' const COMMANDS = { 'store:auth': StoreAuth, 'store:create:dev': StoreCreateDev, + 'store:delete': StoreDelete, 'store:execute': StoreExecute, }