Skip to content

Commit a4ee0b7

Browse files
authored
feat(cli): add link command (#2503)
* feat(cli): add link command * feat: add project framework when users succesfully links * chore: update root-dir * fix: update tests * fix: throw error correctly * feat: add test for no projects * fix: show message when user is not opt in to alpha * fix: use consola.mockTypes * fix: add link to program test
1 parent 59f0d15 commit a4ee0b7

8 files changed

Lines changed: 519 additions & 51 deletions

File tree

packages/cli/src/commands/link.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { Command, Option } from 'commander';
2+
import consola from 'consola';
3+
import z from 'zod';
4+
5+
import { ProjectConfig } from '../lib/project-config';
6+
7+
const fetchProjectsSchema = z.object({
8+
data: z.array(
9+
z.object({
10+
uuid: z.string(),
11+
name: z.string(),
12+
}),
13+
),
14+
});
15+
16+
async function fetchProjects(storeHash: string, accessToken: string, apiHost: string) {
17+
const response = await fetch(`https://${apiHost}/stores/${storeHash}/v3/headless/projects`, {
18+
method: 'GET',
19+
headers: {
20+
'X-Auth-Token': accessToken,
21+
},
22+
});
23+
24+
if (response.status === 404) {
25+
throw new Error(
26+
'Headless Projects API not enabled. If you are part of the alpha, contact support@bigcommerce.com to enable it.',
27+
);
28+
}
29+
30+
if (!response.ok) {
31+
throw new Error(`Failed to fetch projects: ${response.statusText}`);
32+
}
33+
34+
const res: unknown = await response.json();
35+
36+
const { data } = fetchProjectsSchema.parse(res);
37+
38+
return data;
39+
}
40+
41+
export const link = new Command('link')
42+
.description(
43+
'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.',
44+
)
45+
.addOption(
46+
new Option(
47+
'--store-hash <hash>',
48+
'BigCommerce store hash. Can be found in the URL of your store Control Panel.',
49+
).env('BIGCOMMERCE_STORE_HASH'),
50+
)
51+
.addOption(
52+
new Option(
53+
'--access-token <token>',
54+
'BigCommerce access token. Can be found after creating a store-level API account.',
55+
).env('BIGCOMMERCE_ACCESS_TOKEN'),
56+
)
57+
.addOption(
58+
new Option('--api-host <host>', 'BigCommerce API host. The default is api.bigcommerce.com.')
59+
.env('BIGCOMMERCE_API_HOST')
60+
.default('api.bigcommerce.com'),
61+
)
62+
.option(
63+
'--project-uuid <uuid>',
64+
'BigCommerce headless project UUID. Can be found via the BigCommerce API (GET /v3/headless/projects). Use this to link directly without fetching projects.',
65+
)
66+
.option(
67+
'--root-dir <path>',
68+
'Path to the root directory of your Catalyst project (default: current working directory).',
69+
process.cwd(),
70+
)
71+
.action(async (options) => {
72+
try {
73+
const config = new ProjectConfig(options.rootDir);
74+
75+
const writeProjectConfig = (uuid: string) => {
76+
consola.start('Writing project UUID to .bigcommerce/project.json...');
77+
config.set('projectUuid', uuid);
78+
config.set('framework', 'catalyst');
79+
consola.success('Project UUID written to .bigcommerce/project.json.');
80+
};
81+
82+
if (options.projectUuid) {
83+
writeProjectConfig(options.projectUuid);
84+
85+
process.exit(0);
86+
}
87+
88+
if (options.storeHash && options.accessToken) {
89+
consola.start('Fetching projects...');
90+
91+
const projects = await fetchProjects(
92+
options.storeHash,
93+
options.accessToken,
94+
options.apiHost,
95+
);
96+
97+
consola.success('Projects fetched.');
98+
99+
if (!projects.length) {
100+
throw new Error('No headless projects found for this store.');
101+
}
102+
103+
const projectUuid = await consola.prompt('Select a project (Press <enter> to select).', {
104+
type: 'select',
105+
options: projects.map((project) => ({
106+
label: project.name,
107+
value: project.uuid,
108+
hint: project.uuid,
109+
})),
110+
cancel: 'reject',
111+
});
112+
113+
writeProjectConfig(projectUuid);
114+
115+
process.exit(0);
116+
}
117+
118+
consola.error('Insufficient information to link a project.');
119+
consola.info('Provide a project UUID with --project-uuid, or');
120+
consola.info('Provide both --store-hash and --access-token to fetch and select a project.');
121+
process.exit(1);
122+
} catch (error) {
123+
consola.error(error instanceof Error ? error.message : error);
124+
process.exit(1);
125+
}
126+
});

packages/cli/src/lib/project-config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { join } from 'path';
33

44
export interface ProjectConfigSchema {
55
projectUuid: string;
6+
framework: 'catalyst' | 'nextjs';
67
}
78

89
export class ProjectConfig {
@@ -15,6 +16,11 @@ export class ProjectConfig {
1516
configName: 'project',
1617
schema: {
1718
projectUuid: { type: 'string', format: 'uuid' },
19+
framework: {
20+
type: 'string',
21+
enum: ['catalyst', 'nextjs'],
22+
default: 'catalyst',
23+
},
1824
},
1925
});
2026
}

packages/cli/src/program.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import PACKAGE_INFO from '../package.json';
77
import { build } from './commands/build';
88
import { deploy } from './commands/deploy';
99
import { dev } from './commands/dev';
10+
import { link } from './commands/link';
1011
import { start } from './commands/start';
1112
import { version } from './commands/version';
1213

@@ -18,8 +19,9 @@ program
1819
.name(PACKAGE_INFO.name)
1920
.version(PACKAGE_INFO.version)
2021
.description('CLI tool for Catalyst development')
22+
.addCommand(version)
23+
.addCommand(dev)
24+
.addCommand(start)
2125
.addCommand(build)
2226
.addCommand(deploy)
23-
.addCommand(dev)
24-
.addCommand(version)
25-
.addCommand(start);
27+
.addCommand(link);
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import { Command } from 'commander';
2+
import consola from 'consola';
3+
import { http, HttpResponse } from 'msw';
4+
import { afterAll, afterEach, beforeAll, expect, MockInstance, test, vi } from 'vitest';
5+
6+
import { link } from '../../src/commands/link';
7+
import { mkTempDir } from '../../src/lib/mk-temp-dir';
8+
import { ProjectConfig } from '../../src/lib/project-config';
9+
import { program } from '../../src/program';
10+
import { server } from '../mocks/node';
11+
12+
let exitMock: MockInstance;
13+
14+
let tmpDir: string;
15+
let cleanup: () => Promise<void>;
16+
let config: ProjectConfig;
17+
18+
const projectUuid1 = 'a23f5785-fd99-4a94-9fb3-945551623923';
19+
const projectUuid2 = 'b23f5785-fd99-4a94-9fb3-945551623924';
20+
const storeHash = 'test-store';
21+
const accessToken = 'test-token';
22+
23+
beforeAll(async () => {
24+
consola.mockTypes(() => vi.fn());
25+
26+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
27+
exitMock = vi.spyOn(process, 'exit').mockImplementation(() => null as never);
28+
29+
[tmpDir, cleanup] = await mkTempDir();
30+
31+
config = new ProjectConfig(tmpDir);
32+
});
33+
34+
afterEach(() => {
35+
vi.clearAllMocks();
36+
});
37+
38+
afterAll(async () => {
39+
vi.restoreAllMocks();
40+
exitMock.mockRestore();
41+
42+
await cleanup();
43+
});
44+
45+
test('properly configured Command instance', () => {
46+
expect(link).toBeInstanceOf(Command);
47+
expect(link.name()).toBe('link');
48+
expect(link.description()).toBe(
49+
'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.',
50+
);
51+
expect(link.options).toEqual(
52+
expect.arrayContaining([
53+
expect.objectContaining({ flags: '--store-hash <hash>' }),
54+
expect.objectContaining({ flags: '--access-token <token>' }),
55+
expect.objectContaining({ flags: '--api-host <host>', defaultValue: 'api.bigcommerce.com' }),
56+
expect.objectContaining({ flags: '--project-uuid <uuid>' }),
57+
expect.objectContaining({ flags: '--root-dir <path>', defaultValue: process.cwd() }),
58+
]),
59+
);
60+
});
61+
62+
test('sets projectUuid when called with --project-uuid', async () => {
63+
await program.parseAsync([
64+
'node',
65+
'catalyst',
66+
'link',
67+
'--project-uuid',
68+
projectUuid1,
69+
'--root-dir',
70+
tmpDir,
71+
]);
72+
73+
expect(consola.start).toHaveBeenCalledWith(
74+
'Writing project UUID to .bigcommerce/project.json...',
75+
);
76+
expect(consola.success).toHaveBeenCalledWith(
77+
'Project UUID written to .bigcommerce/project.json.',
78+
);
79+
expect(exitMock).toHaveBeenCalledWith(0);
80+
expect(config.get('projectUuid')).toBe(projectUuid1);
81+
expect(config.get('framework')).toBe('catalyst');
82+
});
83+
84+
test('fetches projects and prompts user to select one', async () => {
85+
const consolaPromptMock = vi
86+
.spyOn(consola, 'prompt')
87+
.mockImplementation(async (message, opts) => {
88+
// Assert the prompt message and options
89+
expect(message).toContain('Select a project (Press <enter> to select).');
90+
91+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
92+
const options = (opts as { options: Array<{ label: string; value: string }> }).options;
93+
94+
expect(options).toHaveLength(2);
95+
expect(options[0]).toMatchObject({ label: 'Project One', value: projectUuid1 });
96+
expect(options[1]).toMatchObject({
97+
label: 'Project Two',
98+
value: projectUuid2,
99+
});
100+
101+
// Simulate selecting the second option
102+
return new Promise((resolve) => resolve(projectUuid2));
103+
});
104+
105+
await program.parseAsync([
106+
'node',
107+
'catalyst',
108+
'link',
109+
'--store-hash',
110+
storeHash,
111+
'--access-token',
112+
accessToken,
113+
'--root-dir',
114+
tmpDir,
115+
]);
116+
117+
expect(consola.start).toHaveBeenCalledWith('Fetching projects...');
118+
expect(consola.success).toHaveBeenCalledWith('Projects fetched.');
119+
120+
expect(consola.start).toHaveBeenCalledWith(
121+
'Writing project UUID to .bigcommerce/project.json...',
122+
);
123+
expect(consola.success).toHaveBeenCalledWith(
124+
'Project UUID written to .bigcommerce/project.json.',
125+
);
126+
127+
expect(exitMock).toHaveBeenCalledWith(0);
128+
129+
expect(config.get('projectUuid')).toBe(projectUuid2);
130+
expect(config.get('framework')).toBe('catalyst');
131+
132+
consolaPromptMock.mockRestore();
133+
});
134+
135+
test('errors when no projects are found', async () => {
136+
server.use(
137+
http.get('https://:apiHost/stores/:storeHash/v3/headless/projects', () =>
138+
HttpResponse.json({
139+
data: [],
140+
}),
141+
),
142+
);
143+
144+
await program.parseAsync([
145+
'node',
146+
'catalyst',
147+
'link',
148+
'--store-hash',
149+
storeHash,
150+
'--access-token',
151+
accessToken,
152+
'--root-dir',
153+
tmpDir,
154+
]);
155+
156+
expect(consola.start).toHaveBeenCalledWith('Fetching projects...');
157+
expect(consola.error).toHaveBeenCalledWith('No headless projects found for this store.');
158+
expect(exitMock).toHaveBeenCalledWith(1);
159+
});
160+
161+
test('errors when headless projects API is not found', async () => {
162+
server.use(
163+
http.get('https://:apiHost/stores/:storeHash/v3/headless/projects', () =>
164+
HttpResponse.json({}, { status: 404 }),
165+
),
166+
);
167+
await program.parseAsync([
168+
'node',
169+
'catalyst',
170+
'link',
171+
'--store-hash',
172+
storeHash,
173+
'--access-token',
174+
accessToken,
175+
'--root-dir',
176+
tmpDir,
177+
]);
178+
179+
expect(consola.start).toHaveBeenCalledWith('Fetching projects...');
180+
expect(consola.error).toHaveBeenCalledWith(
181+
'Headless Projects API not enabled. If you are part of the alpha, contact support@bigcommerce.com to enable it.',
182+
);
183+
});
184+
185+
test('errors when no projectUuid, storeHash, or accessToken are provided', async () => {
186+
await program.parseAsync(['node', 'catalyst', 'link', '--root-dir', tmpDir]);
187+
188+
expect(consola.start).not.toHaveBeenCalled();
189+
expect(consola.success).not.toHaveBeenCalled();
190+
expect(consola.error).toHaveBeenCalledWith('Insufficient information to link a project.');
191+
expect(consola.info).toHaveBeenCalledWith('Provide a project UUID with --project-uuid, or');
192+
expect(consola.info).toHaveBeenCalledWith(
193+
'Provide both --store-hash and --access-token to fetch and select a project.',
194+
);
195+
196+
expect(exitMock).toHaveBeenCalledWith(1);
197+
});

packages/cli/tests/index.spec.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@ describe('CLI program', () => {
1515
const commands = program.commands.map((cmd) => cmd.name());
1616

1717
expect(commands).toContain('version');
18-
expect(commands).toContain('build');
19-
expect(commands).toContain('deploy');
2018
expect(commands).toContain('dev');
2119
expect(commands).toContain('start');
20+
expect(commands).toContain('build');
21+
expect(commands).toContain('deploy');
22+
expect(commands).toContain('link');
2223
});
2324
});

packages/cli/tests/lib/project-config.spec.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,12 @@ test('writes and reads field from .bigcommerce/project.json', async () => {
5555

5656
expect(modifiedProjectUuid).toBe(projectUuid);
5757
});
58+
59+
test('sets default framework to catalyst', async () => {
60+
const projectJsonPath = join(tmpDir, '.bigcommerce/project.json');
61+
62+
await mkdir(dirname(projectJsonPath), { recursive: true });
63+
await writeFile(projectJsonPath, JSON.stringify({}));
64+
65+
expect(config.get('framework')).toBe('catalyst');
66+
});

0 commit comments

Comments
 (0)