Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/cli/src/commands/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ export const deploy = new Command('deploy')
.addOption(
new Option(
'--project-uuid <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(
Expand Down
100 changes: 87 additions & 13 deletions packages/cli/src/commands/link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand All @@ -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.',
);
}

Expand All @@ -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(
Expand All @@ -67,7 +116,7 @@ export const link = new Command('link')
)
.option(
'--project-uuid <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 <path>',
Expand Down Expand Up @@ -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 <enter> 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 <enter> 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);

Expand Down
118 changes: 106 additions & 12 deletions packages/cli/tests/commands/link.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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([
Expand Down Expand Up @@ -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 <enter> to select).');
expect(message).toContain(
'Select a project or create a new project (Press <enter> 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));
Expand Down Expand Up @@ -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 <enter> 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 <enter> 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',
Expand All @@ -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',
Expand All @@ -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.',
);
});

Expand Down
12 changes: 12 additions & 0 deletions packages/cli/tests/mocks/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
},
}),
),
];
Loading