Skip to content

Commit 736b761

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

10 files changed

Lines changed: 1849 additions & 2 deletions

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

Lines changed: 207 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,145 @@ 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. 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',
336+
value: false,
337+
description:
338+
'Use standard next dev / build / start. You will host the app yourself and deploy it to a provider of your choice.',
339+
},
340+
{
341+
name: 'Commerce-hosted',
342+
value: true,
343+
description:
344+
'Use catalyst dev / build / deploy. Commerce will host the app and handle deployment for you.',
345+
},
346+
],
347+
});
348+
}
349+
350+
return false;
351+
}
352+
353+
async function resolveHostingMode({
354+
hostingFlag,
355+
storeHash,
356+
accessToken,
357+
cliApiOrigin,
358+
apiHostname,
359+
defaultProjectName,
360+
autoUseProjectName,
361+
useExistingOnCollision,
362+
}: {
363+
hostingFlag?: HostingMode;
364+
storeHash?: string;
365+
accessToken?: string;
366+
cliApiOrigin: string;
367+
apiHostname: string;
368+
defaultProjectName: string;
369+
autoUseProjectName?: boolean;
370+
useExistingOnCollision?: boolean;
371+
}): Promise<HostingResolution> {
372+
if (hostingFlag === 'self-hosted') {
373+
return { mode: 'self-hosted' };
374+
}
375+
376+
if (!storeHash || !accessToken) {
377+
if (hostingFlag === 'commerce') {
378+
console.error(
379+
colorize(
380+
'red',
381+
'\n--hosting commerce requires store credentials (store hash and access token)\n',
382+
),
383+
);
384+
process.exit(1);
385+
}
386+
387+
return { mode: 'self-hosted' };
388+
}
389+
390+
const cliApi = new CliApi({
391+
origin: cliApiOrigin,
392+
storeHash,
393+
accessToken,
394+
apiHostname,
395+
});
396+
397+
const hasAccess = await resolveProjectsAccess(cliApi, hostingFlag);
398+
399+
if (hasAccess === null) {
400+
return { mode: 'self-hosted' };
401+
}
402+
403+
if (hostingFlag === 'commerce' && !hasAccess) {
404+
console.error(
405+
colorize(
406+
'red',
407+
'\nThis store does not have access to the Infrastructure Projects API. Contact support@bigcommerce.com to enable it.\n',
408+
),
409+
);
410+
process.exit(1);
411+
}
412+
413+
const useCommerceHosting = await shouldUseCommerceHosting(hostingFlag, hasAccess);
414+
415+
if (!useCommerceHosting) {
416+
return { mode: 'self-hosted' };
417+
}
418+
419+
const project = await promptForCommerceHostingProject(
420+
cliApi,
421+
defaultProjectName,
422+
autoUseProjectName,
423+
useExistingOnCollision,
424+
);
425+
426+
return { mode: 'commerce', projectUuid: project.uuid, accessToken };
427+
}
428+
286429
function checkRequiredTools() {
287430
try {
288431
execSync(getPlatformCheckCommand('git'), { stdio: 'ignore' });
@@ -316,6 +459,16 @@ export const create = new Command('create')
316459
.option('--reset-main', 'Reset the main branch to the gh-ref')
317460
.option('--repository <repository>', 'GitHub repository to clone from', 'bigcommerce/catalyst')
318461
.option('--env <vars...>', 'Arbitrary environment variables to set in .env.local')
462+
.addOption(
463+
new Option(
464+
'--hosting <mode>',
465+
'Hosting mode: "self-hosted" or "commerce" for Commerce Hosting.',
466+
).choices(['self-hosted', 'commerce'] as const),
467+
)
468+
.option(
469+
'--use-existing',
470+
'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.',
471+
)
319472
.addOption(
320473
new Option('--bigcommerce-hostname <hostname>', 'BigCommerce hostname')
321474
.default('bigcommerce.com')
@@ -330,6 +483,15 @@ export const create = new Command('create')
330483
.action(async (options) => {
331484
const { ghRef, repository } = options;
332485

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

335497
const { projectName, projectDir } = await setupProject({
@@ -363,6 +525,16 @@ export const create = new Command('create')
363525
envVars.BIGCOMMERCE_STOREFRONT_API_TOKEN = storefrontToken;
364526
} else {
365527
if (!storeHash || !accessToken) {
528+
if (options.hosting === 'commerce') {
529+
console.error(
530+
colorize(
531+
'red',
532+
'\n--hosting commerce requires store credentials (store hash and access token)\n',
533+
),
534+
);
535+
process.exit(1);
536+
}
537+
366538
// Create project without credentials
367539
console.log(`\nCreating '${projectName}' at '${projectDir}'\n`);
368540
cloneCatalyst({ repository, projectName, projectDir, ghRef, resetMain: options.resetMain });
@@ -418,6 +590,7 @@ export const create = new Command('create')
418590
origin: options.cliApiOrigin,
419591
storeHash,
420592
accessToken,
593+
apiHostname: `api.${options.bigcommerceHostname}`,
421594
});
422595

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

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

531729
// Write env vars
@@ -540,6 +738,14 @@ export const create = new Command('create')
540738
colorize('green', `\nSuccess! Created '${projectName}' at '${projectDir}'\n`),
541739
'\nNext steps:\n',
542740
colorize('yellow', `\ncd ${projectName} && pnpm run dev\n`),
741+
...(hosting.mode === 'commerce'
742+
? [
743+
colorize(
744+
'yellow',
745+
`\nRun 'cd ${projectName}/core && pnpm run deploy' when ready to deploy to Commerce hosting.\n`,
746+
),
747+
]
748+
: []),
543749
);
544750
});
545751

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)