diff --git a/packages/catalyst/src/cli/commands/init.spec.ts b/packages/catalyst/src/cli/commands/init.spec.ts new file mode 100644 index 000000000..3474b9dd7 --- /dev/null +++ b/packages/catalyst/src/cli/commands/init.spec.ts @@ -0,0 +1,261 @@ +import { select } from '@inquirer/prompts'; +import { Command } from 'commander'; +import { outputFileSync } from 'fs-extra/esm'; +import { realpath } from 'node:fs/promises'; +import { join } from 'node:path'; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + MockInstance, + test, + vi, +} from 'vitest'; + +import { fetchAvailableChannels, getChannelInit } from '../lib/channels'; +import { consola } from '../lib/logger'; +import { login } from '../lib/login'; +import { mkTempDir } from '../lib/mk-temp-dir'; +import { getProjectConfig } from '../lib/project-config'; +import { program } from '../program'; + +import { init } from './init'; + +vi.mock('@inquirer/prompts', () => ({ select: vi.fn() })); +vi.mock('fs-extra/esm', () => ({ outputFileSync: vi.fn() })); + +const { MockLoginAbortedError } = vi.hoisted(() => ({ + MockLoginAbortedError: class extends Error { + constructor() { + super('Login aborted by user.'); + this.name = 'LoginAbortedError'; + } + }, +})); + +vi.mock('../lib/login', () => ({ + login: vi.fn().mockResolvedValue({ + storeHash: 'login-store-hash', + accessToken: 'login-access-token', + }), + LoginAbortedError: MockLoginAbortedError, +})); + +vi.mock('../lib/channels', () => ({ + fetchAvailableChannels: vi.fn(), + getChannelInit: vi.fn(), +})); + +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: 'init', + durationMs: vi.fn().mockReturnValue(0), + analytics: { closeAndFlush: vi.fn().mockResolvedValue(undefined) }, + }; + + return { + Telemetry: vi.fn().mockImplementation(() => instance), + getTelemetry: vi.fn(() => instance), + resetTelemetry: vi.fn(), + }; +}); + +const storeHash = 'flag-store-hash'; +const accessToken = 'flag-access-token'; + +const sampleChannels = [ + { id: 1, name: 'Catalyst Store', platform: 'catalyst' }, + { id: 2, name: 'Legacy Store', platform: 'bigcommerce' }, +]; + +let exitMock: MockInstance; +let tmpDir: string; +let cleanup: () => Promise; + +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(); + tmpDir = await realpath(tmpDir); +}); + +beforeEach(() => { + process.chdir(tmpDir); + + // Default happy-path stubs; individual tests override as needed. + vi.mocked(fetchAvailableChannels).mockResolvedValue(sampleChannels); + vi.mocked(select).mockResolvedValue(sampleChannels[0]); + vi.mocked(getChannelInit).mockResolvedValue({ + storefrontToken: 'sample-storefront-token', + envVars: { + BIGCOMMERCE_STORE_HASH: storeHash, + BIGCOMMERCE_CHANNEL_ID: '1', + BIGCOMMERCE_STOREFRONT_TOKEN: 'sample-storefront-token', + }, + }); +}); + +afterEach(() => { + vi.clearAllMocks(); + + try { + const config = getProjectConfig(); + + config.delete('storeHash'); + config.delete('accessToken'); + } catch { + // ignore if config doesn't exist + } +}); + +afterAll(async () => { + vi.restoreAllMocks(); + exitMock.mockRestore(); + + await cleanup(); +}); + +test('properly configured Command instance', () => { + expect(init).toBeInstanceOf(Command); + expect(init.name()).toBe('init'); + expect(init.description()).toBe( + 'Connect a BigCommerce store and channel to an existing Catalyst project.', + ); +}); + +describe('credential resolution', () => { + test('uses flag-provided credentials without logging in', async () => { + await program.parseAsync([ + 'node', + 'catalyst', + 'init', + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]); + + expect(login).not.toHaveBeenCalled(); + expect(mockIdentify).toHaveBeenCalledWith(storeHash); + expect(fetchAvailableChannels).toHaveBeenCalledWith( + storeHash, + accessToken, + 'api.bigcommerce.com', + ); + expect(getChannelInit).toHaveBeenCalledWith( + 1, + storeHash, + accessToken, + 'https://cxm-prd.bigcommerceapp.com', + ); + expect(outputFileSync).toHaveBeenCalledWith( + join(tmpDir, '.env.local'), + expect.stringContaining(`BIGCOMMERCE_STORE_HASH=${storeHash}`), + ); + expect(consola.success).toHaveBeenCalledWith( + '.env.local file created for channel Catalyst Store!', + ); + }); + + test('falls back to persisted project config credentials', async () => { + const config = getProjectConfig(); + + config.set('storeHash', 'config-store-hash'); + config.set('accessToken', 'config-access-token'); + + await program.parseAsync(['node', 'catalyst', 'init']); + + expect(login).not.toHaveBeenCalled(); + expect(fetchAvailableChannels).toHaveBeenCalledWith( + 'config-store-hash', + 'config-access-token', + 'api.bigcommerce.com', + ); + }); + + test('logs in interactively and persists credentials when none are available', async () => { + await program.parseAsync(['node', 'catalyst', 'init']); + + expect(login).toHaveBeenCalled(); + expect(fetchAvailableChannels).toHaveBeenCalledWith( + 'login-store-hash', + 'login-access-token', + 'api.bigcommerce.com', + ); + + const config = getProjectConfig(); + + expect(config.get('storeHash')).toBe('login-store-hash'); + expect(config.get('accessToken')).toBe('login-access-token'); + }); + + test('exits cleanly when the user aborts login', async () => { + vi.mocked(login).mockRejectedValueOnce(new MockLoginAbortedError()); + + await program.parseAsync(['node', 'catalyst', 'init']); + + expect(consola.info).toHaveBeenCalledWith( + 'Login aborted. Re-run `catalyst init` when you have your credentials ready.', + ); + expect(exitMock).toHaveBeenCalledWith(0); + expect(fetchAvailableChannels).not.toHaveBeenCalled(); + }); + + test('propagates non-abort login failures', async () => { + vi.mocked(login).mockRejectedValueOnce(new Error('network down')); + + await expect(program.parseAsync(['node', 'catalyst', 'init'])).rejects.toThrow('network down'); + + expect(fetchAvailableChannels).not.toHaveBeenCalled(); + }); +}); + +describe('env file', () => { + test('merges --env overrides over channel-provided values', async () => { + await program.parseAsync([ + 'node', + 'catalyst', + 'init', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--env', + 'BIGCOMMERCE_STORE_HASH=overridden', + '--env', + 'EXTRA_FLAG=on', + ]); + + const [, contents] = vi.mocked(outputFileSync).mock.calls[0]; + + expect(contents).toContain('BIGCOMMERCE_STORE_HASH=overridden'); + expect(contents).toContain('EXTRA_FLAG=on'); + }); + + test('rejects malformed --env values', async () => { + await expect( + program.parseAsync([ + 'node', + 'catalyst', + 'init', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--env', + 'NOT_AN_ASSIGNMENT', + ]), + ).rejects.toThrow(/Expected format: KEY=VALUE/); + }); +}); diff --git a/packages/catalyst/src/cli/commands/init.ts b/packages/catalyst/src/cli/commands/init.ts new file mode 100644 index 000000000..5462e2f9b --- /dev/null +++ b/packages/catalyst/src/cli/commands/init.ts @@ -0,0 +1,161 @@ +import { Command, Option } from '@commander-js/extra-typings'; +import { select } from '@inquirer/prompts'; +import type Conf from 'conf'; +import { colorize } from 'consola/utils'; +import { outputFileSync } from 'fs-extra/esm'; +import { join } from 'path'; + +import { fetchAvailableChannels, getChannelInit } from '../lib/channels'; +import { parseEnvAssignment } from '../lib/env-config'; +import { consola } from '../lib/logger'; +import { login, LoginAbortedError, type LoginResult } from '../lib/login'; +import { getProjectConfig, type ProjectConfigSchema } from '../lib/project-config'; +import { + accessTokenOption, + apiHostOption, + loginUrlOption, + storeHashOption, +} from '../lib/shared-options'; +import { getTelemetry } from '../lib/telemetry'; + +// Mirrors `catalyst create`'s channel ordering: surface Catalyst-platform +// channels first, then Next, then Stencil (`bigcommerce`), then anything else. +const channelSortOrder = ['catalyst', 'next', 'bigcommerce']; + +// Writes .env.local in the current working directory. Unlike `catalyst create` +// — which scaffolds a sibling repo and targets `/core/.env.local` — +// `init` runs inside an existing project's `core/` directory (the same place +// `catalyst dev`/`build`/`deploy` run), so the file belongs in the cwd. +function writeEnvToCwd(envVars: Record) { + outputFileSync( + join(process.cwd(), '.env.local'), + `${Object.entries(envVars) + .map(([key, value]) => `${key}=${value}`) + .join('\n')}\n`, + ); +} + +// Resolves store credentials from flags/env, then the persisted project config, +// then an interactive login. Returns null when the user aborts the login so the +// caller can exit cleanly. Credentials obtained via login are persisted so +// subsequent catalyst commands (`deploy`, `project`, ...) don't re-prompt. +async function resolveStoreCredentials( + options: { storeHash?: string; accessToken?: string; loginUrl: string; apiHost: string }, + config: Conf, +): Promise { + const storeHash = options.storeHash ?? config.get('storeHash'); + const accessToken = options.accessToken ?? config.get('accessToken'); + + if (storeHash && accessToken) { + return { storeHash, accessToken }; + } + + try { + const credentials = await login(options.loginUrl, options.apiHost); + + config.set('storeHash', credentials.storeHash); + config.set('accessToken', credentials.accessToken); + + return credentials; + } catch (error) { + if (error instanceof LoginAbortedError) { + return null; + } + + throw error; + } +} + +export const init = new Command('init') + .configureHelp({ showGlobalOptions: true }) + .description('Connect a BigCommerce store and channel to an existing Catalyst project.') + .addHelpText( + 'after', + ` +Examples: + # Interactive: log in, then pick a channel to connect + $ catalyst init + + # Non-interactive with existing credentials + $ catalyst init --store-hash --access-token + + # Append extra environment variables to .env.local + $ catalyst init --env MY_FLAG=1 --env OTHER=value`, + ) + .addOption(storeHashOption()) + .addOption(accessTokenOption()) + .addOption(apiHostOption()) + .addOption(loginUrlOption()) + .option( + '--env ', + 'Arbitrary environment variables to set in .env.local. Format: KEY=VALUE (repeatable).', + ) + .addOption( + new Option('--cli-api-origin ', 'Catalyst CLI API origin') + .default('https://cxm-prd.bigcommerceapp.com') + .hideHelp(), + ) + .action(async (options) => { + const config = getProjectConfig(); + + const credentials = await resolveStoreCredentials(options, config); + + if (!credentials) { + consola.info('Login aborted. Re-run `catalyst init` when you have your credentials ready.'); + process.exit(0); + + return; + } + + const { storeHash, accessToken } = credentials; + + await getTelemetry().identify(storeHash); + + const channels = await fetchAvailableChannels(storeHash, accessToken, options.apiHost); + + const channel = 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) + }`, + })), + }); + + const initData = await getChannelInit(channel.id, storeHash, accessToken, options.cliApiOrigin); + + const envVars: Record = { ...initData.envVars }; + + // Inline `--env KEY=VALUE` overrides win over the channel-provided values. + if (options.env) { + options.env.forEach((entry) => { + const { key, value } = parseEnvAssignment(entry); + + envVars[key] = value; + }); + } + + writeEnvToCwd(envVars); + + consola.success(`.env.local file created for channel ${channel.name}!`); + consola.info('Next steps:'); + consola.info(colorize('yellow', ' pnpm run dev')); + }); diff --git a/packages/catalyst/src/cli/program.ts b/packages/catalyst/src/cli/program.ts index 0103375bc..18fcfb574 100644 --- a/packages/catalyst/src/cli/program.ts +++ b/packages/catalyst/src/cli/program.ts @@ -13,6 +13,7 @@ import { channel } from './commands/channel'; import { create } from './commands/create'; import { deploy } from './commands/deploy'; import { env } from './commands/env'; +import { init } from './commands/init'; import { logs } from './commands/logs'; import { project } from './commands/project'; import { start } from './commands/start'; @@ -83,6 +84,7 @@ program ) .addCommand(version) .addCommand(create) + .addCommand(init) .addCommand(start) .addCommand(build) .addCommand(deploy)