diff --git a/packages/catalyst/src/cli/commands/deploy.spec.ts b/packages/catalyst/src/cli/commands/deploy.spec.ts index e15b8a5348..2cc67ffee4 100644 --- a/packages/catalyst/src/cli/commands/deploy.spec.ts +++ b/packages/catalyst/src/cli/commands/deploy.spec.ts @@ -561,3 +561,149 @@ describe('--prebuilt flag', () => { await emptyDistCleanup(); }); }); + +describe('channel site URL auto-update', () => { + const channelId = 7; + + afterEach(() => { + const config = getProjectConfig(); + + config.delete('channelId'); + }); + + function deployArgs(extra: string[] = []) { + return [ + 'node', + 'catalyst', + 'deploy', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--api-host', + apiHost, + '--project-uuid', + projectUuid, + '--prebuilt', + ...extra, + ]; + } + + test('updates channel site URL after a successful deploy', async () => { + let putBody: unknown; + + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () => + HttpResponse.json({}, { status: 404 }), + ), + http.put( + 'https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', + async ({ request }) => { + putBody = await request.json(); + + return HttpResponse.json({ + data: { id: 1, url: 'https://example.com', channel_id: channelId }, + }); + }, + ), + ); + + await program.parseAsync(deployArgs(['--channel-id', String(channelId)])); + + expect(putBody).toEqual({ url: 'https://example.com' }); + expect(consola.success).toHaveBeenCalledWith( + `Updated channel ${channelId} site URL to https://example.com.`, + ); + }); + + test('reads channel ID from project.json when --channel-id is not passed', async () => { + const config = getProjectConfig(); + + config.set('channelId', channelId); + + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () => + HttpResponse.json({}, { status: 404 }), + ), + http.put('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () => + HttpResponse.json({ + data: { id: 1, url: 'https://example.com', channel_id: channelId }, + }), + ), + ); + + await program.parseAsync(deployArgs()); + + expect(consola.success).toHaveBeenCalledWith( + `Updated channel ${channelId} site URL to https://example.com.`, + ); + }); + + test('skips PUT when current site URL already matches deployment URL', async () => { + let putCalled = false; + + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () => + HttpResponse.json({ + data: { id: 1, url: 'https://example.com', channel_id: channelId }, + }), + ), + http.put('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () => { + putCalled = true; + + return HttpResponse.json({}, { status: 200 }); + }), + ); + + await program.parseAsync(deployArgs(['--channel-id', String(channelId)])); + + expect(putCalled).toBe(false); + expect(consola.info).toHaveBeenCalledWith( + `Channel ${channelId} site URL already up to date (https://example.com).`, + ); + }); + + test('--no-update-channel skips the update entirely', async () => { + let getCalled = false; + + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () => { + getCalled = true; + + return HttpResponse.json({}, { status: 404 }); + }), + ); + + await program.parseAsync( + deployArgs(['--channel-id', String(channelId), '--no-update-channel']), + ); + + expect(getCalled).toBe(false); + expect(consola.success).not.toHaveBeenCalledWith(expect.stringContaining('Updated channel')); + }); + + test('warns and continues when no channel ID is configured', async () => { + await program.parseAsync(deployArgs()); + + expect(consola.warn).toHaveBeenCalledWith(expect.stringContaining('no channel ID configured')); + 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.get('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () => + HttpResponse.json({}, { status: 404 }), + ), + http.put('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () => + HttpResponse.json({}, { status: 401 }), + ), + ); + + await program.parseAsync(deployArgs(['--channel-id', String(channelId)])); + + expect(consola.warn).toHaveBeenCalledWith( + expect.stringContaining('Failed to update channel site URL automatically'), + ); + 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 482d06cfef..f5454cade3 100644 --- a/packages/catalyst/src/cli/commands/deploy.ts +++ b/packages/catalyst/src/cli/commands/deploy.ts @@ -6,6 +6,7 @@ import { join } from 'node:path'; import yoctoSpinner from 'yocto-spinner'; import { z } from 'zod'; +import { fetchChannelSite, updateChannelSiteUrl } from '../lib/channels'; import { getDeploymentErrorMessage } from '../lib/deployment-errors'; import { consola } from '../lib/logger'; import { getProjectConfig } from '../lib/project-config'; @@ -66,6 +67,10 @@ const DeploymentStatusSchema = z.object({ }) .nullable(), deployment_url: z.string().nullable(), + // TODO: deployment_url is being deprecated in favor of deployment_hostnames (string[]). + // When the backend rolls it out, switch over here and update the consumer below to pick + // the primary hostname. + // deployment_hostnames: z.array(z.string()).optional(), error: z .object({ code: z.number(), @@ -255,7 +260,7 @@ export const getDeploymentStatus = async ( storeHash: string, accessToken: string, apiHost: string, -) => { +): Promise => { consola.info('Fetching deployment status...'); const spinner = yoctoSpinner().start('Fetching...'); @@ -336,7 +341,11 @@ export const getDeploymentStatus = async ( const url = deploymentUrl.startsWith('https://') ? deploymentUrl : `https://${deploymentUrl}`; consola.success(`View your deployment at: ${colorize('blue', url)}`); + + return url; } + + return undefined; }; export const fetchProject = async ( @@ -401,6 +410,15 @@ Example: 'BigCommerce intrastructure project UUID. Can be found via the BigCommerce API (GET /v3/infrastructure/projects).', ).env('CATALYST_PROJECT_UUID'), ) + .addOption( + new Option( + '--channel-id ', + 'BigCommerce channel ID to update with the deployment URL. Read from .bigcommerce/project.json when not provided.', + ) + .env('CATALYST_CHANNEL_ID') + .argParser((value: string) => Number(value)), + ) + .option('--no-update-channel', 'Skip updating the BigCommerce channel site URL after deploy.') .addOption( new Option( '--secret ', @@ -479,5 +497,54 @@ Example: environmentVariables, ); - await getDeploymentStatus(deploymentUuid, storeHash, accessToken, options.apiHost); + const deploymentUrl = await getDeploymentStatus( + deploymentUuid, + storeHash, + accessToken, + options.apiHost, + ); + + if (!options.updateChannel) { + return; + } + + const channelId: number | undefined = options.channelId ?? config.get('channelId'); + + if (!channelId) { + consola.warn( + 'Skipping channel site URL update: no channel ID configured. Run `catalyst project link` or pass --channel-id.', + ); + + return; + } + + if (!deploymentUrl) { + consola.warn('Skipping channel site URL update: deployment did not return a URL.'); + + return; + } + + try { + const current = await fetchChannelSite(channelId, storeHash, accessToken, options.apiHost); + + if (current?.url === deploymentUrl) { + consola.info(`Channel ${channelId} site URL already up to date (${deploymentUrl}).`); + } else { + await updateChannelSiteUrl( + channelId, + deploymentUrl, + storeHash, + accessToken, + options.apiHost, + ); + consola.success(`Updated channel ${channelId} site URL to ${deploymentUrl}.`); + } + } catch (error) { + consola.warn( + `Failed to update channel site URL automatically: ${error instanceof Error ? error.message : String(error)}`, + ); + consola.info( + 'Update it manually in the control panel, or re-run after `catalyst auth login` if the token is missing the store_channel_settings scope.', + ); + } }); diff --git a/packages/catalyst/src/cli/commands/project.spec.ts b/packages/catalyst/src/cli/commands/project.spec.ts index 79a4933a71..24e035805c 100644 --- a/packages/catalyst/src/cli/commands/project.spec.ts +++ b/packages/catalyst/src/cli/commands/project.spec.ts @@ -65,6 +65,7 @@ afterEach(() => { config.delete('storeHash'); config.delete('accessToken'); config.delete('projectUuid'); + config.delete('channelId'); }); afterAll(async () => { @@ -553,3 +554,203 @@ describe('project link', () => { expect(exitMock).toHaveBeenCalledWith(1); }); }); + +describe('channel picker (project link)', () => { + const catalystChannel = { + id: 1, + name: 'My Catalyst Storefront', + platform: 'catalyst', + type: 'storefront', + status: 'active', + }; + const otherCatalystChannel = { + id: 5, + name: 'Catalyst Beta', + platform: 'catalyst', + type: 'storefront', + status: 'active', + }; + const stencilChannel = { + id: 2, + name: 'Default Stencil', + platform: 'bigcommerce', + type: 'storefront', + status: 'active', + }; + const posChannel = { + id: 9, + name: 'POS', + platform: 'in_person', + type: 'pos', + status: 'active', + }; + + test('writes selected channelId to project.json', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/channels', () => + HttpResponse.json({ + data: [catalystChannel, otherCatalystChannel, stencilChannel, posChannel], + }), + ), + ); + + const promptMock = vi + .spyOn(consola, 'prompt') + .mockResolvedValueOnce(projectUuid1) + .mockImplementationOnce((_message, opts) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const options = (opts as { options: Array<{ label: string; value: string }> }).options; + + // Two catalyst channels + Skip option; non-catalyst channels are filtered out + expect(options).toHaveLength(3); + expect(options[0]).toMatchObject({ label: 'My Catalyst Storefront', value: '1' }); + expect(options[1]).toMatchObject({ label: 'Catalyst Beta', value: '5' }); + expect(options[2]).toMatchObject({ value: '__skip__' }); + + return Promise.resolve('5'); + }); + + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'link', + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]); + + expect(consola.start).toHaveBeenCalledWith('Fetching channels...'); + expect(consola.success).toHaveBeenCalledWith('Linked channel 5 in .bigcommerce/project.json.'); + expect(config.get('channelId')).toBe(5); + + promptMock.mockRestore(); + }); + + test('skip option leaves channelId unset', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/channels', () => + HttpResponse.json({ data: [catalystChannel] }), + ), + ); + + const promptMock = vi + .spyOn(consola, 'prompt') + .mockResolvedValueOnce(projectUuid1) + .mockResolvedValueOnce('__skip__'); + + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'link', + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]); + + expect(consola.info).toHaveBeenCalledWith(expect.stringContaining('Skipped channel selection')); + expect(config.get('channelId')).toBeUndefined(); + + promptMock.mockRestore(); + }); + + test('warns and continues when channels API returns 403 (missing scope)', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/channels', () => + HttpResponse.json({}, { status: 403 }), + ), + ); + + const promptMock = vi.spyOn(consola, 'prompt').mockResolvedValueOnce(projectUuid1); + + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'link', + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]); + + expect(consola.warn).toHaveBeenCalledWith(expect.stringContaining('Could not fetch channels')); + expect(consola.info).toHaveBeenCalledWith(expect.stringContaining('--channel-id')); + expect(config.get('channelId')).toBeUndefined(); + expect(exitMock).toHaveBeenCalledWith(0); + + promptMock.mockRestore(); + }); + + test('skips selection when no catalyst channels are returned', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/channels', () => + HttpResponse.json({ data: [stencilChannel, posChannel] }), + ), + ); + + const promptMock = vi.spyOn(consola, 'prompt').mockResolvedValueOnce(projectUuid1); + + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'link', + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]); + + expect(consola.info).toHaveBeenCalledWith( + expect.stringContaining('No Catalyst channels found'), + ); + expect(config.get('channelId')).toBeUndefined(); + + promptMock.mockRestore(); + }); +}); + +describe('channel picker (project create)', () => { + test('writes selected channelId to project.json after create', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/channels', () => + HttpResponse.json({ + data: [ + { + id: 7, + name: 'My Catalyst Storefront', + platform: 'catalyst', + type: 'storefront', + status: 'active', + }, + ], + }), + ), + ); + + const promptMock = vi + .spyOn(consola, 'prompt') + .mockResolvedValueOnce('My New Project') + .mockResolvedValueOnce('7'); + + await program.parseAsync([ + 'node', + 'catalyst', + 'project', + 'create', + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]); + + expect(consola.success).toHaveBeenCalledWith('Linked channel 7 in .bigcommerce/project.json.'); + expect(config.get('channelId')).toBe(7); + + promptMock.mockRestore(); + }); +}); diff --git a/packages/catalyst/src/cli/commands/project.ts b/packages/catalyst/src/cli/commands/project.ts index bec2ecb94e..202fca92d7 100644 --- a/packages/catalyst/src/cli/commands/project.ts +++ b/packages/catalyst/src/cli/commands/project.ts @@ -1,11 +1,84 @@ import { Command, Option } from 'commander'; +import Conf from 'conf'; +import { fetchChannels } from '../lib/channels'; import { consola } from '../lib/logger'; import { createProject, fetchProjects } from '../lib/project'; -import { getProjectConfig } from '../lib/project-config'; +import { getProjectConfig, ProjectConfigSchema } from '../lib/project-config'; import { resolveCredentials } from '../lib/resolve-credentials'; import { getTelemetry } from '../lib/telemetry'; +const SKIP_CHANNEL_VALUE = '__skip__'; + +async function promptAndSaveChannel( + storeHash: string, + accessToken: string, + apiHost: string, + config: Conf, +): Promise { + consola.start('Fetching channels...'); + + let channels; + + try { + channels = await fetchChannels(storeHash, accessToken, apiHost); + } catch (error) { + consola.warn( + `Could not fetch channels: ${error instanceof Error ? error.message : String(error)}`, + ); + consola.info( + 'You can pass --channel-id to `catalyst deploy` (or set CATALYST_CHANNEL_ID) to update the channel site URL after deploys.', + ); + + return; + } + + const catalystChannels = channels.filter((c) => c.platform === 'catalyst'); + + if (catalystChannels.length === 0) { + consola.info( + 'No Catalyst channels found. You can pass --channel-id to `catalyst deploy` (or set CATALYST_CHANNEL_ID) once a Catalyst channel is available.', + ); + + return; + } + + const channelOptions = [ + ...catalystChannels.map((channel) => ({ + label: channel.name, + value: String(channel.id), + hint: `id: ${channel.id} • ${channel.platform} • ${channel.status}`, + })), + { + label: "Skip — don't link a channel", + value: SKIP_CHANNEL_VALUE, + hint: 'You can set --channel-id or CATALYST_CHANNEL_ID at deploy time instead.', + }, + ]; + + const selected = await consola.prompt( + 'Select the BigCommerce channel to update with the deployment URL after each `catalyst deploy`.', + { + type: 'select', + options: channelOptions, + cancel: 'reject', + }, + ); + + if (selected === SKIP_CHANNEL_VALUE) { + consola.info( + 'Skipped channel selection. Pass --channel-id to `catalyst deploy` or set CATALYST_CHANNEL_ID to enable the auto-update later.', + ); + + return; + } + + const channelId = Number(selected); + + config.set('channelId', channelId); + consola.success(`Linked channel ${channelId} in .bigcommerce/project.json.`); +} + const list = new Command('list') .configureHelp({ showGlobalOptions: true }) .description('List BigCommerce infrastructure projects for your store.') @@ -108,6 +181,8 @@ Example: config.set('accessToken', accessToken); consola.success('Project UUID written to .bigcommerce/project.json.'); + await promptAndSaveChannel(storeHash, accessToken, options.apiHost, config); + process.exit(0); }); @@ -220,6 +295,8 @@ Examples: writeProjectConfig(projectUuid, { storeHash, accessToken }); + await promptAndSaveChannel(storeHash, accessToken, options.apiHost, config); + process.exit(0); }); diff --git a/packages/catalyst/src/cli/lib/auth.ts b/packages/catalyst/src/cli/lib/auth.ts index b59922b438..1aa1baac21 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/channels.spec.ts b/packages/catalyst/src/cli/lib/channels.spec.ts new file mode 100644 index 0000000000..741ddc17b6 --- /dev/null +++ b/packages/catalyst/src/cli/lib/channels.spec.ts @@ -0,0 +1,227 @@ +import { http, HttpResponse } from 'msw'; +import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest'; + +import { server } from '../../../tests/mocks/node'; + +import { fetchChannels, fetchChannelSite, 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('fetchChannels', () => { + test('returns parsed list of channels', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/channels', () => + HttpResponse.json({ + data: [ + { + id: 1, + name: 'Default Storefront', + platform: 'bigcommerce', + type: 'storefront', + status: 'active', + }, + { + id: 2, + name: 'POS', + platform: 'in_person', + type: 'pos', + status: 'active', + }, + ], + }), + ), + ); + + const result = await fetchChannels(storeHash, accessToken, apiHost); + + expect(result).toEqual([ + { + id: 1, + name: 'Default Storefront', + platform: 'bigcommerce', + type: 'storefront', + status: 'active', + }, + { id: 2, name: 'POS', platform: 'in_person', type: 'pos', status: 'active' }, + ]); + }); + + test('throws with re-auth hint on 401', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/channels', () => + HttpResponse.json({}, { status: 401 }), + ), + ); + + await expect(fetchChannels(storeHash, accessToken, apiHost)).rejects.toThrow( + 'Re-run `catalyst auth login`', + ); + }); + + test('throws with re-auth hint on 403', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/channels', () => + HttpResponse.json({}, { status: 403 }), + ), + ); + + await expect(fetchChannels(storeHash, accessToken, apiHost)).rejects.toThrow( + 'Re-run `catalyst auth login`', + ); + }); + + test('throws with status on other errors', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/channels', () => + HttpResponse.json({}, { status: 500 }), + ), + ); + + await expect(fetchChannels(storeHash, accessToken, apiHost)).rejects.toThrow( + 'Failed to fetch channels: 500', + ); + }); +}); + +describe('fetchChannelSite', () => { + test('returns parsed channel site on 200', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () => + HttpResponse.json({ + data: { + id: 42, + url: 'https://example.com', + channel_id: channelId, + }, + }), + ), + ); + + const result = await fetchChannelSite(channelId, storeHash, accessToken, apiHost); + + expect(result).toEqual({ id: 42, url: 'https://example.com', channelId }); + }); + + test('returns null on 404 (no site exists yet)', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () => + HttpResponse.json({}, { status: 404 }), + ), + ); + + const result = await fetchChannelSite(channelId, storeHash, accessToken, apiHost); + + expect(result).toBeNull(); + }); + + test('throws with re-auth hint on 403', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () => + HttpResponse.json({}, { status: 403 }), + ), + ); + + await expect(fetchChannelSite(channelId, storeHash, accessToken, apiHost)).rejects.toThrow( + 'Re-run `catalyst auth login`', + ); + }); + + test('throws with status on other errors', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () => + HttpResponse.json({}, { status: 502 }), + ), + ); + + await expect(fetchChannelSite(channelId, storeHash, accessToken, apiHost)).rejects.toThrow( + 'Failed to fetch channel site: 502', + ); + }); +}); + +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 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 new file mode 100644 index 0000000000..61e27b65b6 --- /dev/null +++ b/packages/catalyst/src/cli/lib/channels.ts @@ -0,0 +1,147 @@ +import { z } from 'zod'; + +import { getTelemetry } from './telemetry'; + +const channelTypeEnum = z.enum(['storefront', 'pos', 'marketplace', 'marketing']); + +const fetchChannelsSchema = z.object({ + data: z.array( + z.object({ + id: z.number(), + name: z.string(), + platform: z.string(), + type: channelTypeEnum, + status: z.string(), + }), + ), +}); + +export interface ChannelListItem { + id: number; + name: string; + platform: string; + type: z.infer; + status: string; +} + +export async function fetchChannels( + storeHash: string, + accessToken: string, + apiHost: string, +): Promise { + const response = await fetch( + `https://${apiHost}/stores/${storeHash}/v3/channels?available=true`, + { + method: 'GET', + headers: { + 'X-Auth-Token': accessToken, + Accept: 'application/json', + 'X-Correlation-Id': getTelemetry().correlationId, + }, + }, + ); + + if (response.status === 401 || response.status === 403) { + throw new Error( + `Failed to fetch channels (${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 fetch channels: ${response.status} ${response.statusText}`); + } + + const res: unknown = await response.json(); + const { data } = fetchChannelsSchema.parse(res); + + return data; +} + +const channelSiteSchema = z.object({ + data: z.object({ + id: z.number(), + url: z.string(), + channel_id: z.number(), + }), +}); + +export interface ChannelSite { + id: number; + url: string; + channelId: number; +} + +export async function fetchChannelSite( + channelId: number, + storeHash: string, + accessToken: string, + apiHost: string, +): Promise { + const response = await fetch( + `https://${apiHost}/stores/${storeHash}/v3/channels/${channelId}/site`, + { + method: 'GET', + headers: { + 'X-Auth-Token': accessToken, + Accept: 'application/json', + 'X-Correlation-Id': getTelemetry().correlationId, + }, + }, + ); + + if (response.status === 404) { + return null; + } + + if (response.status === 401 || response.status === 403) { + throw new Error( + `Failed to fetch 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 fetch 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 }; +} + +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/lib/project-config.ts b/packages/catalyst/src/cli/lib/project-config.ts index 43c32baa85..a701e0cc75 100644 --- a/packages/catalyst/src/cli/lib/project-config.ts +++ b/packages/catalyst/src/cli/lib/project-config.ts @@ -7,6 +7,7 @@ export interface ProjectConfigSchema { framework: 'catalyst'; storeHash?: string; accessToken?: string; + channelId?: number; telemetry: { enabled: boolean; anonymousId: string; @@ -27,6 +28,7 @@ export function getProjectConfig() { }, storeHash: { type: 'string' }, accessToken: { type: 'string' }, + channelId: { type: 'number' }, telemetry: { type: 'object', properties: { diff --git a/packages/catalyst/tests/mocks/handlers.ts b/packages/catalyst/tests/mocks/handlers.ts index c16ea054db..7bf3ae7071 100644 --- a/packages/catalyst/tests/mocks/handlers.ts +++ b/packages/catalyst/tests/mocks/handlers.ts @@ -138,4 +138,9 @@ export const handlers = [ }, }), ), + + // Default handler for fetchChannels — returns no channels so the + // channel picker exercises the "no storefront channels found" path. + // Tests that need a populated list should override with `server.use(...)`. + http.get('https://:apiHost/stores/:storeHash/v3/channels', () => HttpResponse.json({ data: [] })), ];