From f9ef06839eff2f2e0c305378e4df3e2c9c8d2b67 Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Mon, 15 Jun 2026 15:24:50 -0500 Subject: [PATCH] LTRAC-908: feat(cli) - Add `catalyst init` command Port the `init` command from create-catalyst into the catalyst CLI so it connects an existing Catalyst project to a BigCommerce store and channel. It resolves credentials from flags/env, the persisted project config, or an interactive device-code login (persisting the result), then fetches available channels, prompts for one, fetches its channel init data, and writes .env.local in the current working directory. Reuses the existing channels, login, project-config, telemetry, and env-config libraries rather than porting create-catalyst's bespoke Https/CliApi/Config utilities. Refs LTRAC-908 Co-Authored-By: Claude --- .../catalyst/src/cli/commands/init.spec.ts | 261 ++++++++++++++++++ packages/catalyst/src/cli/commands/init.ts | 161 +++++++++++ packages/catalyst/src/cli/program.ts | 2 + 3 files changed, 424 insertions(+) create mode 100644 packages/catalyst/src/cli/commands/init.spec.ts create mode 100644 packages/catalyst/src/cli/commands/init.ts 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)