From d29b6eda7f41087358155726c577eb726d7bad8d Mon Sep 17 00:00:00 2001 From: jordanarldt Date: Tue, 5 May 2026 13:27:48 -0500 Subject: [PATCH] TRAC-614: Add project delete command to catalyst CLI --- .../catalyst/src/cli/commands/project.spec.ts | 359 +++++++++++++++++- packages/catalyst/src/cli/commands/project.ts | 119 +++++- packages/catalyst/src/cli/lib/project.ts | 26 ++ packages/catalyst/tests/mocks/handlers.ts | 6 + 4 files changed, 507 insertions(+), 3 deletions(-) diff --git a/packages/catalyst/src/cli/commands/project.spec.ts b/packages/catalyst/src/cli/commands/project.spec.ts index 3f4871e860..d8f417768f 100644 --- a/packages/catalyst/src/cli/commands/project.spec.ts +++ b/packages/catalyst/src/cli/commands/project.spec.ts @@ -131,7 +131,7 @@ afterAll(async () => { }); describe('project', () => { - test('has create, link, and list subcommands', () => { + test('has create, link, list, and delete subcommands', () => { expect(project).toBeInstanceOf(Command); expect(project.name()).toBe('project'); expect(project.description()).toBe('Manage your BigCommerce infrastructure project.'); @@ -152,6 +152,19 @@ describe('project', () => { expect(listCmd).toBeDefined(); expect(listCmd?.description()).toContain('List BigCommerce infrastructure projects'); + + const deleteCmd = project.commands.find((cmd) => cmd.name() === 'delete'); + + expect(deleteCmd).toBeDefined(); + expect(deleteCmd?.description()).toContain( + 'Permanently delete a BigCommerce infrastructure project', + ); + expect(deleteCmd?.options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ flags: '--project-uuid ' }), + expect.objectContaining({ flags: '--force' }), + ]), + ); }); }); @@ -786,3 +799,347 @@ describe('project link', () => { expect(exitMock).toHaveBeenCalledWith(1); }); }); + +describe('project delete', () => { + test('with --project-uuid and --force deletes without prompting', async () => { + const consolaPromptMock = vi.spyOn(consola, 'prompt'); + + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'delete', + '--project-uuid', + projectUuid1, + '--force', + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]); + + expect(consolaPromptMock).not.toHaveBeenCalled(); + expect(mockIdentify).toHaveBeenCalledWith(storeHash); + expect(consola.start).toHaveBeenCalledWith(`Deleting project ${projectUuid1}...`); + expect(consola.success).toHaveBeenCalledWith(`Project ${projectUuid1} deleted.`); + expect(exitMock).toHaveBeenCalledWith(0); + + consolaPromptMock.mockRestore(); + }); + + test('with --project-uuid prompts for confirmation and deletes on accept', async () => { + const consolaPromptMock = vi + .spyOn(consola, 'prompt') + .mockImplementation(async (message, opts) => { + expect(message).toContain('Are you sure you want to delete project'); + expect(message).toContain(projectUuid1); + expect(message).toContain('irreversible'); + expect(opts).toMatchObject({ type: 'confirm' }); + + return Promise.resolve(true); + }); + + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'delete', + '--project-uuid', + projectUuid1, + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]); + + expect(consolaPromptMock).toHaveBeenCalledTimes(1); + expect(consola.success).toHaveBeenCalledWith(`Project ${projectUuid1} deleted.`); + expect(exitMock).toHaveBeenCalledWith(0); + + consolaPromptMock.mockRestore(); + }); + + test('aborts when user declines the confirmation prompt', async () => { + let deleteRequested = false; + + server.use( + http.delete( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid', + () => { + deleteRequested = true; + + return new HttpResponse(null, { status: 204 }); + }, + ), + ); + + const consolaPromptMock = vi + .spyOn(consola, 'prompt') + .mockImplementation(async () => Promise.resolve(false)); + + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'delete', + '--project-uuid', + projectUuid1, + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]); + + expect(deleteRequested).toBe(false); + expect(consola.info).toHaveBeenCalledWith('Aborted. No project was deleted.'); + expect(exitMock).toHaveBeenCalledWith(0); + + consolaPromptMock.mockRestore(); + }); + + test('without --project-uuid fetches projects and prompts to select one', async () => { + const consolaPromptMock = vi + .spyOn(consola, 'prompt') + .mockImplementationOnce(async (message, opts) => { + expect(message).toContain('Select a project to delete'); + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const options = (opts as { options: Array<{ label: string; value: string }> }).options; + + expect(options).toHaveLength(3); + expect(options[0]).toMatchObject({ label: 'Project One', value: projectUuid1 }); + expect(options[1]).toMatchObject({ label: 'Project Two', value: projectUuid2 }); + expect(options[2]).toMatchObject({ label: 'Cancel', value: 'cancel' }); + + return Promise.resolve(projectUuid2); + }) + .mockImplementationOnce(async (message) => { + expect(message).toContain('"Project Two"'); + expect(message).toContain(projectUuid2); + + return Promise.resolve(true); + }); + + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'delete', + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]); + + expect(consola.start).toHaveBeenCalledWith('Fetching projects...'); + expect(consola.success).toHaveBeenCalledWith('Projects fetched.'); + expect(consola.success).toHaveBeenCalledWith(`Project ${projectUuid2} deleted.`); + expect(exitMock).toHaveBeenCalledWith(0); + + consolaPromptMock.mockRestore(); + }); + + test('marks the currently linked project with [linked] in the select prompt', async () => { + config.set('projectUuid', projectUuid2); + + const consolaPromptMock = vi + .spyOn(consola, 'prompt') + .mockImplementationOnce(async (_message, opts) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const options = (opts as { options: Array<{ label: string; value: string }> }).options; + + const linkedOption = options.find((o) => o.value === projectUuid2); + const otherOption = options.find((o) => o.value === projectUuid1); + + expect(linkedOption?.label).toContain('[linked]'); + expect(otherOption?.label).not.toContain('[linked]'); + + return Promise.resolve(projectUuid1); + }) + .mockImplementationOnce(async () => Promise.resolve(true)); + + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'delete', + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]); + + consolaPromptMock.mockRestore(); + }); + + test('aborts when user selects Cancel from the project list', async () => { + let deleteRequested = false; + + server.use( + http.delete( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid', + () => { + deleteRequested = true; + + return new HttpResponse(null, { status: 204 }); + }, + ), + ); + + const consolaPromptMock = vi + .spyOn(consola, 'prompt') + .mockImplementation(async () => Promise.resolve('cancel')); + + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'delete', + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]); + + expect(consolaPromptMock).toHaveBeenCalledTimes(1); + expect(deleteRequested).toBe(false); + expect(consola.info).toHaveBeenCalledWith('Aborted. No project was deleted.'); + expect(exitMock).toHaveBeenCalledWith(0); + + consolaPromptMock.mockRestore(); + }); + + test('exits cleanly when there are no projects to delete', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => + HttpResponse.json({ data: [] }), + ), + ); + + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'delete', + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]); + + expect(consola.info).toHaveBeenCalledWith('No projects found.'); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + test('clears linked projectUuid from config when the deleted project was linked', async () => { + config.set('projectUuid', projectUuid1); + config.set('storeHash', storeHash); + config.set('accessToken', accessToken); + + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'delete', + '--project-uuid', + projectUuid1, + '--force', + ]); + + expect(config.get('projectUuid')).toBeUndefined(); + expect(consola.info).toHaveBeenCalledWith( + 'Removed project UUID from .bigcommerce/project.json.', + ); + }); + + test('preserves linked projectUuid when a different project is deleted', async () => { + config.set('projectUuid', projectUuid2); + config.set('storeHash', storeHash); + config.set('accessToken', accessToken); + + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'delete', + '--project-uuid', + projectUuid1, + '--force', + ]); + + expect(config.get('projectUuid')).toBe(projectUuid2); + expect(consola.info).not.toHaveBeenCalledWith( + 'Removed project UUID from .bigcommerce/project.json.', + ); + }); + + test('with insufficient credentials exits with error', async () => { + const savedStoreHash = process.env.CATALYST_STORE_HASH; + const savedAccessToken = process.env.CATALYST_ACCESS_TOKEN; + + delete process.env.CATALYST_STORE_HASH; + delete process.env.CATALYST_ACCESS_TOKEN; + + await expect(program.parseAsync(['node', 'catalyst', 'project', 'delete'])).rejects.toThrow( + 'Missing credentials', + ); + + if (savedStoreHash !== undefined) process.env.CATALYST_STORE_HASH = savedStoreHash; + if (savedAccessToken !== undefined) process.env.CATALYST_ACCESS_TOKEN = savedAccessToken; + + expect(consola.error).toHaveBeenCalledWith('Missing credentials.'); + expect(exitMock).toHaveBeenCalledWith(1); + }); + + test('throws when API returns 404', async () => { + server.use( + http.delete( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid', + () => HttpResponse.json({}, { status: 404 }), + ), + ); + + await expect( + program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'delete', + '--project-uuid', + projectUuid1, + '--force', + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]), + ).rejects.toThrow(`Project ${projectUuid1} not found.`); + }); + + test('throws when API returns 403', async () => { + server.use( + http.delete( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid', + () => HttpResponse.json({}, { status: 403 }), + ), + ); + + await expect( + program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'delete', + '--project-uuid', + projectUuid1, + '--force', + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]), + ).rejects.toThrow( + 'Infrastructure Projects API not enabled. If you are part of the alpha, contact support@bigcommerce.com to enable it.', + ); + }); +}); diff --git a/packages/catalyst/src/cli/commands/project.ts b/packages/catalyst/src/cli/commands/project.ts index d016494610..2df93cd37b 100644 --- a/packages/catalyst/src/cli/commands/project.ts +++ b/packages/catalyst/src/cli/commands/project.ts @@ -9,7 +9,7 @@ import { } from '../lib/commerce-hosting'; import { installDependencies } from '../lib/install-dependencies'; import { consola } from '../lib/logger'; -import { createProject, fetchProjects } from '../lib/project'; +import { createProject, deleteProject, fetchProjects } from '../lib/project'; import { getProjectConfig } from '../lib/project-config'; import { getProjectState } from '../lib/project-state'; import { resolveCredentials } from '../lib/resolve-credentials'; @@ -206,9 +206,124 @@ Examples: process.exit(0); }); +const del = new Command('delete') + .configureHelp({ showGlobalOptions: true }) + .description( + 'Permanently delete a BigCommerce infrastructure project. This action is irreversible.', + ) + .addHelpText( + 'after', + ` +Examples: + # Select a project to delete interactively + $ catalyst project delete + + # Delete a specific project (still prompts for confirmation) + $ catalyst project delete --project-uuid + + # Skip the confirmation prompt + $ catalyst project delete --project-uuid --force`, + ) + .addOption(storeHashOption()) + .addOption(accessTokenOption()) + .addOption(apiHostOption()) + .addOption(projectUuidOption()) + .option('--force', 'Skip the confirmation prompt before deleting.') + .action(async (options) => { + const config = getProjectConfig(); + const { storeHash, accessToken } = resolveCredentials(options, config); + + await getTelemetry().identify(storeHash); + + let targetUuid: string | undefined = options.projectUuid; + let targetName: string | undefined; + + if (!targetUuid) { + consola.start('Fetching projects...'); + + const projects = await fetchProjects(storeHash, accessToken, options.apiHost); + + consola.success('Projects fetched.'); + + if (projects.length === 0) { + consola.info('No projects found.'); + process.exit(0); + + return; + } + + const linkedProjectUuid = config.get('projectUuid'); + + const selected = await consola.prompt( + 'Select a project to delete (Press to select).', + { + type: 'select', + options: [ + ...projects.map((p) => ({ + label: + p.uuid === linkedProjectUuid + ? `${p.name} ${colorize('green', '[linked]')}` + : p.name, + value: p.uuid, + hint: p.uuid, + })), + { label: 'Cancel', value: 'cancel', hint: 'Exit without deleting any project.' }, + ], + cancel: 'reject', + }, + ); + + if (selected === 'cancel') { + consola.info('Aborted. No project was deleted.'); + process.exit(0); + + return; + } + + const matched = projects.find((p) => p.uuid === selected); + + if (!matched) { + throw new Error(`Selected project ${String(selected)} not found in fetched list.`); + } + + targetUuid = matched.uuid; + targetName = matched.name; + } + + if (!options.force) { + const label = targetName ? `"${targetName}" (${targetUuid})` : targetUuid; + + const confirmed = await consola.prompt( + `Are you sure you want to delete project ${label}? This action is irreversible and will permanently destroy the project and all of its data.`, + { type: 'confirm', initial: false }, + ); + + if (!confirmed) { + consola.info('Aborted. No project was deleted.'); + process.exit(0); + + return; + } + } + + consola.start(`Deleting project ${targetUuid}...`); + + await deleteProject(targetUuid, storeHash, accessToken, options.apiHost); + + consola.success(`Project ${targetUuid} deleted.`); + + if (config.get('projectUuid') === targetUuid) { + config.delete('projectUuid'); + consola.info('Removed project UUID from .bigcommerce/project.json.'); + } + + process.exit(0); + }); + export const project = new Command('project') .configureHelp({ showGlobalOptions: true }) .description('Manage your BigCommerce infrastructure project.') .addCommand(create) .addCommand(list) - .addCommand(link); + .addCommand(link) + .addCommand(del); diff --git a/packages/catalyst/src/cli/lib/project.ts b/packages/catalyst/src/cli/lib/project.ts index 32d6f1dd5c..52e99c37bc 100644 --- a/packages/catalyst/src/cli/lib/project.ts +++ b/packages/catalyst/src/cli/lib/project.ts @@ -163,3 +163,29 @@ export async function createProject( return data; } + +export async function deleteProject( + projectUuid: string, + storeHash: string, + accessToken: string, + apiHost: string, +): Promise { + const response = await fetch(`${projectsUrl(storeHash, apiHost)}/${projectUuid}`, { + method: 'DELETE', + headers: authHeaders(accessToken), + }); + + if (response.status === 403) { + throw new Error( + 'Infrastructure Projects API not enabled. If you are part of the alpha, contact support@bigcommerce.com to enable it.', + ); + } + + if (response.status === 404) { + throw new Error(`Project ${projectUuid} not found.`); + } + + if (!response.ok) { + throw new Error(`Failed to delete project: ${response.statusText}`); + } +} diff --git a/packages/catalyst/tests/mocks/handlers.ts b/packages/catalyst/tests/mocks/handlers.ts index c16ea054db..72c4004939 100644 --- a/packages/catalyst/tests/mocks/handlers.ts +++ b/packages/catalyst/tests/mocks/handlers.ts @@ -138,4 +138,10 @@ export const handlers = [ }, }), ), + + // Handler for deleteProject + http.delete( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid', + () => new HttpResponse(null, { status: 204 }), + ), ];