diff --git a/packages/cli/src/commands/deploy.ts b/packages/cli/src/commands/deploy.ts index a16629a53f..f7693ca586 100644 --- a/packages/cli/src/commands/deploy.ts +++ b/packages/cli/src/commands/deploy.ts @@ -284,7 +284,7 @@ export const deploy = new Command('deploy') .addOption( new Option( '--project-uuid ', - 'BigCommerce headless project UUID. Can be found via the BigCommerce API (GET /v3/infrastructure/projects).', + 'BigCommerce intrastructure project UUID. Can be found via the BigCommerce API (GET /v3/infrastructure/projects).', ).env('BIGCOMMERCE_PROJECT_UUID'), ) .option( diff --git a/packages/cli/src/commands/link.ts b/packages/cli/src/commands/link.ts index 5706c01820..86ee35f508 100644 --- a/packages/cli/src/commands/link.ts +++ b/packages/cli/src/commands/link.ts @@ -16,6 +16,15 @@ const fetchProjectsSchema = z.object({ ), }); +const createProjectSchema = z.object({ + data: z.object({ + uuid: z.string(), + name: z.string(), + date_created: z.coerce.date(), + date_modified: z.coerce.date(), + }), +}); + async function fetchProjects(storeHash: string, accessToken: string, apiHost: string) { const response = await fetch( `https://${apiHost}/stores/${storeHash}/v3/infrastructure/projects`, @@ -27,9 +36,9 @@ async function fetchProjects(storeHash: string, accessToken: string, apiHost: st }, ); - if (response.status === 404) { + if (response.status === 403) { throw new Error( - 'Headless Projects API not enabled. If you are part of the alpha, contact support@bigcommerce.com to enable it.', + 'Infrastructure Projects API not enabled. If you are part of the alpha, contact support@bigcommerce.com to enable it.', ); } @@ -44,9 +53,49 @@ async function fetchProjects(storeHash: string, accessToken: string, apiHost: st return data; } +async function createProject( + name: string, + storeHash: string, + accessToken: string, + apiHost: string, +) { + const response = await fetch( + `https://${apiHost}/stores/${storeHash}/v3/infrastructure/projects`, + { + method: 'POST', + headers: { + 'X-Auth-Token': accessToken, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name }), + }, + ); + + if (response.status === 502) { + throw new Error('Failed to create project, is the name already in use?'); + } + + 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.ok) { + throw new Error(`Failed to create project: ${response.statusText}`); + } + + const res: unknown = await response.json(); + + const { data } = createProjectSchema.parse(res); + + return data; +} + export const link = new Command('link') .description( - 'Link your local Catalyst project to a BigCommerce headless project. You can provide a project UUID directly, or fetch and select from available projects using your store credentials.', + 'Link your local Catalyst project to a BigCommerce infrastructure project. You can provide a project UUID directly, or fetch and select from available projects using your store credentials.', ) .addOption( new Option( @@ -67,7 +116,7 @@ export const link = new Command('link') ) .option( '--project-uuid ', - 'BigCommerce headless project UUID. Can be found via the BigCommerce API (GET /v3/infrastructure/projects). Use this to link directly without fetching projects.', + 'BigCommerce infrastructure project UUID. Can be found via the BigCommerce API (GET /v3/infrastructure/projects). Use this to link directly without fetching projects.', ) .option( '--root-dir ', @@ -104,19 +153,44 @@ export const link = new Command('link') consola.success('Projects fetched.'); - if (!projects.length) { - throw new Error('No headless projects found for this store.'); - } - - const projectUuid = await consola.prompt('Select a project (Press to select).', { - type: 'select', - options: projects.map((project) => ({ + const promptOptions = [ + ...projects.map((project) => ({ label: project.name, value: project.uuid, hint: project.uuid, })), - cancel: 'reject', - }); + { + label: 'Create a new project', + value: 'create', + hint: 'Create a new infrastructure project for this BigCommerce store.', + }, + ]; + + let projectUuid = await consola.prompt( + 'Select a project or create a new project (Press to select).', + { + type: 'select', + options: promptOptions, + cancel: 'reject', + }, + ); + + if (projectUuid === 'create') { + const newProjectName = await consola.prompt('Enter a name for the new project:', { + type: 'text', + }); + + const data = await createProject( + newProjectName, + options.storeHash, + options.accessToken, + options.apiHost, + ); + + projectUuid = data.uuid; + + consola.success(`Project "${data.name}" created successfully.`); + } writeProjectConfig(projectUuid); diff --git a/packages/cli/tests/commands/link.spec.ts b/packages/cli/tests/commands/link.spec.ts index fec49e6c48..0f6d4169d2 100644 --- a/packages/cli/tests/commands/link.spec.ts +++ b/packages/cli/tests/commands/link.spec.ts @@ -22,6 +22,7 @@ const { mockIdentify } = vi.hoisted(() => ({ const projectUuid1 = 'a23f5785-fd99-4a94-9fb3-945551623923'; const projectUuid2 = 'b23f5785-fd99-4a94-9fb3-945551623924'; +const projectUuid3 = 'c23f5785-fd99-4a94-9fb3-945551623925'; const storeHash = 'test-store'; const accessToken = 'test-token'; @@ -66,7 +67,7 @@ test('properly configured Command instance', () => { expect(link).toBeInstanceOf(Command); expect(link.name()).toBe('link'); expect(link.description()).toBe( - 'Link your local Catalyst project to a BigCommerce headless project. You can provide a project UUID directly, or fetch and select from available projects using your store credentials.', + 'Link your local Catalyst project to a BigCommerce infrastructure project. You can provide a project UUID directly, or fetch and select from available projects using your store credentials.', ); expect(link.options).toEqual( expect.arrayContaining([ @@ -106,17 +107,20 @@ test('fetches projects and prompts user to select one', async () => { .spyOn(consola, 'prompt') .mockImplementation(async (message, opts) => { // Assert the prompt message and options - expect(message).toContain('Select a project (Press to select).'); + expect(message).toContain( + 'Select a project or create a new project (Press to select).', + ); // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const options = (opts as { options: Array<{ label: string; value: string }> }).options; - expect(options).toHaveLength(2); + 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: 'Create a new project', value: 'create' }); // Simulate selecting the second option return new Promise((resolve) => resolve(projectUuid2)); @@ -154,15 +158,97 @@ test('fetches projects and prompts user to select one', async () => { consolaPromptMock.mockRestore(); }); -test('errors when no projects are found', async () => { +test('prompts to create a new project', async () => { + const consolaPromptMock = vi + .spyOn(consola, 'prompt') + .mockImplementationOnce(async (message, opts) => { + // Assert the prompt message and options + expect(message).toContain( + 'Select a project or create a new project (Press to select).', + ); + + // 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: 'Create a new project', value: 'create' }); + + // Simulate selecting the create option + return new Promise((resolve) => resolve('create')); + }) + .mockImplementationOnce(async (message) => { + expect(message).toBe('Enter a name for the new project:'); + + return new Promise((resolve) => resolve('New Project')); + }); + + await program.parseAsync([ + 'node', + 'catalyst', + 'link', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--root-dir', + tmpDir, + ]); + + expect(mockIdentify).toHaveBeenCalledWith(storeHash); + + expect(consola.start).toHaveBeenCalledWith('Fetching projects...'); + expect(consola.success).toHaveBeenCalledWith('Projects fetched.'); + + expect(consola.success).toHaveBeenCalledWith('Project "New Project" created successfully.'); + + expect(exitMock).toHaveBeenCalledWith(0); + + expect(config.get('projectUuid')).toBe(projectUuid3); + expect(config.get('framework')).toBe('catalyst'); + + consolaPromptMock.mockRestore(); +}); + +test('prompts to create a new project', async () => { server.use( - http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => - HttpResponse.json({ - data: [], - }), + http.post('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => + HttpResponse.json({}, { status: 502 }), ), ); + const consolaPromptMock = vi + .spyOn(consola, 'prompt') + .mockImplementationOnce(async (message, opts) => { + // Assert the prompt message and options + expect(message).toContain( + 'Select a project or create a new project (Press to select).', + ); + + // 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: 'Create a new project', value: 'create' }); + + // Simulate selecting the create option + return new Promise((resolve) => resolve('create')); + }) + .mockImplementationOnce(async (message) => { + expect(message).toBe('Enter a name for the new project:'); + + return new Promise((resolve) => resolve('New Project')); + }); + await program.parseAsync([ 'node', 'catalyst', @@ -178,16 +264,24 @@ test('errors when no projects are found', async () => { expect(mockIdentify).toHaveBeenCalledWith(storeHash); expect(consola.start).toHaveBeenCalledWith('Fetching projects...'); - expect(consola.error).toHaveBeenCalledWith('No headless projects found for this store.'); + expect(consola.success).toHaveBeenCalledWith('Projects fetched.'); + + expect(consola.error).toHaveBeenCalledWith( + 'Failed to create project, is the name already in use?', + ); + expect(exitMock).toHaveBeenCalledWith(1); + + consolaPromptMock.mockRestore(); }); -test('errors when headless projects API is not found', async () => { +test('errors when infrastructure projects API is not found', async () => { server.use( http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => - HttpResponse.json({}, { status: 404 }), + HttpResponse.json({}, { status: 403 }), ), ); + await program.parseAsync([ 'node', 'catalyst', @@ -204,7 +298,7 @@ test('errors when headless projects API is not found', async () => { expect(consola.start).toHaveBeenCalledWith('Fetching projects...'); expect(consola.error).toHaveBeenCalledWith( - 'Headless Projects API not enabled. If you are part of the alpha, contact support@bigcommerce.com to enable it.', + 'Infrastructure Projects API not enabled. If you are part of the alpha, contact support@bigcommerce.com to enable it.', ); }); diff --git a/packages/cli/tests/mocks/handlers.ts b/packages/cli/tests/mocks/handlers.ts index 4863fa2e7b..a2a46c3b42 100644 --- a/packages/cli/tests/mocks/handlers.ts +++ b/packages/cli/tests/mocks/handlers.ts @@ -73,4 +73,16 @@ export const handlers = [ }); }, ), + + // Handle for createProjects + http.post('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => + HttpResponse.json({ + data: { + uuid: 'c23f5785-fd99-4a94-9fb3-945551623925', + name: 'New Project', + date_created: new Date().toISOString(), + date_modified: new Date().toISOString(), + }, + }), + ), ];