From 105a999333e17fa054a3747cba5a435234369033 Mon Sep 17 00:00:00 2001 From: jordanarldt Date: Thu, 30 Apr 2026 13:20:44 -0500 Subject: [PATCH] TRAC-137: Add native hosting option in create-catalyst --- .../create-catalyst/src/commands/create.ts | 208 +++++- packages/create-catalyst/src/commands/init.ts | 1 + packages/create-catalyst/src/utils/auth.ts | 3 + .../src/utils/cli-api-errors.ts | 6 + .../create-catalyst/src/utils/cli-api.spec.ts | 265 +++++++ packages/create-catalyst/src/utils/cli-api.ts | 107 ++- .../prompt-commerce-hosting-project.spec.ts | 655 ++++++++++++++++++ .../utils/prompt-commerce-hosting-project.ts | 214 ++++++ .../src/utils/setup-commerce-hosting.spec.ts | 289 ++++++++ .../src/utils/setup-commerce-hosting.ts | 103 +++ 10 files changed, 1849 insertions(+), 2 deletions(-) create mode 100644 packages/create-catalyst/src/utils/cli-api-errors.ts create mode 100644 packages/create-catalyst/src/utils/cli-api.spec.ts create mode 100644 packages/create-catalyst/src/utils/prompt-commerce-hosting-project.spec.ts create mode 100644 packages/create-catalyst/src/utils/prompt-commerce-hosting-project.ts create mode 100644 packages/create-catalyst/src/utils/setup-commerce-hosting.spec.ts create mode 100644 packages/create-catalyst/src/utils/setup-commerce-hosting.ts diff --git a/packages/create-catalyst/src/commands/create.ts b/packages/create-catalyst/src/commands/create.ts index 06665575a7..e870e554df 100644 --- a/packages/create-catalyst/src/commands/create.ts +++ b/packages/create-catalyst/src/commands/create.ts @@ -13,9 +13,13 @@ import { Https } from '../utils/https'; import { installDependencies } from '../utils/install-dependencies'; import { getAvailableLocales } from '../utils/localization'; import { login, storeCredentials } from '../utils/login'; +import { promptForCommerceHostingProject } from '../utils/prompt-commerce-hosting-project'; +import { setupCommerceHosting } from '../utils/setup-commerce-hosting'; import { Telemetry } from '../utils/telemetry/telemetry'; import { writeEnv } from '../utils/write-env'; +type HostingMode = 'self-hosted' | 'commerce'; + interface Channel { id: number; name: string; @@ -271,7 +275,7 @@ async function setupProject(options: { }; await input({ - message: 'What is the name of your project?', + message: 'What do you want to name your project directory?', default: 'my-catalyst-app', validate: validateProjectName, }); @@ -283,6 +287,145 @@ async function setupProject(options: { return { projectName, projectDir }; } +type HostingResolution = + | { mode: 'self-hosted' } + | { mode: 'commerce'; projectUuid: string; accessToken: string }; + +async function resolveProjectsAccess( + cliApi: CliApi, + hostingFlag: HostingMode | undefined, +): Promise { + try { + return await cliApi.hasProjectsAccess(); + } catch (error) { + const message = error instanceof Error ? error.message : 'unknown error'; + + if (hostingFlag === 'commerce') { + console.error( + colorize( + 'red', + `\nFailed to verify Infrastructure Projects API access: ${message}\nPlease try again.\n`, + ), + ); + process.exit(1); + } + + console.warn( + colorize( + 'yellow', + `\nCould not verify Infrastructure Projects API access: ${message}\nDefaulting to self-hosted. Re-run create-catalyst if you intended to use Commerce-hosted.\n`, + ), + ); + + return null; + } +} + +async function shouldUseCommerceHosting( + hostingFlag: HostingMode | undefined, + hasAccess: boolean, +): Promise { + if (hostingFlag === 'commerce') return true; + + if (hostingFlag === undefined && hasAccess) { + return select({ + message: 'How would you like to host your Catalyst storefront?', + choices: [ + { + name: 'Self-hosted', + value: false, + description: + 'Use standard next dev / build / start. You will host the app yourself and deploy it to a provider of your choice.', + }, + { + name: 'Commerce-hosted', + value: true, + description: + 'Use catalyst dev / build / deploy. Commerce will host the app and handle deployment for you.', + }, + ], + }); + } + + return false; +} + +async function resolveHostingMode({ + hostingFlag, + storeHash, + accessToken, + cliApiOrigin, + apiHostname, + defaultProjectName, + autoUseProjectName, + useExistingOnCollision, +}: { + hostingFlag?: HostingMode; + storeHash?: string; + accessToken?: string; + cliApiOrigin: string; + apiHostname: string; + defaultProjectName: string; + autoUseProjectName?: boolean; + useExistingOnCollision?: boolean; +}): Promise { + if (hostingFlag === 'self-hosted') { + return { mode: 'self-hosted' }; + } + + if (!storeHash || !accessToken) { + if (hostingFlag === 'commerce') { + console.error( + colorize( + 'red', + '\n--hosting commerce requires store credentials (store hash and access token)\n', + ), + ); + process.exit(1); + } + + return { mode: 'self-hosted' }; + } + + const cliApi = new CliApi({ + origin: cliApiOrigin, + storeHash, + accessToken, + apiHostname, + }); + + const hasAccess = await resolveProjectsAccess(cliApi, hostingFlag); + + if (hasAccess === null) { + return { mode: 'self-hosted' }; + } + + if (hostingFlag === 'commerce' && !hasAccess) { + console.error( + colorize( + 'red', + '\nThis store does not have access to the Infrastructure Projects API. Contact support@bigcommerce.com to enable it.\n', + ), + ); + process.exit(1); + } + + const useCommerceHosting = await shouldUseCommerceHosting(hostingFlag, hasAccess); + + if (!useCommerceHosting) { + return { mode: 'self-hosted' }; + } + + const project = await promptForCommerceHostingProject( + cliApi, + defaultProjectName, + autoUseProjectName, + useExistingOnCollision, + ); + + return { mode: 'commerce', projectUuid: project.uuid, accessToken }; +} + function checkRequiredTools() { try { execSync(getPlatformCheckCommand('git'), { stdio: 'ignore' }); @@ -316,6 +459,16 @@ export const create = new Command('create') .option('--reset-main', 'Reset the main branch to the gh-ref') .option('--repository ', 'GitHub repository to clone from', 'bigcommerce/catalyst') .option('--env ', 'Arbitrary environment variables to set in .env.local') + .addOption( + new Option( + '--hosting ', + 'Hosting mode: "self-hosted" or "commerce" for Commerce Hosting.', + ).choices(['self-hosted', 'commerce'] as const), + ) + .option( + '--use-existing', + '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.', + ) .addOption( new Option('--bigcommerce-hostname ', 'BigCommerce hostname') .default('bigcommerce.com') @@ -330,6 +483,15 @@ export const create = new Command('create') .action(async (options) => { const { ghRef, repository } = options; + if (options.useExisting && options.hosting !== 'commerce') { + console.warn( + colorize( + 'yellow', + '\nWarning: --use-existing has no effect without --hosting commerce. Ignoring.\n', + ), + ); + } + checkRequiredTools(); const { projectName, projectDir } = await setupProject({ @@ -363,6 +525,16 @@ export const create = new Command('create') envVars.BIGCOMMERCE_STOREFRONT_API_TOKEN = storefrontToken; } else { if (!storeHash || !accessToken) { + if (options.hosting === 'commerce') { + console.error( + colorize( + 'red', + '\n--hosting commerce requires store credentials (store hash and access token)\n', + ), + ); + process.exit(1); + } + // Create project without credentials console.log(`\nCreating '${projectName}' at '${projectDir}'\n`); cloneCatalyst({ repository, projectName, projectDir, ghRef, resetMain: options.resetMain }); @@ -418,6 +590,7 @@ export const create = new Command('create') origin: options.cliApiOrigin, storeHash, accessToken, + apiHostname: `api.${options.bigcommerceHostname}`, }); // If we have channelId but no storefrontToken, just get the init data @@ -523,9 +696,34 @@ export const create = new Command('create') if (!channelId) throw new Error('Something went wrong, channelId is not defined'); if (!storefrontToken) throw new Error('Something went wrong, storefrontToken is not defined'); + const hosting = await resolveHostingMode({ + hostingFlag: options.hosting, + storeHash, + accessToken, + cliApiOrigin: options.cliApiOrigin, + apiHostname: `api.${options.bigcommerceHostname}`, + defaultProjectName: projectName, + autoUseProjectName: !!options.projectName, + useExistingOnCollision: options.useExisting, + }); + + if (hosting.mode === 'commerce') { + envVars.BIGCOMMERCE_ACCESS_TOKEN = hosting.accessToken; + } + // Create the project with all necessary configuration console.log(`\nCreating '${projectName}' at '${projectDir}'\n`); cloneCatalyst({ repository, projectName, projectDir, ghRef, resetMain: options.resetMain }); + + if (hosting.mode === 'commerce') { + setupCommerceHosting({ + projectDir, + projectUuid: hosting.projectUuid, + storeHash, + accessToken: hosting.accessToken, + }); + } + await installDependencies(projectDir); // Write env vars @@ -540,6 +738,14 @@ export const create = new Command('create') colorize('green', `\nSuccess! Created '${projectName}' at '${projectDir}'\n`), '\nNext steps:\n', colorize('yellow', `\ncd ${projectName} && pnpm run dev\n`), + ...(hosting.mode === 'commerce' + ? [ + colorize( + 'yellow', + `\nRun 'cd ${projectName}/core && pnpm run deploy' when ready to deploy to Commerce hosting.\n`, + ), + ] + : []), ); }); diff --git a/packages/create-catalyst/src/commands/init.ts b/packages/create-catalyst/src/commands/init.ts index 65d7d49975..a98d4abc74 100644 --- a/packages/create-catalyst/src/commands/init.ts +++ b/packages/create-catalyst/src/commands/init.ts @@ -109,6 +109,7 @@ export const init = new Command('init') origin: options.cliApiOrigin, storeHash, accessToken, + apiHostname: `api.${options.bigcommerceHostname}`, }); const channelSortOrder = ['catalyst', 'next', 'bigcommerce']; diff --git a/packages/create-catalyst/src/utils/auth.ts b/packages/create-catalyst/src/utils/auth.ts index c43bc05449..606a12e8ee 100644 --- a/packages/create-catalyst/src/utils/auth.ts +++ b/packages/create-catalyst/src/utils/auth.ts @@ -28,6 +28,9 @@ export class Auth { 'store_v2_information', 'store_v2_products', 'store_cart', + 'store_infrastructure_projects_manage', + 'store_infrastructure_deployments_manage', + 'store_infrastructure_logs_read_only', ].join(' '), client_id: this.DEVICE_OAUTH_CLIENT_ID, }), diff --git a/packages/create-catalyst/src/utils/cli-api-errors.ts b/packages/create-catalyst/src/utils/cli-api-errors.ts new file mode 100644 index 0000000000..c44e040ebe --- /dev/null +++ b/packages/create-catalyst/src/utils/cli-api-errors.ts @@ -0,0 +1,6 @@ +export class InfrastructureProjectValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'InfrastructureProjectValidationError'; + } +} diff --git a/packages/create-catalyst/src/utils/cli-api.spec.ts b/packages/create-catalyst/src/utils/cli-api.spec.ts new file mode 100644 index 0000000000..fdc4f91658 --- /dev/null +++ b/packages/create-catalyst/src/utils/cli-api.spec.ts @@ -0,0 +1,265 @@ +import { CliApi } from './cli-api'; +import { InfrastructureProjectValidationError } from './cli-api-errors'; + +const fetchMock = jest.spyOn(globalThis, 'fetch'); + +beforeEach(() => { + fetchMock.mockReset(); +}); + +afterAll(() => { + fetchMock.mockRestore(); +}); + +function makeResponse(status: number, body?: unknown): Response { + return new Response(body === undefined ? null : JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function makeCliApi() { + return new CliApi({ + origin: 'https://cli.example', + storeHash: 'abc', + accessToken: 'token', + apiHostname: 'api.example.com', + }); +} + +describe('CliApi.checkProjectsAccess', () => { + it('returns true on 200', async () => { + fetchMock.mockResolvedValueOnce(makeResponse(200, { data: [] })); + + await expect(makeCliApi().hasProjectsAccess()).resolves.toBe(true); + }); + + it('returns false on 403', async () => { + fetchMock.mockResolvedValueOnce(makeResponse(403, { detail: 'forbidden' })); + + await expect(makeCliApi().hasProjectsAccess()).resolves.toBe(false); + }); + + it('throws on 500 with status info in the message', async () => { + fetchMock.mockResolvedValueOnce( + new Response(null, { status: 500, statusText: 'Internal Server Error' }), + ); + + await expect(makeCliApi().hasProjectsAccess()).rejects.toThrow( + /GET \/v3\/infrastructure\/projects failed: 500 Internal Server Error/, + ); + }); + + it('throws on 401', async () => { + fetchMock.mockResolvedValueOnce( + new Response(null, { status: 401, statusText: 'Unauthorized' }), + ); + + await expect(makeCliApi().hasProjectsAccess()).rejects.toThrow(/401 Unauthorized/); + }); + + it('propagates network errors from fetch', async () => { + fetchMock.mockRejectedValueOnce(new Error('network unreachable')); + + await expect(makeCliApi().hasProjectsAccess()).rejects.toThrow('network unreachable'); + }); + + it('calls the correct URL with the auth token', async () => { + fetchMock.mockResolvedValueOnce(makeResponse(200, { data: [] })); + + await makeCliApi().hasProjectsAccess(); + + const [url, init] = fetchMock.mock.calls[0] ?? []; + const headers = new Headers(init?.headers); + + expect(url).toBe('https://api.example.com/stores/abc/v3/infrastructure/projects'); + expect(init?.method).toBe('GET'); + expect(headers.get('X-Auth-Token')).toBe('token'); + }); +}); + +describe('CliApi.listInfrastructureProjects', () => { + it('returns the project list on success', async () => { + fetchMock.mockResolvedValueOnce( + makeResponse(200, { + data: [ + { uuid: 'a', name: 'first' }, + { uuid: 'b', name: 'second' }, + ], + }), + ); + + await expect(makeCliApi().listInfrastructureProjects()).resolves.toEqual([ + { uuid: 'a', name: 'first' }, + { uuid: 'b', name: 'second' }, + ]); + }); + + it('returns an empty array when the store has no projects yet', async () => { + fetchMock.mockResolvedValueOnce(makeResponse(200, { data: [] })); + + await expect(makeCliApi().listInfrastructureProjects()).resolves.toEqual([]); + }); + + it('throws a wrapped error on non-OK responses', async () => { + fetchMock.mockResolvedValueOnce( + new Response(null, { status: 500, statusText: 'Internal Server Error' }), + ); + + await expect(makeCliApi().listInfrastructureProjects()).rejects.toThrow( + /Could not load Commerce Hosting projects: 500 Internal Server Error/, + ); + }); + + it('wraps schema-parse failures in the same friendly error', async () => { + fetchMock.mockResolvedValueOnce(makeResponse(200, { data: [{ uuid: 'only-uuid' }] })); + + await expect(makeCliApi().listInfrastructureProjects()).rejects.toThrow( + /Could not load Commerce Hosting projects:/, + ); + }); + + it('wraps network/fetch failures in the same friendly error', async () => { + fetchMock.mockRejectedValueOnce(new TypeError('fetch failed')); + + await expect(makeCliApi().listInfrastructureProjects()).rejects.toThrow( + /Could not load Commerce Hosting projects: fetch failed/, + ); + }); + + it('preserves the original error as cause for debugging', async () => { + const original = new TypeError('fetch failed'); + + fetchMock.mockRejectedValueOnce(original); + + await expect(makeCliApi().listInfrastructureProjects()).rejects.toMatchObject({ + cause: original, + }); + }); +}); + +describe('CliApi.createInfrastructureProject', () => { + it('returns the project data on success', async () => { + fetchMock.mockResolvedValueOnce( + makeResponse(200, { data: { uuid: 'proj-uuid', name: 'my-project' } }), + ); + + await expect(makeCliApi().createInfrastructureProject('my-project')).resolves.toEqual({ + uuid: 'proj-uuid', + name: 'my-project', + }); + }); + + it('POSTs the name as JSON', async () => { + fetchMock.mockResolvedValueOnce(makeResponse(200, { data: { uuid: 'u', name: 'my-project' } })); + + await makeCliApi().createInfrastructureProject('my-project'); + + expect(fetchMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ name: 'my-project' }), + }), + ); + }); + + it('throws InfrastructureProjectValidationError on 422 with errors map', async () => { + fetchMock.mockResolvedValueOnce( + makeResponse(422, { + title: 'Validation failed', + errors: { name: 'Name must be 3-32 characters' }, + }), + ); + + const error = await makeCliApi() + .createInfrastructureProject('x') + .catch((e: unknown) => e); + + expect(error).toBeInstanceOf(InfrastructureProjectValidationError); + + if (!(error instanceof Error)) throw new Error('expected thrown Error'); + + expect(error.message).toBe('Name must be 3-32 characters'); + }); + + it('joins multiple field errors with semicolons', async () => { + fetchMock.mockResolvedValue( + makeResponse(422, { + errors: { name: 'Name is invalid', slug: 'Slug already exists' }, + }), + ); + + await expect(makeCliApi().createInfrastructureProject('x')).rejects.toThrow( + 'Name is invalid; Slug already exists', + ); + }); + + it('falls back to `detail` when no errors map is present', async () => { + fetchMock.mockResolvedValueOnce(makeResponse(422, { detail: 'Detailed failure' })); + + await expect(makeCliApi().createInfrastructureProject('x')).rejects.toThrow('Detailed failure'); + }); + + it('falls back to `title` when neither errors nor detail is present', async () => { + fetchMock.mockResolvedValueOnce(makeResponse(422, { title: 'Bad request' })); + + await expect(makeCliApi().createInfrastructureProject('x')).rejects.toThrow('Bad request'); + }); + + it('falls back to statusText when body is unparseable', async () => { + fetchMock.mockResolvedValueOnce( + new Response('not json', { status: 422, statusText: 'Unprocessable Entity' }), + ); + + await expect(makeCliApi().createInfrastructureProject('x')).rejects.toThrow( + 'Unprocessable Entity', + ); + }); + + it('also treats 400 as a validation error', async () => { + fetchMock.mockResolvedValueOnce(makeResponse(400, { detail: 'Bad input' })); + + await expect(makeCliApi().createInfrastructureProject('x')).rejects.toBeInstanceOf( + InfrastructureProjectValidationError, + ); + }); + + it('wraps non-validation non-OK statuses in a friendly error', async () => { + fetchMock.mockResolvedValueOnce( + new Response(null, { status: 500, statusText: 'Internal Server Error' }), + ); + + const promise = makeCliApi().createInfrastructureProject('x'); + + await expect(promise).rejects.not.toBeInstanceOf(InfrastructureProjectValidationError); + await expect(promise).rejects.toThrow( + /Could not create Commerce Hosting project: 500 Internal Server Error/, + ); + }); + + it('wraps schema-parse failures in the same friendly error', async () => { + fetchMock.mockResolvedValueOnce(makeResponse(200, { data: { uuid: 'only-uuid' } })); + + await expect(makeCliApi().createInfrastructureProject('x')).rejects.toThrow( + /Could not create Commerce Hosting project:/, + ); + }); + + it('wraps network/fetch failures in the same friendly error', async () => { + fetchMock.mockRejectedValueOnce(new TypeError('fetch failed')); + + await expect(makeCliApi().createInfrastructureProject('x')).rejects.toThrow( + /Could not create Commerce Hosting project: fetch failed/, + ); + }); + + it('preserves InfrastructureProjectValidationError as-is (not wrapped)', async () => { + fetchMock.mockResolvedValueOnce(makeResponse(422, { detail: 'Name already taken' })); + + const promise = makeCliApi().createInfrastructureProject('x'); + + await expect(promise).rejects.toBeInstanceOf(InfrastructureProjectValidationError); + await expect(promise).rejects.toThrow('Name already taken'); + }); +}); diff --git a/packages/create-catalyst/src/utils/cli-api.ts b/packages/create-catalyst/src/utils/cli-api.ts index 6d621266fa..cf3f084ba2 100644 --- a/packages/create-catalyst/src/utils/cli-api.ts +++ b/packages/create-catalyst/src/utils/cli-api.ts @@ -1,19 +1,124 @@ +import { z } from 'zod'; + +import { InfrastructureProjectValidationError } from './cli-api-errors'; import { Https } from './https'; interface CliApiConfig { origin: string; storeHash: string; accessToken: string; + apiHostname: string; +} + +const infrastructureProjectSchema = z.object({ + uuid: z.string(), + name: z.string(), +}); + +export type InfrastructureProject = z.infer; + +const createInfrastructureProjectSchema = z.object({ + data: infrastructureProjectSchema, +}); + +const listInfrastructureProjectsSchema = z.object({ + data: z.array(infrastructureProjectSchema), +}); + +const infrastructureErrorSchema = z.object({ + title: z.string().optional(), + detail: z.string().optional(), + errors: z.record(z.string(), z.string()).optional(), +}); + +function extractValidationMessage(body: unknown): string | null { + const parsed = infrastructureErrorSchema.safeParse(body); + + if (!parsed.success) return null; + + const { title, detail, errors } = parsed.data; + + if (errors && Object.keys(errors).length > 0) { + return Object.values(errors).join('; '); + } + + return detail ?? title ?? null; } export class CliApi { private client: Https; + private infrastructureClient: Https; - constructor({ origin, storeHash, accessToken }: CliApiConfig) { + constructor({ origin, storeHash, accessToken, apiHostname }: CliApiConfig) { this.client = new Https({ baseUrl: `${origin}/stores/${storeHash}/cli-api/v3`, accessToken, }); + this.infrastructureClient = new Https({ + baseUrl: `https://${apiHostname}/stores/${storeHash}/v3/infrastructure`, + accessToken, + }); + } + + async hasProjectsAccess(): Promise { + const response = await this.infrastructureClient.fetch('/projects', { method: 'GET' }); + + if (response.status === 200) return true; + if (response.status === 403) return false; + + throw new Error( + `GET /v3/infrastructure/projects failed: ${response.status} ${response.statusText}`, + ); + } + + async listInfrastructureProjects() { + try { + const response = await this.infrastructureClient.fetch('/projects', { method: 'GET' }); + + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}`); + } + + const { data } = listInfrastructureProjectsSchema.parse(await response.json()); + + return data; + } catch (error) { + const reason = error instanceof Error ? error.message : 'unknown error'; + + throw new Error(`Could not load Commerce Hosting projects: ${reason}`, { cause: error }); + } + } + + async createInfrastructureProject(name: string) { + try { + const response = await this.infrastructureClient.fetch('/projects', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }); + + if (response.status === 400 || response.status === 422) { + const body: unknown = await response.json().catch(() => null); + const message = extractValidationMessage(body) ?? response.statusText; + + throw new InfrastructureProjectValidationError(message); + } + + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}`); + } + + const { data } = createInfrastructureProjectSchema.parse(await response.json()); + + return data; + } catch (error) { + // Validation errors carry a specific class that consumers `instanceof`-check; preserve it. + if (error instanceof InfrastructureProjectValidationError) throw error; + + const reason = error instanceof Error ? error.message : 'unknown error'; + + throw new Error(`Could not create Commerce Hosting project: ${reason}`, { cause: error }); + } } async getChannelInit(channelId: number | string) { diff --git a/packages/create-catalyst/src/utils/prompt-commerce-hosting-project.spec.ts b/packages/create-catalyst/src/utils/prompt-commerce-hosting-project.spec.ts new file mode 100644 index 0000000000..a7075d5f1b --- /dev/null +++ b/packages/create-catalyst/src/utils/prompt-commerce-hosting-project.spec.ts @@ -0,0 +1,655 @@ +import { input, select } from '@inquirer/prompts'; + +import { CliApi } from './cli-api'; +import { InfrastructureProjectValidationError } from './cli-api-errors'; +import { + promptAndCreateCommerceHostingProject, + promptForCommerceHostingProject, +} from './prompt-commerce-hosting-project'; + +jest.mock('@inquirer/prompts', () => ({ + input: jest.fn(), + select: jest.fn(), + Separator: class FakeSeparator { + type = 'separator'; + }, +})); + +const inputMock = jest.mocked(input); +const selectMock = jest.mocked(select); + +function makeCliApi(overrides: Partial = {}): CliApi { + const api = new CliApi({ + origin: 'https://cli.example', + storeHash: 'store', + accessToken: 'token', + apiHostname: 'api.example.com', + }); + + Object.assign(api, overrides); + + return api; +} + +function makeCliApiWithCreate(createImpl: jest.Mock): CliApi { + return makeCliApi({ createInfrastructureProject: createImpl }); +} + +function withTtyValue(value: boolean): () => void { + const previous = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); + + Object.defineProperty(process.stdin, 'isTTY', { value, configurable: true }); + + return () => { + if (previous) { + Object.defineProperty(process.stdin, 'isTTY', previous); + } else { + Reflect.deleteProperty(process.stdin, 'isTTY'); + } + }; +} + +const withInteractiveTty = () => withTtyValue(true); +const withNonInteractiveTty = () => withTtyValue(false); + +let consoleErrorSpy: jest.SpyInstance; + +beforeEach(() => { + inputMock.mockReset(); + selectMock.mockReset(); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); +}); + +afterEach(() => { + consoleErrorSpy.mockRestore(); +}); + +describe('promptAndCreateCommerceHostingProject', () => { + it('returns the created project when the first attempt succeeds', async () => { + inputMock.mockResolvedValueOnce('my-project'); + + const create = jest.fn().mockResolvedValue({ uuid: 'u', name: 'my-project' }); + + const result = await promptAndCreateCommerceHostingProject(makeCliApiWithCreate(create), []); + + expect(result).toEqual({ uuid: 'u', name: 'my-project' }); + expect(create).toHaveBeenCalledTimes(1); + expect(create).toHaveBeenCalledWith('my-project'); + }); + + it('trims whitespace from the entered name before calling the API', async () => { + inputMock.mockResolvedValueOnce(' spaced '); + + const create = jest.fn().mockResolvedValue({ uuid: 'u', name: 'spaced' }); + + await promptAndCreateCommerceHostingProject(makeCliApiWithCreate(create), []); + + expect(create).toHaveBeenCalledWith('spaced'); + }); + + it('re-prompts after a validation error and succeeds on retry', async () => { + inputMock.mockResolvedValueOnce('###').mockResolvedValueOnce('good-name'); + + const create = jest + .fn() + .mockRejectedValueOnce(new InfrastructureProjectValidationError('Invalid name')) + .mockResolvedValueOnce({ uuid: 'u', name: 'good-name' }); + + const result = await promptAndCreateCommerceHostingProject(makeCliApiWithCreate(create), []); + + expect(result).toEqual({ uuid: 'u', name: 'good-name' }); + expect(inputMock).toHaveBeenCalledTimes(2); + expect(create).toHaveBeenCalledTimes(2); + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid name')); + }); + + it('re-prompts multiple times until the server accepts the name', async () => { + inputMock + .mockResolvedValueOnce('bad1') + .mockResolvedValueOnce('bad2') + .mockResolvedValueOnce('good'); + + const create = jest + .fn() + .mockRejectedValueOnce(new InfrastructureProjectValidationError('first failure')) + .mockRejectedValueOnce(new InfrastructureProjectValidationError('second failure')) + .mockResolvedValueOnce({ uuid: 'u', name: 'good' }); + + const result = await promptAndCreateCommerceHostingProject(makeCliApiWithCreate(create), []); + + expect(result).toEqual({ uuid: 'u', name: 'good' }); + expect(inputMock).toHaveBeenCalledTimes(3); + expect(create).toHaveBeenCalledTimes(3); + }); + + it('does not retry on non-validation errors', async () => { + inputMock.mockResolvedValueOnce('whatever'); + + const create = jest.fn().mockRejectedValueOnce(new Error('500 server error')); + + await expect( + promptAndCreateCommerceHostingProject(makeCliApiWithCreate(create), []), + ).rejects.toThrow('500 server error'); + + expect(inputMock).toHaveBeenCalledTimes(1); + expect(create).toHaveBeenCalledTimes(1); + }); + + it('passes the supplied default name to the initial prompt', async () => { + inputMock.mockResolvedValueOnce('my-catalyst-store'); + + const create = jest.fn().mockResolvedValue({ uuid: 'u', name: 'my-catalyst-store' }); + + await promptAndCreateCommerceHostingProject( + makeCliApiWithCreate(create), + [], + 'my-catalyst-store', + ); + + expect(inputMock.mock.calls[0]?.[0].default).toBe('my-catalyst-store'); + }); + + it('preserves the original default on retry so the user is not stuck with the rejected value', async () => { + inputMock.mockResolvedValueOnce('bad-name').mockResolvedValueOnce('fixed-name'); + + const create = jest + .fn() + .mockRejectedValueOnce(new InfrastructureProjectValidationError('Invalid')) + .mockResolvedValueOnce({ uuid: 'u', name: 'fixed-name' }); + + await promptAndCreateCommerceHostingProject( + makeCliApiWithCreate(create), + [], + 'original-default', + ); + + expect(inputMock.mock.calls[0]?.[0].default).toBe('original-default'); + expect(inputMock.mock.calls[1]?.[0].default).toBe('original-default'); + }); + + it('uses a validator that rejects empty input', async () => { + inputMock.mockResolvedValueOnce('ok'); + + const create = jest.fn().mockResolvedValue({ uuid: 'u', name: 'ok' }); + + await promptAndCreateCommerceHostingProject(makeCliApiWithCreate(create), []); + + const validator = inputMock.mock.calls[0]?.[0].validate; + + expect(validator).toBeDefined(); + expect(validator?.('')).toBe('Project name is required'); + expect(validator?.(' ')).toBe('Project name is required'); + expect(validator?.('name')).toBe(true); + }); + + it('rejects names that already exist on the store', async () => { + inputMock.mockResolvedValueOnce('available'); + + const create = jest.fn().mockResolvedValue({ uuid: 'u', name: 'available' }); + + await promptAndCreateCommerceHostingProject(makeCliApiWithCreate(create), [ + 'taken-one', + 'taken-two', + ]); + + const validator = inputMock.mock.calls[0]?.[0].validate; + + expect(validator).toBeDefined(); + expect(validator?.('taken-one')).toBe( + 'A Commerce Hosting project named "taken-one" already exists', + ); + expect(validator?.(' taken-two ')).toBe( + 'A Commerce Hosting project named "taken-two" already exists', + ); + expect(validator?.('available')).toBe(true); + }); + + it('rejects names that match an existing project case-insensitively, and reports the stored name', async () => { + inputMock.mockResolvedValueOnce('different'); + + const create = jest.fn().mockResolvedValue({ uuid: 'u', name: 'different' }); + + await promptAndCreateCommerceHostingProject(makeCliApiWithCreate(create), ['MyProject']); + + const validator = inputMock.mock.calls[0]?.[0].validate; + + expect(validator?.('myproject')).toBe( + 'A Commerce Hosting project named "MyProject" already exists', + ); + expect(validator?.('MYPROJECT')).toBe( + 'A Commerce Hosting project named "MyProject" already exists', + ); + expect(validator?.(' MyProject ')).toBe( + 'A Commerce Hosting project named "MyProject" already exists', + ); + }); +}); + +describe('promptForCommerceHostingProject', () => { + it('silently auto-creates with the supplied default name when no Commerce Hosting project conflicts (no existing projects)', async () => { + const list = jest.fn().mockResolvedValue([]); + const create = jest.fn().mockResolvedValue({ uuid: 'u', name: 'fresh' }); + + const api = makeCliApi({ + listInfrastructureProjects: list, + createInfrastructureProject: create, + }); + + const result = await promptForCommerceHostingProject(api, 'fresh'); + + expect(result).toEqual({ uuid: 'u', name: 'fresh' }); + expect(selectMock).not.toHaveBeenCalled(); + expect(inputMock).not.toHaveBeenCalled(); + expect(create).toHaveBeenCalledWith('fresh'); + }); + + it('silently auto-creates with the supplied default name when other projects exist but none conflict', async () => { + const list = jest.fn().mockResolvedValue([ + { uuid: 'aaa', name: 'unrelated-one' }, + { uuid: 'bbb', name: 'unrelated-two' }, + ]); + const create = jest.fn().mockResolvedValue({ uuid: 'new', name: 'my-store' }); + + const api = makeCliApi({ + listInfrastructureProjects: list, + createInfrastructureProject: create, + }); + + const result = await promptForCommerceHostingProject(api, 'my-store'); + + expect(result).toEqual({ uuid: 'new', name: 'my-store' }); + expect(selectMock).not.toHaveBeenCalled(); + expect(inputMock).not.toHaveBeenCalled(); + expect(create).toHaveBeenCalledWith('my-store'); + }); + + it('returns a selected existing project without calling create', async () => { + const existing = [ + { uuid: 'aaa', name: 'first' }, + { uuid: 'bbb', name: 'second' }, + ]; + const list = jest.fn().mockResolvedValue(existing); + const create = jest.fn(); + + selectMock + .mockResolvedValueOnce('select-from-list') + .mockResolvedValueOnce({ uuid: 'bbb', name: 'second' }); + + const api = makeCliApi({ + listInfrastructureProjects: list, + createInfrastructureProject: create, + }); + + // Default name conflicts with one of the existing projects so the action prompt fires. + const result = await promptForCommerceHostingProject(api, 'first'); + + expect(result).toEqual({ uuid: 'bbb', name: 'second' }); + expect(create).not.toHaveBeenCalled(); + expect(inputMock).not.toHaveBeenCalled(); + expect(selectMock).toHaveBeenCalledTimes(2); + }); + + it('routes to the create flow when the default name conflicts and the user chooses to create a new project', async () => { + const list = jest.fn().mockResolvedValue([{ uuid: 'aaa', name: 'default-name' }]); + const create = jest.fn().mockResolvedValue({ uuid: 'new', name: 'new-proj' }); + + selectMock.mockResolvedValueOnce('create'); + inputMock.mockResolvedValueOnce('new-proj'); + + const api = makeCliApi({ + listInfrastructureProjects: list, + createInfrastructureProject: create, + }); + + const result = await promptForCommerceHostingProject(api, 'default-name'); + + expect(result).toEqual({ uuid: 'new', name: 'new-proj' }); + expect(create).toHaveBeenCalledWith('new-proj'); + expect(inputMock.mock.calls[0]?.[0].default).toBe('default-name'); + }); + + it('shows the conflict-aware message and three choices when a conflict exists', async () => { + const list = jest.fn().mockResolvedValue([ + { uuid: 'aaa', name: 'My-Store' }, + { uuid: 'bbb', name: 'other-project' }, + ]); + + selectMock.mockResolvedValueOnce('create'); + inputMock.mockResolvedValueOnce('something-else'); + + const create = jest.fn().mockResolvedValue({ uuid: 'new', name: 'something-else' }); + + const api = makeCliApi({ + listInfrastructureProjects: list, + createInfrastructureProject: create, + }); + + // Default name matches case-insensitively — should surface the stored casing. + await promptForCommerceHostingProject(api, 'my-store'); + + expect(selectMock.mock.calls[0]?.[0].message).toBe( + 'It looks like you already have an existing Commerce Hosting project named "My-Store". Would you like to use it, select from your projects, or create a new one?', + ); + expect(selectMock.mock.calls[0]?.[0].choices).toEqual([ + { name: 'Use "My-Store"', value: 'use-named' }, + { name: 'Select from my projects', value: 'select-from-list' }, + { name: 'Create a new project', value: 'create' }, + ]); + }); + + it('returns the conflicting project directly when the user picks Use ""', async () => { + const conflict = { uuid: 'aaa', name: 'My-Store' }; + const list = jest.fn().mockResolvedValue([conflict, { uuid: 'bbb', name: 'other-project' }]); + const create = jest.fn(); + + selectMock.mockResolvedValueOnce('use-named'); + + const api = makeCliApi({ + listInfrastructureProjects: list, + createInfrastructureProject: create, + }); + + const result = await promptForCommerceHostingProject(api, 'my-store'); + + expect(result).toEqual(conflict); + expect(selectMock).toHaveBeenCalledTimes(1); + expect(create).not.toHaveBeenCalled(); + expect(inputMock).not.toHaveBeenCalled(); + }); + + it('shows all projects (including the conflict) and a "Create a new project" option in the list', async () => { + const conflict = { uuid: 'aaa', name: 'My-Store' }; + const other = { uuid: 'bbb', name: 'other-project' }; + const list = jest.fn().mockResolvedValue([conflict, other]); + const create = jest.fn(); + + selectMock.mockResolvedValueOnce('select-from-list').mockResolvedValueOnce(other); + + const api = makeCliApi({ + listInfrastructureProjects: list, + createInfrastructureProject: create, + }); + + const result = await promptForCommerceHostingProject(api, 'my-store'); + + expect(result).toEqual(other); + + const projectChoices = selectMock.mock.calls[1]?.[0].choices ?? []; + + expect(projectChoices[0]).toEqual({ name: 'My-Store', value: conflict, description: 'aaa' }); + expect(projectChoices[1]).toEqual({ + name: 'other-project', + value: other, + description: 'bbb', + }); + expect(projectChoices[projectChoices.length - 1]).toEqual({ + name: 'Create a new project', + value: 'create-new', + }); + }); + + it('routes to the create flow when the user picks "Create a new project" from the list', async () => { + const conflict = { uuid: 'aaa', name: 'My-Store' }; + const other = { uuid: 'bbb', name: 'other-project' }; + const list = jest.fn().mockResolvedValue([conflict, other]); + const create = jest.fn().mockResolvedValue({ uuid: 'new', name: 'fresh-name' }); + + selectMock.mockResolvedValueOnce('select-from-list').mockResolvedValueOnce('create-new'); + inputMock.mockResolvedValueOnce('fresh-name'); + + const api = makeCliApi({ + listInfrastructureProjects: list, + createInfrastructureProject: create, + }); + + const result = await promptForCommerceHostingProject(api, 'my-store'); + + expect(result).toEqual({ uuid: 'new', name: 'fresh-name' }); + expect(create).toHaveBeenCalledWith('fresh-name'); + }); + + it('omits Select from my projects when the conflict is the only existing project', async () => { + const conflict = { uuid: 'aaa', name: 'My-Store' }; + const list = jest.fn().mockResolvedValue([conflict]); + const create = jest.fn(); + + selectMock.mockResolvedValueOnce('use-named'); + + const api = makeCliApi({ + listInfrastructureProjects: list, + createInfrastructureProject: create, + }); + + await promptForCommerceHostingProject(api, 'my-store'); + + // No "Select from my projects" — the conflict is the only project, and the message should + // omit the now-irrelevant "select from your projects" phrase. + expect(selectMock.mock.calls[0]?.[0].choices).toEqual([ + { name: 'Use "My-Store"', value: 'use-named' }, + { name: 'Create a new project', value: 'create' }, + ]); + expect(selectMock.mock.calls[0]?.[0].message).toBe( + 'It looks like you already have an existing Commerce Hosting project named "My-Store". Would you like to use it, or create a new one?', + ); + }); + + it('offers each existing project as a choice in the second select call', async () => { + const existing = [ + { uuid: 'aaa', name: 'first' }, + { uuid: 'bbb', name: 'second' }, + ]; + + selectMock + .mockResolvedValueOnce('select-from-list') + .mockResolvedValueOnce({ uuid: 'aaa', name: 'first' }); + + const api = makeCliApi({ + listInfrastructureProjects: jest.fn().mockResolvedValue(existing), + createInfrastructureProject: jest.fn(), + }); + + await promptForCommerceHostingProject(api, 'first'); + + const projectChoices = selectMock.mock.calls[1]?.[0].choices ?? []; + + expect(projectChoices[0]).toEqual({ + name: 'first', + value: { uuid: 'aaa', name: 'first' }, + description: 'aaa', + }); + expect(projectChoices[1]).toEqual({ + name: 'second', + value: { uuid: 'bbb', name: 'second' }, + description: 'bbb', + }); + }); + + it('skips all prompts and creates with the supplied name when autoUseDefaultName is true', async () => { + const list = jest.fn().mockResolvedValue([{ uuid: 'other', name: 'unrelated' }]); + const create = jest.fn().mockResolvedValue({ uuid: 'u', name: 'auto-name' }); + + const api = makeCliApi({ + listInfrastructureProjects: list, + createInfrastructureProject: create, + }); + + const result = await promptForCommerceHostingProject(api, 'auto-name', true); + + expect(result).toEqual({ uuid: 'u', name: 'auto-name' }); + expect(list).toHaveBeenCalled(); + expect(selectMock).not.toHaveBeenCalled(); + expect(inputMock).not.toHaveBeenCalled(); + expect(create).toHaveBeenCalledWith('auto-name'); + }); + + it('returns the existing project when --project-name collides and the user picks Yes', async () => { + const existing = { uuid: 'aaa', name: 'taken-name' }; + const list = jest.fn().mockResolvedValue([existing]); + const create = jest.fn(); + + selectMock.mockResolvedValueOnce(true); + + const api = makeCliApi({ + listInfrastructureProjects: list, + createInfrastructureProject: create, + }); + + const restoreTty = withInteractiveTty(); + + try { + const result = await promptForCommerceHostingProject(api, 'taken-name', true); + + expect(result).toEqual(existing); + expect(create).not.toHaveBeenCalled(); + expect(selectMock.mock.calls[0]?.[0].message).toMatch( + /A Commerce Hosting project named "taken-name" already exists/, + ); + } finally { + restoreTty(); + } + }); + + it('reuses the existing project without prompting when --use-existing is passed', async () => { + const existing = { uuid: 'aaa', name: 'taken-name' }; + const list = jest.fn().mockResolvedValue([existing]); + const create = jest.fn(); + + const api = makeCliApi({ + listInfrastructureProjects: list, + createInfrastructureProject: create, + }); + + const result = await promptForCommerceHostingProject(api, 'taken-name', true, true); + + expect(result).toEqual(existing); + expect(selectMock).not.toHaveBeenCalled(); + expect(create).not.toHaveBeenCalled(); + }); + + it('exits without prompting in non-interactive environments when --use-existing is not passed', async () => { + const list = jest.fn().mockResolvedValue([{ uuid: 'aaa', name: 'taken-name' }]); + const create = jest.fn(); + + const api = makeCliApi({ + listInfrastructureProjects: list, + createInfrastructureProject: create, + }); + + const exitSpy = jest.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`process.exit(${String(code)})`); + }); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + const restoreTty = withNonInteractiveTty(); + + try { + await expect(promptForCommerceHostingProject(api, 'taken-name', true)).rejects.toThrow( + 'process.exit(1)', + ); + + expect(selectMock).not.toHaveBeenCalled(); + expect(create).not.toHaveBeenCalled(); + } finally { + exitSpy.mockRestore(); + consoleSpy.mockRestore(); + restoreTty(); + } + }); + + it('detects --project-name collision case-insensitively and reports the stored name', async () => { + const existing = { uuid: 'aaa', name: 'MyProject' }; + const list = jest.fn().mockResolvedValue([existing]); + const create = jest.fn(); + + selectMock.mockResolvedValueOnce(true); + + const api = makeCliApi({ + listInfrastructureProjects: list, + createInfrastructureProject: create, + }); + + const restoreTty = withInteractiveTty(); + + try { + const result = await promptForCommerceHostingProject(api, 'myproject', true); + + expect(result).toEqual(existing); + expect(create).not.toHaveBeenCalled(); + expect(selectMock.mock.calls[0]?.[0].message).toMatch( + /A Commerce Hosting project named "MyProject" already exists/, + ); + } finally { + restoreTty(); + } + }); + + it('exits when --project-name collides and the user picks No', async () => { + const list = jest.fn().mockResolvedValue([{ uuid: 'aaa', name: 'taken-name' }]); + const create = jest.fn(); + + selectMock.mockResolvedValueOnce(false); + + const api = makeCliApi({ + listInfrastructureProjects: list, + createInfrastructureProject: create, + }); + + const exitSpy = jest.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`process.exit(${String(code)})`); + }); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + const restoreTty = withInteractiveTty(); + + try { + await expect(promptForCommerceHostingProject(api, 'taken-name', true)).rejects.toThrow( + 'process.exit(1)', + ); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(create).not.toHaveBeenCalled(); + expect(consoleSpy.mock.calls.flat().join(' ')).toMatch(/Not reusing the existing project/); + } finally { + exitSpy.mockRestore(); + consoleSpy.mockRestore(); + restoreTty(); + } + }); + + it('exits the process when auto-create fails with a validation error instead of re-prompting', async () => { + const create = jest + .fn() + .mockRejectedValueOnce(new InfrastructureProjectValidationError('Name already taken')); + + const api = makeCliApi({ + // Empty list so the client-side check passes and we hit the server-rejection path. + listInfrastructureProjects: jest.fn().mockResolvedValue([]), + createInfrastructureProject: create, + }); + + const exitSpy = jest.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`process.exit(${String(code)})`); + }); + + await expect(promptForCommerceHostingProject(api, 'taken-name', true)).rejects.toThrow( + 'process.exit(1)', + ); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(inputMock).not.toHaveBeenCalled(); + expect(create).toHaveBeenCalledTimes(1); + + exitSpy.mockRestore(); + }); + + it('propagates errors from listInfrastructureProjects', async () => { + const list = jest.fn().mockRejectedValue(new Error('network down')); + + const api = makeCliApi({ + listInfrastructureProjects: list, + createInfrastructureProject: jest.fn(), + }); + + await expect(promptForCommerceHostingProject(api, 'whatever')).rejects.toThrow('network down'); + expect(selectMock).not.toHaveBeenCalled(); + expect(inputMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/create-catalyst/src/utils/prompt-commerce-hosting-project.ts b/packages/create-catalyst/src/utils/prompt-commerce-hosting-project.ts new file mode 100644 index 0000000000..0ff19c8e84 --- /dev/null +++ b/packages/create-catalyst/src/utils/prompt-commerce-hosting-project.ts @@ -0,0 +1,214 @@ +import { input, select, Separator } from '@inquirer/prompts'; +import { colorize } from 'consola/utils'; + +import { CliApi, type InfrastructureProject } from './cli-api'; +import { InfrastructureProjectValidationError } from './cli-api-errors'; + +export async function promptForCommerceHostingProject( + cliApi: CliApi, + defaultName: string, + autoUseDefaultName?: boolean, + useExistingOnCollision?: boolean, +): Promise { + const existingProjects = await cliApi.listInfrastructureProjects(); + const takenNames = existingProjects.map((project) => project.name); + + if (autoUseDefaultName) { + return autoCreateCommerceHostingProject( + cliApi, + defaultName, + existingProjects, + useExistingOnCollision, + ); + } + + // If the project directory name doesn't collide with an existing Commerce Hosting project, + // reuse it as the project name and skip the prompt entirely. (When no projects exist, this + // path also fires — `find` returns undefined, which is exactly what we want.) + const conflict = existingProjects.find( + (project) => project.name.toLowerCase() === defaultName.toLowerCase(), + ); + + if (!conflict) { + return autoCreateCommerceHostingProject( + cliApi, + defaultName, + existingProjects, + useExistingOnCollision, + ); + } + + type Action = 'use-named' | 'select-from-list' | 'create'; + + // Only offer the list selection if there's actually another project to pick. + const hasOtherProjects = existingProjects.length > 1; + + const choices: Array<{ name: string; value: Action }> = [ + { name: `Use "${conflict.name}"`, value: 'use-named' }, + ]; + + if (hasOtherProjects) { + choices.push({ name: 'Select from my projects', value: 'select-from-list' }); + } + + choices.push({ name: 'Create a new project', value: 'create' }); + + const action = await select({ + message: hasOtherProjects + ? `It looks like you already have an existing Commerce Hosting project named "${conflict.name}". Would you like to use it, select from your projects, or create a new one?` + : `It looks like you already have an existing Commerce Hosting project named "${conflict.name}". Would you like to use it, or create a new one?`, + choices, + }); + + if (action === 'use-named') { + console.log(colorize('green', `Using existing Commerce Hosting project "${conflict.name}"`)); + + return conflict; + } + + if (action === 'create') { + return promptAndCreateCommerceHostingProject(cliApi, takenNames, defaultName); + } + + const selected = await select({ + message: 'Which Commerce Hosting project would you like to use?', + choices: [ + ...existingProjects.map((project) => ({ + name: project.name, + value: project, + description: project.uuid, + })), + new Separator(), + { name: 'Create a new project', value: 'create-new' as const }, + ], + }); + + if (selected === 'create-new') { + return promptAndCreateCommerceHostingProject(cliApi, takenNames, defaultName); + } + + console.log(colorize('green', `Using existing Commerce Hosting project "${selected.name}"`)); + + return selected; +} + +export async function promptAndCreateCommerceHostingProject( + cliApi: CliApi, + takenNames: readonly string[], + defaultName?: string, +): Promise { + const projectName = await input({ + message: 'What would you like to name your Commerce Hosting project?', + default: defaultName, + validate: (value) => { + const trimmed = value.trim(); + + if (!trimmed) return 'Project name is required'; + + const conflict = takenNames.find((taken) => taken.toLowerCase() === trimmed.toLowerCase()); + + if (conflict) { + return `A Commerce Hosting project named "${conflict}" already exists`; + } + + return true; + }, + theme: { + style: { + help: () => + colorize( + 'dim', + '(The project that hosts your storefront on Commerce — often matches your folder name.)', + ), + }, + }, + }); + + try { + const created = await cliApi.createInfrastructureProject(projectName.trim()); + + console.log( + colorize('green', `Commerce Hosting project "${created.name}" created successfully`), + ); + + return created; + } catch (error) { + if (error instanceof InfrastructureProjectValidationError) { + console.error(colorize('red', `\n${error.message}\n`)); + + return promptAndCreateCommerceHostingProject(cliApi, takenNames, defaultName); + } + + throw error; + } +} + +async function resolveCollisionChoice( + existingName: string, + useExistingOnCollision: boolean | undefined, +): Promise { + if (useExistingOnCollision === true) return true; + + // Without the flag and no interactive terminal (CI, piped scripts), default to "No" so the + // CLI doesn't hang waiting for input that will never arrive. + if (!process.stdin.isTTY) return false; + + return select({ + message: `A Commerce Hosting project named "${existingName}" already exists. Use the existing project?`, + choices: [ + { name: 'Yes', value: true }, + { name: 'No', value: false }, + ], + }); +} + +async function autoCreateCommerceHostingProject( + cliApi: CliApi, + name: string, + existingProjects: readonly InfrastructureProject[], + useExistingOnCollision?: boolean, +): Promise { + const existing = existingProjects.find( + (project) => project.name.toLowerCase() === name.toLowerCase(), + ); + + if (existing) { + const shouldUseExisting = await resolveCollisionChoice(existing.name, useExistingOnCollision); + + if (shouldUseExisting) { + console.log(colorize('green', `Using existing Commerce Hosting project "${existing.name}"`)); + + return existing; + } + + console.error( + colorize( + 'red', + '\nNot reusing the existing project. Re-run with a different --project-name, or pass --use-existing to reuse it.\n', + ), + ); + process.exit(1); + } + + try { + const created = await cliApi.createInfrastructureProject(name); + + console.log( + colorize('green', `Commerce Hosting project "${created.name}" created successfully`), + ); + + return created; + } catch (error) { + if (error instanceof InfrastructureProjectValidationError) { + console.error( + colorize( + 'red', + `\nFailed to create Commerce Hosting project "${name}": ${error.message}\nRe-run with a different --project-name.\n`, + ), + ); + process.exit(1); + } + + throw error; + } +} diff --git a/packages/create-catalyst/src/utils/setup-commerce-hosting.spec.ts b/packages/create-catalyst/src/utils/setup-commerce-hosting.spec.ts new file mode 100644 index 0000000000..07c89396fd --- /dev/null +++ b/packages/create-catalyst/src/utils/setup-commerce-hosting.spec.ts @@ -0,0 +1,289 @@ +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readlinkSync, + rmSync, + writeFileSync, +} from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { z } from 'zod'; + +import { setupCommerceHosting } from './setup-commerce-hosting'; + +const packageJsonSchema = z.record(z.string(), z.unknown()); +const projectJsonSchema = z.object({ + projectUuid: z.string(), + framework: z.string(), + storeHash: z.string().optional(), + accessToken: z.string().optional(), +}); + +let projectDir: string; + +beforeEach(() => { + projectDir = mkdtempSync(join(tmpdir(), 'create-catalyst-test-')); +}); + +afterEach(() => { + rmSync(projectDir, { recursive: true, force: true }); +}); + +function writeCorePackageJson(contents: unknown) { + const coreDir = join(projectDir, 'core'); + + mkdirSync(coreDir, { recursive: true }); + writeFileSync(join(coreDir, 'package.json'), JSON.stringify(contents, null, 2)); +} + +function writeCoreProxyFile(contents: string) { + const coreDir = join(projectDir, 'core'); + + mkdirSync(coreDir, { recursive: true }); + writeFileSync(join(coreDir, 'proxy.ts'), contents); +} + +function readCorePackageJson() { + return packageJsonSchema.parse( + JSON.parse(readFileSync(join(projectDir, 'core', 'package.json'), 'utf-8')), + ); +} + +function readProjectJson() { + return projectJsonSchema.parse( + JSON.parse(readFileSync(join(projectDir, 'core', '.bigcommerce', 'project.json'), 'utf-8')), + ); +} + +describe('setupCommerceHosting', () => { + it('rewrites the build script and adds a deploy script', () => { + writeCorePackageJson({ + name: '@bigcommerce/catalyst-core', + scripts: { + dev: 'npm run generate && next dev', + generate: 'dotenv -e .env.local -- node ./scripts/generate.cjs', + build: 'npm run generate && next build', + start: 'next start', + }, + }); + + setupCommerceHosting({ projectDir, projectUuid: 'uuid-abc' }); + + expect(readCorePackageJson().scripts).toEqual({ + dev: 'npm run generate && next dev', + generate: 'dotenv -e .env.local -- node ./scripts/generate.cjs', + build: 'npm run generate && catalyst build', + start: 'next start', + deploy: 'catalyst deploy', + }); + }); + + it('leaves the `dev` and `start` scripts alone so local development is unaffected', () => { + writeCorePackageJson({ + scripts: { + dev: 'npm run generate && next dev', + build: 'npm run generate && next build', + start: 'next start', + }, + }); + + setupCommerceHosting({ projectDir, projectUuid: 'u' }); + + expect(readCorePackageJson().scripts).toMatchObject({ + dev: 'npm run generate && next dev', + start: 'next start', + }); + }); + + it('adds native hosting dependencies while preserving existing ones', () => { + writeCorePackageJson({ + scripts: { dev: 'next dev' }, + dependencies: { next: '^15.0.0', react: '^18.0.0' }, + }); + + setupCommerceHosting({ projectDir, projectUuid: 'u' }); + + const pkg = readCorePackageJson(); + + expect(pkg.dependencies).toMatchObject({ next: '^15.0.0', react: '^18.0.0' }); + expect(pkg.dependencies).toHaveProperty('@bigcommerce/catalyst'); + expect(pkg.dependencies).toHaveProperty('@opennextjs/cloudflare'); + }); + + it('preserves unrelated top-level package.json fields', () => { + writeCorePackageJson({ + name: '@bigcommerce/catalyst-core', + description: 'test description', + version: '1.2.3', + private: true, + scripts: { dev: 'next dev' }, + devDependencies: { jest: '^29.0.0' }, + }); + + setupCommerceHosting({ projectDir, projectUuid: 'u' }); + + const pkg = readCorePackageJson(); + + expect(pkg.name).toBe('@bigcommerce/catalyst-core'); + expect(pkg.description).toBe('test description'); + expect(pkg.version).toBe('1.2.3'); + expect(pkg.private).toBe(true); + expect(pkg.devDependencies).toEqual({ jest: '^29.0.0' }); + }); + + it('writes core/.bigcommerce/project.json with the correct shape', () => { + writeCorePackageJson({ scripts: { dev: 'next dev' } }); + + setupCommerceHosting({ projectDir, projectUuid: 'uuid-xyz' }); + + expect(readProjectJson()).toEqual({ projectUuid: 'uuid-xyz', framework: 'catalyst' }); + }); + + it('includes storeHash and accessToken in project.json when provided', () => { + writeCorePackageJson({ scripts: { dev: 'next dev' } }); + + setupCommerceHosting({ + projectDir, + projectUuid: 'uuid-xyz', + storeHash: 'abc123', + accessToken: 'token-xyz', + }); + + expect(readProjectJson()).toEqual({ + projectUuid: 'uuid-xyz', + framework: 'catalyst', + storeHash: 'abc123', + accessToken: 'token-xyz', + }); + }); + + it('omits storeHash and accessToken when not provided', () => { + writeCorePackageJson({ scripts: { dev: 'next dev' } }); + + setupCommerceHosting({ projectDir, projectUuid: 'uuid-xyz' }); + + const projectJson = readProjectJson(); + + expect(projectJson.storeHash).toBeUndefined(); + expect(projectJson.accessToken).toBeUndefined(); + }); + + it('includes only the credentials that are provided', () => { + writeCorePackageJson({ scripts: { dev: 'next dev' } }); + + setupCommerceHosting({ + projectDir, + projectUuid: 'uuid-xyz', + storeHash: 'abc123', + }); + + const projectJson = readProjectJson(); + + expect(projectJson.storeHash).toBe('abc123'); + expect(projectJson.accessToken).toBeUndefined(); + }); + + it('throws when core/package.json is missing', () => { + expect(() => setupCommerceHosting({ projectDir, projectUuid: 'u' })).toThrow(); + }); + + it('throws when core/package.json has an invalid shape', () => { + writeCorePackageJson({ scripts: { dev: 42 } }); + + expect(() => setupCommerceHosting({ projectDir, projectUuid: 'u' })).toThrow(); + }); + + describe('core/.env.local symlink', () => { + it('creates a symlink at core/.env.local pointing to ../.env.local', () => { + writeCorePackageJson({ scripts: { dev: 'next dev' } }); + + setupCommerceHosting({ projectDir, projectUuid: 'u' }); + + const coreEnvPath = join(projectDir, 'core', '.env.local'); + + expect(lstatSync(coreEnvPath).isSymbolicLink()).toBe(true); + expect(readlinkSync(coreEnvPath)).toBe(join('..', '.env.local')); + }); + + it('keeps both files in sync via the symlink target', () => { + writeCorePackageJson({ scripts: { dev: 'next dev' } }); + writeFileSync(join(projectDir, '.env.local'), 'FOO=bar\n'); + + setupCommerceHosting({ projectDir, projectUuid: 'u' }); + + expect(readFileSync(join(projectDir, 'core', '.env.local'), 'utf-8')).toBe('FOO=bar\n'); + + // Writing through the symlink path should land in the root file + writeFileSync(join(projectDir, 'core', '.env.local'), 'FOO=baz\n'); + + expect(readFileSync(join(projectDir, '.env.local'), 'utf-8')).toBe('FOO=baz\n'); + }); + + it('does not clobber an existing core/.env.local file', () => { + writeCorePackageJson({ scripts: { dev: 'next dev' } }); + mkdirSync(join(projectDir, 'core'), { recursive: true }); + writeFileSync(join(projectDir, 'core', '.env.local'), 'PRESERVE=me\n'); + + setupCommerceHosting({ projectDir, projectUuid: 'u' }); + + const coreEnvPath = join(projectDir, 'core', '.env.local'); + + expect(lstatSync(coreEnvPath).isSymbolicLink()).toBe(false); + expect(readFileSync(coreEnvPath, 'utf-8')).toBe('PRESERVE=me\n'); + }); + }); + + describe('proxy.ts → middleware.ts conversion', () => { + const proxyFixture = [ + "import { composeProxies } from './proxies/compose-proxies';", + '', + 'export const proxy = composeProxies();', + '', + 'export const config = {', + " matcher: ['/((?!api).*)'],", + '};', + '', + ].join('\n'); + + it('renames proxy.ts to middleware.ts, renames the export, and injects the edge runtime', () => { + writeCorePackageJson({ scripts: { dev: 'next dev' } }); + writeCoreProxyFile(proxyFixture); + + setupCommerceHosting({ projectDir, projectUuid: 'u' }); + + const middlewarePath = join(projectDir, 'core', 'middleware.ts'); + const proxyPath = join(projectDir, 'core', 'proxy.ts'); + + expect(existsSync(middlewarePath)).toBe(true); + expect(existsSync(proxyPath)).toBe(false); + + const middleware = readFileSync(middlewarePath, 'utf-8'); + + expect(middleware).toContain('export const middleware = composeProxies()'); + expect(middleware).not.toContain('export const proxy'); + expect(middleware).toContain("runtime: 'experimental-edge'"); + }); + + it('preserves the rest of the file contents', () => { + writeCorePackageJson({ scripts: { dev: 'next dev' } }); + writeCoreProxyFile(proxyFixture); + + setupCommerceHosting({ projectDir, projectUuid: 'u' }); + + const middleware = readFileSync(join(projectDir, 'core', 'middleware.ts'), 'utf-8'); + + expect(middleware).toContain("import { composeProxies } from './proxies/compose-proxies';"); + expect(middleware).toContain("matcher: ['/((?!api).*)']"); + }); + + it('is a no-op when proxy.ts does not exist', () => { + writeCorePackageJson({ scripts: { dev: 'next dev' } }); + + expect(() => setupCommerceHosting({ projectDir, projectUuid: 'u' })).not.toThrow(); + expect(existsSync(join(projectDir, 'core', 'middleware.ts'))).toBe(false); + }); + }); +}); diff --git a/packages/create-catalyst/src/utils/setup-commerce-hosting.ts b/packages/create-catalyst/src/utils/setup-commerce-hosting.ts new file mode 100644 index 0000000000..bceb23b08d --- /dev/null +++ b/packages/create-catalyst/src/utils/setup-commerce-hosting.ts @@ -0,0 +1,103 @@ +import { colorize } from 'consola/utils'; +import { + existsSync, + lstatSync, + mkdirSync, + readFileSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from 'fs'; +import { dirname, join } from 'path'; +import { z } from 'zod'; + +const CATALYST_CLI_VERSION = '1.0.0-alpha.3'; +const OPENNEXT_CLOUDFLARE_VERSION = '1.17.3'; + +const corePackageJsonSchema = z + .object({ + scripts: z.record(z.string(), z.string()).optional(), + dependencies: z.record(z.string(), z.string()).optional(), + }) + .passthrough(); + +const writeJson = (path: string, value: unknown) => { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); +}; + +const symlinkRootEnvToCore = (projectDir: string) => { + const coreEnvPath = join(projectDir, 'core', '.env.local'); + + // Don't clobber an existing file or symlink at core/.env.local + if (lstatSync(coreEnvPath, { throwIfNoEntry: false })) return; + + try { + symlinkSync('../.env.local', coreEnvPath); + } catch (error) { + const message = error instanceof Error ? error.message : 'unknown error'; + + console.warn( + colorize( + 'yellow', + `\nCould not create symlink at core/.env.local: ${message}\nOn Windows, enable Developer Mode or run as administrator to allow symlinks.\nYou will need to keep .env.local and core/.env.local in sync manually.\n`, + ), + ); + } +}; + +const convertProxyToMiddleware = (projectDir: string) => { + const proxyPath = join(projectDir, 'core', 'proxy.ts'); + const middlewarePath = join(projectDir, 'core', 'middleware.ts'); + + if (!existsSync(proxyPath)) return; + + const contents = readFileSync(proxyPath, 'utf-8') + .replace('export const proxy', 'export const middleware') + .replace('export const config = {', "export const config = {\n runtime: 'experimental-edge',"); + + writeFileSync(middlewarePath, contents); + unlinkSync(proxyPath); +}; + +export const setupCommerceHosting = ({ + projectDir, + projectUuid, + storeHash, + accessToken, +}: { + projectDir: string; + projectUuid: string; + storeHash?: string; + accessToken?: string; +}) => { + const corePackageJsonPath = join(projectDir, 'core', 'package.json'); + const pkg = corePackageJsonSchema.parse(JSON.parse(readFileSync(corePackageJsonPath, 'utf-8'))); + + pkg.scripts = { + ...pkg.scripts, + build: 'npm run generate && catalyst build', + deploy: 'catalyst deploy', + }; + + pkg.dependencies = { + ...pkg.dependencies, + '@bigcommerce/catalyst': CATALYST_CLI_VERSION, + '@opennextjs/cloudflare': OPENNEXT_CLOUDFLARE_VERSION, + }; + + writeJson(corePackageJsonPath, pkg); + + const projectJson: Record = { + projectUuid, + framework: 'catalyst', + }; + + if (storeHash) projectJson.storeHash = storeHash; + if (accessToken) projectJson.accessToken = accessToken; + + writeJson(join(projectDir, 'core', '.bigcommerce', 'project.json'), projectJson); + + symlinkRootEnvToCore(projectDir); + convertProxyToMiddleware(projectDir); +};