From e49f807cc853e3945eadf2ec2b12630130b4e14e Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Wed, 13 May 2026 11:33:28 -0500 Subject: [PATCH 1/5] LTRAC-446: feat(cli) - Auto-update BC channel site URL on deploy Add `--update-site-url ` (env: CATALYST_UPDATE_SITE_URL) to `catalyst deploy`. After a successful deployment the CLI PUTs the resulting deployment URL to the BC channel site endpoint (v3/channels/:channelId/site). Failures are soft: the deploy itself still succeeds and the user is told to update manually or re-run `catalyst auth login` to refresh the access token with the new `store_channel_settings` scope (added to DEVICE_OAUTH_SCOPES). Rebased onto the current alpha branch; the 5 original PR commits were squashed into one because origin/alpha was force-rewritten after the PR opened (every shared commit had a new SHA), making a per-commit replay infeasible. Refs LTRAC-446 Co-Authored-By: Claude --- .../catalyst/src/cli/commands/deploy.spec.ts | 81 +++++++++++++ packages/catalyst/src/cli/commands/deploy.ts | 52 ++++++++- packages/catalyst/src/cli/lib/auth.ts | 1 + .../catalyst/src/cli/lib/channels.spec.ts | 107 ++++++++++++++++++ packages/catalyst/src/cli/lib/channels.ts | 51 +++++++++ packages/catalyst/tests/mocks/handlers.ts | 8 ++ 6 files changed, 295 insertions(+), 5 deletions(-) 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 1a349660e..bb9011402 100644 --- a/packages/catalyst/src/cli/commands/deploy.spec.ts +++ b/packages/catalyst/src/cli/commands/deploy.spec.ts @@ -817,3 +817,84 @@ describe('transformation guard', () => { expect(installDependencies).not.toHaveBeenCalled(); }); }); + +describe('--update-site-url', () => { + const channelId = 7; + + function deployArgs(extra: string[] = []) { + return [ + 'node', + 'catalyst', + 'deploy', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--api-host', + apiHost, + '--project-uuid', + projectUuid, + '--prebuilt', + ...extra, + ]; + } + + 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, params }) => { + putBody = await request.json(); + receivedChannelId = String(params.channelId); + + return HttpResponse.json({ + data: { id: 1, url: 'https://example.com', channel_id: 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('does not call the channel site API when the flag is omitted', async () => { + let putCalled = false; + + server.use( + http.put('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () => { + putCalled = true; + + return HttpResponse.json({}, { status: 200 }); + }), + ); + + await program.parseAsync(deployArgs()); + + expect(putCalled).toBe(false); + expect(consola.success).not.toHaveBeenCalledWith(expect.stringContaining('Updated channel')); + }); + + test('soft-fails with a warning when the update API returns an error', async () => { + server.use( + http.put('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () => + HttpResponse.json({}, { status: 401 }), + ), + ); + + await program.parseAsync(deployArgs(['--update-site-url', String(channelId)])); + + expect(consola.warn).toHaveBeenCalledWith( + expect.stringContaining('Failed to update channel site URL'), + ); + expect(consola.info).toHaveBeenCalledWith(expect.stringContaining('catalyst auth login')); + }); +}); diff --git a/packages/catalyst/src/cli/commands/deploy.ts b/packages/catalyst/src/cli/commands/deploy.ts index bab1224f8..1d4111b96 100644 --- a/packages/catalyst/src/cli/commands/deploy.ts +++ b/packages/catalyst/src/cli/commands/deploy.ts @@ -6,6 +6,7 @@ import { dirname, join } from 'node:path'; import yoctoSpinner from 'yocto-spinner'; import { z } from 'zod'; +import { updateChannelSiteUrl } from '../lib/channels'; import { NoLinkedProjectError, selectOrCreateInfrastructureProject, @@ -268,7 +269,7 @@ export const getDeploymentStatus = async ( storeHash: string, accessToken: string, apiHost: string, -) => { +): Promise => { consola.info('Fetching deployment status...'); const spinner = yoctoSpinner().start('Fetching...'); @@ -346,10 +347,14 @@ export const getDeploymentStatus = async ( spinner.success('Deployment completed successfully.'); if (deploymentHostname) { - consola.success( - `View your deployment at: ${colorize('blue', `https://${deploymentHostname}`)}`, - ); + const url = `https://${deploymentHostname}`; + + consola.success(`View your deployment at: ${colorize('blue', url)}`); + + return url; } + + return undefined; }; export const fetchProject = async ( @@ -394,6 +399,14 @@ Example: .addOption(accessTokenOption()) .addOption(apiHostOption()) .addOption(projectUuidOption()) + .addOption( + new Option( + '--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_UPDATE_SITE_URL') + .argParser((value: string) => Number(value)), + ) .addOption( new Option( '--secret ', @@ -545,5 +558,34 @@ Example: environmentVariables, ); - await getDeploymentStatus(deploymentUuid, storeHash, accessToken, options.apiHost); + const deploymentUrl = await getDeploymentStatus( + deploymentUuid, + storeHash, + accessToken, + options.apiHost, + ); + + const channelId: number | undefined = options.updateSiteUrl; + + if (!channelId) { + return; + } + + if (!deploymentUrl) { + consola.warn('Skipping channel site URL update: deployment did not return a URL.'); + + return; + } + + try { + 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: ${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/lib/auth.ts b/packages/catalyst/src/cli/lib/auth.ts index b59922b43..1aa1baac2 100644 --- a/packages/catalyst/src/cli/lib/auth.ts +++ b/packages/catalyst/src/cli/lib/auth.ts @@ -6,6 +6,7 @@ export const DEVICE_OAUTH_SCOPES = [ 'store_infrastructure_deployments_manage', 'store_infrastructure_logs_read_only', 'store_infrastructure_projects_manage', + 'store_channel_settings', ].join(' '); export const DEFAULT_LOGIN_URL = 'https://login.bigcommerce.com'; diff --git a/packages/catalyst/src/cli/lib/channels.spec.ts b/packages/catalyst/src/cli/lib/channels.spec.ts new file mode 100644 index 000000000..474b33073 --- /dev/null +++ b/packages/catalyst/src/cli/lib/channels.spec.ts @@ -0,0 +1,107 @@ +import { http, HttpResponse } from 'msw'; +import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest'; + +import { server } from '../../../tests/mocks/node'; + +import { updateChannelSiteUrl } from './channels'; + +const storeHash = 'test-store'; +const accessToken = 'test-token'; +const apiHost = 'api.bigcommerce.com'; +const channelId = 1; + +beforeAll(() => { + vi.mock('./telemetry', () => { + const instance = { + identify: vi.fn(), + isEnabled: vi.fn(() => true), + track: vi.fn(), + correlationId: 'test-session-uuid', + commandName: 'unknown', + durationMs: vi.fn().mockReturnValue(0), + analytics: { + closeAndFlush: vi.fn().mockResolvedValue(undefined), + }, + }; + + return { + Telemetry: vi.fn().mockImplementation(() => instance), + getTelemetry: vi.fn(() => instance), + resetTelemetry: vi.fn(), + }; + }); +}); + +afterAll(() => { + vi.restoreAllMocks(); +}); + +describe('updateChannelSiteUrl', () => { + test('PUTs the URL and returns parsed channel site', async () => { + let receivedBody: unknown; + + server.use( + http.put( + 'https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', + async ({ request }) => { + receivedBody = await request.json(); + + return HttpResponse.json({ + data: { + id: 42, + url: 'https://new.example.com', + channel_id: channelId, + }, + }); + }, + ), + ); + + const result = await updateChannelSiteUrl( + channelId, + 'https://new.example.com', + storeHash, + accessToken, + apiHost, + ); + + expect(receivedBody).toEqual({ url: 'https://new.example.com' }); + expect(result).toEqual({ id: 42, url: 'https://new.example.com', channelId }); + }); + + test('throws with re-auth hint on 401', async () => { + server.use( + http.put('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () => + HttpResponse.json({}, { status: 401 }), + ), + ); + + await expect( + updateChannelSiteUrl(channelId, 'https://x.example', storeHash, accessToken, apiHost), + ).rejects.toThrow('Re-run `catalyst auth login`'); + }); + + test('throws with re-auth hint on 403', async () => { + server.use( + http.put('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () => + HttpResponse.json({}, { status: 403 }), + ), + ); + + await expect( + updateChannelSiteUrl(channelId, 'https://x.example', storeHash, accessToken, apiHost), + ).rejects.toThrow('Re-run `catalyst auth login`'); + }); + + test('throws with status on other errors', async () => { + server.use( + http.put('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () => + HttpResponse.json({}, { status: 500 }), + ), + ); + + await expect( + updateChannelSiteUrl(channelId, 'https://x.example', storeHash, accessToken, apiHost), + ).rejects.toThrow('Failed to update channel site: 500'); + }); +}); diff --git a/packages/catalyst/src/cli/lib/channels.ts b/packages/catalyst/src/cli/lib/channels.ts index 81e3cd017..91e632247 100644 --- a/packages/catalyst/src/cli/lib/channels.ts +++ b/packages/catalyst/src/cli/lib/channels.ts @@ -58,6 +58,14 @@ const eligibilityResponseSchema = z.object({ }), }); +const channelSiteSchema = z.object({ + data: z.object({ + id: z.number(), + url: z.string(), + channel_id: z.number(), + }), +}); + export interface ChannelInit { storefrontToken: string; envVars: Record; @@ -173,3 +181,46 @@ export async function fetchAvailableChannels( return channelsResponseSchema.parse(await response.json()).data; } + +export interface ChannelSite { + id: number; + url: string; + channelId: number; +} + +export async function updateChannelSiteUrl( + channelId: number, + siteUrl: string, + storeHash: string, + accessToken: string, + apiHost: string, +): Promise { + const response = await fetch( + `https://${apiHost}/stores/${storeHash}/v3/channels/${channelId}/site`, + { + method: 'PUT', + headers: { + 'X-Auth-Token': accessToken, + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-Correlation-Id': getTelemetry().correlationId, + }, + body: JSON.stringify({ url: siteUrl }), + }, + ); + + if (response.status === 401 || response.status === 403) { + throw new Error( + `Failed to update channel site (${response.status}). Re-run \`catalyst auth login\` to refresh your access token with the store_channel_settings scope.`, + ); + } + + if (!response.ok) { + throw new Error(`Failed to update channel site: ${response.status} ${response.statusText}`); + } + + const res: unknown = await response.json(); + const { data } = channelSiteSchema.parse(res); + + return { id: data.id, url: data.url, channelId: data.channel_id }; +} diff --git a/packages/catalyst/tests/mocks/handlers.ts b/packages/catalyst/tests/mocks/handlers.ts index d2d685242..08e3fa04b 100644 --- a/packages/catalyst/tests/mocks/handlers.ts +++ b/packages/catalyst/tests/mocks/handlers.ts @@ -155,4 +155,12 @@ export const handlers = [ 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid', () => new HttpResponse(null, { status: 204 }), ), + + // 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 37b0dc0818a97c21e1b9b20b01b946ca29aea6e9 Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Fri, 15 May 2026 11:27:54 -0500 Subject: [PATCH 2/5] LTRAC-446: feat(cli) - Add channel update-site-url command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new `catalyst channel update-site-url` subcommand that PUTs a channel's site URL to one of the linked project's deployment hostnames. The flow is interactive by default — pick channel, pick hostname — and accepts `--channel-id` / `--hostname` for scripted use. When no project is linked or passed, it falls back to `selectOrCreateInfrastructureProject` (same picker the deploy command uses) so the user can resolve it inline. Replace the previous `--update-site-url ` value-bearing option on `catalyst deploy` with a boolean flag that triggers the same shared flow after a successful deploy, defaulting the hostname prompt to the freshly-deployed hostname returned by the deployment SSE. The shared `runChannelSiteUrlFlow` helper lives in lib/channel-site-flow.ts so both commands hit identical code paths; the standalone command hard-fails on errors while the deploy wrapper soft-fails (deploy already succeeded, bundle is live). Refs LTRAC-446 Co-Authored-By: Claude --- packages/catalyst/src/cli/commands/channel.ts | 82 +++++++++++ packages/catalyst/src/cli/commands/deploy.ts | 47 +++--- .../catalyst/src/cli/lib/channel-site-flow.ts | 135 ++++++++++++++++++ packages/catalyst/src/cli/program.ts | 2 + 4 files changed, 239 insertions(+), 27 deletions(-) create mode 100644 packages/catalyst/src/cli/commands/channel.ts create mode 100644 packages/catalyst/src/cli/lib/channel-site-flow.ts diff --git a/packages/catalyst/src/cli/commands/channel.ts b/packages/catalyst/src/cli/commands/channel.ts new file mode 100644 index 000000000..7d523befd --- /dev/null +++ b/packages/catalyst/src/cli/commands/channel.ts @@ -0,0 +1,82 @@ +import { Command, Option } from 'commander'; + +import { runChannelSiteUrlFlow } from '../lib/channel-site-flow'; +import { NoLinkedProjectError } from '../lib/commerce-hosting'; +import { consola } from '../lib/logger'; +import { getProjectConfig } from '../lib/project-config'; +import { resolveCredentials } from '../lib/resolve-credentials'; +import { + accessTokenOption, + apiHostOption, + projectUuidOption, + storeHashOption, +} from '../lib/shared-options'; +import { getTelemetry } from '../lib/telemetry'; + +const updateSiteUrl = new Command('update-site-url') + .configureHelp({ showGlobalOptions: true }) + .description( + "Update a BigCommerce channel's site URL to point at one of your project's deployment hostnames.", + ) + .addHelpText( + 'after', + ` +Examples: + # Pick a channel and hostname interactively + $ catalyst channel update-site-url + + # Skip both prompts + $ catalyst channel update-site-url --channel-id 123 --hostname my-storefront.example.com`, + ) + .addOption(storeHashOption()) + .addOption(accessTokenOption()) + .addOption(apiHostOption()) + .addOption(projectUuidOption()) + .addOption( + new Option( + '--channel-id ', + 'Skip the channel prompt and target this channel directly.', + ).argParser((value: string) => Number(value)), + ) + .addOption( + new Option( + '--hostname ', + "Skip the hostname prompt and use this hostname directly. Must be one of the project's deployment_hostnames.", + ), + ) + .action(async (options) => { + const config = getProjectConfig(); + const { storeHash, accessToken } = resolveCredentials(options, config); + + await getTelemetry().identify(storeHash); + + try { + await runChannelSiteUrlFlow({ + storeHash, + accessToken, + apiHost: options.apiHost, + projectUuid: options.projectUuid ?? config.get('projectUuid'), + channelId: options.channelId, + hostname: options.hostname, + }); + } catch (error) { + if (error instanceof NoLinkedProjectError) { + consola.info( + "When you're ready to create a project, run `catalyst project create` or re-run `catalyst channel update-site-url`.", + ); + process.exit(0); + + // Unreachable in production; prevents continuation when process.exit is mocked in tests. + return; + } + + throw error; + } + + process.exit(0); + }); + +export const channel = new Command('channel') + .configureHelp({ showGlobalOptions: true }) + .description('Manage BigCommerce channels.') + .addCommand(updateSiteUrl); diff --git a/packages/catalyst/src/cli/commands/deploy.ts b/packages/catalyst/src/cli/commands/deploy.ts index 1d4111b96..29c4c0609 100644 --- a/packages/catalyst/src/cli/commands/deploy.ts +++ b/packages/catalyst/src/cli/commands/deploy.ts @@ -6,7 +6,7 @@ import { dirname, join } from 'node:path'; import yoctoSpinner from 'yocto-spinner'; import { z } from 'zod'; -import { updateChannelSiteUrl } from '../lib/channels'; +import { runChannelSiteUrlFlow } from '../lib/channel-site-flow'; import { NoLinkedProjectError, selectOrCreateInfrastructureProject, @@ -347,14 +347,12 @@ export const getDeploymentStatus = async ( spinner.success('Deployment completed successfully.'); if (deploymentHostname) { - const url = `https://${deploymentHostname}`; - - consola.success(`View your deployment at: ${colorize('blue', url)}`); - - return url; + consola.success( + `View your deployment at: ${colorize('blue', `https://${deploymentHostname}`)}`, + ); } - return undefined; + return deploymentHostname; }; export const fetchProject = async ( @@ -399,13 +397,9 @@ Example: .addOption(accessTokenOption()) .addOption(apiHostOption()) .addOption(projectUuidOption()) - .addOption( - new Option( - '--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_UPDATE_SITE_URL') - .argParser((value: string) => Number(value)), + .option( + '--update-site-url', + "After a successful deploy, prompt to update a channel's site URL to the new hostname.", ) .addOption( new Option( @@ -558,34 +552,33 @@ Example: environmentVariables, ); - const deploymentUrl = await getDeploymentStatus( + const deploymentHostname = await getDeploymentStatus( deploymentUuid, storeHash, accessToken, options.apiHost, ); - const channelId: number | undefined = options.updateSiteUrl; - - if (!channelId) { - return; - } - - if (!deploymentUrl) { - consola.warn('Skipping channel site URL update: deployment did not return a URL.'); - + if (!options.updateSiteUrl) { return; } try { - await updateChannelSiteUrl(channelId, deploymentUrl, storeHash, accessToken, options.apiHost); - consola.success(`Updated channel ${channelId} site URL to ${deploymentUrl}.`); + await runChannelSiteUrlFlow({ + storeHash, + accessToken, + apiHost: options.apiHost, + projectUuid, + preferHostname: deploymentHostname, + }); } catch (error) { + // Soft-fail: the deploy already succeeded and the bundle is live. A + // non-zero exit here would be misleading. consola.warn( `Failed to update channel site URL: ${error instanceof Error ? error.message : String(error)}`, ); consola.info( - 'Update it manually in the control panel, or re-run after `catalyst auth login` if the token is missing the store_channel_settings scope.', + 'Update it manually in the control panel, or re-run `catalyst auth login` if the token is missing the store_channel_settings scope.', ); } }); diff --git a/packages/catalyst/src/cli/lib/channel-site-flow.ts b/packages/catalyst/src/cli/lib/channel-site-flow.ts new file mode 100644 index 000000000..c99304c47 --- /dev/null +++ b/packages/catalyst/src/cli/lib/channel-site-flow.ts @@ -0,0 +1,135 @@ +import { type Channel, fetchAvailableChannels, updateChannelSiteUrl } from './channels'; +import { selectOrCreateInfrastructureProject } from './commerce-hosting'; +import { consola } from './logger'; +import { fetchProjects, type ProjectListItem } from './project'; + +export interface ChannelSiteFlowOptions { + storeHash: string; + accessToken: string; + apiHost: string; + // Linked project UUID (from --project-uuid or .bigcommerce/project.json). When + // present and resolvable, skips the project picker; otherwise falls back to + // `selectOrCreateInfrastructureProject`, which may throw NoLinkedProjectError + // (caller decides how to surface it). + projectUuid?: string; + // Non-interactive overrides. When supplied, the corresponding prompt is + // skipped. + channelId?: number; + hostname?: string; + // When set, this hostname is pre-selected in the hostname prompt. Used by + // `catalyst deploy --update-site-url` to default to the freshly-deployed + // hostname. + preferHostname?: string; +} + +async function resolveProject(options: ChannelSiteFlowOptions): Promise { + const api = { + storeHash: options.storeHash, + accessToken: options.accessToken, + apiHost: options.apiHost, + }; + + if (options.projectUuid) { + const projects = await fetchProjects(api.storeHash, api.accessToken, api.apiHost); + const matched = projects.find((p) => p.uuid === options.projectUuid); + + if (matched) return matched; + + consola.warn( + `Project ${options.projectUuid} not found on this store. Pick another to continue.`, + ); + } + + return selectOrCreateInfrastructureProject(api, options.projectUuid); +} + +async function resolveChannel( + options: ChannelSiteFlowOptions, +): Promise<{ id: number; name?: string }> { + if (options.channelId !== undefined) { + return { id: options.channelId }; + } + + consola.start('Fetching channels...'); + + const channels = await fetchAvailableChannels( + options.storeHash, + options.accessToken, + options.apiHost, + ); + + consola.success('Channels fetched.'); + + if (channels.length === 0) { + throw new Error( + 'No available storefront channels found. Create one in the BigCommerce control panel and try again.', + ); + } + + const selectedId = await consola.prompt('Select the channel to update.', { + type: 'select', + options: channels.map((c: Channel) => ({ + label: c.name, + value: String(c.id), + hint: `${c.platform} • id: ${c.id}`, + })), + cancel: 'reject', + }); + + const id = Number(selectedId); + const matched = channels.find((c) => c.id === id); + + return { id, name: matched?.name }; +} + +async function resolveHostname( + project: ProjectListItem, + options: ChannelSiteFlowOptions, +): Promise { + if (options.hostname) { + return options.hostname; + } + + if (project.deployment_hostnames.length === 0) { + throw new Error( + `Project "${project.name}" has no deployment hostnames yet. Run \`catalyst deploy\` first to create one.`, + ); + } + + // When the caller knows which hostname they want surfaced first (e.g. the + // freshly-deployed one from `catalyst deploy --update-site-url`), order it to + // the top of the list so it's the default selection. + const ordered = options.preferHostname + ? [ + ...project.deployment_hostnames.filter((h) => h === options.preferHostname), + ...project.deployment_hostnames.filter((h) => h !== options.preferHostname), + ] + : project.deployment_hostnames; + + const selected = await consola.prompt('Select the hostname to point the channel at.', { + type: 'select', + options: ordered.map((h) => ({ label: h, value: h })), + cancel: 'reject', + }); + + return String(selected); +} + +export async function runChannelSiteUrlFlow(options: ChannelSiteFlowOptions): Promise { + const project = await resolveProject(options); + const channel = await resolveChannel(options); + const hostname = await resolveHostname(project, options); + const siteUrl = hostname.startsWith('https://') ? hostname : `https://${hostname}`; + + await updateChannelSiteUrl( + channel.id, + siteUrl, + options.storeHash, + options.accessToken, + options.apiHost, + ); + + const channelLabel = channel.name ? `"${channel.name}" (${channel.id})` : String(channel.id); + + consola.success(`Updated channel ${channelLabel} site URL to ${siteUrl}.`); +} diff --git a/packages/catalyst/src/cli/program.ts b/packages/catalyst/src/cli/program.ts index 52a6bc411..fa7dc0b2e 100644 --- a/packages/catalyst/src/cli/program.ts +++ b/packages/catalyst/src/cli/program.ts @@ -9,6 +9,7 @@ import PACKAGE_INFO from '../../package.json'; import { auth } from './commands/auth'; import { build } from './commands/build'; +import { channel } from './commands/channel'; import { create } from './commands/create'; import { deploy } from './commands/deploy'; import { logs } from './commands/logs'; @@ -86,6 +87,7 @@ program .addCommand(deploy) .addCommand(logs) .addCommand(project) + .addCommand(channel) .addCommand(auth) .addCommand(telemetry) .hook('preAction', telemetryPreHook) From bc1c93b0662ea305bb38de86d45cd55cd7084ee2 Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Fri, 15 May 2026 11:32:34 -0500 Subject: [PATCH 3/5] LTRAC-446: test(cli) - Cover channel update-site-url flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add coverage for the shared interactive flow and the two surfaces it backs: - lib/channel-site-flow.spec.ts: happy path (channel prompt + hostname prompt + PUT), --channel-id and --hostname short-circuits, both overrides skip all prompts, preferHostname appears first in the hostname options, empty channels throws, project with no hostnames throws, fallback to selectOrCreateInfrastructureProject when no projectUuid is provided, NoLinkedProjectError when no projects exist and user declines to create one, and a warn-and-reprompt path when the linked project no longer exists. - commands/channel.spec.ts: end-to-end via `program.parseAsync` — happy path, projectUuid resolved from .bigcommerce/project.json, --channel-id + --hostname skip all prompts, no-projects exit path via consola.info hint, error propagation from the PUT. - commands/deploy.spec.ts: replace the old `--update-site-url ` tests with the new boolean-flag flow: triggers interactive prompts after deploy, places the freshly-deployed hostname first in the hostname prompt, no API call when the flag is omitted, soft-fail with re-auth hint on 401. - tests/mocks/handlers.ts: add a default `GET /v3/channels` handler returning two storefronts so tests don't have to override the channel list shape per case. Refs LTRAC-446 Co-Authored-By: Claude --- .../catalyst/src/cli/commands/channel.spec.ts | 255 ++++++++++++++++ .../catalyst/src/cli/commands/deploy.spec.ts | 71 ++++- .../src/cli/lib/channel-site-flow.spec.ts | 287 ++++++++++++++++++ packages/catalyst/tests/mocks/handlers.ts | 12 + 4 files changed, 614 insertions(+), 11 deletions(-) create mode 100644 packages/catalyst/src/cli/commands/channel.spec.ts create mode 100644 packages/catalyst/src/cli/lib/channel-site-flow.spec.ts diff --git a/packages/catalyst/src/cli/commands/channel.spec.ts b/packages/catalyst/src/cli/commands/channel.spec.ts new file mode 100644 index 000000000..3b5933a95 --- /dev/null +++ b/packages/catalyst/src/cli/commands/channel.spec.ts @@ -0,0 +1,255 @@ +import { Command } from 'commander'; +import Conf from 'conf'; +import { http, HttpResponse } from 'msw'; +import { afterAll, afterEach, beforeAll, describe, expect, MockInstance, test, vi } from 'vitest'; + +import { server } from '../../../tests/mocks/node'; +import { consola } from '../lib/logger'; +import { mkTempDir } from '../lib/mk-temp-dir'; +import { getProjectConfig, ProjectConfigSchema } from '../lib/project-config'; +import { program } from '../program'; + +import { channel } from './channel'; + +let exitMock: MockInstance; + +let tmpDir: string; +let cleanup: () => Promise; +let config: Conf; + +const { mockIdentify } = vi.hoisted(() => ({ + mockIdentify: vi.fn(), +})); + +const linkedProjectUuid = 'a23f5785-fd99-4a94-9fb3-945551623923'; +const storeHash = 'test-store'; +const accessToken = 'test-token'; + +beforeAll(async () => { + consola.mockTypes(() => vi.fn()); + + vi.mock('../lib/telemetry', () => { + const instance = { + identify: mockIdentify, + isEnabled: vi.fn(() => true), + track: vi.fn(), + correlationId: 'test-session-uuid', + commandName: 'unknown', + durationMs: vi.fn().mockReturnValue(0), + analytics: { + closeAndFlush: vi.fn().mockResolvedValue(undefined), + }, + }; + + return { + Telemetry: vi.fn().mockImplementation(() => instance), + getTelemetry: vi.fn(() => instance), + resetTelemetry: vi.fn(), + }; + }); + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + exitMock = vi.spyOn(process, 'exit').mockImplementation(() => null as never); + + [tmpDir, cleanup] = await mkTempDir(); + + vi.spyOn(process, 'cwd').mockReturnValue(tmpDir); + + config = getProjectConfig(); +}); + +afterEach(() => { + vi.clearAllMocks(); + config.delete('storeHash'); + config.delete('accessToken'); + config.delete('projectUuid'); +}); + +afterAll(async () => { + vi.restoreAllMocks(); + exitMock.mockRestore(); + + await cleanup(); +}); + +describe('channel', () => { + test('has the update-site-url subcommand', () => { + expect(channel).toBeInstanceOf(Command); + expect(channel.name()).toBe('channel'); + + const updateSiteUrl = channel.commands.find((cmd) => cmd.name() === 'update-site-url'); + + expect(updateSiteUrl).toBeDefined(); + expect(updateSiteUrl?.description()).toContain('Update a BigCommerce channel'); + }); +}); + +describe('channel update-site-url', () => { + test('happy path: prompts for channel and hostname, then PUTs', async () => { + let putBody: unknown; + let putChannelId: string | undefined; + + server.use( + http.put( + 'https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', + async ({ request, params }) => { + putBody = await request.json(); + putChannelId = String(params.channelId); + + return HttpResponse.json({ + data: { + id: 1, + url: 'https://project-one.catalyst-sandbox.store', + channel_id: 2, + }, + }); + }, + ), + ); + + const promptMock = vi + .spyOn(consola, 'prompt') + .mockResolvedValueOnce('2') + .mockResolvedValueOnce('project-one.catalyst-sandbox.store'); + + await program.parseAsync([ + 'node', + 'catalyst', + 'channel', + 'update-site-url', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--project-uuid', + linkedProjectUuid, + ]); + + expect(promptMock).toHaveBeenCalledTimes(2); + expect(putChannelId).toBe('2'); + expect(putBody).toEqual({ url: 'https://project-one.catalyst-sandbox.store' }); + expect(consola.success).toHaveBeenCalledWith( + expect.stringContaining('Updated channel "Catalyst Storefront" (2) site URL'), + ); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + test('reads project UUID from .bigcommerce/project.json when no flag is passed', async () => { + config.set('projectUuid', linkedProjectUuid); + + const promptMock = vi + .spyOn(consola, 'prompt') + .mockResolvedValueOnce('1') + .mockResolvedValueOnce('vanity.project-one.example.com'); + + await program.parseAsync([ + 'node', + 'catalyst', + 'channel', + 'update-site-url', + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]); + + expect(promptMock).toHaveBeenCalledTimes(2); + expect(consola.success).toHaveBeenCalledWith( + expect.stringContaining('https://vanity.project-one.example.com'), + ); + }); + + test('--channel-id and --hostname skip both prompts', async () => { + let putBody: unknown; + let putChannelId: string | undefined; + + server.use( + http.put( + 'https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', + async ({ request, params }) => { + putBody = await request.json(); + putChannelId = String(params.channelId); + + return HttpResponse.json({ + data: { id: 1, url: 'https://override.example', channel_id: 5 }, + }); + }, + ), + ); + + const promptMock = vi.spyOn(consola, 'prompt'); + + await program.parseAsync([ + 'node', + 'catalyst', + 'channel', + 'update-site-url', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--project-uuid', + linkedProjectUuid, + '--channel-id', + '5', + '--hostname', + 'override.example', + ]); + + expect(promptMock).not.toHaveBeenCalled(); + expect(putChannelId).toBe('5'); + expect(putBody).toEqual({ url: 'https://override.example' }); + }); + + test('exits gracefully when no projects exist and user declines to create one', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => + HttpResponse.json({ data: [] }), + ), + ); + + // First prompt: "Would you like to create one?" — user says no + vi.spyOn(consola, 'prompt').mockResolvedValueOnce(false); + + await program.parseAsync([ + 'node', + 'catalyst', + 'channel', + 'update-site-url', + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]); + + expect(consola.info).toHaveBeenCalledWith(expect.stringContaining('catalyst project create')); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + test('propagates errors from the channel-site PUT', async () => { + server.use( + http.put('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () => + HttpResponse.json({}, { status: 401 }), + ), + ); + + vi.spyOn(consola, 'prompt') + .mockResolvedValueOnce('1') + .mockResolvedValueOnce('project-one.catalyst-sandbox.store'); + + await expect( + program.parseAsync([ + 'node', + 'catalyst', + 'channel', + 'update-site-url', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--project-uuid', + linkedProjectUuid, + ]), + ).rejects.toThrow('Re-run `catalyst auth login`'); + }); +}); diff --git a/packages/catalyst/src/cli/commands/deploy.spec.ts b/packages/catalyst/src/cli/commands/deploy.spec.ts index bb9011402..463809aee 100644 --- a/packages/catalyst/src/cli/commands/deploy.spec.ts +++ b/packages/catalyst/src/cli/commands/deploy.spec.ts @@ -819,8 +819,6 @@ describe('transformation guard', () => { }); describe('--update-site-url', () => { - const channelId = 7; - function deployArgs(extra: string[] = []) { return [ 'node', @@ -839,33 +837,80 @@ describe('--update-site-url', () => { ]; } - test('PUTs the deployment URL to the given channel', async () => { + test('triggers the interactive flow and PUTs the chosen hostname after deploy', async () => { let putBody: unknown; - let receivedChannelId: string | undefined; + let putChannelId: string | undefined; server.use( http.put( 'https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', async ({ request, params }) => { putBody = await request.json(); - receivedChannelId = String(params.channelId); + putChannelId = String(params.channelId); return HttpResponse.json({ - data: { id: 1, url: 'https://example.com', channel_id: channelId }, + data: { + id: 1, + url: 'https://project-one.catalyst-sandbox.store', + channel_id: 2, + }, }); }, ), ); - await program.parseAsync(deployArgs(['--update-site-url', String(channelId)])); + // Override the default consola.prompt stub (always-true in beforeEach) with + // the channel and hostname selections the interactive flow will ask for. + vi.spyOn(consola, 'prompt') + .mockResolvedValueOnce('2') + .mockResolvedValueOnce('project-one.catalyst-sandbox.store'); + + await program.parseAsync(deployArgs(['--update-site-url'])); - expect(receivedChannelId).toBe(String(channelId)); - expect(putBody).toEqual({ url: 'https://example.com' }); + expect(putChannelId).toBe('2'); + expect(putBody).toEqual({ url: 'https://project-one.catalyst-sandbox.store' }); expect(consola.success).toHaveBeenCalledWith( - `Updated channel ${channelId} site URL to https://example.com.`, + expect.stringContaining('Updated channel "Catalyst Storefront" (2) site URL'), ); }); + test('places the freshly-deployed hostname first in the hostname prompt', async () => { + let hostnameOptions: Array<{ label: string; value: string }> | undefined; + + // Project Two has no hostnames by default; use Project One whose handler + // already returns the two seeded hostnames. Inject the freshly-deployed + // hostname into Project One's list so preferHostname has something to match. + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => + HttpResponse.json({ + data: [ + { + uuid: projectUuid, + name: 'Project One', + deployment_hostnames: [ + 'project-one.catalyst-sandbox.store', + 'example.com', // the just-deployed hostname (per the SSE default) + ], + }, + ], + }), + ), + ); + + vi.spyOn(consola, 'prompt') + .mockResolvedValueOnce('2') // channel + .mockImplementationOnce((_message, opts) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + hostnameOptions = (opts as { options: Array<{ label: string; value: string }> }).options; + + return Promise.resolve('example.com'); + }); + + await program.parseAsync(deployArgs(['--update-site-url'])); + + expect(hostnameOptions?.[0]).toMatchObject({ value: 'example.com' }); + }); + test('does not call the channel site API when the flag is omitted', async () => { let putCalled = false; @@ -890,7 +935,11 @@ describe('--update-site-url', () => { ), ); - await program.parseAsync(deployArgs(['--update-site-url', String(channelId)])); + vi.spyOn(consola, 'prompt') + .mockResolvedValueOnce('1') + .mockResolvedValueOnce('project-one.catalyst-sandbox.store'); + + await program.parseAsync(deployArgs(['--update-site-url'])); expect(consola.warn).toHaveBeenCalledWith( expect.stringContaining('Failed to update channel site URL'), diff --git a/packages/catalyst/src/cli/lib/channel-site-flow.spec.ts b/packages/catalyst/src/cli/lib/channel-site-flow.spec.ts new file mode 100644 index 000000000..a42ee480c --- /dev/null +++ b/packages/catalyst/src/cli/lib/channel-site-flow.spec.ts @@ -0,0 +1,287 @@ +import { http, HttpResponse } from 'msw'; +import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { server } from '../../../tests/mocks/node'; + +import { runChannelSiteUrlFlow } from './channel-site-flow'; +import { NoLinkedProjectError } from './commerce-hosting'; +import { consola } from './logger'; + +const storeHash = 'test-store'; +const accessToken = 'test-token'; +const apiHost = 'api.bigcommerce.com'; +const linkedProjectUuid = 'a23f5785-fd99-4a94-9fb3-945551623923'; + +beforeAll(() => { + consola.mockTypes(() => vi.fn()); + + vi.mock('./telemetry', () => { + const instance = { + identify: vi.fn(), + isEnabled: vi.fn(() => true), + track: vi.fn(), + correlationId: 'test-session-uuid', + commandName: 'unknown', + durationMs: vi.fn().mockReturnValue(0), + analytics: { + closeAndFlush: vi.fn().mockResolvedValue(undefined), + }, + }; + + return { + Telemetry: vi.fn().mockImplementation(() => instance), + getTelemetry: vi.fn(() => instance), + resetTelemetry: vi.fn(), + }; + }); +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterAll(() => { + vi.restoreAllMocks(); +}); + +describe('runChannelSiteUrlFlow', () => { + test('happy path: prompts for channel and hostname, then PUTs', async () => { + let putBody: unknown; + let putChannelId: string | undefined; + + server.use( + http.put( + 'https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', + async ({ request, params }) => { + putBody = await request.json(); + putChannelId = String(params.channelId); + + return HttpResponse.json({ + data: { + id: 1, + url: 'https://project-one.catalyst-sandbox.store', + channel_id: 2, + }, + }); + }, + ), + ); + + const promptMock = vi + .spyOn(consola, 'prompt') + // First prompt — channel select; resolve with channel id (as string) + .mockResolvedValueOnce('2') + // Second prompt — hostname select + .mockResolvedValueOnce('project-one.catalyst-sandbox.store'); + + await runChannelSiteUrlFlow({ + storeHash, + accessToken, + apiHost, + projectUuid: linkedProjectUuid, + }); + + expect(promptMock).toHaveBeenCalledTimes(2); + expect(putChannelId).toBe('2'); + expect(putBody).toEqual({ url: 'https://project-one.catalyst-sandbox.store' }); + expect(consola.success).toHaveBeenCalledWith( + expect.stringContaining('Updated channel "Catalyst Storefront" (2) site URL'), + ); + }); + + test('--channel-id short-circuits the channel prompt', async () => { + const promptMock = vi + .spyOn(consola, 'prompt') + .mockResolvedValueOnce('vanity.project-one.example.com'); + + await runChannelSiteUrlFlow({ + storeHash, + accessToken, + apiHost, + projectUuid: linkedProjectUuid, + channelId: 99, + }); + + // Only the hostname prompt fires + expect(promptMock).toHaveBeenCalledTimes(1); + expect(promptMock).toHaveBeenCalledWith( + 'Select the hostname to point the channel at.', + expect.any(Object), + ); + }); + + test('--hostname short-circuits the hostname prompt', async () => { + const promptMock = vi.spyOn(consola, 'prompt').mockResolvedValueOnce('2'); + + await runChannelSiteUrlFlow({ + storeHash, + accessToken, + apiHost, + projectUuid: linkedProjectUuid, + hostname: 'manual.example.com', + }); + + expect(promptMock).toHaveBeenCalledTimes(1); + expect(promptMock).toHaveBeenCalledWith('Select the channel to update.', expect.any(Object)); + expect(consola.success).toHaveBeenCalledWith( + expect.stringContaining('https://manual.example.com'), + ); + }); + + test('both overrides skip all prompts', async () => { + const promptMock = vi.spyOn(consola, 'prompt'); + + await runChannelSiteUrlFlow({ + storeHash, + accessToken, + apiHost, + projectUuid: linkedProjectUuid, + channelId: 42, + hostname: 'auto.example.com', + }); + + expect(promptMock).not.toHaveBeenCalled(); + expect(consola.success).toHaveBeenCalledWith( + expect.stringContaining('Updated channel 42 site URL to https://auto.example.com.'), + ); + }); + + test('preferHostname is placed first in the hostname options', async () => { + let hostnameOptions: Array<{ label: string; value: string }> | undefined; + + vi.spyOn(consola, 'prompt') + .mockResolvedValueOnce('1') // channel + .mockImplementationOnce((_message, opts) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + hostnameOptions = (opts as { options: Array<{ label: string; value: string }> }).options; + + return Promise.resolve('vanity.project-one.example.com'); + }); + + await runChannelSiteUrlFlow({ + storeHash, + accessToken, + apiHost, + projectUuid: linkedProjectUuid, + preferHostname: 'vanity.project-one.example.com', + }); + + expect(hostnameOptions?.[0]).toMatchObject({ value: 'vanity.project-one.example.com' }); + }); + + test('throws when no storefront channels are available', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/channels', () => + HttpResponse.json({ data: [] }), + ), + ); + + await expect( + runChannelSiteUrlFlow({ + storeHash, + accessToken, + apiHost, + projectUuid: linkedProjectUuid, + }), + ).rejects.toThrow('No available storefront channels found'); + }); + + test('throws when the project has no deployment hostnames', async () => { + // Project Two in the default handler has deployment_hostnames: [] + const projectTwo = 'b23f5785-fd99-4a94-9fb3-945551623924'; + + vi.spyOn(consola, 'prompt').mockResolvedValueOnce('1'); + + await expect( + runChannelSiteUrlFlow({ + storeHash, + accessToken, + apiHost, + projectUuid: projectTwo, + }), + ).rejects.toThrow('has no deployment hostnames yet'); + }); + + test('falls back to selectOrCreateInfrastructureProject when projectUuid is unset', async () => { + let getProjectsCalls = 0; + + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => { + getProjectsCalls += 1; + + return HttpResponse.json({ + data: [ + { + uuid: linkedProjectUuid, + name: 'Project One', + deployment_hostnames: ['project-one.catalyst-sandbox.store'], + }, + ], + }); + }), + ); + + vi.spyOn(consola, 'prompt') + // selectOrCreateInfrastructureProject prompt — pick the only project + .mockResolvedValueOnce(linkedProjectUuid) + // channel + .mockResolvedValueOnce('2') + // hostname + .mockResolvedValueOnce('project-one.catalyst-sandbox.store'); + + await runChannelSiteUrlFlow({ storeHash, accessToken, apiHost }); + + expect(getProjectsCalls).toBeGreaterThanOrEqual(1); + expect(consola.success).toHaveBeenCalledWith( + expect.stringContaining('Updated channel "Catalyst Storefront" (2) site URL'), + ); + }); + + test('throws NoLinkedProjectError when no projects exist and user declines to create', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => + HttpResponse.json({ data: [] }), + ), + ); + + // The "create a new project?" confirm prompt — user says no + vi.spyOn(consola, 'prompt').mockResolvedValueOnce(false); + + await expect(runChannelSiteUrlFlow({ storeHash, accessToken, apiHost })).rejects.toBeInstanceOf( + NoLinkedProjectError, + ); + }); + + test('warns and re-prompts when the linked project no longer exists', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => + HttpResponse.json({ + data: [ + { + uuid: 'different-uuid', + name: 'Some Other Project', + deployment_hostnames: ['other.example.com'], + }, + ], + }), + ), + ); + + vi.spyOn(consola, 'prompt') + // project picker + .mockResolvedValueOnce('different-uuid') + // channel + .mockResolvedValueOnce('1') + // hostname + .mockResolvedValueOnce('other.example.com'); + + await runChannelSiteUrlFlow({ + storeHash, + accessToken, + apiHost, + projectUuid: 'a-uuid-that-does-not-exist', + }); + + expect(consola.warn).toHaveBeenCalledWith(expect.stringContaining('not found on this store')); + }); +}); diff --git a/packages/catalyst/tests/mocks/handlers.ts b/packages/catalyst/tests/mocks/handlers.ts index 08e3fa04b..69a8833ba 100644 --- a/packages/catalyst/tests/mocks/handlers.ts +++ b/packages/catalyst/tests/mocks/handlers.ts @@ -156,6 +156,18 @@ export const handlers = [ () => new HttpResponse(null, { status: 204 }), ), + // Default handler for fetchAvailableChannels — returns two storefront + // channels so the picker has something to render. Tests that need an + // empty list or different channel shapes should override with `server.use(...)`. + http.get('https://:apiHost/stores/:storeHash/v3/channels', () => + HttpResponse.json({ + data: [ + { id: 1, name: 'Default Storefront', platform: 'bigcommerce' }, + { id: 2, name: 'Catalyst Storefront', platform: 'catalyst' }, + ], + }), + ), + // Default handler for updateChannelSiteUrl — succeeds with a generic // payload. Tests that need to assert error handling should override. http.put('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () => From 7e1d61763c60eeddf03e2bc78a0fcc57a5b5caae Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Fri, 15 May 2026 15:00:40 -0500 Subject: [PATCH 4/5] LTRAC-446: ref(cli) - Filter channel picker to platform=catalyst Only Catalyst-platform channels can meaningfully be pointed at a Catalyst deployment hostname, so filter the channel picker in runChannelSiteUrlFlow to drop Stencil / non-Catalyst storefronts. When the resulting list is empty, surface "No Catalyst channels found" with a hint to run `catalyst create`. Refs LTRAC-446 Co-Authored-By: Claude --- .../catalyst/src/cli/commands/channel.spec.ts | 4 +- .../catalyst/src/cli/commands/deploy.spec.ts | 2 +- .../src/cli/lib/channel-site-flow.spec.ts | 38 ++++++++++++++++--- .../catalyst/src/cli/lib/channel-site-flow.ts | 15 +++++--- 4 files changed, 46 insertions(+), 13 deletions(-) diff --git a/packages/catalyst/src/cli/commands/channel.spec.ts b/packages/catalyst/src/cli/commands/channel.spec.ts index 3b5933a95..ac3e6616d 100644 --- a/packages/catalyst/src/cli/commands/channel.spec.ts +++ b/packages/catalyst/src/cli/commands/channel.spec.ts @@ -139,7 +139,7 @@ describe('channel update-site-url', () => { const promptMock = vi .spyOn(consola, 'prompt') - .mockResolvedValueOnce('1') + .mockResolvedValueOnce('2') .mockResolvedValueOnce('vanity.project-one.example.com'); await program.parseAsync([ @@ -234,7 +234,7 @@ describe('channel update-site-url', () => { ); vi.spyOn(consola, 'prompt') - .mockResolvedValueOnce('1') + .mockResolvedValueOnce('2') .mockResolvedValueOnce('project-one.catalyst-sandbox.store'); await expect( diff --git a/packages/catalyst/src/cli/commands/deploy.spec.ts b/packages/catalyst/src/cli/commands/deploy.spec.ts index 463809aee..fe763e231 100644 --- a/packages/catalyst/src/cli/commands/deploy.spec.ts +++ b/packages/catalyst/src/cli/commands/deploy.spec.ts @@ -936,7 +936,7 @@ describe('--update-site-url', () => { ); vi.spyOn(consola, 'prompt') - .mockResolvedValueOnce('1') + .mockResolvedValueOnce('2') .mockResolvedValueOnce('project-one.catalyst-sandbox.store'); await program.parseAsync(deployArgs(['--update-site-url'])); diff --git a/packages/catalyst/src/cli/lib/channel-site-flow.spec.ts b/packages/catalyst/src/cli/lib/channel-site-flow.spec.ts index a42ee480c..67ae9b372 100644 --- a/packages/catalyst/src/cli/lib/channel-site-flow.spec.ts +++ b/packages/catalyst/src/cli/lib/channel-site-flow.spec.ts @@ -150,7 +150,7 @@ describe('runChannelSiteUrlFlow', () => { let hostnameOptions: Array<{ label: string; value: string }> | undefined; vi.spyOn(consola, 'prompt') - .mockResolvedValueOnce('1') // channel + .mockResolvedValueOnce('2') // channel (the catalyst one) .mockImplementationOnce((_message, opts) => { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions hostnameOptions = (opts as { options: Array<{ label: string; value: string }> }).options; @@ -169,10 +169,13 @@ describe('runChannelSiteUrlFlow', () => { expect(hostnameOptions?.[0]).toMatchObject({ value: 'vanity.project-one.example.com' }); }); - test('throws when no storefront channels are available', async () => { + test('throws when no Catalyst channels are available', async () => { server.use( http.get('https://:apiHost/stores/:storeHash/v3/channels', () => - HttpResponse.json({ data: [] }), + HttpResponse.json({ + // Only non-Catalyst channels — filtered out, so the picker is empty. + data: [{ id: 1, name: 'Default Storefront', platform: 'bigcommerce' }], + }), ), ); @@ -183,14 +186,39 @@ describe('runChannelSiteUrlFlow', () => { apiHost, projectUuid: linkedProjectUuid, }), - ).rejects.toThrow('No available storefront channels found'); + ).rejects.toThrow('No Catalyst channels found on this store'); + }); + + test('filters non-Catalyst channels out of the picker', async () => { + let channelOptions: Array<{ label: string; value: string }> | undefined; + + vi.spyOn(consola, 'prompt') + .mockImplementationOnce((_message, opts) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + channelOptions = (opts as { options: Array<{ label: string; value: string }> }).options; + + return Promise.resolve('2'); + }) + .mockResolvedValueOnce('project-one.catalyst-sandbox.store'); + + await runChannelSiteUrlFlow({ + storeHash, + accessToken, + apiHost, + projectUuid: linkedProjectUuid, + }); + + // The default handler returns one bigcommerce + one catalyst channel; only + // the catalyst one should appear in the picker. + expect(channelOptions).toHaveLength(1); + expect(channelOptions?.[0]).toMatchObject({ label: 'Catalyst Storefront', value: '2' }); }); test('throws when the project has no deployment hostnames', async () => { // Project Two in the default handler has deployment_hostnames: [] const projectTwo = 'b23f5785-fd99-4a94-9fb3-945551623924'; - vi.spyOn(consola, 'prompt').mockResolvedValueOnce('1'); + vi.spyOn(consola, 'prompt').mockResolvedValueOnce('2'); await expect( runChannelSiteUrlFlow({ diff --git a/packages/catalyst/src/cli/lib/channel-site-flow.ts b/packages/catalyst/src/cli/lib/channel-site-flow.ts index c99304c47..01fb55297 100644 --- a/packages/catalyst/src/cli/lib/channel-site-flow.ts +++ b/packages/catalyst/src/cli/lib/channel-site-flow.ts @@ -60,24 +60,29 @@ async function resolveChannel( consola.success('Channels fetched.'); - if (channels.length === 0) { + // Only Catalyst-platform channels can meaningfully be pointed at a Catalyst + // deployment hostname; other storefront platforms (Stencil, etc.) are + // filtered out so the picker stays focused. + const catalystChannels = channels.filter((c: Channel) => c.platform === 'catalyst'); + + if (catalystChannels.length === 0) { throw new Error( - 'No available storefront channels found. Create one in the BigCommerce control panel and try again.', + 'No Catalyst channels found on this store. Create one with `catalyst create` and try again.', ); } const selectedId = await consola.prompt('Select the channel to update.', { type: 'select', - options: channels.map((c: Channel) => ({ + options: catalystChannels.map((c: Channel) => ({ label: c.name, value: String(c.id), - hint: `${c.platform} • id: ${c.id}`, + hint: `id: ${c.id}`, })), cancel: 'reject', }); const id = Number(selectedId); - const matched = channels.find((c) => c.id === id); + const matched = catalystChannels.find((c) => c.id === id); return { id, name: matched?.name }; } From d7d7dce866f45ba47e0fd972d6469d6b40d5471d Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Fri, 15 May 2026 15:07:05 -0500 Subject: [PATCH 5/5] LTRAC-446: ref(cli) - Rename `channel update-site-url` to `channel update` The subcommand is shorter and reads more naturally as a verb on the `channel` namespace. The deploy-side flag (`--update-site-url`) keeps its descriptive name since flag context is less obvious at a glance. Refs LTRAC-446 Co-Authored-By: Claude --- .../catalyst/src/cli/commands/channel.spec.ts | 20 +++++++++---------- packages/catalyst/src/cli/commands/channel.ts | 10 +++++----- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/catalyst/src/cli/commands/channel.spec.ts b/packages/catalyst/src/cli/commands/channel.spec.ts index ac3e6616d..1298a79ad 100644 --- a/packages/catalyst/src/cli/commands/channel.spec.ts +++ b/packages/catalyst/src/cli/commands/channel.spec.ts @@ -73,18 +73,18 @@ afterAll(async () => { }); describe('channel', () => { - test('has the update-site-url subcommand', () => { + test('has the update subcommand', () => { expect(channel).toBeInstanceOf(Command); expect(channel.name()).toBe('channel'); - const updateSiteUrl = channel.commands.find((cmd) => cmd.name() === 'update-site-url'); + const update = channel.commands.find((cmd) => cmd.name() === 'update'); - expect(updateSiteUrl).toBeDefined(); - expect(updateSiteUrl?.description()).toContain('Update a BigCommerce channel'); + expect(update).toBeDefined(); + expect(update?.description()).toContain('Update a BigCommerce channel'); }); }); -describe('channel update-site-url', () => { +describe('channel update', () => { test('happy path: prompts for channel and hostname, then PUTs', async () => { let putBody: unknown; let putChannelId: string | undefined; @@ -116,7 +116,7 @@ describe('channel update-site-url', () => { 'node', 'catalyst', 'channel', - 'update-site-url', + 'update', '--store-hash', storeHash, '--access-token', @@ -146,7 +146,7 @@ describe('channel update-site-url', () => { 'node', 'catalyst', 'channel', - 'update-site-url', + 'update', '--store-hash', storeHash, '--access-token', @@ -183,7 +183,7 @@ describe('channel update-site-url', () => { 'node', 'catalyst', 'channel', - 'update-site-url', + 'update', '--store-hash', storeHash, '--access-token', @@ -215,7 +215,7 @@ describe('channel update-site-url', () => { 'node', 'catalyst', 'channel', - 'update-site-url', + 'update', '--store-hash', storeHash, '--access-token', @@ -242,7 +242,7 @@ describe('channel update-site-url', () => { 'node', 'catalyst', 'channel', - 'update-site-url', + 'update', '--store-hash', storeHash, '--access-token', diff --git a/packages/catalyst/src/cli/commands/channel.ts b/packages/catalyst/src/cli/commands/channel.ts index 7d523befd..fe4f61c2d 100644 --- a/packages/catalyst/src/cli/commands/channel.ts +++ b/packages/catalyst/src/cli/commands/channel.ts @@ -13,7 +13,7 @@ import { } from '../lib/shared-options'; import { getTelemetry } from '../lib/telemetry'; -const updateSiteUrl = new Command('update-site-url') +const update = new Command('update') .configureHelp({ showGlobalOptions: true }) .description( "Update a BigCommerce channel's site URL to point at one of your project's deployment hostnames.", @@ -23,10 +23,10 @@ const updateSiteUrl = new Command('update-site-url') ` Examples: # Pick a channel and hostname interactively - $ catalyst channel update-site-url + $ catalyst channel update # Skip both prompts - $ catalyst channel update-site-url --channel-id 123 --hostname my-storefront.example.com`, + $ catalyst channel update --channel-id 123 --hostname my-storefront.example.com`, ) .addOption(storeHashOption()) .addOption(accessTokenOption()) @@ -62,7 +62,7 @@ Examples: } catch (error) { if (error instanceof NoLinkedProjectError) { consola.info( - "When you're ready to create a project, run `catalyst project create` or re-run `catalyst channel update-site-url`.", + "When you're ready to create a project, run `catalyst project create` or re-run `catalyst channel update`.", ); process.exit(0); @@ -79,4 +79,4 @@ Examples: export const channel = new Command('channel') .configureHelp({ showGlobalOptions: true }) .description('Manage BigCommerce channels.') - .addCommand(updateSiteUrl); + .addCommand(update);