diff --git a/packages/catalyst/src/cli/commands/channel.spec.ts b/packages/catalyst/src/cli/commands/channel.spec.ts new file mode 100644 index 000000000..1298a79ad --- /dev/null +++ b/packages/catalyst/src/cli/commands/channel.spec.ts @@ -0,0 +1,255 @@ +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 { server } from '../../../tests/mocks/node'; +import { consola } from '../lib/logger'; +import { mkTempDir } from '../lib/mk-temp-dir'; +import { getProjectConfig, ProjectConfigSchema } from '../lib/project-config'; +import { program } from '../program'; + +import { channel } from './channel'; + +let exitMock: MockInstance; + +let tmpDir: string; +let cleanup: () => Promise; +let config: Conf; + +const { mockIdentify } = vi.hoisted(() => ({ + mockIdentify: vi.fn(), +})); + +const linkedProjectUuid = 'a23f5785-fd99-4a94-9fb3-945551623923'; +const storeHash = 'test-store'; +const accessToken = 'test-token'; + +beforeAll(async () => { + consola.mockTypes(() => vi.fn()); + + vi.mock('../lib/telemetry', () => { + const instance = { + identify: mockIdentify, + isEnabled: vi.fn(() => true), + track: vi.fn(), + correlationId: 'test-session-uuid', + commandName: 'unknown', + durationMs: vi.fn().mockReturnValue(0), + analytics: { + closeAndFlush: vi.fn().mockResolvedValue(undefined), + }, + }; + + return { + Telemetry: vi.fn().mockImplementation(() => instance), + getTelemetry: vi.fn(() => instance), + resetTelemetry: vi.fn(), + }; + }); + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + exitMock = vi.spyOn(process, 'exit').mockImplementation(() => null as never); + + [tmpDir, cleanup] = await mkTempDir(); + + vi.spyOn(process, 'cwd').mockReturnValue(tmpDir); + + config = getProjectConfig(); +}); + +afterEach(() => { + vi.clearAllMocks(); + config.delete('storeHash'); + config.delete('accessToken'); + config.delete('projectUuid'); +}); + +afterAll(async () => { + vi.restoreAllMocks(); + exitMock.mockRestore(); + + await cleanup(); +}); + +describe('channel', () => { + test('has the update subcommand', () => { + expect(channel).toBeInstanceOf(Command); + expect(channel.name()).toBe('channel'); + + const update = channel.commands.find((cmd) => cmd.name() === 'update'); + + expect(update).toBeDefined(); + expect(update?.description()).toContain('Update a BigCommerce channel'); + }); +}); + +describe('channel update', () => { + test('happy path: prompts for channel and hostname, then PUTs', async () => { + let putBody: unknown; + let putChannelId: string | undefined; + + server.use( + http.put( + 'https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', + async ({ request, params }) => { + putBody = await request.json(); + putChannelId = String(params.channelId); + + return HttpResponse.json({ + data: { + id: 1, + url: 'https://project-one.catalyst-sandbox.store', + channel_id: 2, + }, + }); + }, + ), + ); + + const promptMock = vi + .spyOn(consola, 'prompt') + .mockResolvedValueOnce('2') + .mockResolvedValueOnce('project-one.catalyst-sandbox.store'); + + await program.parseAsync([ + 'node', + 'catalyst', + 'channel', + 'update', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--project-uuid', + linkedProjectUuid, + ]); + + expect(promptMock).toHaveBeenCalledTimes(2); + expect(putChannelId).toBe('2'); + expect(putBody).toEqual({ url: 'https://project-one.catalyst-sandbox.store' }); + expect(consola.success).toHaveBeenCalledWith( + expect.stringContaining('Updated channel "Catalyst Storefront" (2) site URL'), + ); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + test('reads project UUID from .bigcommerce/project.json when no flag is passed', async () => { + config.set('projectUuid', linkedProjectUuid); + + const promptMock = vi + .spyOn(consola, 'prompt') + .mockResolvedValueOnce('2') + .mockResolvedValueOnce('vanity.project-one.example.com'); + + await program.parseAsync([ + 'node', + 'catalyst', + 'channel', + 'update', + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]); + + expect(promptMock).toHaveBeenCalledTimes(2); + expect(consola.success).toHaveBeenCalledWith( + expect.stringContaining('https://vanity.project-one.example.com'), + ); + }); + + test('--channel-id and --hostname skip both prompts', async () => { + let putBody: unknown; + let putChannelId: string | undefined; + + server.use( + http.put( + 'https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', + async ({ request, params }) => { + putBody = await request.json(); + putChannelId = String(params.channelId); + + return HttpResponse.json({ + data: { id: 1, url: 'https://override.example', channel_id: 5 }, + }); + }, + ), + ); + + const promptMock = vi.spyOn(consola, 'prompt'); + + await program.parseAsync([ + 'node', + 'catalyst', + 'channel', + 'update', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--project-uuid', + linkedProjectUuid, + '--channel-id', + '5', + '--hostname', + 'override.example', + ]); + + expect(promptMock).not.toHaveBeenCalled(); + expect(putChannelId).toBe('5'); + expect(putBody).toEqual({ url: 'https://override.example' }); + }); + + test('exits gracefully when no projects exist and user declines to create one', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => + HttpResponse.json({ data: [] }), + ), + ); + + // First prompt: "Would you like to create one?" — user says no + vi.spyOn(consola, 'prompt').mockResolvedValueOnce(false); + + await program.parseAsync([ + 'node', + 'catalyst', + 'channel', + 'update', + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]); + + expect(consola.info).toHaveBeenCalledWith(expect.stringContaining('catalyst project create')); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + test('propagates errors from the channel-site PUT', async () => { + server.use( + http.put('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () => + HttpResponse.json({}, { status: 401 }), + ), + ); + + vi.spyOn(consola, 'prompt') + .mockResolvedValueOnce('2') + .mockResolvedValueOnce('project-one.catalyst-sandbox.store'); + + await expect( + program.parseAsync([ + 'node', + 'catalyst', + 'channel', + 'update', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--project-uuid', + linkedProjectUuid, + ]), + ).rejects.toThrow('Re-run `catalyst auth login`'); + }); +}); diff --git a/packages/catalyst/src/cli/commands/channel.ts b/packages/catalyst/src/cli/commands/channel.ts new file mode 100644 index 000000000..fe4f61c2d --- /dev/null +++ b/packages/catalyst/src/cli/commands/channel.ts @@ -0,0 +1,82 @@ +import { Command, Option } from 'commander'; + +import { runChannelSiteUrlFlow } from '../lib/channel-site-flow'; +import { NoLinkedProjectError } from '../lib/commerce-hosting'; +import { consola } from '../lib/logger'; +import { getProjectConfig } from '../lib/project-config'; +import { resolveCredentials } from '../lib/resolve-credentials'; +import { + accessTokenOption, + apiHostOption, + projectUuidOption, + storeHashOption, +} from '../lib/shared-options'; +import { getTelemetry } from '../lib/telemetry'; + +const update = new Command('update') + .configureHelp({ showGlobalOptions: true }) + .description( + "Update a BigCommerce channel's site URL to point at one of your project's deployment hostnames.", + ) + .addHelpText( + 'after', + ` +Examples: + # Pick a channel and hostname interactively + $ catalyst channel update + + # Skip both prompts + $ catalyst channel update --channel-id 123 --hostname my-storefront.example.com`, + ) + .addOption(storeHashOption()) + .addOption(accessTokenOption()) + .addOption(apiHostOption()) + .addOption(projectUuidOption()) + .addOption( + new Option( + '--channel-id ', + 'Skip the channel prompt and target this channel directly.', + ).argParser((value: string) => Number(value)), + ) + .addOption( + new Option( + '--hostname ', + "Skip the hostname prompt and use this hostname directly. Must be one of the project's deployment_hostnames.", + ), + ) + .action(async (options) => { + const config = getProjectConfig(); + const { storeHash, accessToken } = resolveCredentials(options, config); + + await getTelemetry().identify(storeHash); + + try { + await runChannelSiteUrlFlow({ + storeHash, + accessToken, + apiHost: options.apiHost, + projectUuid: options.projectUuid ?? config.get('projectUuid'), + channelId: options.channelId, + hostname: options.hostname, + }); + } catch (error) { + if (error instanceof NoLinkedProjectError) { + consola.info( + "When you're ready to create a project, run `catalyst project create` or re-run `catalyst channel update`.", + ); + process.exit(0); + + // Unreachable in production; prevents continuation when process.exit is mocked in tests. + return; + } + + throw error; + } + + process.exit(0); + }); + +export const channel = new Command('channel') + .configureHelp({ showGlobalOptions: true }) + .description('Manage BigCommerce channels.') + .addCommand(update); diff --git a/packages/catalyst/src/cli/commands/deploy.spec.ts b/packages/catalyst/src/cli/commands/deploy.spec.ts index 1a349660e..fe763e231 100644 --- a/packages/catalyst/src/cli/commands/deploy.spec.ts +++ b/packages/catalyst/src/cli/commands/deploy.spec.ts @@ -817,3 +817,133 @@ describe('transformation guard', () => { expect(installDependencies).not.toHaveBeenCalled(); }); }); + +describe('--update-site-url', () => { + function deployArgs(extra: string[] = []) { + return [ + 'node', + 'catalyst', + 'deploy', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--api-host', + apiHost, + '--project-uuid', + projectUuid, + '--prebuilt', + ...extra, + ]; + } + + test('triggers the interactive flow and PUTs the chosen hostname after deploy', async () => { + let putBody: unknown; + let putChannelId: string | undefined; + + server.use( + http.put( + 'https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', + async ({ request, params }) => { + putBody = await request.json(); + putChannelId = String(params.channelId); + + return HttpResponse.json({ + data: { + id: 1, + url: 'https://project-one.catalyst-sandbox.store', + channel_id: 2, + }, + }); + }, + ), + ); + + // Override the default consola.prompt stub (always-true in beforeEach) with + // the channel and hostname selections the interactive flow will ask for. + vi.spyOn(consola, 'prompt') + .mockResolvedValueOnce('2') + .mockResolvedValueOnce('project-one.catalyst-sandbox.store'); + + await program.parseAsync(deployArgs(['--update-site-url'])); + + expect(putChannelId).toBe('2'); + expect(putBody).toEqual({ url: 'https://project-one.catalyst-sandbox.store' }); + expect(consola.success).toHaveBeenCalledWith( + expect.stringContaining('Updated channel "Catalyst Storefront" (2) site URL'), + ); + }); + + test('places the freshly-deployed hostname first in the hostname prompt', async () => { + let hostnameOptions: Array<{ label: string; value: string }> | undefined; + + // Project Two has no hostnames by default; use Project One whose handler + // already returns the two seeded hostnames. Inject the freshly-deployed + // hostname into Project One's list so preferHostname has something to match. + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => + HttpResponse.json({ + data: [ + { + uuid: projectUuid, + name: 'Project One', + deployment_hostnames: [ + 'project-one.catalyst-sandbox.store', + 'example.com', // the just-deployed hostname (per the SSE default) + ], + }, + ], + }), + ), + ); + + vi.spyOn(consola, 'prompt') + .mockResolvedValueOnce('2') // channel + .mockImplementationOnce((_message, opts) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + hostnameOptions = (opts as { options: Array<{ label: string; value: string }> }).options; + + return Promise.resolve('example.com'); + }); + + await program.parseAsync(deployArgs(['--update-site-url'])); + + expect(hostnameOptions?.[0]).toMatchObject({ value: 'example.com' }); + }); + + test('does not call the channel site API when the flag is omitted', async () => { + let putCalled = false; + + server.use( + http.put('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () => { + putCalled = true; + + return HttpResponse.json({}, { status: 200 }); + }), + ); + + await program.parseAsync(deployArgs()); + + expect(putCalled).toBe(false); + expect(consola.success).not.toHaveBeenCalledWith(expect.stringContaining('Updated channel')); + }); + + test('soft-fails with a warning when the update API returns an error', async () => { + server.use( + http.put('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () => + HttpResponse.json({}, { status: 401 }), + ), + ); + + vi.spyOn(consola, 'prompt') + .mockResolvedValueOnce('2') + .mockResolvedValueOnce('project-one.catalyst-sandbox.store'); + + await program.parseAsync(deployArgs(['--update-site-url'])); + + expect(consola.warn).toHaveBeenCalledWith( + expect.stringContaining('Failed to update channel site URL'), + ); + expect(consola.info).toHaveBeenCalledWith(expect.stringContaining('catalyst auth login')); + }); +}); diff --git a/packages/catalyst/src/cli/commands/deploy.ts b/packages/catalyst/src/cli/commands/deploy.ts index bab1224f8..29c4c0609 100644 --- a/packages/catalyst/src/cli/commands/deploy.ts +++ b/packages/catalyst/src/cli/commands/deploy.ts @@ -6,6 +6,7 @@ import { dirname, join } from 'node:path'; import yoctoSpinner from 'yocto-spinner'; import { z } from 'zod'; +import { runChannelSiteUrlFlow } from '../lib/channel-site-flow'; import { NoLinkedProjectError, selectOrCreateInfrastructureProject, @@ -268,7 +269,7 @@ export const getDeploymentStatus = async ( storeHash: string, accessToken: string, apiHost: string, -) => { +): Promise => { consola.info('Fetching deployment status...'); const spinner = yoctoSpinner().start('Fetching...'); @@ -350,6 +351,8 @@ export const getDeploymentStatus = async ( `View your deployment at: ${colorize('blue', `https://${deploymentHostname}`)}`, ); } + + return deploymentHostname; }; export const fetchProject = async ( @@ -394,6 +397,10 @@ Example: .addOption(accessTokenOption()) .addOption(apiHostOption()) .addOption(projectUuidOption()) + .option( + '--update-site-url', + "After a successful deploy, prompt to update a channel's site URL to the new hostname.", + ) .addOption( new Option( '--secret ', @@ -545,5 +552,33 @@ Example: environmentVariables, ); - await getDeploymentStatus(deploymentUuid, storeHash, accessToken, options.apiHost); + const deploymentHostname = await getDeploymentStatus( + deploymentUuid, + storeHash, + accessToken, + options.apiHost, + ); + + if (!options.updateSiteUrl) { + return; + } + + try { + await runChannelSiteUrlFlow({ + storeHash, + accessToken, + apiHost: options.apiHost, + projectUuid, + preferHostname: deploymentHostname, + }); + } catch (error) { + // Soft-fail: the deploy already succeeded and the bundle is live. A + // non-zero exit here would be misleading. + consola.warn( + `Failed to update channel site URL: ${error instanceof Error ? error.message : String(error)}`, + ); + consola.info( + 'Update it manually in the control panel, or re-run `catalyst auth login` if the token is missing the store_channel_settings scope.', + ); + } }); diff --git a/packages/catalyst/src/cli/lib/auth.ts b/packages/catalyst/src/cli/lib/auth.ts index b59922b43..1aa1baac2 100644 --- a/packages/catalyst/src/cli/lib/auth.ts +++ b/packages/catalyst/src/cli/lib/auth.ts @@ -6,6 +6,7 @@ export const DEVICE_OAUTH_SCOPES = [ 'store_infrastructure_deployments_manage', 'store_infrastructure_logs_read_only', 'store_infrastructure_projects_manage', + 'store_channel_settings', ].join(' '); export const DEFAULT_LOGIN_URL = 'https://login.bigcommerce.com'; diff --git a/packages/catalyst/src/cli/lib/channel-site-flow.spec.ts b/packages/catalyst/src/cli/lib/channel-site-flow.spec.ts new file mode 100644 index 000000000..67ae9b372 --- /dev/null +++ b/packages/catalyst/src/cli/lib/channel-site-flow.spec.ts @@ -0,0 +1,315 @@ +import { http, HttpResponse } from 'msw'; +import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { server } from '../../../tests/mocks/node'; + +import { runChannelSiteUrlFlow } from './channel-site-flow'; +import { NoLinkedProjectError } from './commerce-hosting'; +import { consola } from './logger'; + +const storeHash = 'test-store'; +const accessToken = 'test-token'; +const apiHost = 'api.bigcommerce.com'; +const linkedProjectUuid = 'a23f5785-fd99-4a94-9fb3-945551623923'; + +beforeAll(() => { + consola.mockTypes(() => vi.fn()); + + vi.mock('./telemetry', () => { + const instance = { + identify: vi.fn(), + isEnabled: vi.fn(() => true), + track: vi.fn(), + correlationId: 'test-session-uuid', + commandName: 'unknown', + durationMs: vi.fn().mockReturnValue(0), + analytics: { + closeAndFlush: vi.fn().mockResolvedValue(undefined), + }, + }; + + return { + Telemetry: vi.fn().mockImplementation(() => instance), + getTelemetry: vi.fn(() => instance), + resetTelemetry: vi.fn(), + }; + }); +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterAll(() => { + vi.restoreAllMocks(); +}); + +describe('runChannelSiteUrlFlow', () => { + test('happy path: prompts for channel and hostname, then PUTs', async () => { + let putBody: unknown; + let putChannelId: string | undefined; + + server.use( + http.put( + 'https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', + async ({ request, params }) => { + putBody = await request.json(); + putChannelId = String(params.channelId); + + return HttpResponse.json({ + data: { + id: 1, + url: 'https://project-one.catalyst-sandbox.store', + channel_id: 2, + }, + }); + }, + ), + ); + + const promptMock = vi + .spyOn(consola, 'prompt') + // First prompt — channel select; resolve with channel id (as string) + .mockResolvedValueOnce('2') + // Second prompt — hostname select + .mockResolvedValueOnce('project-one.catalyst-sandbox.store'); + + await runChannelSiteUrlFlow({ + storeHash, + accessToken, + apiHost, + projectUuid: linkedProjectUuid, + }); + + expect(promptMock).toHaveBeenCalledTimes(2); + expect(putChannelId).toBe('2'); + expect(putBody).toEqual({ url: 'https://project-one.catalyst-sandbox.store' }); + expect(consola.success).toHaveBeenCalledWith( + expect.stringContaining('Updated channel "Catalyst Storefront" (2) site URL'), + ); + }); + + test('--channel-id short-circuits the channel prompt', async () => { + const promptMock = vi + .spyOn(consola, 'prompt') + .mockResolvedValueOnce('vanity.project-one.example.com'); + + await runChannelSiteUrlFlow({ + storeHash, + accessToken, + apiHost, + projectUuid: linkedProjectUuid, + channelId: 99, + }); + + // Only the hostname prompt fires + expect(promptMock).toHaveBeenCalledTimes(1); + expect(promptMock).toHaveBeenCalledWith( + 'Select the hostname to point the channel at.', + expect.any(Object), + ); + }); + + test('--hostname short-circuits the hostname prompt', async () => { + const promptMock = vi.spyOn(consola, 'prompt').mockResolvedValueOnce('2'); + + await runChannelSiteUrlFlow({ + storeHash, + accessToken, + apiHost, + projectUuid: linkedProjectUuid, + hostname: 'manual.example.com', + }); + + expect(promptMock).toHaveBeenCalledTimes(1); + expect(promptMock).toHaveBeenCalledWith('Select the channel to update.', expect.any(Object)); + expect(consola.success).toHaveBeenCalledWith( + expect.stringContaining('https://manual.example.com'), + ); + }); + + test('both overrides skip all prompts', async () => { + const promptMock = vi.spyOn(consola, 'prompt'); + + await runChannelSiteUrlFlow({ + storeHash, + accessToken, + apiHost, + projectUuid: linkedProjectUuid, + channelId: 42, + hostname: 'auto.example.com', + }); + + expect(promptMock).not.toHaveBeenCalled(); + expect(consola.success).toHaveBeenCalledWith( + expect.stringContaining('Updated channel 42 site URL to https://auto.example.com.'), + ); + }); + + test('preferHostname is placed first in the hostname options', async () => { + let hostnameOptions: Array<{ label: string; value: string }> | undefined; + + vi.spyOn(consola, 'prompt') + .mockResolvedValueOnce('2') // channel (the catalyst one) + .mockImplementationOnce((_message, opts) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + hostnameOptions = (opts as { options: Array<{ label: string; value: string }> }).options; + + return Promise.resolve('vanity.project-one.example.com'); + }); + + await runChannelSiteUrlFlow({ + storeHash, + accessToken, + apiHost, + projectUuid: linkedProjectUuid, + preferHostname: 'vanity.project-one.example.com', + }); + + expect(hostnameOptions?.[0]).toMatchObject({ value: 'vanity.project-one.example.com' }); + }); + + test('throws when no Catalyst channels are available', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/channels', () => + HttpResponse.json({ + // Only non-Catalyst channels — filtered out, so the picker is empty. + data: [{ id: 1, name: 'Default Storefront', platform: 'bigcommerce' }], + }), + ), + ); + + await expect( + runChannelSiteUrlFlow({ + storeHash, + accessToken, + apiHost, + projectUuid: linkedProjectUuid, + }), + ).rejects.toThrow('No Catalyst channels found on this store'); + }); + + test('filters non-Catalyst channels out of the picker', async () => { + let channelOptions: Array<{ label: string; value: string }> | undefined; + + vi.spyOn(consola, 'prompt') + .mockImplementationOnce((_message, opts) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + channelOptions = (opts as { options: Array<{ label: string; value: string }> }).options; + + return Promise.resolve('2'); + }) + .mockResolvedValueOnce('project-one.catalyst-sandbox.store'); + + await runChannelSiteUrlFlow({ + storeHash, + accessToken, + apiHost, + projectUuid: linkedProjectUuid, + }); + + // The default handler returns one bigcommerce + one catalyst channel; only + // the catalyst one should appear in the picker. + expect(channelOptions).toHaveLength(1); + expect(channelOptions?.[0]).toMatchObject({ label: 'Catalyst Storefront', value: '2' }); + }); + + test('throws when the project has no deployment hostnames', async () => { + // Project Two in the default handler has deployment_hostnames: [] + const projectTwo = 'b23f5785-fd99-4a94-9fb3-945551623924'; + + vi.spyOn(consola, 'prompt').mockResolvedValueOnce('2'); + + await expect( + runChannelSiteUrlFlow({ + storeHash, + accessToken, + apiHost, + projectUuid: projectTwo, + }), + ).rejects.toThrow('has no deployment hostnames yet'); + }); + + test('falls back to selectOrCreateInfrastructureProject when projectUuid is unset', async () => { + let getProjectsCalls = 0; + + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => { + getProjectsCalls += 1; + + return HttpResponse.json({ + data: [ + { + uuid: linkedProjectUuid, + name: 'Project One', + deployment_hostnames: ['project-one.catalyst-sandbox.store'], + }, + ], + }); + }), + ); + + vi.spyOn(consola, 'prompt') + // selectOrCreateInfrastructureProject prompt — pick the only project + .mockResolvedValueOnce(linkedProjectUuid) + // channel + .mockResolvedValueOnce('2') + // hostname + .mockResolvedValueOnce('project-one.catalyst-sandbox.store'); + + await runChannelSiteUrlFlow({ storeHash, accessToken, apiHost }); + + expect(getProjectsCalls).toBeGreaterThanOrEqual(1); + expect(consola.success).toHaveBeenCalledWith( + expect.stringContaining('Updated channel "Catalyst Storefront" (2) site URL'), + ); + }); + + test('throws NoLinkedProjectError when no projects exist and user declines to create', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => + HttpResponse.json({ data: [] }), + ), + ); + + // The "create a new project?" confirm prompt — user says no + vi.spyOn(consola, 'prompt').mockResolvedValueOnce(false); + + await expect(runChannelSiteUrlFlow({ storeHash, accessToken, apiHost })).rejects.toBeInstanceOf( + NoLinkedProjectError, + ); + }); + + test('warns and re-prompts when the linked project no longer exists', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => + HttpResponse.json({ + data: [ + { + uuid: 'different-uuid', + name: 'Some Other Project', + deployment_hostnames: ['other.example.com'], + }, + ], + }), + ), + ); + + vi.spyOn(consola, 'prompt') + // project picker + .mockResolvedValueOnce('different-uuid') + // channel + .mockResolvedValueOnce('1') + // hostname + .mockResolvedValueOnce('other.example.com'); + + await runChannelSiteUrlFlow({ + storeHash, + accessToken, + apiHost, + projectUuid: 'a-uuid-that-does-not-exist', + }); + + expect(consola.warn).toHaveBeenCalledWith(expect.stringContaining('not found on this store')); + }); +}); diff --git a/packages/catalyst/src/cli/lib/channel-site-flow.ts b/packages/catalyst/src/cli/lib/channel-site-flow.ts new file mode 100644 index 000000000..01fb55297 --- /dev/null +++ b/packages/catalyst/src/cli/lib/channel-site-flow.ts @@ -0,0 +1,140 @@ +import { type Channel, fetchAvailableChannels, updateChannelSiteUrl } from './channels'; +import { selectOrCreateInfrastructureProject } from './commerce-hosting'; +import { consola } from './logger'; +import { fetchProjects, type ProjectListItem } from './project'; + +export interface ChannelSiteFlowOptions { + storeHash: string; + accessToken: string; + apiHost: string; + // Linked project UUID (from --project-uuid or .bigcommerce/project.json). When + // present and resolvable, skips the project picker; otherwise falls back to + // `selectOrCreateInfrastructureProject`, which may throw NoLinkedProjectError + // (caller decides how to surface it). + projectUuid?: string; + // Non-interactive overrides. When supplied, the corresponding prompt is + // skipped. + channelId?: number; + hostname?: string; + // When set, this hostname is pre-selected in the hostname prompt. Used by + // `catalyst deploy --update-site-url` to default to the freshly-deployed + // hostname. + preferHostname?: string; +} + +async function resolveProject(options: ChannelSiteFlowOptions): Promise { + const api = { + storeHash: options.storeHash, + accessToken: options.accessToken, + apiHost: options.apiHost, + }; + + if (options.projectUuid) { + const projects = await fetchProjects(api.storeHash, api.accessToken, api.apiHost); + const matched = projects.find((p) => p.uuid === options.projectUuid); + + if (matched) return matched; + + consola.warn( + `Project ${options.projectUuid} not found on this store. Pick another to continue.`, + ); + } + + return selectOrCreateInfrastructureProject(api, options.projectUuid); +} + +async function resolveChannel( + options: ChannelSiteFlowOptions, +): Promise<{ id: number; name?: string }> { + if (options.channelId !== undefined) { + return { id: options.channelId }; + } + + consola.start('Fetching channels...'); + + const channels = await fetchAvailableChannels( + options.storeHash, + options.accessToken, + options.apiHost, + ); + + consola.success('Channels fetched.'); + + // Only Catalyst-platform channels can meaningfully be pointed at a Catalyst + // deployment hostname; other storefront platforms (Stencil, etc.) are + // filtered out so the picker stays focused. + const catalystChannels = channels.filter((c: Channel) => c.platform === 'catalyst'); + + if (catalystChannels.length === 0) { + throw new Error( + 'No Catalyst channels found on this store. Create one with `catalyst create` and try again.', + ); + } + + const selectedId = await consola.prompt('Select the channel to update.', { + type: 'select', + options: catalystChannels.map((c: Channel) => ({ + label: c.name, + value: String(c.id), + hint: `id: ${c.id}`, + })), + cancel: 'reject', + }); + + const id = Number(selectedId); + const matched = catalystChannels.find((c) => c.id === id); + + return { id, name: matched?.name }; +} + +async function resolveHostname( + project: ProjectListItem, + options: ChannelSiteFlowOptions, +): Promise { + if (options.hostname) { + return options.hostname; + } + + if (project.deployment_hostnames.length === 0) { + throw new Error( + `Project "${project.name}" has no deployment hostnames yet. Run \`catalyst deploy\` first to create one.`, + ); + } + + // When the caller knows which hostname they want surfaced first (e.g. the + // freshly-deployed one from `catalyst deploy --update-site-url`), order it to + // the top of the list so it's the default selection. + const ordered = options.preferHostname + ? [ + ...project.deployment_hostnames.filter((h) => h === options.preferHostname), + ...project.deployment_hostnames.filter((h) => h !== options.preferHostname), + ] + : project.deployment_hostnames; + + const selected = await consola.prompt('Select the hostname to point the channel at.', { + type: 'select', + options: ordered.map((h) => ({ label: h, value: h })), + cancel: 'reject', + }); + + return String(selected); +} + +export async function runChannelSiteUrlFlow(options: ChannelSiteFlowOptions): Promise { + const project = await resolveProject(options); + const channel = await resolveChannel(options); + const hostname = await resolveHostname(project, options); + const siteUrl = hostname.startsWith('https://') ? hostname : `https://${hostname}`; + + await updateChannelSiteUrl( + channel.id, + siteUrl, + options.storeHash, + options.accessToken, + options.apiHost, + ); + + const channelLabel = channel.name ? `"${channel.name}" (${channel.id})` : String(channel.id); + + consola.success(`Updated channel ${channelLabel} site URL to ${siteUrl}.`); +} diff --git a/packages/catalyst/src/cli/lib/channels.spec.ts b/packages/catalyst/src/cli/lib/channels.spec.ts new file mode 100644 index 000000000..474b33073 --- /dev/null +++ b/packages/catalyst/src/cli/lib/channels.spec.ts @@ -0,0 +1,107 @@ +import { http, HttpResponse } from 'msw'; +import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest'; + +import { server } from '../../../tests/mocks/node'; + +import { updateChannelSiteUrl } from './channels'; + +const storeHash = 'test-store'; +const accessToken = 'test-token'; +const apiHost = 'api.bigcommerce.com'; +const channelId = 1; + +beforeAll(() => { + vi.mock('./telemetry', () => { + const instance = { + identify: vi.fn(), + isEnabled: vi.fn(() => true), + track: vi.fn(), + correlationId: 'test-session-uuid', + commandName: 'unknown', + durationMs: vi.fn().mockReturnValue(0), + analytics: { + closeAndFlush: vi.fn().mockResolvedValue(undefined), + }, + }; + + return { + Telemetry: vi.fn().mockImplementation(() => instance), + getTelemetry: vi.fn(() => instance), + resetTelemetry: vi.fn(), + }; + }); +}); + +afterAll(() => { + vi.restoreAllMocks(); +}); + +describe('updateChannelSiteUrl', () => { + test('PUTs the URL and returns parsed channel site', async () => { + let receivedBody: unknown; + + server.use( + http.put( + 'https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', + async ({ request }) => { + receivedBody = await request.json(); + + return HttpResponse.json({ + data: { + id: 42, + url: 'https://new.example.com', + channel_id: channelId, + }, + }); + }, + ), + ); + + const result = await updateChannelSiteUrl( + channelId, + 'https://new.example.com', + storeHash, + accessToken, + apiHost, + ); + + expect(receivedBody).toEqual({ url: 'https://new.example.com' }); + expect(result).toEqual({ id: 42, url: 'https://new.example.com', channelId }); + }); + + test('throws with re-auth hint on 401', async () => { + server.use( + http.put('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () => + HttpResponse.json({}, { status: 401 }), + ), + ); + + await expect( + updateChannelSiteUrl(channelId, 'https://x.example', storeHash, accessToken, apiHost), + ).rejects.toThrow('Re-run `catalyst auth login`'); + }); + + test('throws with re-auth hint on 403', async () => { + server.use( + http.put('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () => + HttpResponse.json({}, { status: 403 }), + ), + ); + + await expect( + updateChannelSiteUrl(channelId, 'https://x.example', storeHash, accessToken, apiHost), + ).rejects.toThrow('Re-run `catalyst auth login`'); + }); + + test('throws with status on other errors', async () => { + server.use( + http.put('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () => + HttpResponse.json({}, { status: 500 }), + ), + ); + + await expect( + updateChannelSiteUrl(channelId, 'https://x.example', storeHash, accessToken, apiHost), + ).rejects.toThrow('Failed to update channel site: 500'); + }); +}); diff --git a/packages/catalyst/src/cli/lib/channels.ts b/packages/catalyst/src/cli/lib/channels.ts index 81e3cd017..91e632247 100644 --- a/packages/catalyst/src/cli/lib/channels.ts +++ b/packages/catalyst/src/cli/lib/channels.ts @@ -58,6 +58,14 @@ const eligibilityResponseSchema = z.object({ }), }); +const channelSiteSchema = z.object({ + data: z.object({ + id: z.number(), + url: z.string(), + channel_id: z.number(), + }), +}); + export interface ChannelInit { storefrontToken: string; envVars: Record; @@ -173,3 +181,46 @@ export async function fetchAvailableChannels( return channelsResponseSchema.parse(await response.json()).data; } + +export interface ChannelSite { + id: number; + url: string; + channelId: number; +} + +export async function updateChannelSiteUrl( + channelId: number, + siteUrl: string, + storeHash: string, + accessToken: string, + apiHost: string, +): Promise { + const response = await fetch( + `https://${apiHost}/stores/${storeHash}/v3/channels/${channelId}/site`, + { + method: 'PUT', + headers: { + 'X-Auth-Token': accessToken, + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-Correlation-Id': getTelemetry().correlationId, + }, + body: JSON.stringify({ url: siteUrl }), + }, + ); + + if (response.status === 401 || response.status === 403) { + throw new Error( + `Failed to update channel site (${response.status}). Re-run \`catalyst auth login\` to refresh your access token with the store_channel_settings scope.`, + ); + } + + if (!response.ok) { + throw new Error(`Failed to update channel site: ${response.status} ${response.statusText}`); + } + + const res: unknown = await response.json(); + const { data } = channelSiteSchema.parse(res); + + return { id: data.id, url: data.url, channelId: data.channel_id }; +} diff --git a/packages/catalyst/src/cli/program.ts b/packages/catalyst/src/cli/program.ts index 52a6bc411..fa7dc0b2e 100644 --- a/packages/catalyst/src/cli/program.ts +++ b/packages/catalyst/src/cli/program.ts @@ -9,6 +9,7 @@ import PACKAGE_INFO from '../../package.json'; import { auth } from './commands/auth'; import { build } from './commands/build'; +import { channel } from './commands/channel'; import { create } from './commands/create'; import { deploy } from './commands/deploy'; import { logs } from './commands/logs'; @@ -86,6 +87,7 @@ program .addCommand(deploy) .addCommand(logs) .addCommand(project) + .addCommand(channel) .addCommand(auth) .addCommand(telemetry) .hook('preAction', telemetryPreHook) diff --git a/packages/catalyst/tests/mocks/handlers.ts b/packages/catalyst/tests/mocks/handlers.ts index d2d685242..69a8833ba 100644 --- a/packages/catalyst/tests/mocks/handlers.ts +++ b/packages/catalyst/tests/mocks/handlers.ts @@ -155,4 +155,24 @@ export const handlers = [ 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid', () => new HttpResponse(null, { status: 204 }), ), + + // Default handler for fetchAvailableChannels — returns two storefront + // channels so the picker has something to render. Tests that need an + // empty list or different channel shapes should override with `server.use(...)`. + http.get('https://:apiHost/stores/:storeHash/v3/channels', () => + HttpResponse.json({ + data: [ + { id: 1, name: 'Default Storefront', platform: 'bigcommerce' }, + { id: 2, name: 'Catalyst Storefront', platform: 'catalyst' }, + ], + }), + ), + + // Default handler for updateChannelSiteUrl — succeeds with a generic + // payload. Tests that need to assert error handling should override. + http.put('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () => + HttpResponse.json({ + data: { id: 1, url: 'https://example.com', channel_id: 1 }, + }), + ), ];