Skip to content

Commit a96911f

Browse files
committed
TRAC-614: Add project delete command to catalyst CLI
1 parent f54b012 commit a96911f

4 files changed

Lines changed: 507 additions & 3 deletions

File tree

packages/catalyst/src/cli/commands/project.spec.ts

Lines changed: 358 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ afterAll(async () => {
131131
});
132132

133133
describe('project', () => {
134-
test('has create, link, and list subcommands', () => {
134+
test('has create, link, list, and delete subcommands', () => {
135135
expect(project).toBeInstanceOf(Command);
136136
expect(project.name()).toBe('project');
137137
expect(project.description()).toBe('Manage your BigCommerce infrastructure project.');
@@ -152,6 +152,19 @@ describe('project', () => {
152152

153153
expect(listCmd).toBeDefined();
154154
expect(listCmd?.description()).toContain('List BigCommerce infrastructure projects');
155+
156+
const deleteCmd = project.commands.find((cmd) => cmd.name() === 'delete');
157+
158+
expect(deleteCmd).toBeDefined();
159+
expect(deleteCmd?.description()).toContain(
160+
'Permanently delete a BigCommerce infrastructure project',
161+
);
162+
expect(deleteCmd?.options).toEqual(
163+
expect.arrayContaining([
164+
expect.objectContaining({ flags: '--project-uuid <uuid>' }),
165+
expect.objectContaining({ flags: '--force' }),
166+
]),
167+
);
155168
});
156169
});
157170

@@ -786,3 +799,347 @@ describe('project link', () => {
786799
expect(exitMock).toHaveBeenCalledWith(1);
787800
});
788801
});
802+
803+
describe('project delete', () => {
804+
test('with --project-uuid and --force deletes without prompting', async () => {
805+
const consolaPromptMock = vi.spyOn(consola, 'prompt');
806+
807+
await program.parseAsync([
808+
'node',
809+
'catalyst',
810+
'project',
811+
'delete',
812+
'--project-uuid',
813+
projectUuid1,
814+
'--force',
815+
'--store-hash',
816+
storeHash,
817+
'--access-token',
818+
accessToken,
819+
]);
820+
821+
expect(consolaPromptMock).not.toHaveBeenCalled();
822+
expect(mockIdentify).toHaveBeenCalledWith(storeHash);
823+
expect(consola.start).toHaveBeenCalledWith(`Deleting project ${projectUuid1}...`);
824+
expect(consola.success).toHaveBeenCalledWith(`Project ${projectUuid1} deleted.`);
825+
expect(exitMock).toHaveBeenCalledWith(0);
826+
827+
consolaPromptMock.mockRestore();
828+
});
829+
830+
test('with --project-uuid prompts for confirmation and deletes on accept', async () => {
831+
const consolaPromptMock = vi
832+
.spyOn(consola, 'prompt')
833+
.mockImplementation(async (message, opts) => {
834+
expect(message).toContain('Are you sure you want to delete project');
835+
expect(message).toContain(projectUuid1);
836+
expect(message).toContain('irreversible');
837+
expect(opts).toMatchObject({ type: 'confirm' });
838+
839+
return Promise.resolve(true);
840+
});
841+
842+
await program.parseAsync([
843+
'node',
844+
'catalyst',
845+
'project',
846+
'delete',
847+
'--project-uuid',
848+
projectUuid1,
849+
'--store-hash',
850+
storeHash,
851+
'--access-token',
852+
accessToken,
853+
]);
854+
855+
expect(consolaPromptMock).toHaveBeenCalledTimes(1);
856+
expect(consola.success).toHaveBeenCalledWith(`Project ${projectUuid1} deleted.`);
857+
expect(exitMock).toHaveBeenCalledWith(0);
858+
859+
consolaPromptMock.mockRestore();
860+
});
861+
862+
test('aborts when user declines the confirmation prompt', async () => {
863+
let deleteRequested = false;
864+
865+
server.use(
866+
http.delete(
867+
'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid',
868+
() => {
869+
deleteRequested = true;
870+
871+
return new HttpResponse(null, { status: 204 });
872+
},
873+
),
874+
);
875+
876+
const consolaPromptMock = vi
877+
.spyOn(consola, 'prompt')
878+
.mockImplementation(async () => Promise.resolve(false));
879+
880+
await program.parseAsync([
881+
'node',
882+
'catalyst',
883+
'project',
884+
'delete',
885+
'--project-uuid',
886+
projectUuid1,
887+
'--store-hash',
888+
storeHash,
889+
'--access-token',
890+
accessToken,
891+
]);
892+
893+
expect(deleteRequested).toBe(false);
894+
expect(consola.info).toHaveBeenCalledWith('Aborted. No project was deleted.');
895+
expect(exitMock).toHaveBeenCalledWith(0);
896+
897+
consolaPromptMock.mockRestore();
898+
});
899+
900+
test('without --project-uuid fetches projects and prompts to select one', async () => {
901+
const consolaPromptMock = vi
902+
.spyOn(consola, 'prompt')
903+
.mockImplementationOnce(async (message, opts) => {
904+
expect(message).toContain('Select a project to delete');
905+
906+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
907+
const options = (opts as { options: Array<{ label: string; value: string }> }).options;
908+
909+
expect(options).toHaveLength(3);
910+
expect(options[0]).toMatchObject({ label: 'Project One', value: projectUuid1 });
911+
expect(options[1]).toMatchObject({ label: 'Project Two', value: projectUuid2 });
912+
expect(options[2]).toMatchObject({ label: 'Cancel', value: 'cancel' });
913+
914+
return Promise.resolve(projectUuid2);
915+
})
916+
.mockImplementationOnce(async (message) => {
917+
expect(message).toContain('"Project Two"');
918+
expect(message).toContain(projectUuid2);
919+
920+
return Promise.resolve(true);
921+
});
922+
923+
await program.parseAsync([
924+
'node',
925+
'catalyst',
926+
'project',
927+
'delete',
928+
'--store-hash',
929+
storeHash,
930+
'--access-token',
931+
accessToken,
932+
]);
933+
934+
expect(consola.start).toHaveBeenCalledWith('Fetching projects...');
935+
expect(consola.success).toHaveBeenCalledWith('Projects fetched.');
936+
expect(consola.success).toHaveBeenCalledWith(`Project ${projectUuid2} deleted.`);
937+
expect(exitMock).toHaveBeenCalledWith(0);
938+
939+
consolaPromptMock.mockRestore();
940+
});
941+
942+
test('marks the currently linked project with [linked] in the select prompt', async () => {
943+
config.set('projectUuid', projectUuid2);
944+
945+
const consolaPromptMock = vi
946+
.spyOn(consola, 'prompt')
947+
.mockImplementationOnce(async (_message, opts) => {
948+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
949+
const options = (opts as { options: Array<{ label: string; value: string }> }).options;
950+
951+
const linkedOption = options.find((o) => o.value === projectUuid2);
952+
const otherOption = options.find((o) => o.value === projectUuid1);
953+
954+
expect(linkedOption?.label).toContain('[linked]');
955+
expect(otherOption?.label).not.toContain('[linked]');
956+
957+
return Promise.resolve(projectUuid1);
958+
})
959+
.mockImplementationOnce(async () => Promise.resolve(true));
960+
961+
await program.parseAsync([
962+
'node',
963+
'catalyst',
964+
'project',
965+
'delete',
966+
'--store-hash',
967+
storeHash,
968+
'--access-token',
969+
accessToken,
970+
]);
971+
972+
consolaPromptMock.mockRestore();
973+
});
974+
975+
test('aborts when user selects Cancel from the project list', async () => {
976+
let deleteRequested = false;
977+
978+
server.use(
979+
http.delete(
980+
'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid',
981+
() => {
982+
deleteRequested = true;
983+
984+
return new HttpResponse(null, { status: 204 });
985+
},
986+
),
987+
);
988+
989+
const consolaPromptMock = vi
990+
.spyOn(consola, 'prompt')
991+
.mockImplementation(async () => Promise.resolve('cancel'));
992+
993+
await program.parseAsync([
994+
'node',
995+
'catalyst',
996+
'project',
997+
'delete',
998+
'--store-hash',
999+
storeHash,
1000+
'--access-token',
1001+
accessToken,
1002+
]);
1003+
1004+
expect(consolaPromptMock).toHaveBeenCalledTimes(1);
1005+
expect(deleteRequested).toBe(false);
1006+
expect(consola.info).toHaveBeenCalledWith('Aborted. No project was deleted.');
1007+
expect(exitMock).toHaveBeenCalledWith(0);
1008+
1009+
consolaPromptMock.mockRestore();
1010+
});
1011+
1012+
test('exits cleanly when there are no projects to delete', async () => {
1013+
server.use(
1014+
http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () =>
1015+
HttpResponse.json({ data: [] }),
1016+
),
1017+
);
1018+
1019+
await program.parseAsync([
1020+
'node',
1021+
'catalyst',
1022+
'project',
1023+
'delete',
1024+
'--store-hash',
1025+
storeHash,
1026+
'--access-token',
1027+
accessToken,
1028+
]);
1029+
1030+
expect(consola.info).toHaveBeenCalledWith('No projects found.');
1031+
expect(exitMock).toHaveBeenCalledWith(0);
1032+
});
1033+
1034+
test('clears linked projectUuid from config when the deleted project was linked', async () => {
1035+
config.set('projectUuid', projectUuid1);
1036+
config.set('storeHash', storeHash);
1037+
config.set('accessToken', accessToken);
1038+
1039+
await program.parseAsync([
1040+
'node',
1041+
'catalyst',
1042+
'project',
1043+
'delete',
1044+
'--project-uuid',
1045+
projectUuid1,
1046+
'--force',
1047+
]);
1048+
1049+
expect(config.get('projectUuid')).toBeUndefined();
1050+
expect(consola.info).toHaveBeenCalledWith(
1051+
'Removed project UUID from .bigcommerce/project.json.',
1052+
);
1053+
});
1054+
1055+
test('preserves linked projectUuid when a different project is deleted', async () => {
1056+
config.set('projectUuid', projectUuid2);
1057+
config.set('storeHash', storeHash);
1058+
config.set('accessToken', accessToken);
1059+
1060+
await program.parseAsync([
1061+
'node',
1062+
'catalyst',
1063+
'project',
1064+
'delete',
1065+
'--project-uuid',
1066+
projectUuid1,
1067+
'--force',
1068+
]);
1069+
1070+
expect(config.get('projectUuid')).toBe(projectUuid2);
1071+
expect(consola.info).not.toHaveBeenCalledWith(
1072+
'Removed project UUID from .bigcommerce/project.json.',
1073+
);
1074+
});
1075+
1076+
test('with insufficient credentials exits with error', async () => {
1077+
const savedStoreHash = process.env.CATALYST_STORE_HASH;
1078+
const savedAccessToken = process.env.CATALYST_ACCESS_TOKEN;
1079+
1080+
delete process.env.CATALYST_STORE_HASH;
1081+
delete process.env.CATALYST_ACCESS_TOKEN;
1082+
1083+
await expect(program.parseAsync(['node', 'catalyst', 'project', 'delete'])).rejects.toThrow(
1084+
'Missing credentials',
1085+
);
1086+
1087+
if (savedStoreHash !== undefined) process.env.CATALYST_STORE_HASH = savedStoreHash;
1088+
if (savedAccessToken !== undefined) process.env.CATALYST_ACCESS_TOKEN = savedAccessToken;
1089+
1090+
expect(consola.error).toHaveBeenCalledWith('Missing credentials.');
1091+
expect(exitMock).toHaveBeenCalledWith(1);
1092+
});
1093+
1094+
test('throws when API returns 404', async () => {
1095+
server.use(
1096+
http.delete(
1097+
'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid',
1098+
() => HttpResponse.json({}, { status: 404 }),
1099+
),
1100+
);
1101+
1102+
await expect(
1103+
program.parseAsync([
1104+
'node',
1105+
'catalyst',
1106+
'project',
1107+
'delete',
1108+
'--project-uuid',
1109+
projectUuid1,
1110+
'--force',
1111+
'--store-hash',
1112+
storeHash,
1113+
'--access-token',
1114+
accessToken,
1115+
]),
1116+
).rejects.toThrow(`Project ${projectUuid1} not found.`);
1117+
});
1118+
1119+
test('throws when API returns 403', async () => {
1120+
server.use(
1121+
http.delete(
1122+
'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid',
1123+
() => HttpResponse.json({}, { status: 403 }),
1124+
),
1125+
);
1126+
1127+
await expect(
1128+
program.parseAsync([
1129+
'node',
1130+
'catalyst',
1131+
'project',
1132+
'delete',
1133+
'--project-uuid',
1134+
projectUuid1,
1135+
'--force',
1136+
'--store-hash',
1137+
storeHash,
1138+
'--access-token',
1139+
accessToken,
1140+
]),
1141+
).rejects.toThrow(
1142+
'Infrastructure Projects API not enabled. If you are part of the alpha, contact support@bigcommerce.com to enable it.',
1143+
);
1144+
});
1145+
});

0 commit comments

Comments
 (0)