From cce879a6f9b602f557fc415b3479a264fb214323 Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Fri, 1 May 2026 21:00:19 -0500 Subject: [PATCH 1/5] LTRAC-446: feat(cli) - Auto-update BC channel site URL on deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a successful `catalyst deploy`, automatically update the linked BigCommerce channel's site URL to the new deployment URL so merchants no longer have to remember to do it manually in the control panel. The channel ID is selected interactively at `catalyst project link` / `project create` time (with a "skip" option for users who don't want to link a channel) and persisted to `.bigcommerce/project.json`. At deploy time the resolution order is `--channel-id` flag, then `CATALYST_CHANNEL_ID`, then project.json. Pass `--no-update-channel` to opt out of the auto-update. The deploy first GETs the current channel site and skips the PUT when the URL already matches, and any failure during the update is treated as a soft warning so the deploy itself remains successful — the bundle is already live by the time we hit this step. Adds the `store_channel_settings` scope to the device-OAuth flow; existing logged-in users will need to re-run `catalyst auth login` to pick it up. The new endpoint also requires that scope, so the soft-fail path points users at `catalyst auth login` if they hit a 401/403. Refs LTRAC-446 Co-Authored-By: Claude --- packages/catalyst/src/cli/commands/deploy.ts | 71 ++++++++- packages/catalyst/src/cli/commands/project.ts | 79 +++++++++- packages/catalyst/src/cli/lib/auth.ts | 1 + packages/catalyst/src/cli/lib/channels.ts | 147 ++++++++++++++++++ .../catalyst/src/cli/lib/project-config.ts | 2 + 5 files changed, 297 insertions(+), 3 deletions(-) create mode 100644 packages/catalyst/src/cli/lib/channels.ts 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.ts b/packages/catalyst/src/cli/commands/project.ts index bec2ecb94e..47cfb31ad6 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 storefrontChannels = channels.filter((c) => c.type === 'storefront'); + + if (storefrontChannels.length === 0) { + consola.info( + 'No storefront channels found. You can pass --channel-id to `catalyst deploy` (or set CATALYST_CHANNEL_ID) once a channel is available.', + ); + + return; + } + + const channelOptions = [ + ...storefrontChannels.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.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: { From b4a274171e1b8e7d58634f764ebb242bce5bee2e Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Fri, 1 May 2026 21:05:21 -0500 Subject: [PATCH 2/5] LTRAC-446: test(cli) - Cover channel site URL auto-update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add coverage for the post-deploy channel site URL auto-update introduced in cce879a6f: - channels.spec.ts: unit tests for fetchChannels, fetchChannelSite (incl. 404→null), and updateChannelSiteUrl, asserting that 401/403 responses surface the re-auth hint. - project.spec.ts: tests the new channel picker in `project link` and `project create` — a selection writes channelId to project.json, the Skip option leaves it untouched, an empty storefront list is handled, and a 403 from the channels API soft-fails so users can still proceed. - deploy.spec.ts: tests the post-deploy update path — successful update, channelId resolution from project.json, the GET-and-skip-when-equal optimization, --no-update-channel as a hard skip, the no-channel-id warning, and the 401 soft-fail with re-auth hint. - tests/mocks/handlers.ts: default `/v3/channels` handler returns an empty list so unrelated tests no longer hit the unhandled-request warning while still exercising the soft-fail path. Refs LTRAC-446 Co-Authored-By: Claude --- .../catalyst/src/cli/commands/deploy.spec.ts | 146 +++++++++++ .../catalyst/src/cli/commands/project.spec.ts | 192 +++++++++++++++ .../catalyst/src/cli/lib/channels.spec.ts | 227 ++++++++++++++++++ packages/catalyst/tests/mocks/handlers.ts | 5 + 4 files changed, 570 insertions(+) create mode 100644 packages/catalyst/src/cli/lib/channels.spec.ts 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/project.spec.ts b/packages/catalyst/src/cli/commands/project.spec.ts index 79a4933a71..4b27bfc795 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,194 @@ describe('project link', () => { expect(exitMock).toHaveBeenCalledWith(1); }); }); + +describe('channel picker (project link)', () => { + const storefrontChannel = { + id: 1, + name: 'Default Storefront', + platform: 'bigcommerce', + type: 'storefront', + status: 'active', + }; + const otherStorefront = { + id: 5, + name: 'Headless Storefront', + platform: 'custom', + 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: [storefrontChannel, otherStorefront, 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 storefronts + Skip option; POS channel is filtered out + expect(options).toHaveLength(3); + expect(options[0]).toMatchObject({ label: 'Default Storefront', value: '1' }); + expect(options[1]).toMatchObject({ label: 'Headless Storefront', 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: [storefrontChannel] }), + ), + ); + + 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 storefront channels are returned', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/channels', () => + HttpResponse.json({ data: [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 storefront 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: 'bigcommerce', + 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/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/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: [] })), ]; From c3a45366f6c1c84bc9b24eacbaeee57fbb94f5bb Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Fri, 1 May 2026 21:22:53 -0500 Subject: [PATCH 3/5] LTRAC-446: ref(cli) - Filter channel picker to platform=catalyst The channel picker added in cce879a6f filtered to type=storefront, which still surfaces non-Catalyst storefronts (Stencil, headless, etc.) that the user can't meaningfully target with `catalyst deploy`. Narrow the filter to platform=catalyst so the picker only shows channels actually backed by Catalyst. Refs LTRAC-446 Co-Authored-By: Claude --- .../catalyst/src/cli/commands/project.spec.ts | 39 ++++++++++++------- packages/catalyst/src/cli/commands/project.ts | 8 ++-- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/packages/catalyst/src/cli/commands/project.spec.ts b/packages/catalyst/src/cli/commands/project.spec.ts index 4b27bfc795..24e035805c 100644 --- a/packages/catalyst/src/cli/commands/project.spec.ts +++ b/packages/catalyst/src/cli/commands/project.spec.ts @@ -556,17 +556,24 @@ describe('project link', () => { }); describe('channel picker (project link)', () => { - const storefrontChannel = { + const catalystChannel = { id: 1, - name: 'Default Storefront', - platform: 'bigcommerce', + name: 'My Catalyst Storefront', + platform: 'catalyst', type: 'storefront', status: 'active', }; - const otherStorefront = { + const otherCatalystChannel = { id: 5, - name: 'Headless Storefront', - platform: 'custom', + name: 'Catalyst Beta', + platform: 'catalyst', + type: 'storefront', + status: 'active', + }; + const stencilChannel = { + id: 2, + name: 'Default Stencil', + platform: 'bigcommerce', type: 'storefront', status: 'active', }; @@ -581,7 +588,9 @@ describe('channel picker (project link)', () => { test('writes selected channelId to project.json', async () => { server.use( http.get('https://:apiHost/stores/:storeHash/v3/channels', () => - HttpResponse.json({ data: [storefrontChannel, otherStorefront, posChannel] }), + HttpResponse.json({ + data: [catalystChannel, otherCatalystChannel, stencilChannel, posChannel], + }), ), ); @@ -592,10 +601,10 @@ describe('channel picker (project link)', () => { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const options = (opts as { options: Array<{ label: string; value: string }> }).options; - // Two storefronts + Skip option; POS channel is filtered out + // Two catalyst channels + Skip option; non-catalyst channels are filtered out expect(options).toHaveLength(3); - expect(options[0]).toMatchObject({ label: 'Default Storefront', value: '1' }); - expect(options[1]).toMatchObject({ label: 'Headless Storefront', value: '5' }); + 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'); @@ -622,7 +631,7 @@ describe('channel picker (project link)', () => { test('skip option leaves channelId unset', async () => { server.use( http.get('https://:apiHost/stores/:storeHash/v3/channels', () => - HttpResponse.json({ data: [storefrontChannel] }), + HttpResponse.json({ data: [catalystChannel] }), ), ); @@ -676,10 +685,10 @@ describe('channel picker (project link)', () => { promptMock.mockRestore(); }); - test('skips selection when no storefront channels are returned', async () => { + test('skips selection when no catalyst channels are returned', async () => { server.use( http.get('https://:apiHost/stores/:storeHash/v3/channels', () => - HttpResponse.json({ data: [posChannel] }), + HttpResponse.json({ data: [stencilChannel, posChannel] }), ), ); @@ -697,7 +706,7 @@ describe('channel picker (project link)', () => { ]); expect(consola.info).toHaveBeenCalledWith( - expect.stringContaining('No storefront channels found'), + expect.stringContaining('No Catalyst channels found'), ); expect(config.get('channelId')).toBeUndefined(); @@ -714,7 +723,7 @@ describe('channel picker (project create)', () => { { id: 7, name: 'My Catalyst Storefront', - platform: 'bigcommerce', + platform: 'catalyst', type: 'storefront', status: 'active', }, diff --git a/packages/catalyst/src/cli/commands/project.ts b/packages/catalyst/src/cli/commands/project.ts index 47cfb31ad6..202fca92d7 100644 --- a/packages/catalyst/src/cli/commands/project.ts +++ b/packages/catalyst/src/cli/commands/project.ts @@ -33,18 +33,18 @@ async function promptAndSaveChannel( return; } - const storefrontChannels = channels.filter((c) => c.type === 'storefront'); + const catalystChannels = channels.filter((c) => c.platform === 'catalyst'); - if (storefrontChannels.length === 0) { + if (catalystChannels.length === 0) { consola.info( - 'No storefront channels found. You can pass --channel-id to `catalyst deploy` (or set CATALYST_CHANNEL_ID) once a channel is available.', + '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 = [ - ...storefrontChannels.map((channel) => ({ + ...catalystChannels.map((channel) => ({ label: channel.name, value: String(channel.id), hint: `id: ${channel.id} • ${channel.platform} • ${channel.status}`, From 65435dc2629b30b4787889435239340d83726ada Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Mon, 4 May 2026 10:59:53 -0500 Subject: [PATCH 4/5] LTRAC-446: test(cli) - Hoist channel-site default handlers to handlers.ts Move the GET (404) and PUT (success) channel-site MSW handlers used by the post-deploy auto-update tests into the shared handlers.ts so each test only has to override the path it actually exercises. Refs LTRAC-446 Co-Authored-By: Claude --- .../catalyst/src/cli/commands/deploy.spec.ts | 14 -------------- packages/catalyst/tests/mocks/handlers.ts | 18 +++++++++++++++++- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/packages/catalyst/src/cli/commands/deploy.spec.ts b/packages/catalyst/src/cli/commands/deploy.spec.ts index 2cc67ffee4..757ea7283c 100644 --- a/packages/catalyst/src/cli/commands/deploy.spec.ts +++ b/packages/catalyst/src/cli/commands/deploy.spec.ts @@ -593,9 +593,6 @@ describe('channel site URL auto-update', () => { 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 }) => { @@ -621,17 +618,6 @@ describe('channel site URL auto-update', () => { 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( diff --git a/packages/catalyst/tests/mocks/handlers.ts b/packages/catalyst/tests/mocks/handlers.ts index 7bf3ae7071..e741ddf969 100644 --- a/packages/catalyst/tests/mocks/handlers.ts +++ b/packages/catalyst/tests/mocks/handlers.ts @@ -140,7 +140,23 @@ export const handlers = [ ), // Default handler for fetchChannels — returns no channels so the - // channel picker exercises the "no storefront channels found" path. + // channel picker exercises the "no Catalyst 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: [] })), + + // Default handler for fetchChannelSite — returns 404 (no site linked + // yet) so the deploy command's auto-update path takes the "PUT a fresh + // URL" branch. Tests that need a different shape should override with + // `server.use(...)`. + http.get('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () => + HttpResponse.json({}, { status: 404 }), + ), + + // 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 }, + }), + ), ]; From fa310a90f747881ecaac74b9fc03f937e2310dac Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Mon, 4 May 2026 15:52:04 -0500 Subject: [PATCH 5/5] LTRAC-446: ref(cli) - Replace channel picker with --update-site-url flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the channel-picker UX added to `project link` / `project create` and the persisted `channelId` in project.json. The auto-update is now driven by a single `--update-site-url ` flag (or `CATALYST_UPDATE_SITE_URL` env) on `catalyst deploy`: when passed, the deployed URL is PUT to that channel's site after the bundle goes live. Also drop the GET-then-skip-when-equal optimization so the command unconditionally PUTs the deployment URL when the flag is supplied — the caller opted in explicitly, so always make the call. The implementation collapses to: keep `updateChannelSiteUrl`, drop `fetchChannels` and `fetchChannelSite`, and keep the soft-fail (warn + re-auth hint) so a 401/403 doesn't sour an otherwise-successful deploy. Refs LTRAC-446 Co-Authored-By: Claude --- .../catalyst/src/cli/commands/deploy.spec.ts | 73 +------ packages/catalyst/src/cli/commands/deploy.ts | 37 +--- .../catalyst/src/cli/commands/project.spec.ts | 201 ------------------ packages/catalyst/src/cli/commands/project.ts | 79 +------ .../catalyst/src/cli/lib/channels.spec.ts | 146 ++----------- packages/catalyst/src/cli/lib/channels.ts | 93 -------- .../catalyst/src/cli/lib/project-config.ts | 2 - packages/catalyst/tests/mocks/handlers.ts | 13 -- 8 files changed, 33 insertions(+), 611 deletions(-) diff --git a/packages/catalyst/src/cli/commands/deploy.spec.ts b/packages/catalyst/src/cli/commands/deploy.spec.ts index 757ea7283c..db0a4dec4a 100644 --- a/packages/catalyst/src/cli/commands/deploy.spec.ts +++ b/packages/catalyst/src/cli/commands/deploy.spec.ts @@ -562,15 +562,9 @@ describe('--prebuilt flag', () => { }); }); -describe('channel site URL auto-update', () => { +describe('--update-site-url', () => { const channelId = 7; - afterEach(() => { - const config = getProjectConfig(); - - config.delete('channelId'); - }); - function deployArgs(extra: string[] = []) { return [ 'node', @@ -589,14 +583,16 @@ describe('channel site URL auto-update', () => { ]; } - test('updates channel site URL after a successful deploy', async () => { + test('PUTs the deployment URL to the given channel', async () => { let putBody: unknown; + let receivedChannelId: string | undefined; server.use( http.put( 'https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', - async ({ request }) => { + async ({ request, params }) => { putBody = await request.json(); + receivedChannelId = String(params.channelId); return HttpResponse.json({ data: { id: 1, url: 'https://example.com', channel_id: channelId }, @@ -605,35 +601,19 @@ describe('channel site URL auto-update', () => { ), ); - await program.parseAsync(deployArgs(['--channel-id', String(channelId)])); + await program.parseAsync(deployArgs(['--update-site-url', String(channelId)])); + expect(receivedChannelId).toBe(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); - - 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 () => { + test('does not call the channel site API when the flag is omitted', 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; @@ -641,54 +621,23 @@ describe('channel site URL auto-update', () => { }), ); - 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(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.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)])); + await program.parseAsync(deployArgs(['--update-site-url', String(channelId)])); expect(consola.warn).toHaveBeenCalledWith( - expect.stringContaining('Failed to update channel site URL automatically'), + 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 f5454cade3..3f4ebd01b6 100644 --- a/packages/catalyst/src/cli/commands/deploy.ts +++ b/packages/catalyst/src/cli/commands/deploy.ts @@ -6,7 +6,7 @@ import { join } from 'node:path'; import yoctoSpinner from 'yocto-spinner'; import { z } from 'zod'; -import { fetchChannelSite, updateChannelSiteUrl } from '../lib/channels'; +import { updateChannelSiteUrl } from '../lib/channels'; import { getDeploymentErrorMessage } from '../lib/deployment-errors'; import { consola } from '../lib/logger'; import { getProjectConfig } from '../lib/project-config'; @@ -412,13 +412,12 @@ Example: ) .addOption( new Option( - '--channel-id ', - 'BigCommerce channel ID to update with the deployment URL. Read from .bigcommerce/project.json when not provided.', + '--update-site-url ', + "BigCommerce channel ID whose site URL should be updated with this deployment's URL. When omitted, no channel is updated.", ) - .env('CATALYST_CHANNEL_ID') + .env('CATALYST_UPDATE_SITE_URL') .argParser((value: string) => Number(value)), ) - .option('--no-update-channel', 'Skip updating the BigCommerce channel site URL after deploy.') .addOption( new Option( '--secret ', @@ -504,17 +503,9 @@ Example: options.apiHost, ); - if (!options.updateChannel) { - return; - } - - const channelId: number | undefined = options.channelId ?? config.get('channelId'); + const channelId: number | undefined = options.updateSiteUrl; if (!channelId) { - consola.warn( - 'Skipping channel site URL update: no channel ID configured. Run `catalyst project link` or pass --channel-id.', - ); - return; } @@ -525,23 +516,11 @@ Example: } 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}.`); - } + 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)}`, + `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 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 24e035805c..79a4933a71 100644 --- a/packages/catalyst/src/cli/commands/project.spec.ts +++ b/packages/catalyst/src/cli/commands/project.spec.ts @@ -65,7 +65,6 @@ afterEach(() => { config.delete('storeHash'); config.delete('accessToken'); config.delete('projectUuid'); - config.delete('channelId'); }); afterAll(async () => { @@ -554,203 +553,3 @@ 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 202fca92d7..bec2ecb94e 100644 --- a/packages/catalyst/src/cli/commands/project.ts +++ b/packages/catalyst/src/cli/commands/project.ts @@ -1,84 +1,11 @@ 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, ProjectConfigSchema } from '../lib/project-config'; +import { getProjectConfig } 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.') @@ -181,8 +108,6 @@ Example: config.set('accessToken', accessToken); consola.success('Project UUID written to .bigcommerce/project.json.'); - await promptAndSaveChannel(storeHash, accessToken, options.apiHost, config); - process.exit(0); }); @@ -295,8 +220,6 @@ Examples: writeProjectConfig(projectUuid, { storeHash, accessToken }); - await promptAndSaveChannel(storeHash, accessToken, options.apiHost, config); - process.exit(0); }); diff --git a/packages/catalyst/src/cli/lib/channels.spec.ts b/packages/catalyst/src/cli/lib/channels.spec.ts index 741ddc17b6..474b330731 100644 --- a/packages/catalyst/src/cli/lib/channels.spec.ts +++ b/packages/catalyst/src/cli/lib/channels.spec.ts @@ -3,7 +3,7 @@ import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest'; import { server } from '../../../tests/mocks/node'; -import { fetchChannels, fetchChannelSite, updateChannelSiteUrl } from './channels'; +import { updateChannelSiteUrl } from './channels'; const storeHash = 'test-store'; const accessToken = 'test-token'; @@ -36,138 +36,6 @@ 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; @@ -213,6 +81,18 @@ describe('updateChannelSiteUrl', () => { ).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', () => diff --git a/packages/catalyst/src/cli/lib/channels.ts b/packages/catalyst/src/cli/lib/channels.ts index 61e27b65b6..cf5872bbd1 100644 --- a/packages/catalyst/src/cli/lib/channels.ts +++ b/packages/catalyst/src/cli/lib/channels.ts @@ -2,61 +2,6 @@ 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(), @@ -71,44 +16,6 @@ export interface ChannelSite { 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, diff --git a/packages/catalyst/src/cli/lib/project-config.ts b/packages/catalyst/src/cli/lib/project-config.ts index a701e0cc75..43c32baa85 100644 --- a/packages/catalyst/src/cli/lib/project-config.ts +++ b/packages/catalyst/src/cli/lib/project-config.ts @@ -7,7 +7,6 @@ export interface ProjectConfigSchema { framework: 'catalyst'; storeHash?: string; accessToken?: string; - channelId?: number; telemetry: { enabled: boolean; anonymousId: string; @@ -28,7 +27,6 @@ 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 e741ddf969..65fd8d81c3 100644 --- a/packages/catalyst/tests/mocks/handlers.ts +++ b/packages/catalyst/tests/mocks/handlers.ts @@ -139,19 +139,6 @@ export const handlers = [ }), ), - // Default handler for fetchChannels — returns no channels so the - // channel picker exercises the "no Catalyst 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: [] })), - - // Default handler for fetchChannelSite — returns 404 (no site linked - // yet) so the deploy command's auto-update path takes the "PUT a fresh - // URL" branch. Tests that need a different shape should override with - // `server.use(...)`. - http.get('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () => - HttpResponse.json({}, { status: 404 }), - ), - // 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', () =>