From e4ae60e15e1d7fda2f337b8b31ab533951d9a55d Mon Sep 17 00:00:00 2001 From: jordanarldt Date: Tue, 5 May 2026 11:35:24 -0500 Subject: [PATCH] TRAC-137: Add native hosting option in catalyst CLI --- packages/catalyst/package.json | 9 + .../catalyst/src/cli/commands/build.spec.ts | 50 +- packages/catalyst/src/cli/commands/build.ts | 18 + .../catalyst/src/cli/commands/create.spec.ts | 471 ++++++++++ packages/catalyst/src/cli/commands/create.ts | 503 +++++++++++ .../catalyst/src/cli/commands/deploy.spec.ts | 256 ++++++ packages/catalyst/src/cli/commands/deploy.ts | 126 ++- .../catalyst/src/cli/commands/project.spec.ts | 235 ++++- packages/catalyst/src/cli/commands/project.ts | 181 ++-- .../catalyst/src/cli/commands/start.spec.ts | 39 + packages/catalyst/src/cli/commands/start.ts | 17 + .../catalyst/src/cli/commands/telemetry.ts | 2 +- .../src/cli/lib/build-workspace-packages.ts | 33 + packages/catalyst/src/cli/lib/channels.ts | 175 ++++ packages/catalyst/src/cli/lib/checkout-ref.ts | 42 + .../catalyst/src/cli/lib/clone-catalyst.ts | 46 + .../src/cli/lib/commerce-hosting.spec.ts | 806 ++++++++++++++++++ .../catalyst/src/cli/lib/commerce-hosting.ts | 427 ++++++++++ .../catalyst/src/cli/lib/has-github-ssh.ts | 26 + .../src/cli/lib/install-dependencies.ts | 16 + .../catalyst/src/cli/lib/is-exec-exception.ts | 5 + packages/catalyst/src/cli/lib/localization.ts | 64 ++ packages/catalyst/src/cli/lib/login.ts | 41 + .../catalyst/src/cli/lib/project-config.ts | 11 - .../src/cli/lib/project-state.spec.ts | 164 ++++ .../catalyst/src/cli/lib/project-state.ts | 67 ++ packages/catalyst/src/cli/lib/project.ts | 110 ++- .../src/cli/lib/reset-branch-to-ref.ts | 15 + .../src/cli/lib/setup-core-project.spec.ts | 110 +++ .../src/cli/lib/setup-core-project.ts | 40 + .../catalyst/src/cli/lib/shared-options.ts | 37 +- .../src/cli/lib/sort-package-json.spec.ts | 81 ++ .../catalyst/src/cli/lib/sort-package-json.ts | 32 + packages/catalyst/src/cli/lib/telemetry.ts | 14 +- packages/catalyst/src/cli/lib/user-config.ts | 36 + packages/catalyst/src/cli/lib/write-env.ts | 13 + packages/catalyst/src/cli/program.ts | 21 +- packages/catalyst/tsconfig.json | 1 + pnpm-lock.yaml | 28 +- 39 files changed, 4172 insertions(+), 196 deletions(-) create mode 100644 packages/catalyst/src/cli/commands/create.spec.ts create mode 100644 packages/catalyst/src/cli/commands/create.ts create mode 100644 packages/catalyst/src/cli/lib/build-workspace-packages.ts create mode 100644 packages/catalyst/src/cli/lib/channels.ts create mode 100644 packages/catalyst/src/cli/lib/checkout-ref.ts create mode 100644 packages/catalyst/src/cli/lib/clone-catalyst.ts create mode 100644 packages/catalyst/src/cli/lib/commerce-hosting.spec.ts create mode 100644 packages/catalyst/src/cli/lib/commerce-hosting.ts create mode 100644 packages/catalyst/src/cli/lib/has-github-ssh.ts create mode 100644 packages/catalyst/src/cli/lib/install-dependencies.ts create mode 100644 packages/catalyst/src/cli/lib/is-exec-exception.ts create mode 100644 packages/catalyst/src/cli/lib/localization.ts create mode 100644 packages/catalyst/src/cli/lib/login.ts create mode 100644 packages/catalyst/src/cli/lib/project-state.spec.ts create mode 100644 packages/catalyst/src/cli/lib/project-state.ts create mode 100644 packages/catalyst/src/cli/lib/reset-branch-to-ref.ts create mode 100644 packages/catalyst/src/cli/lib/setup-core-project.spec.ts create mode 100644 packages/catalyst/src/cli/lib/setup-core-project.ts create mode 100644 packages/catalyst/src/cli/lib/sort-package-json.spec.ts create mode 100644 packages/catalyst/src/cli/lib/sort-package-json.ts create mode 100644 packages/catalyst/src/cli/lib/user-config.ts create mode 100644 packages/catalyst/src/cli/lib/write-env.ts diff --git a/packages/catalyst/package.json b/packages/catalyst/package.json index 4011c1eced..d17f486639 100644 --- a/packages/catalyst/package.json +++ b/packages/catalyst/package.json @@ -22,14 +22,20 @@ "node": "^20.0.0 || ^22.0.0 || ^24.0.0" }, "dependencies": { + "@inquirer/prompts": "^7.5.3", "@segment/analytics-node": "^2.2.1", "adm-zip": "^0.5.16", "commander": "^14.0.0", "conf": "^13.1.0", "consola": "^3.4.2", + "cross-spawn": "^7.0.6", "dotenv": "^16.5.0", "execa": "^9.6.0", + "fs-extra": "^11.3.0", + "lodash.kebabcase": "^4.1.1", + "nypm": "^0.5.4", "open": "^10.1.0", + "std-env": "^3.9.0", "yocto-spinner": "^1.0.0", "zod": "^4.0.5" }, @@ -38,6 +44,9 @@ "@bigcommerce/eslint-config-catalyst": "workspace:^", "@commander-js/extra-typings": "^14.0.0", "@types/adm-zip": "^0.5.7", + "@types/cross-spawn": "^6.0.6", + "@types/fs-extra": "^11.0.4", + "@types/lodash.kebabcase": "^4.1.9", "@types/node": "^22.15.30", "@vitest/coverage-v8": "^3.2.4", "@vitest/ui": "^3.2.4", diff --git a/packages/catalyst/src/cli/commands/build.spec.ts b/packages/catalyst/src/cli/commands/build.spec.ts index 9de6f02008..ecdb46ea87 100644 --- a/packages/catalyst/src/cli/commands/build.spec.ts +++ b/packages/catalyst/src/cli/commands/build.spec.ts @@ -1,11 +1,47 @@ import { Command } from 'commander'; -import { expect, test, vi } from 'vitest'; +import { execa } from 'execa'; +import { afterEach, beforeAll, beforeEach, expect, test, vi } from 'vitest'; + +import { consola } from '../lib/logger'; +import { getProjectState } from '../lib/project-state'; +import { program } from '../program'; import { build } from './build'; +vi.mock('execa', () => ({ + execa: vi.fn(() => Promise.resolve({})), + __esModule: true, +})); + +vi.mock('../lib/project-state', () => ({ + getProjectState: vi.fn(), +})); + +const untransformedState = { + projectUuid: undefined, + hasMiddleware: false, + hasProxy: true, + hasOpenNextDep: false, + isLinked: false, + isTransformed: false, + isFullySetUp: false, +}; + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions vi.spyOn(process, 'exit').mockImplementation(() => null as never); +beforeAll(() => { + consola.wrapAll(); +}); + +beforeEach(() => { + consola.mockTypes(() => vi.fn()); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + test('properly configured Command instance', () => { expect(build).toBeInstanceOf(Command); expect(build.name()).toBe('build'); @@ -13,3 +49,15 @@ test('properly configured Command instance', () => { expect.arrayContaining([expect.objectContaining({ long: '--project-uuid' })]), ); }); + +test('falls through to `next build` when project is not transformed', async () => { + vi.mocked(getProjectState).mockReturnValue(untransformedState); + + await program.parseAsync(['node', 'catalyst', 'build']); + + expect(execa).toHaveBeenCalledWith( + 'pnpm', + ['exec', 'next', 'build'], + expect.objectContaining({ stdio: 'inherit', cwd: process.cwd() }), + ); +}); diff --git a/packages/catalyst/src/cli/commands/build.ts b/packages/catalyst/src/cli/commands/build.ts index d7999363ee..e1107ee47c 100644 --- a/packages/catalyst/src/cli/commands/build.ts +++ b/packages/catalyst/src/cli/commands/build.ts @@ -6,6 +6,7 @@ import { join } from 'node:path'; import { getModuleCliPath } from '../lib/get-module-cli-path'; import { consola } from '../lib/logger'; import { getProjectConfig } from '../lib/project-config'; +import { getProjectState } from '../lib/project-state'; import { getWranglerConfig } from '../lib/wrangler-config'; const WRANGLER_VERSION = '4.24.3'; @@ -96,6 +97,23 @@ Examples: ).env('CATALYST_PROJECT_UUID'), ) .action(async (options) => { + // Project must be transformed (middleware swapped in, OpenNext dep installed) + // before the OpenNext build pipeline can run. If it isn't, fall through to + // `next build` so this command works for self-hosted Catalyst projects too. + const state = getProjectState(); + + if (!state.isTransformed) { + consola.info('Project is not set up for Commerce Hosting — running `next build`.'); + consola.info('To deploy to Commerce Hosting, run `catalyst deploy`.'); + + await execa('pnpm', ['exec', 'next', 'build'], { + stdio: 'inherit', + cwd: process.cwd(), + }); + + return; + } + const config = getProjectConfig(); const projectUuid = options.projectUuid ?? config.get('projectUuid'); diff --git a/packages/catalyst/src/cli/commands/create.spec.ts b/packages/catalyst/src/cli/commands/create.spec.ts new file mode 100644 index 0000000000..11818b45c0 --- /dev/null +++ b/packages/catalyst/src/cli/commands/create.spec.ts @@ -0,0 +1,471 @@ +import { Command } from 'commander'; +import { mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { afterAll, afterEach, beforeAll, describe, expect, MockInstance, test, vi } from 'vitest'; + +import { buildWorkspacePackages } from '../lib/build-workspace-packages'; +import { cloneCatalyst } from '../lib/clone-catalyst'; +import { promptForCommerceHostingProject, setupCommerceHosting } from '../lib/commerce-hosting'; +import { installDependencies } from '../lib/install-dependencies'; +import { consola } from '../lib/logger'; +import { login } from '../lib/login'; +import { mkTempDir } from '../lib/mk-temp-dir'; +import { hasProjectsAccess } from '../lib/project'; +import { setupCoreProject } from '../lib/setup-core-project'; +import { writeEnv } from '../lib/write-env'; +import { program } from '../program'; + +import { create } from './create'; + +// Mock all side-effecting modules so the action runs end-to-end without +// actually cloning, installing, writing files, or hitting the network. +vi.mock('child_process', () => ({ execSync: vi.fn() })); + +vi.mock('@inquirer/prompts', () => ({ + input: vi.fn(), + select: vi.fn(), +})); + +vi.mock('../lib/login', () => ({ + login: vi.fn().mockResolvedValue({ + storeHash: 'login-store-hash', + accessToken: 'login-access-token', + }), +})); + +vi.mock('../lib/clone-catalyst', () => ({ cloneCatalyst: vi.fn() })); +vi.mock('../lib/setup-core-project', () => ({ setupCoreProject: vi.fn() })); +vi.mock('../lib/install-dependencies', () => ({ + installDependencies: vi.fn().mockResolvedValue(undefined), +})); +vi.mock('../lib/build-workspace-packages', () => ({ buildWorkspacePackages: vi.fn() })); +vi.mock('../lib/write-env', () => ({ writeEnv: vi.fn() })); + +vi.mock('../lib/commerce-hosting', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + setupCommerceHosting: vi.fn(), + promptForCommerceHostingProject: vi.fn().mockResolvedValue({ + uuid: 'commerce-project-uuid', + name: 'commerce-project', + }), + }; +}); + +vi.mock('../lib/project', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + hasProjectsAccess: vi.fn().mockResolvedValue(true), + }; +}); + +vi.mock('../lib/localization', () => ({ + getAvailableLocales: vi.fn().mockResolvedValue([ + { name: 'English', value: 'en' }, + { name: 'Spanish', value: 'es' }, + ]), +})); + +const { mockIdentify } = vi.hoisted(() => ({ mockIdentify: vi.fn() })); + +vi.mock('../lib/telemetry', () => { + const instance = { + identify: mockIdentify, + isEnabled: vi.fn(() => true), + track: vi.fn(), + correlationId: 'test-session-uuid', + commandName: 'create', + durationMs: vi.fn().mockReturnValue(0), + analytics: { closeAndFlush: vi.fn().mockResolvedValue(undefined) }, + }; + + return { + Telemetry: vi.fn().mockImplementation(() => instance), + getTelemetry: vi.fn(() => instance), + resetTelemetry: vi.fn(), + }; +}); + +let exitMock: MockInstance; +let tmpDir: string; +let cleanup: () => Promise; +let testCounter = 0; + +const storeHash = 'flag-store-hash'; +const accessToken = 'flag-access-token'; + +// Each test gets a unique --project-name so the computed projectDir +// (`${tmpDir}/${name}`) doesn't collide with prior tests' directories +// when cloneCatalyst's mock creates them. +const uniqueProjectName = () => `test-project-${(testCounter += 1)}`; + +beforeAll(async () => { + consola.mockTypes(() => vi.fn()); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + exitMock = vi.spyOn(process, 'exit').mockImplementation(() => null as never); + + [tmpDir, cleanup] = await mkTempDir(); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +afterAll(async () => { + vi.restoreAllMocks(); + exitMock.mockRestore(); + + await cleanup(); +}); + +test('properly configured Command instance', () => { + expect(create).toBeInstanceOf(Command); + expect(create.name()).toBe('create'); + expect(create.description()).toBe( + 'Scaffold and connect a Catalyst storefront to your BigCommerce store.', + ); +}); + +describe('happy paths', () => { + test('scaffolds with full creds + flag-provided channel info (no commerce hosting)', async () => { + const projectName = uniqueProjectName(); + + await program.parseAsync([ + 'node', + 'catalyst', + 'create', + '--project-name', + projectName, + '--project-dir', + tmpDir, + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--channel-id', + '42', + '--storefront-token', + 'flag-storefront-token', + ]); + + expect(login).not.toHaveBeenCalled(); + expect(mockIdentify).toHaveBeenCalledWith(storeHash); + expect(cloneCatalyst).toHaveBeenCalled(); + expect(setupCoreProject).toHaveBeenCalled(); + expect(setupCommerceHosting).not.toHaveBeenCalled(); + expect(installDependencies).toHaveBeenCalled(); + expect(buildWorkspacePackages).toHaveBeenCalled(); + expect(writeEnv).toHaveBeenCalledWith( + join(tmpDir, projectName), + expect.objectContaining({ + BIGCOMMERCE_STORE_HASH: storeHash, + BIGCOMMERCE_CHANNEL_ID: '42', + BIGCOMMERCE_STOREFRONT_TOKEN: 'flag-storefront-token', + CATALYST_ACCESS_TOKEN: accessToken, + }), + ); + }); + + test('--hosting commerce sets up commerce hosting and writes BIGCOMMERCE_ACCESS_TOKEN', async () => { + const projectName = uniqueProjectName(); + + await program.parseAsync([ + 'node', + 'catalyst', + 'create', + '--project-name', + projectName, + '--project-dir', + tmpDir, + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--channel-id', + '42', + '--storefront-token', + 'flag-storefront-token', + '--hosting', + 'commerce', + ]); + + expect(hasProjectsAccess).toHaveBeenCalledWith(storeHash, accessToken, 'api.bigcommerce.com'); + expect(promptForCommerceHostingProject).toHaveBeenCalled(); + expect(setupCommerceHosting).toHaveBeenCalledWith({ + projectDir: join(tmpDir, projectName), + projectUuid: 'commerce-project-uuid', + storeHash, + accessToken, + }); + expect(writeEnv).toHaveBeenCalledWith( + join(tmpDir, projectName), + expect.objectContaining({ BIGCOMMERCE_ACCESS_TOKEN: accessToken }), + ); + }); + + test('login is invoked when creds are missing — channel info alone is insufficient', async () => { + // Regression test for edge case #1: previously, providing channel info via + // flags caused the login gate to be skipped, leaving BIGCOMMERCE_STORE_HASH + // unset and the storefront unable to start. + const projectName = uniqueProjectName(); + + await program.parseAsync([ + 'node', + 'catalyst', + 'create', + '--project-name', + projectName, + '--project-dir', + tmpDir, + '--channel-id', + '42', + '--storefront-token', + 'flag-storefront-token', + ]); + + expect(login).toHaveBeenCalled(); + expect(writeEnv).toHaveBeenCalledWith( + join(tmpDir, projectName), + expect.objectContaining({ + BIGCOMMERCE_STORE_HASH: 'login-store-hash', + BIGCOMMERCE_CHANNEL_ID: '42', + BIGCOMMERCE_STOREFRONT_TOKEN: 'flag-storefront-token', + }), + ); + }); + + test('warns when --use-existing is passed without --hosting commerce', async () => { + await program.parseAsync([ + 'node', + 'catalyst', + 'create', + '--project-name', + uniqueProjectName(), + '--project-dir', + tmpDir, + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--channel-id', + '42', + '--storefront-token', + 'flag-storefront-token', + '--use-existing', + ]); + + expect(consola.warn).toHaveBeenCalledWith( + '--use-existing has no effect without --hosting commerce. Ignoring.', + ); + }); +}); + +describe('parser validation', () => { + test('--channel-id with non-numeric value throws InvalidArgumentError', async () => { + await expect( + program.parseAsync([ + 'node', + 'catalyst', + 'create', + '--project-name', + uniqueProjectName(), + '--project-dir', + tmpDir, + '--channel-id', + 'abc', + ]), + ).rejects.toThrow(/not a valid channel ID/); + }); + + test('--env without = throws InvalidArgumentError', async () => { + await expect( + program.parseAsync([ + 'node', + 'catalyst', + 'create', + '--project-name', + uniqueProjectName(), + '--project-dir', + tmpDir, + '--env', + 'BAD_VALUE', + ]), + ).rejects.toThrow(/Expected KEY=VALUE/); + }); + + test('--env with KEY=VAL=UE preserves the full value past the first =', async () => { + const projectName = uniqueProjectName(); + + await program.parseAsync([ + 'node', + 'catalyst', + 'create', + '--project-name', + projectName, + '--project-dir', + tmpDir, + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--channel-id', + '42', + '--storefront-token', + 'flag-storefront-token', + '--env', + 'CONNECTION_STRING=postgres://user:pass@host/db?ssl=true', + ]); + + expect(writeEnv).toHaveBeenCalledWith( + join(tmpDir, projectName), + expect.objectContaining({ + CONNECTION_STRING: 'postgres://user:pass@host/db?ssl=true', + }), + ); + }); +}); + +describe('ordering invariants', () => { + test('writeEnv runs before installDependencies and buildWorkspacePackages', async () => { + // Regression test for edge case #2: previously env vars were written after + // install/build, which would break any future workspace build script that + // reads env vars. + await program.parseAsync([ + 'node', + 'catalyst', + 'create', + '--project-name', + uniqueProjectName(), + '--project-dir', + tmpDir, + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--channel-id', + '42', + '--storefront-token', + 'flag-storefront-token', + ]); + + const [writeEnvOrder] = vi.mocked(writeEnv).mock.invocationCallOrder; + const [installOrder] = vi.mocked(installDependencies).mock.invocationCallOrder; + const [buildOrder] = vi.mocked(buildWorkspacePackages).mock.invocationCallOrder; + + expect(writeEnvOrder).toBeLessThan(installOrder); + expect(writeEnvOrder).toBeLessThan(buildOrder); + }); +}); + +describe('failure handling', () => { + test('mid-flow failure surfaces cleanup warning when projectDir exists', async () => { + // Regression test for edge case #5. cloneCatalyst's mock creates the dir + // so the cleanup-warning's pathExistsSync check passes; installDependencies + // then throws to simulate a mid-flow failure. + const projectName = uniqueProjectName(); + const projectDir = join(tmpDir, projectName); + + vi.mocked(cloneCatalyst).mockImplementationOnce(() => { + mkdirSync(projectDir, { recursive: true }); + }); + vi.mocked(installDependencies).mockRejectedValueOnce(new Error('install failed')); + + await expect( + program.parseAsync([ + 'node', + 'catalyst', + 'create', + '--project-name', + projectName, + '--project-dir', + tmpDir, + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--channel-id', + '42', + '--storefront-token', + 'flag-storefront-token', + ]), + ).rejects.toThrow('install failed'); + + expect(consola.warn).toHaveBeenCalledWith( + expect.stringContaining(`'${projectDir}' may be in a partial state`), + ); + }); + + test('mid-flow failure does not log cleanup warning if projectDir does not exist', async () => { + // cloneCatalyst is mocked but does NOT create the dir, so pathExistsSync + // returns false and the cleanup warning is suppressed. + vi.mocked(cloneCatalyst).mockImplementationOnce(() => { + throw new Error('clone failed before creating directory'); + }); + + await expect( + program.parseAsync([ + 'node', + 'catalyst', + 'create', + '--project-name', + uniqueProjectName(), + '--project-dir', + tmpDir, + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--channel-id', + '42', + '--storefront-token', + 'flag-storefront-token', + ]), + ).rejects.toThrow('clone failed before creating directory'); + + expect(consola.warn).not.toHaveBeenCalledWith(expect.stringContaining('partial state')); + }); +}); + +describe('--hosting commerce preconditions', () => { + test('exits with error when hasProjectsAccess returns false', async () => { + vi.mocked(hasProjectsAccess).mockResolvedValueOnce(false); + + // The promptForCommerceHostingProject mock would normally return a project, + // but after process.exit (mocked) the action falls through. Make it throw + // so we can verify the precondition fired before reaching the prompt. + vi.mocked(promptForCommerceHostingProject).mockRejectedValueOnce( + new Error('should not have prompted after access denied'), + ); + + await expect( + program.parseAsync([ + 'node', + 'catalyst', + 'create', + '--project-name', + uniqueProjectName(), + '--project-dir', + tmpDir, + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--channel-id', + '42', + '--storefront-token', + 'flag-storefront-token', + '--hosting', + 'commerce', + ]), + ).rejects.toThrow(/should not have prompted/); + + expect(consola.error).toHaveBeenCalledWith( + expect.stringContaining('does not have access to the Infrastructure Projects API'), + ); + expect(exitMock).toHaveBeenCalledWith(1); + }); +}); diff --git a/packages/catalyst/src/cli/commands/create.ts b/packages/catalyst/src/cli/commands/create.ts new file mode 100644 index 0000000000..05e4db8e24 --- /dev/null +++ b/packages/catalyst/src/cli/commands/create.ts @@ -0,0 +1,503 @@ +import { Command, InvalidArgumentError, Option } from '@commander-js/extra-typings'; +import { input, select } from '@inquirer/prompts'; +import { execSync } from 'child_process'; +import { colorize } from 'consola/utils'; +import { pathExistsSync } from 'fs-extra/esm'; +import kebabCase from 'lodash.kebabcase'; +import { join } from 'path'; + +import { DEFAULT_LOGIN_URL } from '../lib/auth'; +import { buildWorkspacePackages } from '../lib/build-workspace-packages'; +import { + checkChannelEligibility, + createChannel, + fetchAvailableChannels, + getChannelInit, +} from '../lib/channels'; +import { cloneCatalyst } from '../lib/clone-catalyst'; +import { promptForCommerceHostingProject, setupCommerceHosting } from '../lib/commerce-hosting'; +import { installDependencies } from '../lib/install-dependencies'; +import { getAvailableLocales } from '../lib/localization'; +import { consola } from '../lib/logger'; +import { login } from '../lib/login'; +import { hasProjectsAccess, type ProjectListItem } from '../lib/project'; +import { setupCoreProject } from '../lib/setup-core-project'; +import { accessTokenOption, storeHashOption } from '../lib/shared-options'; +import { getTelemetry } from '../lib/telemetry'; +import { writeEnv } from '../lib/write-env'; + +function getPlatformCheckCommand(command: string): string { + const isWindows = process.platform === 'win32'; + + return isWindows ? `where.exe ${command}` : `which ${command}`; +} + +function parseChannelId(value: string): number { + const parsed = parseInt(value, 10); + + if (Number.isNaN(parsed)) { + throw new InvalidArgumentError(`"${value}" is not a valid channel ID (expected a number).`); + } + + return parsed; +} + +// Variadic argParser: called once per `--env KEY=VALUE`, accumulating into a +// merged record. Splits on the first `=` so values containing `=` are preserved. +function parseEnvFlag( + value: string, + previous: Record = {}, +): Record { + const eqIdx = value.indexOf('='); + + if (eqIdx <= 0) { + throw new InvalidArgumentError(`Expected KEY=VALUE, got "${value}".`); + } + + const key = value.substring(0, eqIdx); + const val = value.substring(eqIdx + 1); + + if (!val) { + throw new InvalidArgumentError(`Expected KEY=VALUE with non-empty value, got "${value}".`); + } + + return { ...previous, [key]: val }; +} + +async function handleChannelCreation( + storeHash: string, + accessToken: string, + apiHost: string, + cliApiOrigin: string, +) { + const newChannelName = await input({ + message: 'What would you like to name your new channel?', + }); + + const availableLocales = await getAvailableLocales(storeHash, accessToken, apiHost); + + const storefrontLocale = await select({ + message: 'Which default language would you like to set for your channel?', + default: 'en', + choices: availableLocales, + theme: { + style: { + help: () => colorize('dim', '(Select locale from the list or start typing the name)'), + }, + }, + }); + + const shouldAddAdditionalLocales = await select({ + message: 'Would you like to add additional languages?', + choices: [ + { name: 'Yes', value: true }, + { name: 'No', value: false }, + ], + }); + + let additionalLocales: string[] = []; + + if (shouldAddAdditionalLocales) { + const localeOptions = availableLocales + .filter(({ value }) => value !== storefrontLocale) + .map(({ name, value }) => ({ label: name, value, hint: value })); + + // consola's multiselect returns the value strings at runtime, but its typed + // return is loose (the whole option array). Recursion + cast avoids the + // no-await-in-loop / no-constant-condition lint hits and re-prompts on overflow. + const pickLocales = async (): Promise => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const selected = (await consola.prompt( + 'Which additional languages would you like to add to your channel?', + { type: 'multiselect', options: localeOptions, cancel: 'reject' }, + )) as unknown as string[]; + + if (selected.length > 4) { + consola.warn('You can only select up to 4 additional languages. Please try again.'); + + return pickLocales(); + } + + return selected; + }; + + additionalLocales = await pickLocales(); + } + + const shouldInstallSampleData = await select({ + message: 'Would you like to install sample data?', + choices: [ + { name: 'Yes', value: true }, + { name: 'No', value: false }, + ], + }); + + return createChannel( + newChannelName, + storefrontLocale, + additionalLocales, + shouldInstallSampleData, + storeHash, + accessToken, + cliApiOrigin, + ); +} + +async function handleChannelSelection(storeHash: string, accessToken: string, apiHost: string) { + const channelSortOrder = ['catalyst', 'next', 'bigcommerce']; + const channels = await fetchAvailableChannels(storeHash, accessToken, apiHost); + + const existingChannel = await select({ + message: 'Which channel would you like to use?', + choices: channels + .sort((a, b) => { + const aIndex = channelSortOrder.indexOf(a.platform); + const bIndex = channelSortOrder.indexOf(b.platform); + + if (aIndex === -1 && bIndex === -1) { + return 0; + } + + if (aIndex === -1) return 1; + if (bIndex === -1) return -1; + + return aIndex - bIndex; + }) + .map((ch) => ({ + name: ch.name, + value: ch, + description: `Channel Platform: ${ + ch.platform === 'bigcommerce' + ? 'Stencil' + : ch.platform.charAt(0).toUpperCase() + ch.platform.slice(1) + }`, + })), + }); + + return existingChannel.id; +} + +async function setupProject(options: { + projectName?: string; + projectDir: string; +}): Promise<{ projectName: string; projectDir: string }> { + let { projectName, projectDir } = options; + + if (!pathExistsSync(projectDir)) { + consola.error(`--project-dir ${projectDir} is not a valid path`); + process.exit(1); + } + + if (projectName) { + projectName = kebabCase(projectName); + projectDir = join(options.projectDir, projectName); + + if (pathExistsSync(projectDir)) { + consola.error(`${projectDir} already exists`); + process.exit(1); + } + } + + if (!projectName) { + const validateProjectName = (i: string) => { + const formatted = kebabCase(i); + + if (!formatted) return 'Project name is required'; + + const targetDir = join(options.projectDir, formatted); + + if (pathExistsSync(targetDir)) return `Destination '${targetDir}' already exists`; + + projectName = formatted; + projectDir = targetDir; + + return true; + }; + + await input({ + message: 'What do you want to name your project directory?', + default: 'my-catalyst-app', + validate: validateProjectName, + }); + } + + if (!projectName) throw new Error('Something went wrong, projectName is not defined'); + if (!projectDir) throw new Error('Something went wrong, projectDir is not defined'); + + return { projectName, projectDir }; +} + +function checkRequiredTools() { + try { + execSync(getPlatformCheckCommand('git'), { stdio: 'ignore' }); + } catch { + consola.error('git is required to create a Catalyst project'); + process.exit(1); + } + + try { + execSync(getPlatformCheckCommand('pnpm'), { stdio: 'ignore' }); + } catch { + consola.error( + 'pnpm is required to create a Catalyst project. Enable it by running `corepack enable pnpm`.', + ); + process.exit(1); + } +} + +export const create = new Command('create') + .configureHelp({ showGlobalOptions: true }) + .description('Scaffold and connect a Catalyst storefront to your BigCommerce store.') + .addHelpText( + 'after', + ` +Examples: + # Interactive scaffold (default — self-hosted, no hosting prompt) + $ catalyst create + + # Non-interactive: skip the project-name prompt + $ catalyst create --project-name my-store + + # Eagerly set up Commerce Hosting at create time + $ catalyst create --project-name my-store --hosting commerce`, + ) + .option('--project-name ', 'Name of your Catalyst project') + .option('--project-dir ', 'Directory in which to create your project', process.cwd()) + .addOption(storeHashOption()) + .addOption(accessTokenOption()) + .option('--channel-id ', 'BigCommerce channel ID', parseChannelId) + .option('--storefront-token ', 'BigCommerce storefront token') + .option( + '--gh-ref ', + 'Clone a specific ref from the source repository', + '@bigcommerce/catalyst-core@latest', + ) + .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. Format: KEY=VALUE (repeatable).', + parseEnvFlag, + ) + .addOption( + new Option( + '--hosting ', + 'Hosting mode: "self-hosted" (default) or "commerce" to set up Commerce Hosting at create time. When omitted, scaffolding is hosting-agnostic; run `catalyst deploy` later to opt in.', + ).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') + .hideHelp(), + ) + .addOption( + new Option('--login-url ', 'BigCommerce login URL.') + .env('BIGCOMMERCE_LOGIN_URL') + .default(DEFAULT_LOGIN_URL) + .hideHelp(), + ) + .addOption( + new Option('--cli-api-origin ', 'Catalyst CLI API origin') + .default('https://cxm-prd.bigcommerceapp.com') + .hideHelp(), + ) + // eslint-disable-next-line complexity + .action(async (options) => { + const { ghRef, repository } = options; + + if (options.useExisting && options.hosting !== 'commerce') { + consola.warn('--use-existing has no effect without --hosting commerce. Ignoring.'); + } + + checkRequiredTools(); + + const { projectName, projectDir } = await setupProject({ + projectName: options.projectName, + projectDir: options.projectDir, + }); + + let storeHash = options.storeHash; + let accessToken = options.accessToken; + let channelId = options.channelId; + let storefrontToken = options.storefrontToken; + + let envVars: Record = {}; + + // Always require store creds. `--channel-id` + `--storefront-token` aren't + // enough on their own — the storefront also needs BIGCOMMERCE_STORE_HASH at + // runtime, and downstream catalyst commands (deploy, project, ...) need an + // access token. Device login covers the missing pieces; the user picks the + // store during the OAuth flow regardless of any partial flags they passed. + if (!storeHash || !accessToken) { + const credentials = await login(options.loginUrl); + + storeHash = credentials.storeHash; + accessToken = credentials.accessToken; + } + + const useCommerceHosting = options.hosting === 'commerce'; + + await getTelemetry().identify(storeHash); + + // Seed env vars from local state when all three were flag-provided. Channel + // resolution below overwrites this wholesale via `envVars = { ...initData.envVars }`, + // which is fine — that path means the user wanted us to fetch fresh values. + if (storeHash && channelId && storefrontToken) { + envVars.BIGCOMMERCE_STORE_HASH = storeHash; + envVars.BIGCOMMERCE_CHANNEL_ID = channelId.toString(); + envVars.BIGCOMMERCE_STOREFRONT_TOKEN = storefrontToken; + } + + // Resolve channel only when we have creds and are missing channel info. + if (storeHash && accessToken && (!channelId || !storefrontToken)) { + const apiHost = `api.${options.bigcommerceHostname}`; + const cliApiOrigin = options.cliApiOrigin; + + if (channelId && !storefrontToken) { + const initData = await getChannelInit(channelId, storeHash, accessToken, cliApiOrigin); + + envVars = { ...initData.envVars }; + storefrontToken = initData.storefrontToken; + } else if (!channelId) { + const eligibility = await checkChannelEligibility(storeHash, accessToken, cliApiOrigin); + + if (!eligibility.eligible) { + consola.warn(eligibility.message); + } + + let shouldCreateChannel; + + if (eligibility.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 channelData = await handleChannelCreation( + storeHash, + accessToken, + apiHost, + cliApiOrigin, + ); + + channelId = channelData.channelId; + storefrontToken = channelData.storefrontToken; + envVars = { ...channelData.envVars }; + + consola.success('Channel created successfully.'); + consola.warn( + 'A preview storefront has been deployed in your BigCommerce control panel. This preview may look different from your local environment as it may be running different code. Additionally, it may take a few minutes for the channel storefront to be accessible.', + ); + } + + if (!shouldCreateChannel) { + channelId = await handleChannelSelection(storeHash, accessToken, apiHost); + + const initData = await getChannelInit(channelId, storeHash, accessToken, cliApiOrigin); + + envVars = { ...initData.envVars }; + storefrontToken = initData.storefrontToken; + } + } + } + + if (options.env) { + Object.assign(envVars, options.env); + } + + if (options.storeHash) envVars.BIGCOMMERCE_STORE_HASH = options.storeHash; + if (options.channelId) envVars.BIGCOMMERCE_CHANNEL_ID = options.channelId.toString(); + if (options.storefrontToken) envVars.BIGCOMMERCE_STOREFRONT_TOKEN = options.storefrontToken; + + if (useCommerceHosting && accessToken) { + envVars.BIGCOMMERCE_ACCESS_TOKEN = accessToken; + } + + // Pre-populate CATALYST_ACCESS_TOKEN so subsequent catalyst CLI commands + // (`catalyst deploy`, `catalyst project ...`, etc.) work without re-auth. + // CATALYST_STORE_HASH isn't written — the CLI falls back to BIGCOMMERCE_STORE_HASH + // (already in envVars from the channel init response) at startup. + if (accessToken) envVars.CATALYST_ACCESS_TOKEN = accessToken; + + // Resolve the Commerce Hosting project before cloning so credential checks + // and prompts happen up-front. We defer the file mutations + // (`setupCommerceHosting`) until after the clone. + let commerceHostingProject: ProjectListItem | undefined; + + if (useCommerceHosting && storeHash && accessToken) { + const apiHost = `api.${options.bigcommerceHostname}`; + const hasAccess = await hasProjectsAccess(storeHash, accessToken, apiHost); + + if (!hasAccess) { + consola.error( + 'This store does not have access to the Infrastructure Projects API. Contact support@bigcommerce.com to enable it.', + ); + process.exit(1); + } + + commerceHostingProject = await promptForCommerceHostingProject( + { storeHash, accessToken, apiHost }, + projectName, + !!options.projectName, + options.useExisting, + ); + } + + consola.info(`Creating '${projectName}' at '${projectDir}'`); + + // Anything that mutates `projectDir` runs inside this block. If a step + // fails, the directory is likely partially populated — surface that to the + // user so they can clean up before retrying. We don't auto-delete because + // they may want to inspect the partial state first. + try { + cloneCatalyst({ repository, projectName, projectDir, ghRef, resetMain: options.resetMain }); + setupCoreProject(projectDir); + + if (useCommerceHosting && commerceHostingProject && storeHash && accessToken) { + setupCommerceHosting({ + projectDir, + projectUuid: commerceHostingProject.uuid, + storeHash, + accessToken, + }); + } + + // Write env before install/build — keeps env vars available to any future + // workspace build script that might read them, and matters today for + // postinstall scripts that may resolve env-driven config. + writeEnv(projectDir, envVars); + + await installDependencies(projectDir); + buildWorkspacePackages(projectDir); + } catch (error) { + if (pathExistsSync(projectDir)) { + consola.warn( + `Setup failed before completion. '${projectDir}' may be in a partial state — review and delete it before re-running 'catalyst create'.`, + ); + } + + throw error; + } + + consola.success(`Created '${projectName}' at '${projectDir}'`); + consola.info('Next steps:'); + consola.info(colorize('yellow', ` cd ${projectName}/core && pnpm run dev`)); + + if (useCommerceHosting) { + consola.info( + colorize( + 'yellow', + ` Run 'cd ${projectName}/core && pnpm run deploy' when ready to deploy to Commerce Hosting.`, + ), + ); + } + }); diff --git a/packages/catalyst/src/cli/commands/deploy.spec.ts b/packages/catalyst/src/cli/commands/deploy.spec.ts index e15b8a5348..a723311415 100644 --- a/packages/catalyst/src/cli/commands/deploy.spec.ts +++ b/packages/catalyst/src/cli/commands/deploy.spec.ts @@ -17,9 +17,12 @@ import { import { server } from '../../../tests/mocks/node'; import { textHistory } from '../../../tests/mocks/spinner'; +import { setupCommerceHosting } from '../lib/commerce-hosting'; +import { installDependencies } from '../lib/install-dependencies'; import { consola } from '../lib/logger'; import { mkTempDir } from '../lib/mk-temp-dir'; import { getProjectConfig } from '../lib/project-config'; +import { getProjectState } from '../lib/project-state'; import { program } from '../program'; import { buildCatalystProject } from './build'; @@ -42,6 +45,36 @@ vi.mock('./build', async (importOriginal) => { return { ...actual, buildCatalystProject: vi.fn() }; }); +// Default to a transformed project so the deploy flow's transformation guard +// is a no-op for tests that don't care about it. Tests that exercise the +// guard override this per-case via `vi.mocked(getProjectState).mockReturnValueOnce(...)`. +vi.mock('../lib/project-state', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + getProjectState: vi.fn(() => ({ + projectUuid: 'mock-uuid', + hasMiddleware: true, + hasProxy: false, + hasOpenNextDep: true, + isLinked: true, + isTransformed: true, + isFullySetUp: true, + })), + }; +}); + +vi.mock('../lib/commerce-hosting', async (importOriginal) => { + const actual = await importOriginal(); + + return { ...actual, setupCommerceHosting: vi.fn() }; +}); + +vi.mock('../lib/install-dependencies', () => ({ + installDependencies: vi.fn(), +})); + let exitMock: MockInstance; let tmpDir: string; @@ -288,6 +321,136 @@ describe('deployment and event streaming', () => { }); }); +describe('linked project verification', () => { + test('proceeds when the linked project still exists on the server', async () => { + await program.parseAsync([ + 'node', + 'catalyst', + 'deploy', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--api-host', + apiHost, + '--project-uuid', + projectUuid, + '--dry-run', + ]); + + expect(consola.info).not.toHaveBeenCalledWith('No project is currently linked.'); + expect(consola.warn).not.toHaveBeenCalledWith(expect.stringContaining('no longer exists')); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + test('prompts for a new project when the linked uuid no longer exists', async () => { + const config = getProjectConfig(); + const staleUuid = '00000000-0000-0000-0000-000000000000'; + + config.set('projectUuid', staleUuid); + config.set('storeHash', storeHash); + config.set('accessToken', accessToken); + + const consolaPromptMock = vi + .spyOn(consola, 'prompt') + .mockImplementation(async () => Promise.resolve(projectUuid)); + + await program.parseAsync(['node', 'catalyst', 'deploy', '--dry-run']); + + expect(consola.warn).toHaveBeenCalledWith( + expect.stringContaining(`The linked project (${staleUuid}) no longer exists`), + ); + expect(consola.success).toHaveBeenCalledWith('Linked project "Project One".'); + expect(config.get('projectUuid')).toBe(projectUuid); + expect(exitMock).toHaveBeenCalledWith(0); + + consolaPromptMock.mockRestore(); + }); + + test('prompts for a project when none is linked yet', async () => { + const config = getProjectConfig(); + + config.delete('projectUuid'); + config.set('storeHash', storeHash); + config.set('accessToken', accessToken); + + const consolaPromptMock = vi + .spyOn(consola, 'prompt') + .mockImplementation(async () => Promise.resolve(projectUuid)); + + await program.parseAsync(['node', 'catalyst', 'deploy', '--dry-run']); + + expect(consola.info).toHaveBeenCalledWith('No project is currently linked.'); + expect(consola.success).toHaveBeenCalledWith('Linked project "Project One".'); + expect(config.get('projectUuid')).toBe(projectUuid); + expect(exitMock).toHaveBeenCalledWith(0); + + consolaPromptMock.mockRestore(); + }); + + test('offers to create when no projects exist on the store', async () => { + const config = getProjectConfig(); + + config.delete('projectUuid'); + config.set('storeHash', storeHash); + config.set('accessToken', accessToken); + + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => + HttpResponse.json({ data: [] }), + ), + ); + + const consolaPromptMock = vi + .spyOn(consola, 'prompt') + .mockImplementationOnce(async (message) => { + expect(message).toContain('There are not any hosting projects that you can link to yet'); + + return Promise.resolve(true); + }) + .mockImplementationOnce(async (message) => { + expect(message).toBe('Enter a name for the new project:'); + + return Promise.resolve('My New Project'); + }); + + await program.parseAsync(['node', 'catalyst', 'deploy', '--dry-run']); + + expect(exitMock).toHaveBeenCalledWith(0); + + consolaPromptMock.mockRestore(); + }); + + test('exits gracefully with guidance when user declines to create', async () => { + const config = getProjectConfig(); + + config.delete('projectUuid'); + config.set('storeHash', storeHash); + config.set('accessToken', accessToken); + + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => + HttpResponse.json({ data: [] }), + ), + ); + + const consolaPromptMock = vi + .spyOn(consola, 'prompt') + .mockImplementation(async () => Promise.resolve(false)); + + await expect(program.parseAsync(['node', 'catalyst', 'deploy', '--dry-run'])).rejects.toThrow( + 'No infrastructure project linked', + ); + + expect(consola.info).toHaveBeenCalledWith( + "When you're ready to create a project, run `catalyst project create` or re-run `catalyst deploy`.", + ); + expect(exitMock).toHaveBeenCalledWith(0); + + consolaPromptMock.mockRestore(); + }); +}); + test('--dry-run skips upload and deployment', async () => { await program.parseAsync([ 'node', @@ -561,3 +724,96 @@ describe('--prebuilt flag', () => { await emptyDistCleanup(); }); }); + +describe('transformation guard', () => { + const untransformedState = { + projectUuid: undefined, + hasMiddleware: false, + hasProxy: true, + hasOpenNextDep: false, + isLinked: false, + isTransformed: false, + isFullySetUp: false, + }; + + test('runs setupCommerceHosting + installDependencies when project is not transformed', async () => { + vi.mocked(getProjectState).mockReturnValueOnce(untransformedState); + + await program.parseAsync([ + 'node', + 'catalyst', + 'deploy', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--api-host', + apiHost, + '--project-uuid', + projectUuid, + '--prebuilt', + '--dry-run', + ]); + + expect(consola.prompt).toHaveBeenCalledWith( + expect.stringContaining('not yet set up for Commerce Hosting deployments'), + expect.objectContaining({ type: 'confirm' }), + ); + expect(setupCommerceHosting).toHaveBeenCalledWith({ + projectDir: dirname(tmpDir), + projectUuid, + storeHash, + accessToken, + }); + expect(installDependencies).toHaveBeenCalledWith(dirname(tmpDir)); + }); + + test('exits gracefully when user declines to run setup', async () => { + vi.mocked(getProjectState).mockReturnValueOnce(untransformedState); + vi.mocked(consola.prompt).mockResolvedValueOnce(false); + + // In production, process.exit halts. In tests it's mocked, so we can only + // verify the user-visible signals: the guidance log and the exit code. + await program.parseAsync([ + 'node', + 'catalyst', + 'deploy', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--api-host', + apiHost, + '--project-uuid', + projectUuid, + '--prebuilt', + '--dry-run', + ]); + + expect(consola.info).toHaveBeenCalledWith( + "When you're ready to deploy, re-run `catalyst deploy` to complete setup.", + ); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + test('skips setup when project is already transformed', async () => { + await program.parseAsync([ + 'node', + 'catalyst', + 'deploy', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--api-host', + apiHost, + '--project-uuid', + projectUuid, + '--prebuilt', + '--dry-run', + ]); + + expect(setupCommerceHosting).not.toHaveBeenCalled(); + expect(installDependencies).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/catalyst/src/cli/commands/deploy.ts b/packages/catalyst/src/cli/commands/deploy.ts index 482d06cfef..ca4994fc02 100644 --- a/packages/catalyst/src/cli/commands/deploy.ts +++ b/packages/catalyst/src/cli/commands/deploy.ts @@ -2,14 +2,27 @@ import AdmZip from 'adm-zip'; import { Command, Option } from 'commander'; import { colorize } from 'consola/utils'; import { access, readdir, readFile } from 'node:fs/promises'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import yoctoSpinner from 'yocto-spinner'; import { z } from 'zod'; +import { + NoLinkedProjectError, + selectOrCreateInfrastructureProject, + setupCommerceHosting, +} from '../lib/commerce-hosting'; import { getDeploymentErrorMessage } from '../lib/deployment-errors'; +import { installDependencies } from '../lib/install-dependencies'; import { consola } from '../lib/logger'; import { getProjectConfig } from '../lib/project-config'; +import { getProjectState } from '../lib/project-state'; import { resolveCredentials } from '../lib/resolve-credentials'; +import { + accessTokenOption, + apiHostOption, + projectUuidOption, + storeHashOption, +} from '../lib/shared-options'; import { getTelemetry } from '../lib/telemetry'; import { buildCatalystProject } from './build'; @@ -378,29 +391,17 @@ Example: $ catalyst deploy --secret BIGCOMMERCE_STORE_HASH= --secret BIGCOMMERCE_STOREFRONT_TOKEN=`, ) .addOption( - new Option( - '--store-hash ', - 'BigCommerce store hash. Can be found in the URL of your store Control Panel. Read from .bigcommerce/project.json when not provided.', - ).env('CATALYST_STORE_HASH'), + storeHashOption( + 'BigCommerce store hash. Can be found in the URL of your store Control Panel. Read from .bigcommerce/project.json or .env when not provided.', + ), ) .addOption( - new Option( - '--access-token ', - 'BigCommerce access token. Can be found after creating a store-level API account. Read from .bigcommerce/project.json when not provided.', - ).env('CATALYST_ACCESS_TOKEN'), - ) - .addOption( - new Option('--api-host ', 'BigCommerce API host. The default is api.bigcommerce.com.') - .env('BIGCOMMERCE_API_HOST') - .default('api.bigcommerce.com') - .hideHelp(), - ) - .addOption( - new Option( - '--project-uuid ', - 'BigCommerce intrastructure project UUID. Can be found via the BigCommerce API (GET /v3/infrastructure/projects).', - ).env('CATALYST_PROJECT_UUID'), + accessTokenOption( + 'BigCommerce access token. Can be found after creating a store-level API account. Read from .bigcommerce/project.json or .env when not provided.', + ), ) + .addOption(apiHostOption()) + .addOption(projectUuidOption()) .addOption( new Option( '--secret ', @@ -418,15 +419,88 @@ Example: const config = getProjectConfig(); const { storeHash, accessToken } = resolveCredentials(options, config); const telemetry = getTelemetry(); - const projectUuid = options.projectUuid ?? config.get('projectUuid'); - if (!projectUuid) { - throw new Error( - 'Project UUID is required. Please run either `catalyst project link` or `catalyst project create` or this command again with --project-uuid .', + await telemetry.identify(storeHash); + + // Resolve a *valid* projectUuid before doing any expensive build/upload + // work. If the linked UUID no longer exists on the server (e.g. project + // deleted out from under us), prompt the user to pick a new one rather + // than failing mid-deploy. + const linkedProjectUuid = options.projectUuid ?? config.get('projectUuid'); + let projectUuid: string; + + const promptForProject = async (): Promise<{ uuid: string; name: string }> => { + try { + return await selectOrCreateInfrastructureProject({ + storeHash, + accessToken, + apiHost: options.apiHost, + }); + } catch (error) { + if (error instanceof NoLinkedProjectError) { + consola.info( + "When you're ready to create a project, run `catalyst project create` or re-run `catalyst deploy`.", + ); + process.exit(0); + } + + throw error; + } + }; + + if (linkedProjectUuid) { + const existing = await fetchProject( + linkedProjectUuid, + storeHash, + accessToken, + options.apiHost, ); + + if (existing) { + projectUuid = linkedProjectUuid; + } else { + consola.warn( + `The linked project (${linkedProjectUuid}) no longer exists on this store. It may have been deleted.`, + ); + + const selected = await promptForProject(); + + projectUuid = selected.uuid; + config.set('projectUuid', projectUuid); + consola.success(`Linked project "${selected.name}".`); + } + } else { + consola.info('No project is currently linked.'); + + const selected = await promptForProject(); + + projectUuid = selected.uuid; + config.set('projectUuid', projectUuid); + consola.success(`Linked project "${selected.name}".`); } - await telemetry.identify(storeHash); + // The OpenNext build pipeline requires the project to be transformed + // (proxy.ts → middleware.ts, @opennextjs/cloudflare installed). Run setup + // here so first-run `catalyst deploy` works on a fresh self-hosted scaffold + // without forcing the user to re-run after a separate setup step. + if (!getProjectState().isTransformed) { + const shouldSetup = await consola.prompt( + 'Your project is not yet set up for Commerce Hosting deployments. Would you like to run the Commerce Hosting setup now?', + { type: 'confirm', initial: true }, + ); + + if (!shouldSetup) { + consola.info("When you're ready to deploy, re-run `catalyst deploy` to complete setup."); + process.exit(0); + } + + const projectDir = dirname(process.cwd()); + + setupCommerceHosting({ projectDir, projectUuid, storeHash, accessToken }); + consola.success('Commerce Hosting setup complete.'); + + await installDependencies(projectDir); + } if (options.prebuilt) { const distDir = join(process.cwd(), '.bigcommerce', 'dist'); diff --git a/packages/catalyst/src/cli/commands/project.spec.ts b/packages/catalyst/src/cli/commands/project.spec.ts index 79a4933a71..3f4871e860 100644 --- a/packages/catalyst/src/cli/commands/project.spec.ts +++ b/packages/catalyst/src/cli/commands/project.spec.ts @@ -1,16 +1,66 @@ import { Command } from 'commander'; import Conf from 'conf'; import { http, HttpResponse } from 'msw'; -import { afterAll, afterEach, beforeAll, describe, expect, MockInstance, test, vi } from 'vitest'; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + MockInstance, + test, + vi, +} from 'vitest'; import { server } from '../../../tests/mocks/node'; +import { setupCommerceHosting } from '../lib/commerce-hosting'; +import { installDependencies } from '../lib/install-dependencies'; import { consola } from '../lib/logger'; import { mkTempDir } from '../lib/mk-temp-dir'; import { getProjectConfig, ProjectConfigSchema } from '../lib/project-config'; +import { getProjectState } from '../lib/project-state'; import { program } from '../program'; import { link, project } from './project'; +vi.mock('../lib/project-state', () => ({ + getProjectState: vi.fn(), +})); + +vi.mock('../lib/install-dependencies', () => ({ + installDependencies: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../lib/commerce-hosting', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + setupCommerceHosting: vi.fn(), + }; +}); + +const transformedState = { + projectUuid: 'abc-123', + hasMiddleware: true, + hasProxy: false, + hasOpenNextDep: true, + isLinked: true, + isTransformed: true, + isFullySetUp: true, +}; + +const untransformedState = { + projectUuid: undefined, + hasMiddleware: false, + hasProxy: true, + hasOpenNextDep: false, + isLinked: false, + isTransformed: false, + isFullySetUp: false, +}; + let exitMock: MockInstance; let tmpDir: string; @@ -60,6 +110,12 @@ beforeAll(async () => { config = getProjectConfig(); }); +beforeEach(() => { + // Default to a fully-transformed project so existing tests skip the + // post-link Commerce Hosting setup prompt. Override per-test as needed. + vi.mocked(getProjectState).mockReturnValue(transformedState); +}); + afterEach(() => { vi.clearAllMocks(); config.delete('storeHash'); @@ -231,6 +287,46 @@ describe('project list', () => { expect(exitMock).toHaveBeenCalledWith(0); }); + test('marks the currently linked project with [linked]', async () => { + config.set('projectUuid', projectUuid2); + + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'list', + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]); + + const logCalls = vi.mocked(consola.log).mock.calls.map(([msg]) => String(msg)); + + const linkedLine = logCalls.find((line) => line.includes(projectUuid2)); + const otherLine = logCalls.find((line) => line.includes(projectUuid1)); + + expect(linkedLine).toContain('[linked]'); + expect(otherLine).not.toContain('[linked]'); + }); + + test('does not mark any project when nothing is linked', async () => { + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'list', + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]); + + const logCalls = vi.mocked(consola.log).mock.calls.map(([msg]) => String(msg)); + + expect(logCalls.every((line) => !line.includes('[linked]'))).toBe(true); + }); + test('with insufficient credentials exits with error', async () => { const savedStoreHash = process.env.CATALYST_STORE_HASH; const savedAccessToken = process.env.CATALYST_ACCESS_TOKEN; @@ -511,6 +607,70 @@ describe('project link', () => { consolaPromptMock.mockRestore(); }); + test('marks the currently linked project with [linked] in the select prompt', async () => { + config.set('projectUuid', projectUuid2); + + const consolaPromptMock = vi + .spyOn(consola, 'prompt') + .mockImplementationOnce(async (_message, opts) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const options = (opts as { options: Array<{ label: string; value: string }> }).options; + + const linkedOption = options.find((o) => o.value === projectUuid2); + const otherOption = options.find((o) => o.value === projectUuid1); + + expect(linkedOption?.label).toContain('[linked]'); + expect(otherOption?.label).not.toContain('[linked]'); + + return Promise.resolve(projectUuid2); + }); + + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'link', + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]); + + consolaPromptMock.mockRestore(); + }); + + test('exits gracefully with guidance when user declines to create from empty list', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => + HttpResponse.json({ data: [] }), + ), + ); + + const consolaPromptMock = vi + .spyOn(consola, 'prompt') + .mockImplementation(async () => Promise.resolve(false)); + + await expect( + program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'link', + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]), + ).rejects.toThrow('No infrastructure project linked'); + + expect(consola.info).toHaveBeenCalledWith( + "When you're ready to create a project, run `catalyst project create` or re-run `catalyst project link`.", + ); + expect(exitMock).toHaveBeenCalledWith(0); + + consolaPromptMock.mockRestore(); + }); + test('errors when infrastructure projects API is not found', async () => { server.use( http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => @@ -538,6 +698,79 @@ describe('project link', () => { expect(consola.start).toHaveBeenCalledWith('Fetching projects...'); }); + describe('post-link Commerce Hosting setup prompt', () => { + test('does not prompt when project is already transformed', async () => { + const consolaPromptMock = vi.spyOn(consola, 'prompt'); + + vi.mocked(getProjectState).mockReturnValue(transformedState); + + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'link', + '--project-uuid', + projectUuid1, + ]); + + const promptMessages = consolaPromptMock.mock.calls.map(([msg]) => msg); + + expect(promptMessages).not.toContain( + expect.stringContaining('not fully set up for Commerce Hosting'), + ); + + consolaPromptMock.mockRestore(); + }); + + test('prompts and runs setup when user accepts', async () => { + vi.mocked(getProjectState).mockReturnValue(untransformedState); + + const consolaPromptMock = vi.spyOn(consola, 'prompt').mockImplementation(async (message) => { + expect(message).toContain('not fully set up for Commerce Hosting'); + + return Promise.resolve(true); + }); + + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'link', + '--project-uuid', + projectUuid1, + ]); + + expect(setupCommerceHosting).toHaveBeenCalledWith( + expect.objectContaining({ projectUuid: projectUuid1 }), + ); + expect(installDependencies).toHaveBeenCalled(); + + consolaPromptMock.mockRestore(); + }); + + test('skips setup when user declines', async () => { + vi.mocked(getProjectState).mockReturnValue(untransformedState); + + const consolaPromptMock = vi + .spyOn(consola, 'prompt') + .mockImplementation(async () => Promise.resolve(false)); + + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'link', + '--project-uuid', + projectUuid1, + ]); + + expect(setupCommerceHosting).not.toHaveBeenCalled(); + expect(installDependencies).not.toHaveBeenCalled(); + + consolaPromptMock.mockRestore(); + }); + }); + test('errors when no projectUuid, storeHash, or accessToken are provided', async () => { await expect(program.parseAsync(['node', 'catalyst', 'project', 'link'])).rejects.toThrow( 'Missing credentials', diff --git a/packages/catalyst/src/cli/commands/project.ts b/packages/catalyst/src/cli/commands/project.ts index bec2ecb94e..d016494610 100644 --- a/packages/catalyst/src/cli/commands/project.ts +++ b/packages/catalyst/src/cli/commands/project.ts @@ -1,11 +1,55 @@ -import { Command, Option } from 'commander'; - +import { Command } from 'commander'; +import { colorize } from 'consola/utils'; +import { dirname } from 'node:path'; + +import { + NoLinkedProjectError, + selectOrCreateInfrastructureProject, + setupCommerceHosting, +} from '../lib/commerce-hosting'; +import { installDependencies } from '../lib/install-dependencies'; import { consola } from '../lib/logger'; import { createProject, fetchProjects } from '../lib/project'; import { getProjectConfig } from '../lib/project-config'; +import { getProjectState } from '../lib/project-state'; import { resolveCredentials } from '../lib/resolve-credentials'; +import { + accessTokenOption, + apiHostOption, + projectUuidOption, + storeHashOption, +} from '../lib/shared-options'; import { getTelemetry } from '../lib/telemetry'; +// `catalyst project link` runs from inside `core/`, so the project root (which +// `setupCommerceHosting` and `installDependencies` expect) is one level up. +async function offerCommerceHostingSetup( + projectUuid: string, + credentials?: { storeHash: string; accessToken: string }, +) { + if (getProjectState().isTransformed) return; + + const shouldSetup = await consola.prompt( + 'Your project has been linked, but is not fully set up for Commerce Hosting deployments yet. Would you like to run the setup now?', + { type: 'confirm', initial: true }, + ); + + if (!shouldSetup) return; + + const projectDir = dirname(process.cwd()); + + setupCommerceHosting({ + projectDir, + projectUuid, + storeHash: credentials?.storeHash, + accessToken: credentials?.accessToken, + }); + + consola.success('Commerce Hosting setup complete.'); + + await installDependencies(projectDir); +} + const list = new Command('list') .configureHelp({ showGlobalOptions: true }) .description('List BigCommerce infrastructure projects for your store.') @@ -15,24 +59,9 @@ const list = new Command('list') Example: $ catalyst project list`, ) - .addOption( - new Option( - '--store-hash ', - 'BigCommerce store hash. Can be found in the URL of your store Control Panel.', - ).env('CATALYST_STORE_HASH'), - ) - .addOption( - new Option( - '--access-token ', - 'BigCommerce access token. Can be found after creating a store-level API account.', - ).env('CATALYST_ACCESS_TOKEN'), - ) - .addOption( - new Option('--api-host ', 'BigCommerce API host. The default is api.bigcommerce.com.') - .env('BIGCOMMERCE_API_HOST') - .default('api.bigcommerce.com') - .hideHelp(), - ) + .addOption(storeHashOption()) + .addOption(accessTokenOption()) + .addOption(apiHostOption()) .action(async (options) => { const config = getProjectConfig(); const { storeHash, accessToken } = resolveCredentials(options, config); @@ -52,8 +81,12 @@ Example: return; } + const linkedProjectUuid = config.get('projectUuid'); + projects.forEach((p) => { - consola.log(`${p.name} (${p.uuid})`); + const marker = p.uuid === linkedProjectUuid ? ` ${colorize('green', '[linked]')}` : ''; + + consola.log(`${p.name} (${p.uuid})${marker}`); }); process.exit(0); @@ -70,24 +103,9 @@ const create = new Command('create') Example: $ catalyst project create`, ) - .addOption( - new Option( - '--store-hash ', - 'BigCommerce store hash. Can be found in the URL of your store Control Panel.', - ).env('CATALYST_STORE_HASH'), - ) - .addOption( - new Option( - '--access-token ', - 'BigCommerce access token. Can be found after creating a store-level API account.', - ).env('CATALYST_ACCESS_TOKEN'), - ) - .addOption( - new Option('--api-host ', 'BigCommerce API host. The default is api.bigcommerce.com.') - .env('BIGCOMMERCE_API_HOST') - .default('api.bigcommerce.com') - .hideHelp(), - ) + .addOption(storeHashOption()) + .addOption(accessTokenOption()) + .addOption(apiHostOption()) .action(async (options) => { const config = getProjectConfig(); const { storeHash, accessToken } = resolveCredentials(options, config); @@ -126,28 +144,10 @@ Examples: # Link using a project UUID directly $ catalyst project link --project-uuid `, ) - .addOption( - new Option( - '--store-hash ', - 'BigCommerce store hash. Can be found in the URL of your store Control Panel.', - ).env('CATALYST_STORE_HASH'), - ) - .addOption( - new Option( - '--access-token ', - 'BigCommerce access token. Can be found after creating a store-level API account.', - ).env('CATALYST_ACCESS_TOKEN'), - ) - .addOption( - new Option('--api-host ', 'BigCommerce API host. The default is api.bigcommerce.com.') - .env('BIGCOMMERCE_API_HOST') - .default('api.bigcommerce.com') - .hideHelp(), - ) - .option( - '--project-uuid ', - 'BigCommerce infrastructure project UUID. Can be found via the BigCommerce API (GET /v3/infrastructure/projects). Use this to link directly without fetching projects.', - ) + .addOption(storeHashOption()) + .addOption(accessTokenOption()) + .addOption(apiHostOption()) + .addOption(projectUuidOption()) .action(async (options) => { const config = getProjectConfig(); @@ -168,6 +168,7 @@ Examples: if (options.projectUuid) { writeProjectConfig(options.projectUuid); + await offerCommerceHostingSetup(options.projectUuid); process.exit(0); @@ -178,47 +179,29 @@ Examples: await getTelemetry().identify(storeHash); - consola.start('Fetching projects...'); - - const projects = await fetchProjects(storeHash, accessToken, options.apiHost); - - consola.success('Projects fetched.'); + let selected; + + try { + selected = await selectOrCreateInfrastructureProject( + { storeHash, accessToken, apiHost: options.apiHost }, + config.get('projectUuid'), + ); + } catch (error) { + if (error instanceof NoLinkedProjectError) { + consola.info( + "When you're ready to create a project, run `catalyst project create` or re-run `catalyst project link`.", + ); + process.exit(0); + + // Unreachable in production; prevents continuation when process.exit is mocked in tests. + throw error; + } - const promptOptions = [ - ...projects.map((proj) => ({ - label: proj.name, - value: proj.uuid, - hint: proj.uuid, - })), - { - label: 'Create a new project', - value: 'create', - hint: 'Create a new infrastructure project for this BigCommerce store.', - }, - ]; - - let projectUuid = await consola.prompt( - 'Select a project or create a new project (Press to select).', - { - type: 'select', - options: promptOptions, - cancel: 'reject', - }, - ); - - if (projectUuid === 'create') { - const newProjectName = await consola.prompt('Enter a name for the new project:', { - type: 'text', - }); - - const data = await createProject(newProjectName, storeHash, accessToken, options.apiHost); - - projectUuid = data.uuid; - - consola.success(`Project "${data.name}" created successfully.`); + throw error; } - writeProjectConfig(projectUuid, { storeHash, accessToken }); + writeProjectConfig(selected.uuid, { storeHash, accessToken }); + await offerCommerceHostingSetup(selected.uuid, { storeHash, accessToken }); process.exit(0); }); diff --git a/packages/catalyst/src/cli/commands/start.spec.ts b/packages/catalyst/src/cli/commands/start.spec.ts index 5ee3e0ec38..b442d00184 100644 --- a/packages/catalyst/src/cli/commands/start.spec.ts +++ b/packages/catalyst/src/cli/commands/start.spec.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; import { afterEach, beforeAll, beforeEach, expect, test, vi } from 'vitest'; import { consola } from '../lib/logger'; +import { getProjectState } from '../lib/project-state'; import { program } from '../program'; import { start } from './start'; @@ -20,12 +21,37 @@ vi.mock('execa', () => ({ __esModule: true, })); +vi.mock('../lib/project-state', () => ({ + getProjectState: vi.fn(), +})); + +const transformedState = { + projectUuid: 'abc-123', + hasMiddleware: true, + hasProxy: false, + hasOpenNextDep: true, + isLinked: true, + isTransformed: true, + isFullySetUp: true, +}; + +const untransformedState = { + projectUuid: undefined, + hasMiddleware: false, + hasProxy: true, + hasOpenNextDep: false, + isLinked: false, + isTransformed: false, + isFullySetUp: false, +}; + beforeAll(() => { consola.wrapAll(); }); beforeEach(() => { consola.mockTypes(() => vi.fn()); + vi.mocked(getProjectState).mockReturnValue(transformedState); }); afterEach(() => { @@ -93,3 +119,16 @@ test('warns when .env.local does not exist', async () => { expect(symlinkSync).not.toHaveBeenCalled(); }); + +test('falls through to `next start` when project is not transformed', async () => { + vi.mocked(getProjectState).mockReturnValue(untransformedState); + + await program.parseAsync(['node', 'catalyst', 'start']); + + expect(execa).toHaveBeenCalledWith( + 'pnpm', + ['exec', 'next', 'start'], + expect.objectContaining({ stdio: 'inherit', cwd: process.cwd() }), + ); + expect(symlinkSync).not.toHaveBeenCalled(); +}); diff --git a/packages/catalyst/src/cli/commands/start.ts b/packages/catalyst/src/cli/commands/start.ts index 468cc6309e..9d1749ad9e 100644 --- a/packages/catalyst/src/cli/commands/start.ts +++ b/packages/catalyst/src/cli/commands/start.ts @@ -4,6 +4,7 @@ import { existsSync, lstatSync, symlinkSync } from 'node:fs'; import { join, relative } from 'node:path'; import { consola } from '../lib/logger'; +import { getProjectState } from '../lib/project-state'; export const start = new Command('start') .configureHelp({ showGlobalOptions: true }) @@ -17,6 +18,22 @@ Example: $ catalyst start`, ) .action(async () => { + // Project must be transformed before the OpenNext preview can run. If it + // isn't, fall through to `next start` so this command works for self-hosted + // Catalyst projects too. + const state = getProjectState(); + + if (!state.isTransformed) { + consola.info('Project is not set up for Commerce Hosting — running `next start`.'); + + await execa('pnpm', ['exec', 'next', 'start'], { + stdio: 'inherit', + cwd: process.cwd(), + }); + + return; + } + const envLocal = join(process.cwd(), '.env.local'); const devVars = join(process.cwd(), '.bigcommerce', '.dev.vars'); diff --git a/packages/catalyst/src/cli/commands/telemetry.ts b/packages/catalyst/src/cli/commands/telemetry.ts index c60b76e0dd..9075e73dcb 100644 --- a/packages/catalyst/src/cli/commands/telemetry.ts +++ b/packages/catalyst/src/cli/commands/telemetry.ts @@ -38,7 +38,7 @@ Examples: telemetryService.setEnabled(false); if (isEnabled) { - consola.success('Your preference has been saved to .bigcommerce/project.json'); + consola.success('Your preference has been saved.'); } else { consola.info(`Catalyst CLI telemetry collection is already disabled.`); } diff --git a/packages/catalyst/src/cli/lib/build-workspace-packages.ts b/packages/catalyst/src/cli/lib/build-workspace-packages.ts new file mode 100644 index 0000000000..4533f72701 --- /dev/null +++ b/packages/catalyst/src/cli/lib/build-workspace-packages.ts @@ -0,0 +1,33 @@ +import { execSync } from 'child_process'; +import { existsSync } from 'fs'; +import { join } from 'path'; +import yoctoSpinner from 'yocto-spinner'; + +const WORKSPACE_PACKAGES = ['packages/catalyst', 'packages/client'] as const; + +// Catalyst monorepo layouts ship pre-built workspace packages, but a fresh +// clone needs them rebuilt before `core` can resolve them. Skip silently when +// the layout doesn't match (e.g. flat repo, custom fork) so this stays a no-op +// for non-monorepo scaffolds. +export const buildWorkspacePackages = (projectDir: string) => { + const hasCore = existsSync(join(projectDir, 'core')); + const hasAllWorkspacePackages = WORKSPACE_PACKAGES.every((pkg) => + existsSync(join(projectDir, pkg)), + ); + + if (!hasCore || !hasAllWorkspacePackages) return; + + WORKSPACE_PACKAGES.forEach((pkg) => { + const spinner = yoctoSpinner().start(`Building ${pkg}...`); + + try { + execSync('pnpm build', { cwd: join(projectDir, pkg), stdio: 'ignore' }); + spinner.success(`Built ${pkg}.`); + } catch (error) { + const message = error instanceof Error ? error.message : 'unknown error'; + + spinner.error(`Failed to build ${pkg}: ${message}`); + throw error; + } + }); +}; diff --git a/packages/catalyst/src/cli/lib/channels.ts b/packages/catalyst/src/cli/lib/channels.ts new file mode 100644 index 0000000000..81e3cd0178 --- /dev/null +++ b/packages/catalyst/src/cli/lib/channels.ts @@ -0,0 +1,175 @@ +import { z } from 'zod'; + +import { getTelemetry } from './telemetry'; + +// `origin` is the CLI-API gateway (configured via `--cli-api-origin`, default +// `https://cxm-prd.bigcommerceapp.com`). Distinct from `apiHost` used for the +// BC store API (api.bigcommerce.com). +const cliApiUrl = (origin: string, storeHash: string, path: string) => + `${origin}/stores/${storeHash}/cli-api/v3${path}`; + +const channelsUrl = (storeHash: string, apiHost: string, query: Record = {}) => { + const params = new URLSearchParams(query).toString(); + + return `https://${apiHost}/stores/${storeHash}/v3/channels${params ? `?${params}` : ''}`; +}; + +const authHeaders = (accessToken: string) => ({ + 'X-Auth-Token': accessToken, + 'X-Correlation-Id': getTelemetry().correlationId, + Accept: 'application/json', +}); + +// envVars values are coerced to strings: the BC API returns mixed primitives +// (e.g. BIGCOMMERCE_CHANNEL_ID is a number) but they all end up in .env.local as text. +const envVarsSchema = z.record(z.string(), z.coerce.string()); + +const channelSchema = z.object({ + id: z.number(), + name: z.string(), + platform: z.string(), +}); + +export type Channel = z.infer; + +const channelsResponseSchema = z.object({ + data: z.array(channelSchema), +}); + +const initResponseSchema = z.object({ + data: z.object({ + storefront_api_token: z.string(), + envVars: envVarsSchema, + }), +}); + +const createChannelResponseSchema = z.object({ + data: z.object({ + id: z.number(), + storefront_api_token: z.string(), + envVars: envVarsSchema, + }), +}); + +const eligibilityResponseSchema = z.object({ + data: z.object({ + eligible: z.boolean(), + message: z.string(), + }), +}); + +export interface ChannelInit { + storefrontToken: string; + envVars: Record; +} + +export async function getChannelInit( + channelId: number | string, + storeHash: string, + accessToken: string, + origin: string, +): Promise { + const response = await fetch(cliApiUrl(origin, storeHash, `/channels/${channelId}/init`), { + method: 'GET', + headers: authHeaders(accessToken), + }); + + if (!response.ok) { + throw new Error( + `GET /channels/${channelId}/init failed: ${response.status} ${response.statusText}`, + ); + } + + const { data } = initResponseSchema.parse(await response.json()); + + return { storefrontToken: data.storefront_api_token, envVars: data.envVars }; +} + +export interface ChannelEligibility { + eligible: boolean; + message: string; +} + +export async function checkChannelEligibility( + storeHash: string, + accessToken: string, + origin: string, +): Promise { + const response = await fetch(cliApiUrl(origin, storeHash, '/channels/catalyst/eligibility'), { + method: 'GET', + headers: authHeaders(accessToken), + }); + + if (!response.ok) { + throw new Error( + `GET /channels/catalyst/eligibility failed: ${response.status} ${response.statusText}`, + ); + } + + return eligibilityResponseSchema.parse(await response.json()).data; +} + +export interface CreatedChannel { + channelId: number; + storefrontToken: string; + envVars: Record; +} + +export async function createChannel( + name: string, + storefrontLocale: string, + additionalLocales: string[], + installSampleData: boolean, + storeHash: string, + accessToken: string, + origin: string, +): Promise { + const response = await fetch(cliApiUrl(origin, storeHash, '/channels/catalyst'), { + method: 'POST', + headers: { ...authHeaders(accessToken), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name, + initialData: { type: installSampleData ? 'sample' : 'none' }, + deployStorefront: true, + devOrigin: 'http://localhost:3000', + storefrontLanguage: storefrontLocale, + additionalLocales, + }), + }); + + if (!response.ok) { + throw new Error(`POST /channels/catalyst failed: ${response.status} ${response.statusText}`); + } + + const { data } = createChannelResponseSchema.parse(await response.json()); + + return { + channelId: data.id, + storefrontToken: data.storefront_api_token, + envVars: data.envVars, + }; +} + +export async function fetchAvailableChannels( + storeHash: string, + accessToken: string, + apiHost: string, +): Promise { + const response = await fetch( + channelsUrl(storeHash, apiHost, { available: 'true', type: 'storefront' }), + { + method: 'GET', + headers: { + 'X-Auth-Token': accessToken, + 'X-Correlation-Id': getTelemetry().correlationId, + Accept: 'application/json', + }, + }, + ); + + if (!response.ok) { + throw new Error(`GET /v3/channels failed: ${response.status} ${response.statusText}`); + } + + return channelsResponseSchema.parse(await response.json()).data; +} diff --git a/packages/catalyst/src/cli/lib/checkout-ref.ts b/packages/catalyst/src/cli/lib/checkout-ref.ts new file mode 100644 index 0000000000..1bddda6fd5 --- /dev/null +++ b/packages/catalyst/src/cli/lib/checkout-ref.ts @@ -0,0 +1,42 @@ +import { sync as spawnSync } from 'cross-spawn'; + +import { isExecException } from './is-exec-exception'; +import { consola } from './logger'; + +export function checkoutRef(repoDir: string, ref: string): void { + try { + const spawn = spawnSync('git', ['checkout', ref, '--'], { + cwd: repoDir, + encoding: 'utf8', + shell: false, + }); + + const stderr = spawn.stderr.trim(); + + if (spawn.status !== 0 && stderr) { + throw new Error(stderr); + } + + consola.success(`Checked out ref ${ref} successfully.`); + } catch (error: unknown) { + if (isExecException(error)) { + const stderr = error.stderr ? error.stderr.toString() : ''; + + if ( + stderr.includes(`fatal: reference is not a tree: ${ref}`) || + stderr.includes(`fatal: ambiguous argument '${ref}'`) || + stderr.includes(`unknown revision or path not in the working tree`) + ) { + consola.error(`Ref '${ref}' not found in the repository.`); + } else { + consola.error(`Error checking out ref '${ref}':`, stderr.trim()); + } + } else if (error instanceof Error) { + consola.error(`Error checking out ref '${ref}':`, error.message); + } else { + consola.error(`Unknown error occurred while checking out ref '${ref}'.`); + } + + consola.warn(`Falling back to the default branch.`); + } +} diff --git a/packages/catalyst/src/cli/lib/clone-catalyst.ts b/packages/catalyst/src/cli/lib/clone-catalyst.ts new file mode 100644 index 0000000000..52eb009ace --- /dev/null +++ b/packages/catalyst/src/cli/lib/clone-catalyst.ts @@ -0,0 +1,46 @@ +import { execSync } from 'child_process'; + +import { checkoutRef } from './checkout-ref'; +import { hasGitHubSSH } from './has-github-ssh'; +import { consola } from './logger'; +import { resetBranchToRef } from './reset-branch-to-ref'; + +export const cloneCatalyst = ({ + repository, + projectName, + projectDir, + ghRef, + resetMain = false, +}: { + repository: string; + projectName: string; + projectDir: string; + ghRef?: string; + resetMain?: boolean; +}) => { + const useSSH = hasGitHubSSH(); + + consola.info(`Cloning ${repository} using ${useSSH ? 'SSH' : 'HTTPS'}...`); + + const cloneCommand = `git clone ${ + useSSH ? `git@github.com:${repository}` : `https://github.com/${repository}` + }.git${projectName ? ` ${projectName}` : ''}`; + + execSync(cloneCommand, { stdio: 'inherit' }); + + execSync('git remote rename origin upstream', { cwd: projectDir, stdio: 'inherit' }); + + if (ghRef) { + if (resetMain) { + execSync('git checkout -b main', { cwd: projectDir, stdio: 'inherit' }); + + resetBranchToRef(projectDir, ghRef); + + consola.success(`Reset main to ${ghRef} successfully.`); + + return; + } + + checkoutRef(projectDir, ghRef); + } +}; diff --git a/packages/catalyst/src/cli/lib/commerce-hosting.spec.ts b/packages/catalyst/src/cli/lib/commerce-hosting.spec.ts new file mode 100644 index 0000000000..473e0d7e1f --- /dev/null +++ b/packages/catalyst/src/cli/lib/commerce-hosting.spec.ts @@ -0,0 +1,806 @@ +import { input, select } from '@inquirer/prompts'; +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readlinkSync, + rmSync, + writeFileSync, +} from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from 'vitest'; +import { z } from 'zod'; + +import { + promptAndCreateCommerceHostingProject, + promptForCommerceHostingProject, + setupCommerceHosting, +} from './commerce-hosting'; +import * as projectLib from './project'; +import { InfrastructureProjectValidationError } from './project'; + +vi.mock('@inquirer/prompts', () => ({ + input: vi.fn(), + select: vi.fn(), + Separator: class FakeSeparator { + type = 'separator'; + }, +})); + +const inputMock = vi.mocked(input); +const selectMock = vi.mocked(select); + +const API = { storeHash: 'store', accessToken: 'token', apiHost: 'api.example.com' }; + +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 fetchProjectsSpy: MockInstance; +let createProjectSpy: MockInstance; +let consoleErrorSpy: MockInstance<(typeof console)['error']>; + +beforeEach(() => { + inputMock.mockReset(); + selectMock.mockReset(); + fetchProjectsSpy = vi.spyOn(projectLib, 'fetchProjects').mockResolvedValue([]); + createProjectSpy = vi.spyOn(projectLib, 'createProject'); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); +}); + +afterEach(() => { + vi.restoreAllMocks(); + consoleErrorSpy.mockRestore(); +}); + +function createdProject(uuid: string, name: string) { + return { + uuid, + name, + date_created: new Date(), + date_modified: new Date(), + }; +} + +describe('promptAndCreateCommerceHostingProject', () => { + it('returns the created project when the first attempt succeeds', async () => { + inputMock.mockResolvedValueOnce('my-project'); + createProjectSpy.mockResolvedValueOnce(createdProject('u', 'my-project')); + + const result = await promptAndCreateCommerceHostingProject(API, []); + + expect(result).toEqual({ uuid: 'u', name: 'my-project' }); + expect(createProjectSpy).toHaveBeenCalledTimes(1); + expect(createProjectSpy).toHaveBeenCalledWith( + 'my-project', + API.storeHash, + API.accessToken, + API.apiHost, + ); + }); + + it('trims whitespace from the entered name before calling the API', async () => { + inputMock.mockResolvedValueOnce(' spaced '); + createProjectSpy.mockResolvedValueOnce(createdProject('u', 'spaced')); + + await promptAndCreateCommerceHostingProject(API, []); + + expect(createProjectSpy).toHaveBeenCalledWith( + 'spaced', + API.storeHash, + API.accessToken, + API.apiHost, + ); + }); + + it('re-prompts after a validation error and succeeds on retry', async () => { + inputMock.mockResolvedValueOnce('###').mockResolvedValueOnce('good-name'); + + createProjectSpy + .mockRejectedValueOnce(new InfrastructureProjectValidationError('Invalid name')) + .mockResolvedValueOnce(createdProject('u', 'good-name')); + + const result = await promptAndCreateCommerceHostingProject(API, []); + + expect(result).toEqual({ uuid: 'u', name: 'good-name' }); + expect(inputMock).toHaveBeenCalledTimes(2); + expect(createProjectSpy).toHaveBeenCalledTimes(2); + }); + + it('re-prompts multiple times until the server accepts the name', async () => { + inputMock + .mockResolvedValueOnce('bad1') + .mockResolvedValueOnce('bad2') + .mockResolvedValueOnce('good'); + + createProjectSpy + .mockRejectedValueOnce(new InfrastructureProjectValidationError('first failure')) + .mockRejectedValueOnce(new InfrastructureProjectValidationError('second failure')) + .mockResolvedValueOnce(createdProject('u', 'good')); + + const result = await promptAndCreateCommerceHostingProject(API, []); + + expect(result).toEqual({ uuid: 'u', name: 'good' }); + expect(inputMock).toHaveBeenCalledTimes(3); + expect(createProjectSpy).toHaveBeenCalledTimes(3); + }); + + it('does not retry on non-validation errors', async () => { + inputMock.mockResolvedValueOnce('whatever'); + createProjectSpy.mockRejectedValueOnce(new Error('500 server error')); + + await expect(promptAndCreateCommerceHostingProject(API, [])).rejects.toThrow( + '500 server error', + ); + + expect(inputMock).toHaveBeenCalledTimes(1); + expect(createProjectSpy).toHaveBeenCalledTimes(1); + }); + + it('passes the supplied default name to the initial prompt', async () => { + inputMock.mockResolvedValueOnce('my-catalyst-store'); + createProjectSpy.mockResolvedValueOnce(createdProject('u', 'my-catalyst-store')); + + await promptAndCreateCommerceHostingProject(API, [], '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'); + + createProjectSpy + .mockRejectedValueOnce(new InfrastructureProjectValidationError('Invalid')) + .mockResolvedValueOnce(createdProject('u', 'fixed-name')); + + await promptAndCreateCommerceHostingProject(API, [], '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'); + createProjectSpy.mockResolvedValueOnce(createdProject('u', 'ok')); + + await promptAndCreateCommerceHostingProject(API, []); + + 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'); + createProjectSpy.mockResolvedValueOnce(createdProject('u', 'available')); + + await promptAndCreateCommerceHostingProject(API, ['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'); + createProjectSpy.mockResolvedValueOnce(createdProject('u', 'different')); + + await promptAndCreateCommerceHostingProject(API, ['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 () => { + fetchProjectsSpy.mockResolvedValue([]); + createProjectSpy.mockResolvedValueOnce(createdProject('u', 'fresh')); + + const result = await promptForCommerceHostingProject(API, 'fresh'); + + expect(result).toEqual({ uuid: 'u', name: 'fresh' }); + expect(selectMock).not.toHaveBeenCalled(); + expect(inputMock).not.toHaveBeenCalled(); + expect(createProjectSpy).toHaveBeenCalledWith( + 'fresh', + API.storeHash, + API.accessToken, + API.apiHost, + ); + }); + + it('silently auto-creates with the supplied default name when other projects exist but none conflict', async () => { + fetchProjectsSpy.mockResolvedValue([ + { uuid: 'aaa', name: 'unrelated-one' }, + { uuid: 'bbb', name: 'unrelated-two' }, + ]); + createProjectSpy.mockResolvedValueOnce(createdProject('new', 'my-store')); + + const result = await promptForCommerceHostingProject(API, 'my-store'); + + expect(result).toEqual({ uuid: 'new', name: 'my-store' }); + expect(selectMock).not.toHaveBeenCalled(); + expect(inputMock).not.toHaveBeenCalled(); + expect(createProjectSpy).toHaveBeenCalledWith( + 'my-store', + API.storeHash, + API.accessToken, + API.apiHost, + ); + }); + + it('returns a selected existing project without calling create', async () => { + const existing = [ + { uuid: 'aaa', name: 'first' }, + { uuid: 'bbb', name: 'second' }, + ]; + + fetchProjectsSpy.mockResolvedValue(existing); + + selectMock + .mockResolvedValueOnce('select-from-list') + .mockResolvedValueOnce({ uuid: 'bbb', name: 'second' }); + + const result = await promptForCommerceHostingProject(API, 'first'); + + expect(result).toEqual({ uuid: 'bbb', name: 'second' }); + expect(createProjectSpy).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 () => { + fetchProjectsSpy.mockResolvedValue([{ uuid: 'aaa', name: 'default-name' }]); + createProjectSpy.mockResolvedValueOnce(createdProject('new', 'new-proj')); + + selectMock.mockResolvedValueOnce('create'); + inputMock.mockResolvedValueOnce('new-proj'); + + const result = await promptForCommerceHostingProject(API, 'default-name'); + + expect(result).toEqual({ uuid: 'new', name: 'new-proj' }); + expect(createProjectSpy).toHaveBeenCalledWith( + 'new-proj', + API.storeHash, + API.accessToken, + API.apiHost, + ); + expect(inputMock.mock.calls[0]?.[0].default).toBe('default-name'); + }); + + it('shows the conflict-aware message and three choices when a conflict exists', async () => { + fetchProjectsSpy.mockResolvedValue([ + { uuid: 'aaa', name: 'My-Store' }, + { uuid: 'bbb', name: 'other-project' }, + ]); + + selectMock.mockResolvedValueOnce('create'); + inputMock.mockResolvedValueOnce('something-else'); + createProjectSpy.mockResolvedValueOnce(createdProject('new', 'something-else')); + + 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' }; + + fetchProjectsSpy.mockResolvedValue([conflict, { uuid: 'bbb', name: 'other-project' }]); + + selectMock.mockResolvedValueOnce('use-named'); + + const result = await promptForCommerceHostingProject(API, 'my-store'); + + expect(result).toEqual(conflict); + expect(selectMock).toHaveBeenCalledTimes(1); + expect(createProjectSpy).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' }; + + fetchProjectsSpy.mockResolvedValue([conflict, other]); + + selectMock.mockResolvedValueOnce('select-from-list').mockResolvedValueOnce(other); + + 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' }; + + fetchProjectsSpy.mockResolvedValue([conflict, other]); + createProjectSpy.mockResolvedValueOnce(createdProject('new', 'fresh-name')); + + selectMock.mockResolvedValueOnce('select-from-list').mockResolvedValueOnce('create-new'); + inputMock.mockResolvedValueOnce('fresh-name'); + + const result = await promptForCommerceHostingProject(API, 'my-store'); + + expect(result).toEqual({ uuid: 'new', name: 'fresh-name' }); + expect(createProjectSpy).toHaveBeenCalledWith( + 'fresh-name', + API.storeHash, + API.accessToken, + API.apiHost, + ); + }); + + it('omits Select from my projects when the conflict is the only existing project', async () => { + const conflict = { uuid: 'aaa', name: 'My-Store' }; + + fetchProjectsSpy.mockResolvedValue([conflict]); + + selectMock.mockResolvedValueOnce('use-named'); + + await promptForCommerceHostingProject(API, 'my-store'); + + 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('skips all prompts and creates with the supplied name when autoUseDefaultName is true', async () => { + fetchProjectsSpy.mockResolvedValue([{ uuid: 'other', name: 'unrelated' }]); + createProjectSpy.mockResolvedValueOnce(createdProject('u', 'auto-name')); + + const result = await promptForCommerceHostingProject(API, 'auto-name', true); + + expect(result).toEqual({ uuid: 'u', name: 'auto-name' }); + expect(fetchProjectsSpy).toHaveBeenCalled(); + expect(selectMock).not.toHaveBeenCalled(); + expect(inputMock).not.toHaveBeenCalled(); + expect(createProjectSpy).toHaveBeenCalledWith( + 'auto-name', + API.storeHash, + API.accessToken, + API.apiHost, + ); + }); + + it('returns the existing project when --project-name collides and the user picks Yes', async () => { + const existing = { uuid: 'aaa', name: 'taken-name' }; + + fetchProjectsSpy.mockResolvedValue([existing]); + + selectMock.mockResolvedValueOnce(true); + + const restoreTty = withInteractiveTty(); + + try { + const result = await promptForCommerceHostingProject(API, 'taken-name', true); + + expect(result).toEqual(existing); + expect(createProjectSpy).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' }; + + fetchProjectsSpy.mockResolvedValue([existing]); + + const result = await promptForCommerceHostingProject(API, 'taken-name', true, true); + + expect(result).toEqual(existing); + expect(selectMock).not.toHaveBeenCalled(); + expect(createProjectSpy).not.toHaveBeenCalled(); + }); + + it('exits without prompting in non-interactive environments when --use-existing is not passed', async () => { + fetchProjectsSpy.mockResolvedValue([{ uuid: 'aaa', name: 'taken-name' }]); + + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`process.exit(${String(code)})`); + }); + const restoreTty = withNonInteractiveTty(); + + try { + await expect(promptForCommerceHostingProject(API, 'taken-name', true)).rejects.toThrow( + 'process.exit(1)', + ); + + expect(selectMock).not.toHaveBeenCalled(); + expect(createProjectSpy).not.toHaveBeenCalled(); + } finally { + exitSpy.mockRestore(); + restoreTty(); + } + }); + + it('detects --project-name collision case-insensitively and reports the stored name', async () => { + const existing = { uuid: 'aaa', name: 'MyProject' }; + + fetchProjectsSpy.mockResolvedValue([existing]); + + selectMock.mockResolvedValueOnce(true); + + const restoreTty = withInteractiveTty(); + + try { + const result = await promptForCommerceHostingProject(API, 'myproject', true); + + expect(result).toEqual(existing); + expect(createProjectSpy).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 () => { + fetchProjectsSpy.mockResolvedValue([{ uuid: 'aaa', name: 'taken-name' }]); + + selectMock.mockResolvedValueOnce(false); + + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`process.exit(${String(code)})`); + }); + const restoreTty = withInteractiveTty(); + + try { + await expect(promptForCommerceHostingProject(API, 'taken-name', true)).rejects.toThrow( + 'process.exit(1)', + ); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(createProjectSpy).not.toHaveBeenCalled(); + } finally { + exitSpy.mockRestore(); + restoreTty(); + } + }); + + it('exits the process when auto-create fails with a validation error instead of re-prompting', async () => { + fetchProjectsSpy.mockResolvedValue([]); + createProjectSpy.mockRejectedValueOnce( + new InfrastructureProjectValidationError('Name already taken'), + ); + + const exitSpy = vi.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(createProjectSpy).toHaveBeenCalledTimes(1); + + exitSpy.mockRestore(); + }); + + it('propagates errors from fetchProjects', async () => { + fetchProjectsSpy.mockRejectedValue(new Error('network down')); + + await expect(promptForCommerceHostingProject(API, 'whatever')).rejects.toThrow('network down'); + expect(selectMock).not.toHaveBeenCalled(); + expect(inputMock).not.toHaveBeenCalled(); + }); +}); + +describe('setupCommerceHosting', () => { + 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(), 'catalyst-create-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')), + ); + } + + it('adds the OpenNext Cloudflare dep while preserving existing dependencies', () => { + 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('@opennextjs/cloudflare'); + }); + + it('does not modify package.json scripts (handled by setupCoreProject)', () => { + writeCorePackageJson({ + scripts: { + dev: 'npm run generate && next dev', + build: 'npm run generate && next build', + start: 'next start', + }, + }); + + setupCommerceHosting({ projectDir, projectUuid: 'u' }); + + expect(readCorePackageJson().scripts).toEqual({ + dev: 'npm run generate && next dev', + build: 'npm run generate && next build', + start: 'next start', + }); + }); + + 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({ dependencies: { next: 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'); + + 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/catalyst/src/cli/lib/commerce-hosting.ts b/packages/catalyst/src/cli/lib/commerce-hosting.ts new file mode 100644 index 0000000000..4457f276b7 --- /dev/null +++ b/packages/catalyst/src/cli/lib/commerce-hosting.ts @@ -0,0 +1,427 @@ +import { input, select, Separator } from '@inquirer/prompts'; +import { colorize } from 'consola/utils'; +import { + existsSync, + lstatSync, + mkdirSync, + readFileSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from 'fs'; +import { dirname, join } from 'path'; +import { z } from 'zod'; + +import { consola } from './logger'; +import { + createProject, + fetchProjects, + InfrastructureProjectValidationError, + type ProjectListItem, +} from './project'; +import { sortPackageJsonFields } from './sort-package-json'; + +const OPENNEXT_CLOUDFLARE_VERSION = '1.17.3'; + +const corePackageJsonSchema = z.looseObject({ + dependencies: z.record(z.string(), z.string()).optional(), +}); + +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'); + + if (lstatSync(coreEnvPath, { throwIfNoEntry: false })) return; + + try { + symlinkSync('../.env.local', coreEnvPath); + } catch (error) { + const message = error instanceof Error ? error.message : 'unknown error'; + + consola.warn( + `Could not create symlink at core/.env.local: ${message}\n` + + 'On Windows, enable Developer Mode or run as administrator to allow symlinks.\n' + + 'You will need to keep .env.local and core/.env.local in sync manually.', + ); + } +}; + +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.dependencies = { + ...pkg.dependencies, + '@opennextjs/cloudflare': OPENNEXT_CLOUDFLARE_VERSION, + }; + + writeJson(corePackageJsonPath, sortPackageJsonFields(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); +}; + +interface CommerceHostingApiContext { + storeHash: string; + accessToken: string; + apiHost: string; +} + +// Thrown by `selectOrCreateInfrastructureProject` when the user declines the +// "Would you like to create one?" prompt that surfaces when no projects exist. +// Callers translate this into a context-appropriate error (e.g. "Cannot deploy +// without being linked to a project"). +export class NoLinkedProjectError extends Error { + constructor() { + super('No infrastructure project linked: user declined to create one.'); + this.name = 'NoLinkedProjectError'; + } +} + +async function promptForNewProjectName(api: CommerceHostingApiContext): Promise { + const newProjectName = await consola.prompt('Enter a name for the new project:', { + type: 'text', + }); + + const data = await createProject( + String(newProjectName), + api.storeHash, + api.accessToken, + api.apiHost, + ); + + consola.success(`Project "${data.name}" created successfully.`); + + return { uuid: data.uuid, name: data.name }; +} + +// Generic "select an existing project, or create a new one" prompt — used by +// `catalyst project link` and by `catalyst deploy` when its linked project is +// missing. Distinct from `promptForCommerceHostingProject` which has +// default-name + auto-create semantics tailored to `catalyst create`. +// +// Pass `linkedProjectUuid` to decorate the matching project's label with +// `[linked]` so the user can see which one is the current selection. +export async function selectOrCreateInfrastructureProject( + api: CommerceHostingApiContext, + linkedProjectUuid?: string, +): Promise { + consola.start('Fetching projects...'); + + const existingProjects = await fetchProjects(api.storeHash, api.accessToken, api.apiHost); + + consola.success('Projects fetched.'); + + // No existing projects on the store — skip the select prompt and offer + // creation directly. Declining means we have nothing to link to. + if (existingProjects.length === 0) { + const shouldCreate = await consola.prompt( + 'There are not any hosting projects that you can link to yet. Would you like to create one?', + { type: 'confirm', initial: true }, + ); + + if (!shouldCreate) { + throw new NoLinkedProjectError(); + } + + return promptForNewProjectName(api); + } + + const promptOptions = [ + ...existingProjects.map((p) => ({ + label: p.uuid === linkedProjectUuid ? `${p.name} ${colorize('green', '[linked]')}` : p.name, + value: p.uuid, + hint: p.uuid, + })), + { + label: 'Create a new project', + value: 'create', + hint: 'Create a new hosting project for this Catalyst storefront.', + }, + ]; + + const selected = await consola.prompt( + 'Select a project or create a new project (Press to select).', + { type: 'select', options: promptOptions, cancel: 'reject' }, + ); + + if (selected === 'create') { + return promptForNewProjectName(api); + } + + const matched = existingProjects.find((p) => p.uuid === selected); + + if (!matched) { + throw new Error(`Selected project ${String(selected)} not found in fetched list.`); + } + + return matched; +} + +export async function promptForCommerceHostingProject( + api: CommerceHostingApiContext, + defaultName: string, + autoUseDefaultName?: boolean, + useExistingOnCollision?: boolean, +): Promise { + const existingProjects = await fetchProjects(api.storeHash, api.accessToken, api.apiHost); + const takenNames = existingProjects.map((project) => project.name); + + if (autoUseDefaultName) { + return autoCreateCommerceHostingProject( + api, + defaultName, + existingProjects, + useExistingOnCollision, + ); + } + + const conflict = existingProjects.find( + (project) => project.name.toLowerCase() === defaultName.toLowerCase(), + ); + + if (!conflict) { + return autoCreateCommerceHostingProject( + api, + defaultName, + existingProjects, + useExistingOnCollision, + ); + } + + type Action = 'use-named' | 'select-from-list' | 'create'; + + 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') { + consola.success(`Using existing Commerce Hosting project "${conflict.name}"`); + + return conflict; + } + + if (action === 'create') { + return promptAndCreateCommerceHostingProject(api, 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(api, takenNames, defaultName); + } + + consola.success(`Using existing Commerce Hosting project "${selected.name}"`); + + return selected; +} + +export async function promptAndCreateCommerceHostingProject( + api: CommerceHostingApiContext, + 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 createProject( + projectName.trim(), + api.storeHash, + api.accessToken, + api.apiHost, + ); + + consola.success(`Commerce Hosting project "${created.name}" created successfully`); + + return { uuid: created.uuid, name: created.name }; + } catch (error) { + if (error instanceof InfrastructureProjectValidationError) { + consola.error(error.message); + + return promptAndCreateCommerceHostingProject(api, takenNames, defaultName); + } + + throw error; + } +} + +async function resolveCollisionChoice( + existingName: string, + useExistingOnCollision: boolean | undefined, +): Promise { + if (useExistingOnCollision === true) return true; + + 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( + api: CommerceHostingApiContext, + name: string, + existingProjects: readonly ProjectListItem[], + useExistingOnCollision?: boolean, +): Promise { + const existing = existingProjects.find( + (project) => project.name.toLowerCase() === name.toLowerCase(), + ); + + if (existing) { + const shouldUseExisting = await resolveCollisionChoice(existing.name, useExistingOnCollision); + + if (shouldUseExisting) { + consola.success(`Using existing Commerce Hosting project "${existing.name}"`); + + return existing; + } + + consola.error( + 'Not reusing the existing project. Re-run with a different --project-name, or pass --use-existing to reuse it.', + ); + process.exit(1); + } + + try { + const created = await createProject(name, api.storeHash, api.accessToken, api.apiHost); + + consola.success(`Commerce Hosting project "${created.name}" created successfully`); + + return { uuid: created.uuid, name: created.name }; + } catch (error) { + if (error instanceof InfrastructureProjectValidationError) { + consola.error( + `Failed to create Commerce Hosting project "${name}": ${error.message}\nRe-run with a different --project-name.`, + ); + process.exit(1); + } + + throw error; + } +} + +// Orchestrates prompt + file mutations. Callable from `catalyst create --hosting commerce` +// (eager) and `catalyst deploy` (lazy). Idempotent — safe to re-run. +export async function runCommerceHostingSetup({ + api, + projectDir, + defaultProjectName, + autoUseProjectName, + useExistingOnCollision, +}: { + api: CommerceHostingApiContext; + projectDir: string; + defaultProjectName: string; + autoUseProjectName?: boolean; + useExistingOnCollision?: boolean; +}): Promise { + const project = await promptForCommerceHostingProject( + api, + defaultProjectName, + autoUseProjectName, + useExistingOnCollision, + ); + + setupCommerceHosting({ + projectDir, + projectUuid: project.uuid, + storeHash: api.storeHash, + accessToken: api.accessToken, + }); + + return project; +} diff --git a/packages/catalyst/src/cli/lib/has-github-ssh.ts b/packages/catalyst/src/cli/lib/has-github-ssh.ts new file mode 100644 index 0000000000..509d72538c --- /dev/null +++ b/packages/catalyst/src/cli/lib/has-github-ssh.ts @@ -0,0 +1,26 @@ +import { execSync } from 'child_process'; + +import { isExecException } from './is-exec-exception'; + +export function hasGitHubSSH(): boolean { + try { + const output = execSync('ssh -T git@github.com', { + encoding: 'utf8', + stdio: 'pipe', + }).toString(); + + return output.includes('successfully authenticated'); + } catch (error: unknown) { + if (isExecException(error)) { + const stdout = error.stdout ? error.stdout.toString() : ''; + const stderr = error.stderr ? error.stderr.toString() : ''; + const combinedOutput = stdout + stderr; + + if (combinedOutput.includes('successfully authenticated')) { + return true; + } + } + + return false; + } +} diff --git a/packages/catalyst/src/cli/lib/install-dependencies.ts b/packages/catalyst/src/cli/lib/install-dependencies.ts new file mode 100644 index 0000000000..76c56c09b6 --- /dev/null +++ b/packages/catalyst/src/cli/lib/install-dependencies.ts @@ -0,0 +1,16 @@ +import { installDependencies as installDeps } from 'nypm'; +import yoctoSpinner from 'yocto-spinner'; + +export const installDependencies = async (projectDir: string) => { + const spinner = yoctoSpinner().start('Installing dependencies. This could take a minute...'); + + try { + await installDeps({ cwd: projectDir, silent: true, packageManager: 'pnpm' }); + spinner.success('Dependencies installed successfully.'); + } catch (error) { + const message = error instanceof Error ? error.message : 'unknown error'; + + spinner.error(`Failed to install dependencies: ${message}`); + throw error; + } +}; diff --git a/packages/catalyst/src/cli/lib/is-exec-exception.ts b/packages/catalyst/src/cli/lib/is-exec-exception.ts new file mode 100644 index 0000000000..9cdf5a6280 --- /dev/null +++ b/packages/catalyst/src/cli/lib/is-exec-exception.ts @@ -0,0 +1,5 @@ +import { ExecException } from 'node:child_process'; + +export function isExecException(error: unknown): error is ExecException { + return typeof error === 'object' && error !== null && 'stdout' in error && 'stderr' in error; +} diff --git a/packages/catalyst/src/cli/lib/localization.ts b/packages/catalyst/src/cli/lib/localization.ts new file mode 100644 index 0000000000..1f85b1a10f --- /dev/null +++ b/packages/catalyst/src/cli/lib/localization.ts @@ -0,0 +1,64 @@ +import { z } from 'zod'; + +import { getTelemetry } from './telemetry'; + +const allowedLocales = [ + 'en', + 'da', + 'es-AR', + 'es-CL', + 'es-CO', + 'es-MX', + 'es-PE', + 'es-419', + 'es', + 'it', + 'nl', + 'pl', + 'pt', + 'de', + 'fr', + 'ja', + 'no', + 'pt-BR', + 'sv', +]; + +const AvailableLocalesSuccessSchema = z.object({ + data: z.array( + z.object({ + id: z.string(), + name: z.string(), + fallback: z.string().nullable(), + is_supported: z.boolean(), + }), + ), +}); + +export const getAvailableLocales = async ( + storeHash: string, + accessToken: string, + apiHost: string, +) => { + const response = await fetch( + `https://${apiHost}/stores/${storeHash}/v3/settings/store/available-locales`, + { + method: 'GET', + headers: { + 'X-Auth-Token': accessToken, + 'X-Correlation-Id': getTelemetry().correlationId, + Accept: 'application/json', + }, + }, + ); + + if (!response.ok) { + throw new Error( + `GET /v3/settings/store/available-locales failed: ${response.status} ${response.statusText}`, + ); + } + + return AvailableLocalesSuccessSchema.parse(await response.json()) + .data.filter(({ id }) => allowedLocales.includes(id)) + .map(({ name, id }) => ({ name: `${name} (${id})`, value: id })); +}; diff --git a/packages/catalyst/src/cli/lib/login.ts b/packages/catalyst/src/cli/lib/login.ts new file mode 100644 index 0000000000..49cb4344fe --- /dev/null +++ b/packages/catalyst/src/cli/lib/login.ts @@ -0,0 +1,41 @@ +import { colorize } from 'consola/utils'; +import open from 'open'; +import yoctoSpinner from 'yocto-spinner'; + +import { requestDeviceCode, waitForDeviceToken } from './auth'; +import { consola } from './logger'; + +export interface LoginResult { + storeHash: string; + accessToken: string; +} + +export async function login(loginUrl: string): Promise { + const deviceCode = await requestDeviceCode(loginUrl); + + consola.info( + `${colorize('yellow', 'Your one-time code:')} ${colorize('bold', deviceCode.user_code)}`, + ); + + try { + await open(deviceCode.verification_uri); + consola.info(`Opened ${deviceCode.verification_uri} in your browser.`); + } catch { + consola.info(`Open ${deviceCode.verification_uri} in your browser and enter the code above.`); + } + + const spinner = yoctoSpinner().start('Waiting for authentication...'); + + const credentials = await waitForDeviceToken( + loginUrl, + deviceCode.device_code, + deviceCode.interval, + ); + + spinner.success('Authentication complete.'); + + return { + storeHash: credentials.store_hash, + accessToken: credentials.access_token, + }; +} diff --git a/packages/catalyst/src/cli/lib/project-config.ts b/packages/catalyst/src/cli/lib/project-config.ts index 43c32baa85..07eddd7506 100644 --- a/packages/catalyst/src/cli/lib/project-config.ts +++ b/packages/catalyst/src/cli/lib/project-config.ts @@ -7,10 +7,6 @@ export interface ProjectConfigSchema { framework: 'catalyst'; storeHash?: string; accessToken?: string; - telemetry: { - enabled: boolean; - anonymousId: string; - }; } export function getProjectConfig() { @@ -27,13 +23,6 @@ export function getProjectConfig() { }, storeHash: { type: 'string' }, accessToken: { type: 'string' }, - telemetry: { - type: 'object', - properties: { - enabled: { type: 'boolean' }, - anonymousId: { type: 'string' }, - }, - }, }, }); } diff --git a/packages/catalyst/src/cli/lib/project-state.spec.ts b/packages/catalyst/src/cli/lib/project-state.spec.ts new file mode 100644 index 0000000000..5788908786 --- /dev/null +++ b/packages/catalyst/src/cli/lib/project-state.spec.ts @@ -0,0 +1,164 @@ +import { existsSync } from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; + +import { mkTempDir } from './mk-temp-dir'; +import { getProjectState } from './project-state'; + +let tmpDir: string; +let cleanup: () => Promise; + +const writeFileEnsured = async (path: string, contents: string) => { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, contents); +}; + +const writeProjectJson = (cwd: string, value: unknown) => + writeFileEnsured(join(cwd, '.bigcommerce', 'project.json'), JSON.stringify(value)); + +const writePackageJson = (cwd: string, value: unknown) => + writeFileEnsured(join(cwd, 'package.json'), JSON.stringify(value)); + +beforeEach(async () => { + [tmpDir, cleanup] = await mkTempDir(); +}); + +afterEach(async () => { + await cleanup(); +}); + +describe('getProjectState', () => { + test('empty directory returns all-false flags', () => { + const state = getProjectState(tmpDir); + + expect(state).toEqual({ + projectUuid: undefined, + hasMiddleware: false, + hasProxy: false, + hasOpenNextDep: false, + isLinked: false, + isTransformed: false, + isFullySetUp: false, + }); + }); + + test('does not create .bigcommerce/ as a side effect', () => { + getProjectState(tmpDir); + + expect(existsSync(join(tmpDir, '.bigcommerce'))).toBe(false); + }); + + test('isLinked when projectUuid is set', async () => { + await writeProjectJson(tmpDir, { projectUuid: 'abc-123' }); + + const state = getProjectState(tmpDir); + + expect(state.projectUuid).toBe('abc-123'); + expect(state.isLinked).toBe(true); + expect(state.isTransformed).toBe(false); + expect(state.isFullySetUp).toBe(false); + }); + + test('malformed project.json is treated as unlinked', async () => { + await writeFileEnsured(join(tmpDir, '.bigcommerce', 'project.json'), '{not json'); + + const state = getProjectState(tmpDir); + + expect(state.projectUuid).toBeUndefined(); + expect(state.isLinked).toBe(false); + }); + + test('hasMiddleware/hasProxy reflect file presence', async () => { + await writeFileEnsured(join(tmpDir, 'proxy.ts'), '// proxy'); + + const before = getProjectState(tmpDir); + + expect(before.hasProxy).toBe(true); + expect(before.hasMiddleware).toBe(false); + + await writeFileEnsured(join(tmpDir, 'middleware.ts'), '// middleware'); + + const after = getProjectState(tmpDir); + + expect(after.hasProxy).toBe(true); + expect(after.hasMiddleware).toBe(true); + }); + + test('hasOpenNextDep reflects package.json dependencies', async () => { + await writePackageJson(tmpDir, { + dependencies: { '@opennextjs/cloudflare': '1.17.3' }, + }); + + expect(getProjectState(tmpDir).hasOpenNextDep).toBe(true); + }); + + test('package.json without OpenNext dep returns false', async () => { + await writePackageJson(tmpDir, { dependencies: { next: '15.0.0' } }); + + expect(getProjectState(tmpDir).hasOpenNextDep).toBe(false); + }); + + test('isTransformed requires middleware present, proxy absent, and OpenNext dep installed', async () => { + await writeFileEnsured(join(tmpDir, 'middleware.ts'), '// middleware'); + await writePackageJson(tmpDir, { + dependencies: { '@opennextjs/cloudflare': '1.17.3' }, + }); + + expect(getProjectState(tmpDir).isTransformed).toBe(true); + }); + + test('isTransformed is false if proxy.ts still exists', async () => { + await writeFileEnsured(join(tmpDir, 'middleware.ts'), '// middleware'); + await writeFileEnsured(join(tmpDir, 'proxy.ts'), '// proxy'); + await writePackageJson(tmpDir, { + dependencies: { '@opennextjs/cloudflare': '1.17.3' }, + }); + + expect(getProjectState(tmpDir).isTransformed).toBe(false); + }); + + test('isTransformed is false if OpenNext dep missing', async () => { + await writeFileEnsured(join(tmpDir, 'middleware.ts'), '// middleware'); + + expect(getProjectState(tmpDir).isTransformed).toBe(false); + }); + + test('isFullySetUp requires both linked and transformed', async () => { + await writeProjectJson(tmpDir, { projectUuid: 'abc-123' }); + await writeFileEnsured(join(tmpDir, 'middleware.ts'), '// middleware'); + await writePackageJson(tmpDir, { + dependencies: { '@opennextjs/cloudflare': '1.17.3' }, + }); + + const state = getProjectState(tmpDir); + + expect(state.isLinked).toBe(true); + expect(state.isTransformed).toBe(true); + expect(state.isFullySetUp).toBe(true); + }); + + test('linked but untransformed (e.g. after `catalyst project create` only)', async () => { + await writeProjectJson(tmpDir, { projectUuid: 'abc-123' }); + await writeFileEnsured(join(tmpDir, 'proxy.ts'), '// proxy'); + + const state = getProjectState(tmpDir); + + expect(state.isLinked).toBe(true); + expect(state.isTransformed).toBe(false); + expect(state.isFullySetUp).toBe(false); + }); + + test('transformed but unlinked (e.g. mid-setup before UUID is written)', async () => { + await writeFileEnsured(join(tmpDir, 'middleware.ts'), '// middleware'); + await writePackageJson(tmpDir, { + dependencies: { '@opennextjs/cloudflare': '1.17.3' }, + }); + + const state = getProjectState(tmpDir); + + expect(state.isLinked).toBe(false); + expect(state.isTransformed).toBe(true); + expect(state.isFullySetUp).toBe(false); + }); +}); diff --git a/packages/catalyst/src/cli/lib/project-state.ts b/packages/catalyst/src/cli/lib/project-state.ts new file mode 100644 index 0000000000..479ebc0158 --- /dev/null +++ b/packages/catalyst/src/cli/lib/project-state.ts @@ -0,0 +1,67 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { z } from 'zod'; + +const projectJsonSchema = z.looseObject({ + projectUuid: z.string().optional(), +}); + +const packageJsonSchema = z.looseObject({ + dependencies: z.record(z.string(), z.string()).optional(), +}); + +export interface ProjectState { + projectUuid: string | undefined; + hasMiddleware: boolean; + hasProxy: boolean; + hasOpenNextDep: boolean; + + // Derived signals — `isLinked` reflects intent (UUID registered via + // `catalyst project create` or commerce-hosting setup); `isTransformed` + // reflects on-disk readiness (middleware.ts swapped in, OpenNext dep + // installed). A deploy needs both, but `catalyst build` only cares about + // `isTransformed` so it can dispatch to OpenNext vs `next build`. + isLinked: boolean; + isTransformed: boolean; + isFullySetUp: boolean; +} + +const safeReadJson = (path: string): unknown => { + try { + return JSON.parse(readFileSync(path, 'utf-8')); + } catch { + return null; + } +}; + +// Read-only inspection of a Catalyst project at `cwd` (typically `core/`). +// Avoids `getProjectConfig()` deliberately — that would instantiate `Conf` +// and create `.bigcommerce/` as a side effect. +export function getProjectState(cwd: string = process.cwd()): ProjectState { + const projectJson = projectJsonSchema.safeParse( + safeReadJson(join(cwd, '.bigcommerce', 'project.json')), + ); + const projectUuid = projectJson.success ? projectJson.data.projectUuid : undefined; + + const hasMiddleware = existsSync(join(cwd, 'middleware.ts')); + const hasProxy = existsSync(join(cwd, 'proxy.ts')); + + const pkgJson = packageJsonSchema.safeParse(safeReadJson(join(cwd, 'package.json'))); + const hasOpenNextDep = pkgJson.success + ? Boolean(pkgJson.data.dependencies?.['@opennextjs/cloudflare']) + : false; + + const isLinked = Boolean(projectUuid); + const isTransformed = hasMiddleware && !hasProxy && hasOpenNextDep; + const isFullySetUp = isLinked && isTransformed; + + return { + projectUuid, + hasMiddleware, + hasProxy, + hasOpenNextDep, + isLinked, + isTransformed, + isFullySetUp, + }; +} diff --git a/packages/catalyst/src/cli/lib/project.ts b/packages/catalyst/src/cli/lib/project.ts index 1dc66420ee..32d6f1dd5c 100644 --- a/packages/catalyst/src/cli/lib/project.ts +++ b/packages/catalyst/src/cli/lib/project.ts @@ -2,6 +2,13 @@ import { z } from 'zod'; import { getTelemetry } from './telemetry'; +export class InfrastructureProjectValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'InfrastructureProjectValidationError'; + } +} + const fetchProjectsSchema = z.object({ data: z.array( z.object({ @@ -16,21 +23,44 @@ export interface ProjectListItem { name: string; } +function projectsUrl(storeHash: string, apiHost: string) { + return `https://${apiHost}/stores/${storeHash}/v3/infrastructure/projects`; +} + +function authHeaders(accessToken: string) { + return { + 'X-Auth-Token': accessToken, + 'X-Correlation-Id': getTelemetry().correlationId, + }; +} + +export async function hasProjectsAccess( + storeHash: string, + accessToken: string, + apiHost: string, +): Promise { + const response = await fetch(projectsUrl(storeHash, apiHost), { + method: 'GET', + headers: authHeaders(accessToken), + }); + + if (response.status === 200) return true; + if (response.status === 403) return false; + + throw new Error( + `GET /v3/infrastructure/projects failed: ${response.status} ${response.statusText}`, + ); +} + export async function fetchProjects( storeHash: string, accessToken: string, apiHost: string, ): Promise { - const response = await fetch( - `https://${apiHost}/stores/${storeHash}/v3/infrastructure/projects`, - { - method: 'GET', - headers: { - 'X-Auth-Token': accessToken, - 'X-Correlation-Id': getTelemetry().correlationId, - }, - }, - ); + const response = await fetch(projectsUrl(storeHash, apiHost), { + method: 'GET', + headers: authHeaders(accessToken), + }); if (response.status === 403) { throw new Error( @@ -65,28 +95,51 @@ export interface CreateProjectResult { date_modified: Date; } +const validationErrorBodySchema = 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 = validationErrorBodySchema.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 async function createProject( name: string, storeHash: string, accessToken: string, apiHost: string, ): Promise { - const response = await fetch( - `https://${apiHost}/stores/${storeHash}/v3/infrastructure/projects`, - { - method: 'POST', - headers: { - 'X-Auth-Token': accessToken, - Accept: 'application/json', - 'Content-Type': 'application/json', - 'X-Correlation-Id': getTelemetry().correlationId, - }, - body: JSON.stringify({ name }), + const response = await fetch(projectsUrl(storeHash, apiHost), { + method: 'POST', + headers: { + ...authHeaders(accessToken), + Accept: 'application/json', + 'Content-Type': 'application/json', }, - ); - - if (response.status === 502) { - throw new Error('Failed to create project, is the name already in use?'); + body: JSON.stringify({ name }), + }); + + if (response.status === 400 || response.status === 422) { + const body: unknown = await response.json().catch(() => null); + const fallback = + response.status === 422 + ? "The project name you entered doesn't meet the requirements. It must be 3–32 characters long and use only letters, numbers, hyphens (-), underscores (_), and periods (.)" + : response.statusText; + const message = extractValidationMessage(body) ?? fallback; + + throw new InfrastructureProjectValidationError(message); } if (response.status === 403) { @@ -95,10 +148,9 @@ export async function createProject( ); } - if (response.status === 422) { - throw new Error( - "The project name you entered doesn't meet the requirements. It must be 3–32 characters long and use only letters, numbers, hyphens (-), underscores (_), and periods (.)", - ); + // TODO: TRAC-592 - remove this check once the API returns proper 400/422 with validation messages for duplicate names instead of 502 + if (response.status === 502) { + throw new Error('Failed to create project, is the name already in use?'); } if (!response.ok) { diff --git a/packages/catalyst/src/cli/lib/reset-branch-to-ref.ts b/packages/catalyst/src/cli/lib/reset-branch-to-ref.ts new file mode 100644 index 0000000000..9a68c41ecf --- /dev/null +++ b/packages/catalyst/src/cli/lib/reset-branch-to-ref.ts @@ -0,0 +1,15 @@ +import { sync as spawnSync } from 'cross-spawn'; + +export function resetBranchToRef(projectDir: string, ghRef: string) { + const spawn = spawnSync('git', ['reset', '--hard', ghRef, '--'], { + cwd: projectDir, + encoding: 'utf8', + shell: false, + }); + + const stderr = spawn.stderr.trim(); + + if (spawn.status !== 0 && stderr) { + throw new Error(stderr); + } +} diff --git a/packages/catalyst/src/cli/lib/setup-core-project.spec.ts b/packages/catalyst/src/cli/lib/setup-core-project.spec.ts new file mode 100644 index 0000000000..e781a46990 --- /dev/null +++ b/packages/catalyst/src/cli/lib/setup-core-project.spec.ts @@ -0,0 +1,110 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import PACKAGE_INFO from '../../../package.json'; + +import { setupCoreProject } from './setup-core-project'; + +const packageJsonSchema = z.looseObject({ + name: z.string().optional(), + scripts: z.record(z.string(), z.string()).optional(), + dependencies: z.record(z.string(), z.string()).optional(), +}); + +let projectDir: string; + +const writeCorePackageJson = (contents: unknown) => { + const coreDir = join(projectDir, 'core'); + + mkdirSync(coreDir, { recursive: true }); + writeFileSync(join(coreDir, 'package.json'), JSON.stringify(contents, null, 2)); +}; + +const readCorePackageJson = () => + packageJsonSchema.parse( + JSON.parse(readFileSync(join(projectDir, 'core', 'package.json'), 'utf-8')), + ); + +beforeEach(() => { + projectDir = mkdtempSync(join(tmpdir(), 'catalyst-setup-core-test-')); +}); + +afterEach(() => { + rmSync(projectDir, { recursive: true, force: true }); +}); + +describe('setupCoreProject', () => { + it('overrides build, start, and deploy scripts to dispatch through the catalyst CLI', () => { + writeCorePackageJson({ + 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', + }, + }); + + setupCoreProject(projectDir); + + 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: 'catalyst start', + deploy: 'npm run generate && catalyst deploy', + }); + }); + + it('keeps the `npm run generate &&` prefix on build', () => { + writeCorePackageJson({ scripts: { build: 'next build' } }); + + setupCoreProject(projectDir); + + expect(readCorePackageJson().scripts?.build).toBe('npm run generate && catalyst build'); + }); + + it('leaves unrelated scripts (dev, generate, lint, etc.) untouched', () => { + writeCorePackageJson({ + scripts: { + dev: 'npm run generate && next dev', + generate: 'dotenv -e .env.local -- node ./scripts/generate.cjs', + lint: 'eslint .', + typecheck: 'tsc --noEmit', + }, + }); + + setupCoreProject(projectDir); + + expect(readCorePackageJson().scripts).toMatchObject({ + dev: 'npm run generate && next dev', + generate: 'dotenv -e .env.local -- node ./scripts/generate.cjs', + lint: 'eslint .', + typecheck: 'tsc --noEmit', + }); + }); + + it('adds @bigcommerce/catalyst dep at the CLI version', () => { + writeCorePackageJson({ dependencies: { next: '^15.0.0' } }); + + setupCoreProject(projectDir); + + const pkg = readCorePackageJson(); + + expect(pkg.dependencies?.['@bigcommerce/catalyst']).toBe(PACKAGE_INFO.version); + expect(pkg.dependencies?.next).toBe('^15.0.0'); + }); + + it('preserves unrelated top-level fields like name and version', () => { + writeCorePackageJson({ + name: '@bigcommerce/catalyst-core', + scripts: { dev: 'next dev' }, + }); + + setupCoreProject(projectDir); + + expect(readCorePackageJson().name).toBe('@bigcommerce/catalyst-core'); + }); +}); diff --git a/packages/catalyst/src/cli/lib/setup-core-project.ts b/packages/catalyst/src/cli/lib/setup-core-project.ts new file mode 100644 index 0000000000..0d812c8408 --- /dev/null +++ b/packages/catalyst/src/cli/lib/setup-core-project.ts @@ -0,0 +1,40 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { dirname, join } from 'path'; +import { z } from 'zod'; + +import PACKAGE_INFO from '../../../package.json'; + +import { sortPackageJsonFields } from './sort-package-json'; + +const corePackageJsonSchema = z.looseObject({ + scripts: z.record(z.string(), z.string()).optional(), + dependencies: z.record(z.string(), z.string()).optional(), +}); + +const writeJson = (path: string, value: unknown) => { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); +}; + +// Wires Catalyst CLI scripts and the `@bigcommerce/catalyst` dep into a freshly +// cloned `core/`. Always runs at create time, regardless of hosting choice — +// `catalyst build` / `catalyst start` / `catalyst deploy` dispatch on project +// state, so these scripts work for self-hosted projects too without rewrite. +export const setupCoreProject = (projectDir: 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', + start: 'catalyst start', + deploy: 'npm run generate && catalyst deploy', + }; + + pkg.dependencies = { + ...pkg.dependencies, + '@bigcommerce/catalyst': PACKAGE_INFO.version, + }; + + writeJson(corePackageJsonPath, sortPackageJsonFields(pkg)); +}; diff --git a/packages/catalyst/src/cli/lib/shared-options.ts b/packages/catalyst/src/cli/lib/shared-options.ts index f753314b23..a41779136a 100644 --- a/packages/catalyst/src/cli/lib/shared-options.ts +++ b/packages/catalyst/src/cli/lib/shared-options.ts @@ -2,29 +2,28 @@ import { Option } from 'commander'; import { getProjectConfig } from './project-config'; -export const storeHashOption = () => - new Option( - '--store-hash ', - 'BigCommerce store hash. Can be found in the URL of your store Control Panel.', - ).env('CATALYST_STORE_HASH'); - -export const accessTokenOption = () => - new Option( - '--access-token ', - 'BigCommerce access token. Can be found after creating a store-level API account.', - ).env('CATALYST_ACCESS_TOKEN'); - -export const apiHostOption = () => - new Option('--api-host ', 'BigCommerce API host. The default is api.bigcommerce.com.') +// Each helper accepts an optional `description` to override the default +// help text — useful when a command needs to surface command-specific +// behavior (e.g. `catalyst deploy` falling back to .bigcommerce/project.json). +export const storeHashOption = ( + description = 'BigCommerce store hash. Can be found in the URL of your store Control Panel.', +) => new Option('--store-hash ', description).env('CATALYST_STORE_HASH'); + +export const accessTokenOption = ( + description = 'BigCommerce access token. Can be found after creating a store-level API account.', +) => new Option('--access-token ', description).env('CATALYST_ACCESS_TOKEN'); + +export const apiHostOption = ( + description = 'BigCommerce API host. The default is api.bigcommerce.com.', +) => + new Option('--api-host ', description) .env('BIGCOMMERCE_API_HOST') .default('api.bigcommerce.com') .hideHelp(); -export const projectUuidOption = () => - new Option( - '--project-uuid ', - 'BigCommerce infrastructure project UUID. Can be found via the BigCommerce API (GET /v3/infrastructure/projects).', - ).env('CATALYST_PROJECT_UUID'); +export const projectUuidOption = ( + description = 'BigCommerce infrastructure project UUID. Can be found via the BigCommerce API (GET /v3/infrastructure/projects).', +) => new Option('--project-uuid ', description).env('CATALYST_PROJECT_UUID'); export const resolveProjectUuid = (options: { projectUuid?: string }) => { const config = getProjectConfig(); diff --git a/packages/catalyst/src/cli/lib/sort-package-json.spec.ts b/packages/catalyst/src/cli/lib/sort-package-json.spec.ts new file mode 100644 index 0000000000..6fc1daf120 --- /dev/null +++ b/packages/catalyst/src/cli/lib/sort-package-json.spec.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; + +import { sortPackageJsonFields } from './sort-package-json'; + +describe('sortPackageJsonFields', () => { + it('places known fields in canonical order', () => { + const input = { + dependencies: { next: '^15.0.0' }, + scripts: { build: 'next build' }, + version: '1.0.0', + name: '@bigcommerce/catalyst-core', + }; + + expect(Object.keys(sortPackageJsonFields(input))).toEqual([ + 'name', + 'version', + 'scripts', + 'dependencies', + ]); + }); + + it('preserves all canonical fields when present', () => { + const input = { + devDependencies: { vitest: '^3.0.0' }, + dependencies: { next: '^15.0.0' }, + scripts: { build: 'next build' }, + engines: { node: '^20.0.0' }, + private: true, + version: '1.0.0', + description: 'A Catalyst storefront', + name: '@bigcommerce/catalyst-core', + }; + + expect(Object.keys(sortPackageJsonFields(input))).toEqual([ + 'name', + 'description', + 'version', + 'private', + 'engines', + 'scripts', + 'dependencies', + 'devDependencies', + ]); + }); + + it('appends unknown fields after canonical ones, preserving their relative order', () => { + const input = { + keywords: ['catalyst'], + name: '@bigcommerce/catalyst-core', + repository: { type: 'git', url: 'git+https://example.com/repo.git' }, + scripts: { build: 'next build' }, + license: 'MIT', + }; + + expect(Object.keys(sortPackageJsonFields(input))).toEqual([ + 'name', + 'scripts', + 'keywords', + 'repository', + 'license', + ]); + }); + + it('does not mutate values', () => { + const input = { + name: '@bigcommerce/catalyst-core', + scripts: { build: 'next build' }, + dependencies: { next: '^15.0.0' }, + }; + + const result = sortPackageJsonFields(input); + + expect(result.scripts).toEqual({ build: 'next build' }); + expect(result.dependencies).toEqual({ next: '^15.0.0' }); + expect(result.name).toBe('@bigcommerce/catalyst-core'); + }); + + it('handles an empty object', () => { + expect(sortPackageJsonFields({})).toEqual({}); + }); +}); diff --git a/packages/catalyst/src/cli/lib/sort-package-json.ts b/packages/catalyst/src/cli/lib/sort-package-json.ts new file mode 100644 index 0000000000..56b49fd17b --- /dev/null +++ b/packages/catalyst/src/cli/lib/sort-package-json.ts @@ -0,0 +1,32 @@ +// Canonical order for top-level fields in core/package.json. Anything not in +// this list is appended afterwards in its original position relative to other +// unknown keys, so we don't drop or reshuffle uncommon fields like `keywords`. +const FIELD_ORDER = [ + 'name', + 'description', + 'version', + 'private', + 'engines', + 'scripts', + 'dependencies', + 'devDependencies', +] as const; + +export function sortPackageJsonFields>(pkg: T): T { + const ordered: Record = {}; + + FIELD_ORDER.forEach((field) => { + if (field in pkg) { + ordered[field] = pkg[field]; + } + }); + + Object.keys(pkg).forEach((key) => { + if (!(key in ordered)) { + ordered[key] = pkg[key]; + } + }); + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return ordered as T; +} diff --git a/packages/catalyst/src/cli/lib/telemetry.ts b/packages/catalyst/src/cli/lib/telemetry.ts index 356f95e1a4..6c74e2a96d 100644 --- a/packages/catalyst/src/cli/lib/telemetry.ts +++ b/packages/catalyst/src/cli/lib/telemetry.ts @@ -4,7 +4,7 @@ import { randomBytes, randomUUID } from 'node:crypto'; import PACKAGE_INFO from '../../../package.json'; -import { getProjectConfig, ProjectConfigSchema } from './project-config'; +import { getUserConfig, UserConfigSchema } from './user-config'; const TELEMETRY_KEY_ENABLED = 'telemetry.enabled'; const TELEMETRY_KEY_ID = `telemetry.anonymousId`; @@ -15,7 +15,7 @@ export class Telemetry { readonly startTime: number; commandName = 'unknown'; - private projectConfig: Conf; + private userConfig: Conf; private CATALYST_TELEMETRY_DISABLED: string | undefined; private readonly projectName = 'catalyst-cli'; @@ -24,7 +24,7 @@ export class Telemetry { constructor() { this.CATALYST_TELEMETRY_DISABLED = process.env.CATALYST_TELEMETRY_DISABLED; - this.projectConfig = getProjectConfig(); + this.userConfig = getUserConfig(); this.correlationId = randomUUID(); this.startTime = Date.now(); @@ -86,18 +86,18 @@ export class Telemetry { setEnabled = (_enabled: boolean) => { const enabled = Boolean(_enabled); - this.projectConfig.set('telemetry.enabled', enabled); + this.userConfig.set('telemetry.enabled', enabled); }; isEnabled() { return ( !this.CATALYST_TELEMETRY_DISABLED && - this.projectConfig.get(TELEMETRY_KEY_ENABLED, true) + this.userConfig.get(TELEMETRY_KEY_ENABLED, true) ); } private getAnonymousId(): string { - const val = this.projectConfig.get(TELEMETRY_KEY_ID); + const val = this.userConfig.get(TELEMETRY_KEY_ID); if (val) { return val; @@ -105,7 +105,7 @@ export class Telemetry { const generated = randomBytes(32).toString('hex'); - this.projectConfig.set(TELEMETRY_KEY_ID, generated); + this.userConfig.set(TELEMETRY_KEY_ID, generated); return generated; } diff --git a/packages/catalyst/src/cli/lib/user-config.ts b/packages/catalyst/src/cli/lib/user-config.ts new file mode 100644 index 0000000000..2fb7f48ebe --- /dev/null +++ b/packages/catalyst/src/cli/lib/user-config.ts @@ -0,0 +1,36 @@ +import Conf from 'conf'; + +export interface UserConfigSchema { + telemetry: { + enabled: boolean; + anonymousId: string; + }; +} + +let userConfigInstance: Conf | undefined; + +// User-scoped config (per-machine, not per-project). Lives in the OS config dir +// — keeping telemetry out of the user's working directory so commands like +// `catalyst create` don't drop a stray `.bigcommerce/` next to where they're run. +export function getUserConfig(): Conf { + userConfigInstance ??= new Conf({ + projectName: 'catalyst-cli', + projectSuffix: '', + configName: 'config', + schema: { + telemetry: { + type: 'object', + properties: { + enabled: { type: 'boolean' }, + anonymousId: { type: 'string' }, + }, + }, + }, + }); + + return userConfigInstance; +} + +export function resetUserConfig(): void { + userConfigInstance = undefined; +} diff --git a/packages/catalyst/src/cli/lib/write-env.ts b/packages/catalyst/src/cli/lib/write-env.ts new file mode 100644 index 0000000000..bbf2f49b1c --- /dev/null +++ b/packages/catalyst/src/cli/lib/write-env.ts @@ -0,0 +1,13 @@ +import { outputFileSync } from 'fs-extra/esm'; +import { join } from 'path'; + +// Writes core/.env.local — Next.js (and all the catalyst CLI commands) read env vars +// from there, since `core/` is the package they run inside. +export const writeEnv = (projectDir: string, envVars: Record) => { + outputFileSync( + join(projectDir, 'core', '.env.local'), + `${Object.entries(envVars) + .map(([key, value]) => `${key}=${value}`) + .join('\n')}\n`, + ); +}; diff --git a/packages/catalyst/src/cli/program.ts b/packages/catalyst/src/cli/program.ts index 2f327f653e..52a6bc411a 100644 --- a/packages/catalyst/src/cli/program.ts +++ b/packages/catalyst/src/cli/program.ts @@ -2,12 +2,14 @@ import { Option } from '@commander-js/extra-typings'; import { Command } from 'commander'; import { colorize } from 'consola/utils'; import { config } from 'dotenv'; +import { existsSync } from 'node:fs'; import { resolve } from 'node:path'; import PACKAGE_INFO from '../../package.json'; import { auth } from './commands/auth'; import { build } from './commands/build'; +import { create } from './commands/create'; import { deploy } from './commands/deploy'; import { logs } from './commands/logs'; import { project } from './commands/project'; @@ -17,6 +19,22 @@ import { version } from './commands/version'; import { telemetryPostHook, telemetryPreHook } from './hooks/telemetry'; import { consola } from './lib/logger'; +// Auto-load .env.local from cwd if present, matching Next.js convention. +// `--env-path` (loaded later via argParser) overrides anything we load here. +const defaultEnvPath = resolve(process.cwd(), '.env.local'); + +if (existsSync(defaultEnvPath)) { + config({ path: defaultEnvPath }); +} + +// CATALYST_STORE_HASH falls back to BIGCOMMERCE_STORE_HASH so freshly-scaffolded +// projects work without duplicating the same value under two names. Aliasing +// here means every command's `.env('CATALYST_STORE_HASH')` binding picks it up +// without per-command changes. +if (!process.env.CATALYST_STORE_HASH && process.env.BIGCOMMERCE_STORE_HASH) { + process.env.CATALYST_STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH; +} + export const program = new Command(); consola.log(colorize('cyanBright', `◢ ${PACKAGE_INFO.name} v${PACKAGE_INFO.version}\n`)); @@ -26,7 +44,7 @@ program .version(PACKAGE_INFO.version) .summary('CLI tool for Catalyst development') .description( - 'CLI tool for Catalyst development.\n\nConfiguration priority: flags > env file (--env-path) > process.env > .bigcommerce/project.json.\nRun `catalyst --help` for details on a specific command.', + 'CLI tool for Catalyst development.\n\nConfiguration priority: flags > env file (--env-path) > process.env > .env.local (auto-loaded from cwd) > .bigcommerce/project.json.\n\nCATALYST_STORE_HASH falls back to BIGCOMMERCE_STORE_HASH if unset.\n\nRun `catalyst --help` for details on a specific command.', ) .configureHelp({ showGlobalOptions: true }) .addOption( @@ -62,6 +80,7 @@ program }), ) .addCommand(version) + .addCommand(create) .addCommand(start) .addCommand(build) .addCommand(deploy) diff --git a/packages/catalyst/tsconfig.json b/packages/catalyst/tsconfig.json index 37e0c17d44..d7bf0af065 100644 --- a/packages/catalyst/tsconfig.json +++ b/packages/catalyst/tsconfig.json @@ -3,6 +3,7 @@ "display": "Node", "compilerOptions": { "strict": true, + "lib": ["esnext", "dom", "dom.iterable"], "target": "es2020", "module": "esnext", "moduleResolution": "bundler", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a57fcfd13..825aa98813 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -306,6 +306,9 @@ importers: packages/catalyst: dependencies: + '@inquirer/prompts': + specifier: ^7.5.3 + version: 7.5.3(@types/node@22.15.30) '@opennextjs/cloudflare': specifier: 1.17.3 version: 1.17.3(encoding@0.1.13)(next@16.1.6(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.5(react@19.1.5))(react@19.1.5))(wrangler@4.31.0) @@ -324,15 +327,30 @@ importers: consola: specifier: ^3.4.2 version: 3.4.2 + cross-spawn: + specifier: ^7.0.6 + version: 7.0.6 dotenv: specifier: ^16.5.0 version: 16.5.0 execa: specifier: ^9.6.0 version: 9.6.0 + fs-extra: + specifier: ^11.3.0 + version: 11.3.0 + lodash.kebabcase: + specifier: ^4.1.1 + version: 4.1.1 + nypm: + specifier: ^0.5.4 + version: 0.5.4 open: specifier: ^10.1.0 version: 10.1.2 + std-env: + specifier: ^3.9.0 + version: 3.9.0 yocto-spinner: specifier: ^1.0.0 version: 1.0.0 @@ -352,6 +370,15 @@ importers: '@types/adm-zip': specifier: ^0.5.7 version: 0.5.7 + '@types/cross-spawn': + specifier: ^6.0.6 + version: 6.0.6 + '@types/fs-extra': + specifier: ^11.0.4 + version: 11.0.4 + '@types/lodash.kebabcase': + specifier: ^4.1.9 + version: 4.1.9 '@types/node': specifier: ^22.15.30 version: 22.15.30 @@ -8452,7 +8479,6 @@ packages: puppeteer@24.10.0: resolution: {integrity: sha512-Oua9VkGpj0S2psYu5e6mCer6W9AU9POEQh22wRgSXnLXASGH+MwLUVWgLCLeP9QPHHcJ7tySUlg4Sa9OJmaLpw==} engines: {node: '>=18'} - deprecated: < 24.15.0 is no longer supported hasBin: true pure-rand@6.1.0: