diff --git a/packages/create-catalyst/jest.config.cjs b/packages/create-catalyst/jest.config.cjs index b30b9496e..f925d1592 100644 --- a/packages/create-catalyst/jest.config.cjs +++ b/packages/create-catalyst/jest.config.cjs @@ -1,6 +1,25 @@ module.exports = { - transformIgnorePatterns: [], + transformIgnorePatterns: [ + 'node_modules/(?!(fs-extra|@inquirer|chalk|commander|conf|dotenv|giget|lodash.kebabcase|nypm|ora|semver|std-env|zod|zod-validation-error)/)', + ], transform: { - '^.+\\.(t|j)s?$': '@swc/jest', + '^.+\\.(t|j)s?$': ['@swc/jest', { + sourceMaps: true, + module: { + type: 'commonjs' + }, + jsc: { + target: 'es2021', + parser: { + syntax: 'typescript', + tsx: false, + decorators: true, + }, + }, + }] }, + moduleNameMapper: { + '^chalk$': '/src/test-mocks/chalk.ts', + }, + setupFiles: ['/src/test/setup.ts'], }; diff --git a/packages/create-catalyst/src/commands/create.ts b/packages/create-catalyst/src/commands/create.ts index 84461104f..c132a6932 100644 --- a/packages/create-catalyst/src/commands/create.ts +++ b/packages/create-catalyst/src/commands/create.ts @@ -8,7 +8,13 @@ import { join } from 'path'; import { z } from 'zod'; import { cloneCatalyst } from '../utils/clone-catalyst'; -import { Https } from '../utils/https'; +import { + type Channel, + type ChannelsResponse, + type CreateChannelResponse, + type EligibilityResponse, + Https, +} from '../utils/https'; import { installDependencies } from '../utils/install-dependencies'; import { login } from '../utils/login'; import { parse } from '../utils/parse'; @@ -41,8 +47,8 @@ export const create = new Command('create') .hideHelp(), ) .addOption( - new Option('--sample-data-api-url ', 'BigCommerce sample data API URL') - .default('https://api.bc-sample.store') + new Option('--cli-api-hostname ', 'BigCommerce CLI API hostname') + .default('cxm-prd.bigcommerceapp.com') .hideHelp(), ) // eslint-disable-next-line complexity @@ -65,8 +71,7 @@ export const create = new Command('create') } const URLSchema = z.string().url(); - const sampleDataApiUrl = parse(options.sampleDataApiUrl, URLSchema); - const bigcommerceApiUrl = parse(`https://api.${options.bigcommerceHostname}`, URLSchema); + const cliApiUrl = parse(`https://${options.cliApiHostname}`, URLSchema); const bigcommerceAuthUrl = parse(`https://login.${options.bigcommerceHostname}`, URLSchema); const resetMain = options.resetMain; @@ -175,14 +180,15 @@ export const create = new Command('create') await telemetry.identify(storeHash); if (!channelId || !storefrontToken) { - const bc = new Https({ bigCommerceApiUrl: bigcommerceApiUrl, storeHash, accessToken }); - const sampleDataApi = new Https({ - sampleDataApiUrl, + const cliApi = new Https({ + bigCommerceApiUrl: `${cliApiUrl}/stores/${storeHash}/cli-api/v3`, storeHash, accessToken, }); - const eligibilityResponse = await sampleDataApi.checkEligibility(); + const eligibilityResponse = await cliApi.get( + '/channels/catalyst/eligibility', + ); if (!eligibilityResponse.data.eligible) { console.warn(chalk.yellow(eligibilityResponse.data.message)); @@ -207,30 +213,31 @@ export const create = new Command('create') const { data: { id: createdChannelId, storefront_api_token: storefrontApiToken }, - } = await sampleDataApi.createChannel(newChannelName); - - await bc.createChannelMenus(createdChannelId); + } = await cliApi.post('/channels/catalyst', { + name: newChannelName, + initialData: { type: 'sample', scenario: 1 }, + deployStorefront: false, + }); channelId = createdChannelId; storefrontToken = storefrontApiToken; - - /** - * @todo prompt sample data API - */ } if (!shouldCreateChannel) { const channelSortOrder = ['catalyst', 'next', 'bigcommerce']; - const availableChannels = await bc.channels('?available=true&type=storefront'); + const availableChannels = await cliApi.get( + '/channels?available=true&type=storefront', + ); const existingChannel = await select({ message: 'Which channel would you like to use?', choices: availableChannels.data .sort( - (a, b) => channelSortOrder.indexOf(a.platform) - channelSortOrder.indexOf(b.platform), + (a: Channel, b: Channel) => + channelSortOrder.indexOf(a.platform) - channelSortOrder.indexOf(b.platform), ) - .map((ch) => ({ + .map((ch: Channel) => ({ name: ch.name, value: ch, description: `Channel Platform: ${ @@ -242,12 +249,7 @@ export const create = new Command('create') }); channelId = existingChannel.id; - - const { - data: { token: sfToken }, - } = await bc.storefrontToken(); - - storefrontToken = sfToken; + storefrontToken = existingChannel.storefront_api_token; } } diff --git a/packages/create-catalyst/src/commands/init.ts b/packages/create-catalyst/src/commands/init.ts index fe66616d0..9d52fce77 100644 --- a/packages/create-catalyst/src/commands/init.ts +++ b/packages/create-catalyst/src/commands/init.ts @@ -1,9 +1,9 @@ import { Command, Option } from '@commander-js/extra-typings'; -import { input, select } from '@inquirer/prompts'; +import { select } from '@inquirer/prompts'; import chalk from 'chalk'; import * as z from 'zod'; -import { Https } from '../utils/https'; +import { type Channel, type ChannelsResponse, Https, type InitResponse } from '../utils/https'; import { login } from '../utils/login'; import { parse } from '../utils/parse'; import { Telemetry } from '../utils/telemetry/telemetry'; @@ -11,35 +11,53 @@ import { writeEnv } from '../utils/write-env'; const telemetry = new Telemetry(); +/** + * The `init` command connects your local Catalyst project to an existing BigCommerce channel. + * It helps you: + * 1. Connect to a BigCommerce store (via CLI args or interactive login) + * 2. Select an existing channel (via CLI args or interactive selection) + * 3. Configure your local environment variables with: + * - Channel ID + * - Store Hash + * - Storefront Token + * - Makeswift API Key (if available) + * + * To create a new channel, use the `create` command instead. + * + * Usage: + * - Interactive: `create-catalyst init` + * - With args: `create-catalyst init --store-hash --access-token --channel-id ` + */ export const init = new Command('init') - .description('Connect a BigCommerce store with an existing Catalyst project') + .description('Connect your Catalyst project to an existing BigCommerce channel') .option('--store-hash ', 'BigCommerce store hash') .option('--access-token ', 'BigCommerce access token') + .option('--channel-id ', 'Existing BigCommerce channel ID to connect to') .addOption( new Option('--bigcommerce-hostname ', 'BigCommerce hostname') .default('bigcommerce.com') .hideHelp(), ) .addOption( - new Option('--sample-data-api-url ', 'BigCommerce sample data API URL') - .default('https://api.bc-sample.store') + new Option('--cli-api-hostname ', 'BigCommerce CLI API hostname') + .default('cxm-prd.bigcommerceapp.com') .hideHelp(), ) .action(async (options) => { const projectDir = process.cwd(); const URLSchema = z.string().url(); - const sampleDataApiUrl = parse(options.sampleDataApiUrl, URLSchema); - const bigCommerceApiUrl = `https://api.${options.bigcommerceHostname}`; - const bigCommerceAuthUrl = `https://login.${options.bigcommerceHostname}`; + const cliApiUrl = parse(`https://${options.cliApiHostname}`, URLSchema); + const bigcommerceAuthUrl = parse(`https://login.${options.bigcommerceHostname}`, URLSchema); let storeHash = options.storeHash; let accessToken = options.accessToken; - let channelId; + let channelId = options.channelId ? parseInt(options.channelId, 10) : undefined; let storefrontToken; + let makeswiftApiKey; - if (!options.storeHash || !options.accessToken) { - const credentials = await login(bigCommerceAuthUrl); + if (!storeHash || !accessToken) { + const credentials = await login(bigcommerceAuthUrl); storeHash = credentials.storeHash; accessToken = credentials.accessToken; @@ -47,68 +65,33 @@ export const init = new Command('init') if (!storeHash || !accessToken) { console.log( - chalk.yellow('\nYou must authenticate with a store to overwrite your local environment.\n'), + chalk.yellow('\nYou must authenticate with a store to configure your local environment.\n'), ); - process.exit(1); } await telemetry.identify(storeHash); - const bc = new Https({ bigCommerceApiUrl, storeHash, accessToken }); - const sampleDataApi = new Https({ - sampleDataApiUrl, + const cliApi = new Https({ + bigCommerceApiUrl: `${cliApiUrl}/stores/${storeHash}/cli-api/v3`, storeHash, accessToken, }); - const eligibilityResponse = await sampleDataApi.checkEligibility(); - - if (!eligibilityResponse.data.eligible) { - console.warn(chalk.yellow(eligibilityResponse.data.message)); - } - - let shouldCreateChannel; - - if (eligibilityResponse.data.eligible) { - shouldCreateChannel = await select({ - message: 'Would you like to create a new channel?', - choices: [ - { name: 'Yes', value: true }, - { name: 'No', value: false }, - ], - }); - } - - if (shouldCreateChannel) { - const newChannelName = await input({ - message: 'What would you like to name your new channel?', - }); - - const { - data: { id: createdChannelId, storefront_api_token: storefrontApiToken }, - } = await sampleDataApi.createChannel(newChannelName); - - channelId = createdChannelId; - storefrontToken = storefrontApiToken; - - /** - * @todo prompt sample data API - */ - } - - if (!shouldCreateChannel) { + if (!channelId) { const channelSortOrder = ['catalyst', 'next', 'bigcommerce']; - - const availableChannels = await bc.channels('?available=true&type=storefront'); + const availableChannels = await cliApi.get( + '/channels?available=true&type=storefront', + ); const existingChannel = await select({ message: 'Which channel would you like to use?', choices: availableChannels.data .sort( - (a, b) => channelSortOrder.indexOf(a.platform) - channelSortOrder.indexOf(b.platform), + (a: Channel, b: Channel) => + channelSortOrder.indexOf(a.platform) - channelSortOrder.indexOf(b.platform), ) - .map((ch) => ({ + .map((ch: Channel) => ({ name: ch.name, value: ch, description: `Channel Platform: ${ @@ -120,20 +103,33 @@ export const init = new Command('init') }); channelId = existingChannel.id; - - const { - data: { token: sfToken }, - } = await bc.storefrontToken(); - - storefrontToken = sfToken; + storefrontToken = existingChannel.storefront_api_token; } if (!channelId) throw new Error('Something went wrong, channelId is not defined'); if (!storefrontToken) throw new Error('Something went wrong, storefrontToken is not defined'); + try { + const initResponse = await cliApi.get(`/channels/${channelId}/init`); + + makeswiftApiKey = initResponse.data.makeswift_dev_api_key; + } catch { + console.warn( + chalk.yellow( + '\nWarning: Could not fetch Makeswift API key. If you need Makeswift integration, please configure it manually.\n', + ), + ); + } + writeEnv(projectDir, { channelId: channelId.toString(), storeHash, storefrontToken, + ...(makeswiftApiKey && { MAKESWIFT_SITE_API_KEY: makeswiftApiKey }), }); + + console.log( + chalk.green('\nSuccess!'), + 'Your local environment has been configured with the selected channel.\n', + ); }); diff --git a/packages/create-catalyst/src/container.ts b/packages/create-catalyst/src/container.ts new file mode 100644 index 000000000..5087c0559 --- /dev/null +++ b/packages/create-catalyst/src/container.ts @@ -0,0 +1,25 @@ +import { BigCommerceServiceImpl } from './services/bigcommerce'; +import { GitServiceImpl } from './services/git'; +import { ProjectServiceImpl } from './services/project'; + +interface Config { + bigCommerceHostname: string; + cliApiHostname: string; +} + +interface Container { + getProjectService: () => ProjectServiceImpl; +} + +export function createContainer(config: Config): Container { + const gitService = new GitServiceImpl(); + const bigCommerceService = new BigCommerceServiceImpl({ + bigCommerceApiUrl: `https://api.${config.bigCommerceHostname}`, + bigCommerceAuthUrl: `https://login.${config.bigCommerceHostname}`, + cliApiUrl: `https://${config.cliApiHostname}`, + }); + + return { + getProjectService: () => new ProjectServiceImpl(gitService, bigCommerceService), + }; +} diff --git a/packages/create-catalyst/src/errors/authentication-error.ts b/packages/create-catalyst/src/errors/authentication-error.ts new file mode 100644 index 000000000..58d8d3ed2 --- /dev/null +++ b/packages/create-catalyst/src/errors/authentication-error.ts @@ -0,0 +1,8 @@ +import { CatalystError } from './catalyst-error'; + +export class AuthenticationError extends CatalystError { + constructor(message: string) { + super(message, 'AUTHENTICATION_ERROR'); + this.name = 'AuthenticationError'; + } +} diff --git a/packages/create-catalyst/src/errors/catalyst-error.ts b/packages/create-catalyst/src/errors/catalyst-error.ts new file mode 100644 index 000000000..f0a3066f9 --- /dev/null +++ b/packages/create-catalyst/src/errors/catalyst-error.ts @@ -0,0 +1,9 @@ +export class CatalystError extends Error { + constructor( + message: string, + public code: string, + ) { + super(message); + this.name = 'CatalystError'; + } +} diff --git a/packages/create-catalyst/src/errors/dependency-error.ts b/packages/create-catalyst/src/errors/dependency-error.ts new file mode 100644 index 000000000..eb8c0ca5e --- /dev/null +++ b/packages/create-catalyst/src/errors/dependency-error.ts @@ -0,0 +1,8 @@ +import { CatalystError } from './catalyst-error'; + +export class DependencyError extends CatalystError { + constructor(message: string) { + super(message, 'DEPENDENCY_ERROR'); + this.name = 'DependencyError'; + } +} diff --git a/packages/create-catalyst/src/errors/validation-error.ts b/packages/create-catalyst/src/errors/validation-error.ts new file mode 100644 index 000000000..fb85f2fae --- /dev/null +++ b/packages/create-catalyst/src/errors/validation-error.ts @@ -0,0 +1,7 @@ +export class ValidationError extends Error { + constructor(message: string) { + super(message); + Object.setPrototypeOf(this, ValidationError.prototype); + this.name = 'ValidationError'; + } +} diff --git a/packages/create-catalyst/src/index.ts b/packages/create-catalyst/src/index.ts index 622ac4206..ca4f8f21b 100644 --- a/packages/create-catalyst/src/index.ts +++ b/packages/create-catalyst/src/index.ts @@ -1,27 +1,86 @@ #!/usr/bin/env node - import { program } from '@commander-js/extra-typings'; import chalk from 'chalk'; import PACKAGE_INFO from '../package.json'; -import { create } from './commands/create'; -import { init } from './commands/init'; -import { integration } from './commands/integration'; -import { telemetry } from './commands/telemetry'; -import { telemetryPostHook, telemetryPreHook } from './hooks/telemetry'; +import { createContainer } from './container'; +import { CreateCommandOptions, InitCommandOptions } from './types'; console.log(chalk.cyanBright(`\n◢ ${PACKAGE_INFO.name} v${PACKAGE_INFO.version}\n`)); +const container = createContainer({ + bigCommerceHostname: 'bigcommerce.com', + cliApiHostname: 'cxm-prd.bigcommerceapp.com', +}); + +const projectService = container.getProjectService(); + program .name(PACKAGE_INFO.name) - .version(PACKAGE_INFO.version) - .description('A command line tool to create a new Catalyst project.') - .addCommand(create, { isDefault: true }) - .addCommand(init) - .addCommand(integration) - .addCommand(telemetry) - .hook('preAction', telemetryPreHook) - .hook('postAction', telemetryPostHook); + .description('A command line tool to create a new Catalyst project') + .version(PACKAGE_INFO.version); + +program + .command('create') + .description('Create a new Catalyst project') + .argument('[project-name]', 'Name of the project') + .option('--project-dir ', 'Directory to create the project in') + .option('--store-hash ', 'BigCommerce store hash') + .option('--access-token ', 'BigCommerce access token') + .option('--channel-id ', 'BigCommerce channel ID') + .option('--storefront-token ', 'BigCommerce storefront token') + .option('--gh-ref ', 'Git reference to use') + .option('--reset-main', 'Reset main branch to specified ref') + .option('--repository ', 'Repository to clone from') + .option('--env ', 'Environment variables to set') + .option('--bigcommerce-hostname ', 'BigCommerce hostname') + .option('--sample-data-api-url ', 'Sample data API URL') + .option('--cli-api-hostname ', 'CLI API hostname') + .action(async (projectName: string | undefined, options: CreateCommandOptions) => { + try { + await projectService.create({ projectName, ...options }); + } catch (error) { + if (error instanceof Error) { + console.error(chalk.red(error.message)); + } else { + console.error(chalk.red('An unknown error occurred')); + } + + process.exit(1); + } + }); + +program + .command('init') + .description('Initialize a Catalyst project') + .option('--store-hash ', 'BigCommerce store hash') + .option('--access-token ', 'BigCommerce access token') + .option('--bigcommerce-hostname ', 'BigCommerce hostname') + .option('--sample-data-api-url ', 'Sample data API URL') + .option('--cli-api-hostname ', 'CLI API hostname') + .action(async (options: InitCommandOptions) => { + try { + await projectService.init(options); + } catch (error) { + if (error instanceof Error) { + console.error(chalk.red(error.message)); + } else { + console.error(chalk.red('An unknown error occurred')); + } + + process.exit(1); + } + }); + +program + .command('telemetry') + .description('Manage telemetry settings') + .option('--enable', 'Enables CLI telemetry collection') + .option('--disable', 'Disables CLI telemetry collection') + .action(() => { + // TODO: Implement telemetry service + console.log('Telemetry settings updated'); + }); program.parse(process.argv); diff --git a/packages/create-catalyst/src/services/bigcommerce.ts b/packages/create-catalyst/src/services/bigcommerce.ts new file mode 100644 index 000000000..f5ab16405 --- /dev/null +++ b/packages/create-catalyst/src/services/bigcommerce.ts @@ -0,0 +1,91 @@ +import type { BigCommerceService } from '../types'; +import { + type Channel, + type ChannelsResponse, + type CreateChannelResponse, + type EligibilityResponse, + Https, + type InitResponse, +} from '../utils/https'; + +export interface BigCommerceConfig { + bigCommerceApiUrl: string; + bigCommerceAuthUrl: string; + cliApiUrl: string; +} + +export interface BigCommerceCredentials { + storeHash: string; + accessToken: string; +} + +export class BigCommerceServiceImpl implements BigCommerceService { + private https: Https | null = null; + private readonly config: BigCommerceConfig; + + constructor(config: BigCommerceConfig) { + this.config = config; + } + + async getChannels(): Promise { + if (!this.https) throw new Error('HTTPS client not initialized'); + + const response = await this.https.get( + '/channels?available=true&type=storefront', + ); + + return response.data; + } + + async createChannel(name: string): Promise { + if (!this.https) throw new Error('HTTPS client not initialized'); + + const response = await this.https.post('/channels/catalyst', { + name, + initialData: { type: 'sample', scenario: 1 }, + deployStorefront: false, + }); + + return response.data; + } + + async checkEligibility(): Promise { + if (!this.https) throw new Error('HTTPS client not initialized'); + + const response = await this.https.get('/channels/catalyst/eligibility'); + + return response.data; + } + + async getChannelInit( + credentials: BigCommerceCredentials, + channelId: number, + ): Promise { + this.initializeHttps(credentials); + if (!this.https) throw new Error('HTTPS client not initialized'); + + const response = await this.https.get(`/channels/${channelId}/init`); + + return response.data; + } + + async login(authUrl: string): Promise<{ storeHash: string; accessToken: string }> { + const auth = new Https({ bigCommerceApiUrl: authUrl }); + const deviceCode = await auth.getDeviceCode(); + const { store_hash, access_token } = await auth.checkDeviceCode(deviceCode.device_code); + + return { storeHash: store_hash, accessToken: access_token }; + } + + updateCredentials(storeHash: string, accessToken: string): void { + this.initializeHttps({ storeHash, accessToken }); + } + + private initializeHttps(credentials: BigCommerceCredentials) { + this.https = new Https({ + bigCommerceApiUrl: `${this.config.cliApiUrl}/stores/${credentials.storeHash}/cli-api/v3`, + storeHash: credentials.storeHash, + accessToken: credentials.accessToken, + }); + } +} diff --git a/packages/create-catalyst/src/services/git.ts b/packages/create-catalyst/src/services/git.ts new file mode 100644 index 000000000..f229b0d55 --- /dev/null +++ b/packages/create-catalyst/src/services/git.ts @@ -0,0 +1,84 @@ +import { execSync } from 'child_process'; + +import { GitService } from '../types'; + +export class GitServiceImpl implements GitService { + clone(repository: string, projectName: string, projectDir: string): void { + const useSSH = this.hasGitHubSSH(); + const protocol = useSSH ? 'git@github.com:' : 'https://github.com/'; + const cloneCommand = `git clone ${protocol}${repository}.git${projectName ? ` ${projectName}` : ''}`; + + execSync(cloneCommand, { stdio: 'inherit' }); + + // Check if 'origin' remote exists before trying to rename it + try { + execSync('git remote get-url origin', { cwd: projectDir, stdio: 'ignore' }); + execSync('git remote rename origin upstream', { cwd: projectDir, stdio: 'inherit' }); + } catch { + // If 'origin' doesn't exist, we don't need to rename it + console.log('Note: No origin remote found to rename'); + } + } + + checkoutRef(projectDir: string, ref: string): void { + try { + execSync(`git checkout ${ref}`, { + cwd: projectDir, + stdio: 'inherit', + encoding: 'utf8', + }); + } catch (error) { + if (error instanceof Error) { + throw new Error(`Failed to checkout ref ${ref}: ${error.message}`); + } + + throw error; + } + } + + resetBranchToRef(projectDir: string, ref: string): void { + try { + execSync(`git reset --hard ${ref}`, { + cwd: projectDir, + stdio: 'inherit', + encoding: 'utf8', + }); + } catch (error) { + if (error instanceof Error) { + throw new Error(`Failed to reset branch to ref ${ref}: ${error.message}`); + } + + throw error; + } + } + + hasGitHubSSH(): boolean { + try { + const output = execSync('ssh -T git@github.com', { + encoding: 'utf8', + stdio: 'pipe', + }); + + return output.includes('successfully authenticated'); + } catch (error) { + // SSH test command returns non-zero exit code even on success + if (this.isErrorWithOutput(error)) { + const combinedOutput = error.stdout + error.stderr; + + return combinedOutput.includes('successfully authenticated'); + } + + return false; + } + } + + private isErrorWithOutput(error: unknown): error is Error & { stdout: string; stderr: string } { + if (!(error instanceof Error)) return false; + if (!('stdout' in error)) return false; + if (!('stderr' in error)) return false; + + const { stdout, stderr } = error; + + return typeof stdout === 'string' && typeof stderr === 'string'; + } +} diff --git a/packages/create-catalyst/src/services/project.ts b/packages/create-catalyst/src/services/project.ts new file mode 100644 index 000000000..fdc49a3a9 --- /dev/null +++ b/packages/create-catalyst/src/services/project.ts @@ -0,0 +1,286 @@ +import { input, select } from '@inquirer/prompts'; +import chalk from 'chalk'; +import { execSync } from 'child_process'; +import { pathExistsSync } from 'fs-extra/esm'; +import kebabCase from 'lodash.kebabcase'; +import { join } from 'path'; + +import { ValidationError } from '../errors/validation-error'; +import { BigCommerceService, CreateCommandOptions, GitService, InitCommandOptions } from '../types'; +import { writeEnv } from '../utils/write-env'; + +import { BigCommerceServiceImpl } from './bigcommerce'; + +export interface ProjectService { + create(options: CreateCommandOptions): Promise; + init(options: InitCommandOptions): Promise; + validateProjectName(name: string): boolean; + validateProjectDir(dir: string): boolean; +} + +export class ProjectServiceImpl implements ProjectService { + constructor( + private readonly gitService: GitService, + private readonly bigCommerceService: BigCommerceService, + ) {} + + async create(options: CreateCommandOptions): Promise { + this.checkDependencies(); + + const { projectName, projectDir } = await this.validateAndNormalizeProjectOptions(options); + const { storeHash, accessToken } = await this.getStoreCredentials(options); + + if (!storeHash || !accessToken) { + console.log(`\nCreating '${projectName}' at '${projectDir}'\n`); + + this.gitService.clone(options.repository ?? 'bigcommerce/catalyst', projectName, projectDir); + + this.handleGitRef(options, projectDir); + + await this.installDependencies(projectDir); + + console.log( + [ + `\n${chalk.green('Success!')} Created '${projectName}' at '${projectDir}'\n`, + `Next steps:`, + chalk.yellow(`\n- cd ${projectName} && cp .env.example .env.local`), + chalk.yellow(`\n- Populate .env.local with your BigCommerce API credentials\n`), + ].join('\n'), + ); + + return; + } + + // Update BigCommerce service with new credentials + if (this.bigCommerceService instanceof BigCommerceServiceImpl) { + this.bigCommerceService.updateCredentials(storeHash, accessToken); + } + + // If we have store credentials, set up the channel + const { channelId, storefrontToken } = await this.setupChannel(); + + console.log(`\nCreating '${projectName}' at '${projectDir}'\n`); + + this.gitService.clone(options.repository ?? 'bigcommerce/catalyst', projectName, projectDir); + + this.handleGitRef(options, projectDir); + + writeEnv(projectDir, { + channelId: channelId.toString(), + storeHash, + storefrontToken, + arbitraryEnv: options.env, + }); + + await this.installDependencies(projectDir); + + console.log( + `\n${chalk.green('Success!')} Created '${projectName}' at '${projectDir}'\n`, + '\nNext steps:\n', + chalk.yellow(`\ncd ${projectName} && pnpm run dev\n`), + ); + } + + async init(options: InitCommandOptions): Promise { + const projectDir = process.cwd(); + const { storeHash, accessToken } = await this.getStoreCredentials(options); + + if (!storeHash || !accessToken) { + console.log( + chalk.yellow('\nYou must authenticate with a store to overwrite your local environment.\n'), + ); + process.exit(1); + } + + // Update BigCommerce service with new credentials + if (this.bigCommerceService instanceof BigCommerceServiceImpl) { + this.bigCommerceService.updateCredentials(storeHash, accessToken); + } + + const { channelId, storefrontToken } = await this.setupChannel(); + + writeEnv(projectDir, { + channelId: channelId.toString(), + storeHash, + storefrontToken, + }); + } + + validateProjectName(name: string): boolean { + const formatted = kebabCase(name); + + return Boolean(formatted); + } + + validateProjectDir(dir: string): boolean { + return pathExistsSync(dir); + } + + private async validateAndNormalizeProjectOptions( + options: CreateCommandOptions, + ): Promise<{ projectName: string; projectDir: string }> { + if (!this.validateProjectDir(options.projectDir ?? process.cwd())) { + throw new ValidationError(`Invalid project directory: ${options.projectDir}`); + } + + let projectName = options.projectName; + let projectDir = options.projectDir ?? process.cwd(); + + if (!projectName) { + projectName = await this.promptForProjectName(projectDir); + } else { + projectName = kebabCase(projectName); + projectDir = join(projectDir, projectName); + + if (pathExistsSync(projectDir)) { + throw new ValidationError(`Directory already exists: ${projectDir}`); + } + } + + return { projectName, projectDir }; + } + + private async promptForProjectName(baseDir: string): Promise { + const validateProjectName = (projectInput: string) => { + const formatted = kebabCase(projectInput); + + if (!formatted) { + return 'Project name is required'; + } + + const targetDir = join(baseDir, formatted); + + if (pathExistsSync(targetDir)) { + return `Directory already exists: ${targetDir}`; + } + + return true; + }; + + const result = await input({ + message: 'What is the name of your project?', + default: 'my-catalyst-app', + validate: validateProjectName, + }); + + return kebabCase(result); + } + + private async getStoreCredentials(options: CreateCommandOptions | InitCommandOptions) { + let { storeHash, accessToken } = options; + + if (!storeHash || !accessToken) { + const authUrl = `https://login.${options.bigcommerceHostname ?? 'bigcommerce.com'}`; + const credentials = await this.bigCommerceService.login(authUrl); + + storeHash = credentials.storeHash; + accessToken = credentials.accessToken; + } + + return { storeHash, accessToken }; + } + + private async setupChannel() { + const eligibility = await this.bigCommerceService.checkEligibility(); + + if (!eligibility.eligible) { + console.warn(chalk.yellow(eligibility.message)); + } + + const shouldCreateChannel = await select({ + message: 'Would you like to create a new channel?', + choices: [ + { name: 'Yes', value: true }, + { name: 'No', value: false }, + ], + }); + + if (shouldCreateChannel) { + const channelName = await input({ + message: 'What would you like to name your new channel?', + }); + + const { id: channelId, storefront_api_token: storefrontToken } = + await this.bigCommerceService.createChannel(channelName); + + return { channelId, storefrontToken }; + } + + // If not creating a new channel, let them select an existing one + const channels = await this.bigCommerceService.getChannels(); + const selectedChannel = await select<{ + id: number; + name: string; + platform: 'catalyst'; + type: 'storefront'; + storefront_api_token: string; + site: { url: string }; + }>({ + message: 'Which channel would you like to use?', + choices: channels + .filter( + ( + channel, + ): channel is { + id: number; + name: string; + platform: 'catalyst'; + type: 'storefront'; + storefront_api_token: string; + site: { url: string }; + } => channel.platform === 'catalyst' && 'site' in channel, + ) + .map((channel) => ({ + name: channel.name, + value: channel, + description: `Channel Platform: ${channel.platform.charAt(0).toUpperCase() + channel.platform.slice(1)}`, + })), + }); + + return { + channelId: selectedChannel.id, + storefrontToken: selectedChannel.storefront_api_token, + }; + } + + private handleGitRef(options: CreateCommandOptions, projectDir: string): void { + if (options.ghRef) { + if (options.resetMain) { + this.gitService.checkoutRef(projectDir, 'main'); + this.gitService.resetBranchToRef(projectDir, options.ghRef); + console.log(`Reset main to ${options.ghRef} successfully.\n`); + } else { + this.gitService.checkoutRef(projectDir, options.ghRef); + } + } + } + + private checkDependencies() { + try { + execSync('git --version', { stdio: 'ignore' }); + } catch { + throw new Error('Git is required but not installed.'); + } + + try { + execSync('pnpm --version', { stdio: 'ignore' }); + } catch { + throw new Error('pnpm is required but not installed.'); + } + } + + private installDependencies(projectDir: string): Promise { + return new Promise((resolve, reject) => { + try { + execSync('pnpm install', { cwd: projectDir, stdio: 'inherit' }); + resolve(); + } catch (error) { + if (error instanceof Error) { + reject(error); + } else { + reject(new Error('Failed to install dependencies')); + } + } + }); + } +} diff --git a/packages/create-catalyst/src/services/tests/bigcommerce.test.ts b/packages/create-catalyst/src/services/tests/bigcommerce.test.ts new file mode 100644 index 000000000..715e5204c --- /dev/null +++ b/packages/create-catalyst/src/services/tests/bigcommerce.test.ts @@ -0,0 +1,15 @@ +import { BigCommerceServiceImpl } from '../bigcommerce'; + +describe('BigCommerceService', () => { + const config = { + bigCommerceApiUrl: 'https://api.bigcommerce.com', + bigCommerceAuthUrl: 'https://login.bigcommerce.com', + cliApiUrl: 'https://cli-api.bigcommerce.com', + }; + + const service = new BigCommerceServiceImpl(config); + + it('should initialize correctly', () => { + expect(service).toBeInstanceOf(BigCommerceServiceImpl); + }); +}); diff --git a/packages/create-catalyst/src/services/tests/git.test.ts b/packages/create-catalyst/src/services/tests/git.test.ts new file mode 100644 index 000000000..d186098f7 --- /dev/null +++ b/packages/create-catalyst/src/services/tests/git.test.ts @@ -0,0 +1,99 @@ +import { execSync } from 'child_process'; + +import { GitServiceImpl } from '../git'; + +jest.mock('child_process'); + +describe('GitService', () => { + const service = new GitServiceImpl(); + const mockExecSync = jest.mocked(execSync); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('clone', () => { + it('should clone a repository with HTTPS', () => { + mockExecSync.mockReturnValueOnce(Buffer.from('')); + + service.clone('bigcommerce/catalyst', 'my-project', '/path/to/project'); + + expect(mockExecSync).toHaveBeenCalledWith( + 'git clone https://github.com/bigcommerce/catalyst.git my-project', + { stdio: 'inherit' }, + ); + }); + + it('should clone a repository with SSH if available', () => { + mockExecSync.mockReturnValueOnce(Buffer.from('successfully authenticated')); + + service.clone('bigcommerce/catalyst', 'my-project', '/path/to/project'); + + expect(mockExecSync).toHaveBeenCalledWith( + 'git clone git@github.com:bigcommerce/catalyst.git my-project', + { stdio: 'inherit' }, + ); + }); + }); + + describe('checkoutRef', () => { + it('should checkout a ref', () => { + service.checkoutRef('/path/to/project', 'main'); + + expect(mockExecSync).toHaveBeenCalledWith('git checkout main', { + cwd: '/path/to/project', + stdio: 'inherit', + encoding: 'utf8', + }); + }); + }); + + describe('resetBranchToRef', () => { + it('should reset a branch to a ref', () => { + service.resetBranchToRef('/path/to/project', 'main'); + + expect(mockExecSync).toHaveBeenCalledWith('git reset --hard main', { + cwd: '/path/to/project', + stdio: 'inherit', + encoding: 'utf8', + }); + }); + }); + + describe('hasGitHubSSH', () => { + it('should return true if SSH is configured', () => { + mockExecSync.mockReturnValueOnce(Buffer.from('successfully authenticated')); + + const result = service.hasGitHubSSH(); + + expect(result).toBe(true); + }); + + it('should return true if SSH is configured but command fails with success message', () => { + const error = new Error('Command failed'); + + Object.assign(error, { + stdout: 'successfully authenticated', + stderr: '', + }); + + mockExecSync.mockImplementationOnce(() => { + throw error; + }); + + const result = service.hasGitHubSSH(); + + expect(result).toBe(true); + }); + + it('should return false if SSH is not configured', () => { + mockExecSync.mockImplementationOnce(() => { + throw new Error('Command failed'); + }); + + const result = service.hasGitHubSSH(); + + expect(result).toBe(false); + }); + }); +}); diff --git a/packages/create-catalyst/src/services/tests/project.test.ts b/packages/create-catalyst/src/services/tests/project.test.ts new file mode 100644 index 000000000..7a6a5a5c1 --- /dev/null +++ b/packages/create-catalyst/src/services/tests/project.test.ts @@ -0,0 +1,57 @@ +import type { BigCommerceService } from '../../types'; +import { GitServiceImpl } from '../git'; +import { ProjectServiceImpl } from '../project'; + +jest.mock('../git'); + +describe('ProjectService', () => { + const gitService = new GitServiceImpl(); + const mockBigCommerceService = { + getChannels: jest.fn().mockResolvedValue([ + { + id: 1, + name: 'Test Channel', + platform: 'catalyst' as const, + type: 'storefront' as const, + storefront_api_token: 'token', + site: { url: 'https://example.com' }, + }, + ]), + createChannel: jest.fn().mockResolvedValue({ + id: 1, + name: 'Test Channel', + platform: 'catalyst' as const, + type: 'storefront' as const, + storefront_api_token: 'token', + site: { url: 'https://example.com' }, + }), + checkEligibility: jest.fn().mockResolvedValue({ + eligible: true, + message: 'Store is eligible', + }), + login: jest.fn().mockResolvedValue({ + storeHash: 'test-store', + accessToken: 'test-token', + }), + updateCredentials: jest.fn(), + } satisfies BigCommerceService; + + const projectService = new ProjectServiceImpl(gitService, mockBigCommerceService); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('validateProjectName', () => { + it('should return true for valid project names', () => { + expect(projectService.validateProjectName('my-project')).toBe(true); + expect(projectService.validateProjectName('project123')).toBe(true); + expect(projectService.validateProjectName('My Project')).toBe(true); + }); + + it('should return false for invalid project names', () => { + expect(projectService.validateProjectName('')).toBe(false); + expect(projectService.validateProjectName(' ')).toBe(false); + }); + }); +}); diff --git a/packages/create-catalyst/src/test-mocks/chalk.ts b/packages/create-catalyst/src/test-mocks/chalk.ts new file mode 100644 index 000000000..22cbab4db --- /dev/null +++ b/packages/create-catalyst/src/test-mocks/chalk.ts @@ -0,0 +1,7 @@ +const chalk = { + green: (text: string) => text, + yellow: (text: string) => text, + red: (text: string) => text, +}; + +export default chalk; diff --git a/packages/create-catalyst/src/test/setup.ts b/packages/create-catalyst/src/test/setup.ts new file mode 100644 index 000000000..75db769cd --- /dev/null +++ b/packages/create-catalyst/src/test/setup.ts @@ -0,0 +1,15 @@ +// Mock fs-extra/esm +jest.mock('fs-extra/esm', () => ({ + pathExistsSync: jest.fn().mockReturnValue(true), +})); + +// Mock @inquirer/prompts +jest.mock('@inquirer/prompts', () => ({ + input: jest.fn().mockResolvedValue('test-project'), + select: jest.fn().mockResolvedValue(true), +})); + +// Mock child_process +jest.mock('child_process', () => ({ + execSync: jest.fn(), +})); diff --git a/packages/create-catalyst/src/types.ts b/packages/create-catalyst/src/types.ts new file mode 100644 index 000000000..76f062b59 --- /dev/null +++ b/packages/create-catalyst/src/types.ts @@ -0,0 +1,61 @@ +import type { Channel, CreateChannelResponse, EligibilityResponse } from './utils/https'; + +// Core configuration types +export interface Config { + bigCommerceHostname: string; + sampleDataApiUrl: string; +} + +// Command options types +export interface CreateCommandOptions { + projectName?: string; + projectDir?: string; + storeHash?: string; + accessToken?: string; + channelId?: string; + storefrontToken?: string; + ghRef?: string; + resetMain?: boolean; + repository?: string; + env?: string[]; + bigcommerceHostname?: string; + sampleDataApiUrl?: string; + cliApiHostname?: string; +} + +export interface InitCommandOptions { + storeHash?: string; + accessToken?: string; + bigcommerceHostname?: string; + sampleDataApiUrl?: string; + cliApiHostname?: string; +} + +export interface TelemetryCommandOptions { + enable?: boolean; + disable?: boolean; +} + +export interface IntegrationCommandOptions { + commitHash?: string; +} + +export interface BigCommerceCredentials { + storeHash: string; + accessToken: string; +} + +export interface BigCommerceService { + login(authUrl: string): Promise; + getChannels(): Promise; + createChannel(name: string): Promise; + checkEligibility(): Promise; + updateCredentials(storeHash: string, accessToken: string): void; +} + +export interface GitService { + clone(repository: string, name: string, directory: string): void; + checkoutRef(directory: string, ref: string): void; + resetBranchToRef(directory: string, ref: string): void; + hasGitHubSSH(): boolean; +} diff --git a/packages/create-catalyst/src/types/index.ts b/packages/create-catalyst/src/types/index.ts new file mode 100644 index 000000000..29097001d --- /dev/null +++ b/packages/create-catalyst/src/types/index.ts @@ -0,0 +1,91 @@ +// Core configuration types +export interface Config { + bigCommerceHostname: string; + sampleDataApiUrl: string; +} + +// Command options types +export interface CreateCommandOptions { + projectName?: string; + projectDir?: string; + storeHash?: string; + accessToken?: string; + channelId?: string; + storefrontToken?: string; + ghRef?: string; + resetMain?: boolean; + repository?: string; + env?: string[]; + bigcommerceHostname?: string; + sampleDataApiUrl?: string; + cliApiHostname?: string; +} + +export interface InitCommandOptions { + storeHash?: string; + accessToken?: string; + bigcommerceHostname?: string; + sampleDataApiUrl?: string; + cliApiHostname?: string; +} + +export interface TelemetryCommandOptions { + enable?: boolean; + disable?: boolean; +} + +export interface IntegrationCommandOptions { + commitHash?: string; +} + +// Service interfaces +export interface GitService { + clone(repository: string, projectName: string, projectDir: string): void; + checkoutRef(projectDir: string, ref: string): void; + resetBranchToRef(projectDir: string, ref: string): void; + hasGitHubSSH(): boolean; +} + +export interface ProjectService { + create(options: CreateCommandOptions): Promise; + init(options: InitCommandOptions): Promise; + validateProjectName(name: string): boolean; + validateProjectDir(dir: string): boolean; +} + +export interface TelemetryService { + track(eventName: string, payload: Record): Promise; + identify(storeHash: string): Promise; + setEnabled(enabled: boolean): void; + isEnabled(): boolean; +} + +export interface BigCommerceService { + getChannels(): Promise< + Array<{ + id: number; + name: string; + platform: 'catalyst'; + type: 'storefront'; + storefront_api_token: string; + site: { url: string }; + deployment?: { id: string; url: string; created_at: string }; + makeswift_api_key?: string; + envVars?: Record; + }> + >; + createChannel(name: string): Promise<{ + id: number; + name: string; + platform: 'catalyst'; + type: 'storefront'; + storefront_api_token: string; + site: { url: string }; + deployment?: { id: string; url: string; created_at: string }; + makeswift_api_key?: string; + envVars?: Record; + }>; + checkEligibility(): Promise<{ eligible: boolean; message: string }>; + login(authUrl: string): Promise<{ storeHash: string; accessToken: string }>; + updateCredentials(storeHash: string, accessToken: string): void; +} diff --git a/packages/create-catalyst/src/utils/https.ts b/packages/create-catalyst/src/utils/https.ts index 8e48f9653..3ec9d76c0 100644 --- a/packages/create-catalyst/src/utils/https.ts +++ b/packages/create-catalyst/src/utils/https.ts @@ -1,330 +1,140 @@ -/* eslint-disable @typescript-eslint/unified-signatures */ - -import chalk from 'chalk'; -import { z } from 'zod'; - -import { parse } from './parse'; -import { getCLIUserAgent } from './user-agent'; - -interface BigCommerceRestApiConfig { - bigCommerceApiUrl: string; - storeHash: string; - accessToken: string; +interface Channel { + id: number; + name: string; + platform: 'catalyst' | 'next' | 'bigcommerce'; + type: 'storefront'; + storefront_api_token: string; } -interface SampleDataApiConfig { - sampleDataApiUrl: string; - storeHash: string; - accessToken: string; +interface EligibilityResponse { + data: { + eligible: boolean; + total_channels: number; + active_channels: number; + channel_limit: number; + active_channel_limit: number; + message: string; + }; } -interface DeviceOAuthConfig { - bigCommerceAuthUrl: string; +interface CreateChannelResponse { + data: { + id: number; + name: string; + platform: 'catalyst'; + type: 'storefront'; + storefront_api_token: string; + site: { + url: string; + }; + deployment?: { + id: string; + url: string; + created_at: string; + }; + makeswift_api_key?: string; + envVars?: { + BIGCOMMERCE_STORE_HASH: string; + BIGCOMMERCE_CUSTOMER_IMPERSONATION_TOKEN: string; + BIGCOMMERCE_STOREFRONT_TOKEN: string; + BIGCOMMERCE_CHANNEL_ID: string; + MAKESWIFT_SITE_API_KEY: string; + TRAILING_SLASH: string; + }; + }; } -interface HttpsConfig { - bigCommerceApiUrl?: string; - bigCommerceAuthUrl?: string; - sampleDataApiUrl?: string; - storeHash?: string; - accessToken?: string; +interface InitResponse { + data: { + makeswift_dev_api_key: string; + }; } -const BigCommerceStoreInfo = z.object({ - features: z.object({ - storefront_limits: z.object({ - active: z.number(), - total_including_inactive: z.number(), - }), - }), -}); - -export type BigCommerceStoreInfo = z.infer; - -const BigCommerceV3ApiResponseSchema = (schema: z.ZodType) => - z.object({ - data: schema, - meta: z.object({}), - }); +interface ChannelsResponse { + data: Channel[]; +} -const BigCommerceChannelsV3ResponseSchema = BigCommerceV3ApiResponseSchema( - z.array( - z.object({ - id: z.number(), - name: z.string(), - status: z.string(), - platform: z.string(), - }), - ), -); +interface DeviceCodeResponse { + device_code: string; + user_code: string; + verification_uri: string; + expires_in: number; + interval: number; +} -export type BigCommerceChannelsV3Response = z.infer; +interface DeviceTokenResponse { + store_hash: string; + access_token: string; +} export class Https { - bigCommerceApiUrl: string; - bigCommerceAuthUrl: string; - sampleDataApiUrl: string; - storeHash: string; - accessToken: string; - userAgent: string; - - private DEVICE_OAUTH_CLIENT_ID = 'acse0vvawm9r1n0evag4b8e1ea1fo90'; - private MAX_EPOC_EXPIRES_AT = 2147483647; - - constructor({ bigCommerceApiUrl, storeHash, accessToken }: BigCommerceRestApiConfig); - constructor({ sampleDataApiUrl, storeHash, accessToken }: SampleDataApiConfig); - constructor({ bigCommerceAuthUrl }: DeviceOAuthConfig); - constructor({ - bigCommerceApiUrl, - bigCommerceAuthUrl, - sampleDataApiUrl, - storeHash, - accessToken, - }: HttpsConfig) { - this.bigCommerceApiUrl = bigCommerceApiUrl ?? ''; - this.bigCommerceAuthUrl = bigCommerceAuthUrl ?? ''; - this.sampleDataApiUrl = sampleDataApiUrl ?? ''; - this.storeHash = storeHash ?? ''; - this.accessToken = accessToken ?? ''; - this.userAgent = getCLIUserAgent(); + constructor( + private readonly config: { + bigCommerceApiUrl: string; + storeHash?: string; + accessToken?: string; + }, + ) {} + + async get(path: string): Promise { + return this.request(path, { method: 'GET' }); } - auth(path: string, opts: RequestInit = {}) { - if (!this.bigCommerceAuthUrl) { - throw new Error('bigCommerceAuthUrl is required to make API requests'); - } - - const { headers = {}, ...rest } = opts; - - const options = { + async post(path: string, body: unknown): Promise { + return this.request(path, { method: 'POST', - headers: { - ...headers, - Accept: 'application/json', - 'Content-Type': 'application/json', - 'User-Agent': this.userAgent, - }, - ...rest, - }; - - return fetch(`${this.bigCommerceAuthUrl}${path}`, options); - } - - async getDeviceCode() { - const response = await this.auth('/device/token', { - body: JSON.stringify({ - scopes: [ - 'store_channel_settings', - 'store_sites', - 'store_storefront_api', - 'store_v2_content', - 'store_v2_information', - 'store_v2_products', - 'store_cart', - ].join(' '), - client_id: this.DEVICE_OAUTH_CLIENT_ID, - }), + body: JSON.stringify(body), }); - - if (!response.ok) { - console.error( - chalk.red(`\nPOST /device/token failed: ${response.status} ${response.statusText}\n`), - ); - process.exit(1); - } - - const DeviceCodeSchema = z.object({ - device_code: z.string(), - user_code: z.string(), - verification_uri: z.string(), - expires_in: z.number(), - interval: z.number(), - }); - - return parse(await response.json(), DeviceCodeSchema); } - async checkDeviceCode(deviceCode: string) { - const response = await this.auth('/device/token', { - body: JSON.stringify({ - device_code: deviceCode, - client_id: this.DEVICE_OAUTH_CLIENT_ID, - }), - }); - - if (response.status !== 200) { - throw new Error('Device code not yet verified'); - } - - const DeviceCodeSuccessSchema = z.object({ - access_token: z.string(), - store_hash: z.string(), - context: z.string(), - api_uri: z.string().url(), - }); - - return parse(await response.json(), DeviceCodeSuccessSchema); + async getDeviceCode(): Promise { + return this.post('/oauth/device', {}); } - api(path: string, opts: RequestInit = {}) { - if (!this.bigCommerceApiUrl || !this.storeHash || !this.accessToken) { - throw new Error( - 'bigCommerceApiUrl, storeHash, and accessToken are required to make API requests', - ); - } - - const { headers = {}, ...rest } = opts; - - const options = { - headers: { - ...headers, - Accept: 'application/json', - 'X-Auth-Token': this.accessToken, - 'User-Agent': this.userAgent, - }, - ...rest, - }; - - return fetch(`${this.bigCommerceApiUrl}/stores/${this.storeHash}${path}`, options); + async checkDeviceCode(deviceCode: string): Promise { + return this.post('/oauth/device/token', { device_code: deviceCode }); } - async storeInformation() { - const res = await this.api('/v2/store'); - - if (!res.ok) { - console.error(chalk.red(`\nGET /v2/store failed: ${res.status} ${res.statusText}\n`)); - process.exit(1); - } - - return parse(await res.json(), BigCommerceStoreInfo); - } - - async channels(query = '') { - const res = await this.api(`/v3/channels${query}`); - - if (!res.ok) { - console.error(chalk.red(`\nGET /v3/channels failed: ${res.status} ${res.statusText}\n`)); - process.exit(1); - } - - return parse(await res.json(), BigCommerceChannelsV3ResponseSchema); - } - - async createChannelMenus(channelId: number) { - const res = await this.api(`/v3/channels/${channelId}/channel-menus`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - bigcommerce_protected_app_sections: [ - 'storefront_settings', - 'currencies', - 'domains', - 'notifications', - 'social', - ], - }), - }); - - if (!res.ok) { - console.warn( - chalk.yellow( - `\nFailed to create channel menus: ${res.status} ${res.statusText}. You may want to create these later: https://developer.bigcommerce.com/docs/rest-management/channels/menus#create-channel-menus\n`, - ), - ); - } - } - - async storefrontToken() { - const res = await this.api('/v3/storefront/api-token', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ expires_at: this.MAX_EPOC_EXPIRES_AT, channel_ids: [] }), - }); - - if (!res.ok) { - console.error( - chalk.red(`\nPOST /v3/storefront/api-token failed: ${res.status} ${res.statusText}\n`), - ); - process.exit(1); - } - - const BigCommerceStorefrontTokenSchema = z.object({ - data: z.object({ - token: z.string(), - }), - }); - - return parse(await res.json(), BigCommerceStorefrontTokenSchema); - } - - sampleDataApi(path: string, opts: RequestInit = {}) { - if (!this.sampleDataApiUrl || !this.storeHash || !this.accessToken) { - throw new Error( - 'sampleDataApiUrl, storeHash, and accessToken are required to make API requests', - ); - } - - const { headers = {}, ...rest } = opts; - - const options = { - method: 'POST', - headers: { - ...headers, - Accept: 'application/json', - 'Content-Type': 'application/json', - 'X-Auth-Token': this.accessToken, - 'User-Agent': this.userAgent, - }, - ...rest, + private async request(path: string, options: RequestInit = {}): Promise { + const headers = { + 'Content-Type': 'application/json', + ...(this.config.accessToken ? { 'X-Auth-Token': this.config.accessToken } : {}), + ...(options.headers ? Object.fromEntries(Object.entries(options.headers)) : {}), }; - return fetch(`${this.sampleDataApiUrl}/stores/${this.storeHash}${path}`, options); - } - - async checkEligibility() { - const res = await this.sampleDataApi('/v3/channels/catalyst/eligibility', { - method: 'GET', + const response = await fetch(`${this.config.bigCommerceApiUrl}${path}`, { + ...options, + headers, }); - if (!res.ok) { - console.error( - chalk.red( - `\nGET /v3/channels/catalyst/eligibility failed: ${res.status} ${res.statusText}\n`, - ), - ); - process.exit(1); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); } - const CheckEligibilitySchema = z.object({ - data: z.object({ - eligible: z.boolean(), - message: z.string(), - }), - }); + const data: unknown = await response.json(); - return parse(await res.json(), CheckEligibilitySchema); - } - - async createChannel(channelName: string) { - const res = await this.sampleDataApi('/v3/channels/catalyst', { - body: JSON.stringify({ name: channelName, tokenType: 'normal' }), - }); - - if (!res.ok) { - console.error( - chalk.red(`\nPOST /v3/channels/catalyst failed: ${res.status} ${res.statusText}\n`), - ); - process.exit(1); + if (!this.isValidResponse(data)) { + throw new Error('Invalid response data'); } - const SampleDataChannelCreateSchema = z.object({ - data: z.object({ - id: z.number(), - name: z.string().min(1), - storefront_api_token: z.string(), - }), - }); + // We need to trust that the caller knows the shape of T + // This is a common pattern in TypeScript when dealing with JSON responses + // where the exact shape is known by the caller but not by the generic function + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return data as T; + } - return parse(await res.json(), SampleDataChannelCreateSchema); + private isValidResponse(data: unknown): data is Record { + return data !== null && typeof data === 'object'; } } + +export type { + Channel, + EligibilityResponse, + CreateChannelResponse, + ChannelsResponse, + InitResponse, + DeviceCodeResponse, + DeviceTokenResponse, +}; diff --git a/packages/create-catalyst/src/utils/login.ts b/packages/create-catalyst/src/utils/login.ts index 819e5953b..703c7dfb5 100644 --- a/packages/create-catalyst/src/utils/login.ts +++ b/packages/create-catalyst/src/utils/login.ts @@ -46,7 +46,7 @@ export const login = async ( return { storeHash, accessToken }; } - const auth = new Https({ bigCommerceAuthUrl }); + const auth = new Https({ bigCommerceApiUrl: bigCommerceAuthUrl }); const deviceCode = await auth.getDeviceCode();