Skip to content

Commit f69ce47

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

10 files changed

Lines changed: 1672 additions & 2 deletions

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

Lines changed: 205 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,143 @@ 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',
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+
useExistingOnCollision,
360+
}: {
361+
hostingFlag?: HostingMode;
362+
storeHash?: string;
363+
accessToken?: string;
364+
cliApiOrigin: string;
365+
apiHostname: string;
366+
defaultProjectName?: string;
367+
autoUseProjectName?: boolean;
368+
useExistingOnCollision?: boolean;
369+
}): Promise<HostingResolution> {
370+
if (hostingFlag === 'self-hosted') {
371+
return { mode: 'self-hosted' };
372+
}
373+
374+
if (!storeHash || !accessToken) {
375+
if (hostingFlag === 'commerce') {
376+
console.error(
377+
colorize(
378+
'red',
379+
'\n--hosting commerce requires store credentials (store hash and access token)\n',
380+
),
381+
);
382+
process.exit(1);
383+
}
384+
385+
return { mode: 'self-hosted' };
386+
}
387+
388+
const cliApi = new CliApi({
389+
origin: cliApiOrigin,
390+
storeHash,
391+
accessToken,
392+
apiHostname,
393+
});
394+
395+
const hasAccess = await resolveProjectsAccess(cliApi, hostingFlag);
396+
397+
if (hasAccess === null) {
398+
return { mode: 'self-hosted' };
399+
}
400+
401+
if (hostingFlag === 'commerce' && !hasAccess) {
402+
console.error(
403+
colorize(
404+
'red',
405+
'\nThis store does not have access to the Infrastructure Projects API. Contact support@bigcommerce.com to enable it.\n',
406+
),
407+
);
408+
process.exit(1);
409+
}
410+
411+
const useCommerceHosting = await shouldUseCommerceHosting(hostingFlag, hasAccess);
412+
413+
if (!useCommerceHosting) {
414+
return { mode: 'self-hosted' };
415+
}
416+
417+
const project = await promptForCommerceHostingProject(
418+
cliApi,
419+
defaultProjectName,
420+
autoUseProjectName,
421+
useExistingOnCollision,
422+
);
423+
424+
return { mode: 'commerce', projectUuid: project.uuid, accessToken };
425+
}
426+
286427
function checkRequiredTools() {
287428
try {
288429
execSync(getPlatformCheckCommand('git'), { stdio: 'ignore' });
@@ -316,6 +457,16 @@ export const create = new Command('create')
316457
.option('--reset-main', 'Reset the main branch to the gh-ref')
317458
.option('--repository <repository>', 'GitHub repository to clone from', 'bigcommerce/catalyst')
318459
.option('--env <vars...>', 'Arbitrary environment variables to set in .env.local')
460+
.addOption(
461+
new Option(
462+
'--hosting <mode>',
463+
'Hosting mode: "self-hosted" or "commerce" for Commerce Hosting.',
464+
).choices(['self-hosted', 'commerce'] as const),
465+
)
466+
.option(
467+
'--use-existing',
468+
'Only used with --hosting commerce and --project-name. When the named project already exists on the store, reuse it instead of prompting. Has no effect without --hosting commerce.',
469+
)
319470
.addOption(
320471
new Option('--bigcommerce-hostname <hostname>', 'BigCommerce hostname')
321472
.default('bigcommerce.com')
@@ -330,6 +481,15 @@ export const create = new Command('create')
330481
.action(async (options) => {
331482
const { ghRef, repository } = options;
332483

484+
if (options.useExisting && options.hosting !== 'commerce') {
485+
console.warn(
486+
colorize(
487+
'yellow',
488+
'\nWarning: --use-existing has no effect without --hosting commerce. Ignoring.\n',
489+
),
490+
);
491+
}
492+
333493
checkRequiredTools();
334494

335495
const { projectName, projectDir } = await setupProject({
@@ -363,6 +523,16 @@ export const create = new Command('create')
363523
envVars.BIGCOMMERCE_STOREFRONT_API_TOKEN = storefrontToken;
364524
} else {
365525
if (!storeHash || !accessToken) {
526+
if (options.hosting === 'commerce') {
527+
console.error(
528+
colorize(
529+
'red',
530+
'\n--hosting commerce requires store credentials (store hash and access token)\n',
531+
),
532+
);
533+
process.exit(1);
534+
}
535+
366536
// Create project without credentials
367537
console.log(`\nCreating '${projectName}' at '${projectDir}'\n`);
368538
cloneCatalyst({ repository, projectName, projectDir, ghRef, resetMain: options.resetMain });
@@ -418,6 +588,7 @@ export const create = new Command('create')
418588
origin: options.cliApiOrigin,
419589
storeHash,
420590
accessToken,
591+
apiHostname: `api.${options.bigcommerceHostname}`,
421592
});
422593

423594
// If we have channelId but no storefrontToken, just get the init data
@@ -523,9 +694,34 @@ export const create = new Command('create')
523694
if (!channelId) throw new Error('Something went wrong, channelId is not defined');
524695
if (!storefrontToken) throw new Error('Something went wrong, storefrontToken is not defined');
525696

697+
const hosting = await resolveHostingMode({
698+
hostingFlag: options.hosting,
699+
storeHash,
700+
accessToken,
701+
cliApiOrigin: options.cliApiOrigin,
702+
apiHostname: `api.${options.bigcommerceHostname}`,
703+
defaultProjectName: projectName,
704+
autoUseProjectName: !!options.projectName,
705+
useExistingOnCollision: options.useExisting,
706+
});
707+
708+
if (hosting.mode === 'commerce') {
709+
envVars.BIGCOMMERCE_ACCESS_TOKEN = hosting.accessToken;
710+
}
711+
526712
// Create the project with all necessary configuration
527713
console.log(`\nCreating '${projectName}' at '${projectDir}'\n`);
528714
cloneCatalyst({ repository, projectName, projectDir, ghRef, resetMain: options.resetMain });
715+
716+
if (hosting.mode === 'commerce') {
717+
setupCommerceHosting({
718+
projectDir,
719+
projectUuid: hosting.projectUuid,
720+
storeHash,
721+
accessToken: hosting.accessToken,
722+
});
723+
}
724+
529725
await installDependencies(projectDir);
530726

531727
// Write env vars
@@ -540,6 +736,14 @@ export const create = new Command('create')
540736
colorize('green', `\nSuccess! Created '${projectName}' at '${projectDir}'\n`),
541737
'\nNext steps:\n',
542738
colorize('yellow', `\ncd ${projectName} && pnpm run dev\n`),
739+
...(hosting.mode === 'commerce'
740+
? [
741+
colorize(
742+
'yellow',
743+
`\nRun 'cd ${projectName}/core && pnpm run deploy' when ready to deploy to Commerce hosting.\n`,
744+
),
745+
]
746+
: []),
543747
);
544748
});
545749

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)