Skip to content

Commit 31cea39

Browse files
committed
TRAC-137: Add native hosting option in create-catalyst
1 parent 9f11983 commit 31cea39

10 files changed

Lines changed: 1379 additions & 2 deletions

packages/create-catalyst/src/commands/create.ts

Lines changed: 188 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,13 @@ import { Https } from '../utils/https';
1313
import { installDependencies } from '../utils/install-dependencies';
1414
import { getAvailableLocales } from '../utils/localization';
1515
import { login, storeCredentials } from '../utils/login';
16+
import { promptForCommerceHostingProject } from '../utils/prompt-commerce-hosting-project';
17+
import { setupCommerceHosting } from '../utils/setup-commerce-hosting';
1618
import { Telemetry } from '../utils/telemetry/telemetry';
1719
import { writeEnv } from '../utils/write-env';
1820

21+
type HostingMode = 'self-hosted' | 'commerce';
22+
1923
interface Channel {
2024
id: number;
2125
name: string;
@@ -271,7 +275,7 @@ async function setupProject(options: {
271275
};
272276

273277
await input({
274-
message: 'What is the name of your project?',
278+
message: 'What do you want to name your project directory?',
275279
default: 'my-catalyst-app',
276280
validate: validateProjectName,
277281
});
@@ -283,6 +287,140 @@ async function setupProject(options: {
283287
return { projectName, projectDir };
284288
}
285289

290+
type HostingResolution =
291+
| { mode: 'self-hosted' }
292+
| { mode: 'commerce'; projectUuid: string; accessToken: string };
293+
294+
async function resolveProjectsAccess(
295+
cliApi: CliApi,
296+
hostingFlag: HostingMode | undefined,
297+
): Promise<boolean | null> {
298+
try {
299+
return await cliApi.hasProjectsAccess();
300+
} catch (error) {
301+
const message = error instanceof Error ? error.message : 'unknown error';
302+
303+
if (hostingFlag === 'commerce') {
304+
console.error(
305+
colorize(
306+
'red',
307+
`\nFailed to verify Infrastructure Projects API access: ${message}\nPlease try again.\n`,
308+
),
309+
);
310+
process.exit(1);
311+
}
312+
313+
console.warn(
314+
colorize(
315+
'yellow',
316+
`\nCould not verify Infrastructure Projects API access: ${message}\nDefaulting to self-hosted (Next.js). Re-run create-catalyst if you intended to use Commerce-hosted.\n`,
317+
),
318+
);
319+
320+
return null;
321+
}
322+
}
323+
324+
async function shouldUseCommerceHosting(
325+
hostingFlag: HostingMode | undefined,
326+
hasAccess: boolean,
327+
): Promise<boolean> {
328+
if (hostingFlag === 'commerce') return true;
329+
330+
if (hostingFlag === undefined && hasAccess) {
331+
return select({
332+
message: 'How would you like to host your Catalyst storefront?',
333+
choices: [
334+
{
335+
name: 'Self-hosted (Next.js)',
336+
value: false,
337+
description: 'Use standard next dev / build / start',
338+
},
339+
{
340+
name: 'Commerce-hosted (Native)',
341+
value: true,
342+
description: 'Use catalyst dev / build / deploy',
343+
},
344+
],
345+
});
346+
}
347+
348+
return false;
349+
}
350+
351+
async function resolveHostingMode({
352+
hostingFlag,
353+
storeHash,
354+
accessToken,
355+
cliApiOrigin,
356+
apiHostname,
357+
defaultProjectName,
358+
autoUseProjectName,
359+
}: {
360+
hostingFlag?: HostingMode;
361+
storeHash?: string;
362+
accessToken?: string;
363+
cliApiOrigin: string;
364+
apiHostname: string;
365+
defaultProjectName?: string;
366+
autoUseProjectName?: boolean;
367+
}): Promise<HostingResolution> {
368+
if (hostingFlag === 'self-hosted') {
369+
return { mode: 'self-hosted' };
370+
}
371+
372+
if (!storeHash || !accessToken) {
373+
if (hostingFlag === 'commerce') {
374+
console.error(
375+
colorize(
376+
'red',
377+
'\n--hosting commerce requires store credentials (store hash and access token)\n',
378+
),
379+
);
380+
process.exit(1);
381+
}
382+
383+
return { mode: 'self-hosted' };
384+
}
385+
386+
const cliApi = new CliApi({
387+
origin: cliApiOrigin,
388+
storeHash,
389+
accessToken,
390+
apiHostname,
391+
});
392+
393+
const hasAccess = await resolveProjectsAccess(cliApi, hostingFlag);
394+
395+
if (hasAccess === null) {
396+
return { mode: 'self-hosted' };
397+
}
398+
399+
if (hostingFlag === 'commerce' && !hasAccess) {
400+
console.error(
401+
colorize(
402+
'red',
403+
'\nThis store does not have access to the Infrastructure Projects API. Contact support@bigcommerce.com to enable it.\n',
404+
),
405+
);
406+
process.exit(1);
407+
}
408+
409+
const useCommerceHosting = await shouldUseCommerceHosting(hostingFlag, hasAccess);
410+
411+
if (!useCommerceHosting) {
412+
return { mode: 'self-hosted' };
413+
}
414+
415+
const project = await promptForCommerceHostingProject(
416+
cliApi,
417+
defaultProjectName,
418+
autoUseProjectName,
419+
);
420+
421+
return { mode: 'commerce', projectUuid: project.uuid, accessToken };
422+
}
423+
286424
function checkRequiredTools() {
287425
try {
288426
execSync(getPlatformCheckCommand('git'), { stdio: 'ignore' });
@@ -316,6 +454,12 @@ export const create = new Command('create')
316454
.option('--reset-main', 'Reset the main branch to the gh-ref')
317455
.option('--repository <repository>', 'GitHub repository to clone from', 'bigcommerce/catalyst')
318456
.option('--env <vars...>', 'Arbitrary environment variables to set in .env.local')
457+
.addOption(
458+
new Option(
459+
'--hosting <mode>',
460+
'Hosting mode: "self-hosted" or "commerce" for Commerce Hosting.',
461+
).choices(['self-hosted', 'commerce'] as const),
462+
)
319463
.addOption(
320464
new Option('--bigcommerce-hostname <hostname>', 'BigCommerce hostname')
321465
.default('bigcommerce.com')
@@ -363,6 +507,16 @@ export const create = new Command('create')
363507
envVars.BIGCOMMERCE_STOREFRONT_API_TOKEN = storefrontToken;
364508
} else {
365509
if (!storeHash || !accessToken) {
510+
if (options.hosting === 'commerce') {
511+
console.error(
512+
colorize(
513+
'red',
514+
'\n--hosting commerce requires store credentials (store hash and access token)\n',
515+
),
516+
);
517+
process.exit(1);
518+
}
519+
366520
// Create project without credentials
367521
console.log(`\nCreating '${projectName}' at '${projectDir}'\n`);
368522
cloneCatalyst({ repository, projectName, projectDir, ghRef, resetMain: options.resetMain });
@@ -418,6 +572,7 @@ export const create = new Command('create')
418572
origin: options.cliApiOrigin,
419573
storeHash,
420574
accessToken,
575+
apiHostname: `api.${options.bigcommerceHostname}`,
421576
});
422577

423578
// If we have channelId but no storefrontToken, just get the init data
@@ -523,9 +678,33 @@ export const create = new Command('create')
523678
if (!channelId) throw new Error('Something went wrong, channelId is not defined');
524679
if (!storefrontToken) throw new Error('Something went wrong, storefrontToken is not defined');
525680

681+
const hosting = await resolveHostingMode({
682+
hostingFlag: options.hosting,
683+
storeHash,
684+
accessToken,
685+
cliApiOrigin: options.cliApiOrigin,
686+
apiHostname: `api.${options.bigcommerceHostname}`,
687+
defaultProjectName: projectName,
688+
autoUseProjectName: !!options.projectName,
689+
});
690+
691+
if (hosting.mode === 'commerce') {
692+
envVars.BIGCOMMERCE_ACCESS_TOKEN = hosting.accessToken;
693+
}
694+
526695
// Create the project with all necessary configuration
527696
console.log(`\nCreating '${projectName}' at '${projectDir}'\n`);
528697
cloneCatalyst({ repository, projectName, projectDir, ghRef, resetMain: options.resetMain });
698+
699+
if (hosting.mode === 'commerce') {
700+
setupCommerceHosting({
701+
projectDir,
702+
projectUuid: hosting.projectUuid,
703+
storeHash,
704+
accessToken: hosting.accessToken,
705+
});
706+
}
707+
529708
await installDependencies(projectDir);
530709

531710
// Write env vars
@@ -540,6 +719,14 @@ export const create = new Command('create')
540719
colorize('green', `\nSuccess! Created '${projectName}' at '${projectDir}'\n`),
541720
'\nNext steps:\n',
542721
colorize('yellow', `\ncd ${projectName} && pnpm run dev\n`),
722+
...(hosting.mode === 'commerce'
723+
? [
724+
colorize(
725+
'yellow',
726+
`\nRun 'cd ${projectName}/core && pnpm run deploy' when ready to deploy to Commerce hosting.\n`,
727+
),
728+
]
729+
: []),
543730
);
544731
});
545732

packages/create-catalyst/src/commands/init.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ export const init = new Command('init')
109109
origin: options.cliApiOrigin,
110110
storeHash,
111111
accessToken,
112+
apiHostname: `api.${options.bigcommerceHostname}`,
112113
});
113114

114115
const channelSortOrder = ['catalyst', 'next', 'bigcommerce'];

packages/create-catalyst/src/utils/auth.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ export class Auth {
2828
'store_v2_information',
2929
'store_v2_products',
3030
'store_cart',
31+
'store_infrastructure_projects_manage',
32+
'store_infrastructure_deployments_manage',
33+
'store_infrastructure_logs_read_only',
3134
].join(' '),
3235
client_id: this.DEVICE_OAUTH_CLIENT_ID,
3336
}),
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export class InfrastructureProjectValidationError extends Error {
2+
constructor(message: string) {
3+
super(message);
4+
this.name = 'InfrastructureProjectValidationError';
5+
}
6+
}

0 commit comments

Comments
 (0)