@@ -131,7 +131,7 @@ afterAll(async () => {
131131} ) ;
132132
133133describe ( '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