diff --git a/packages/catalyst/src/cli/commands/deploy.spec.ts b/packages/catalyst/src/cli/commands/deploy.spec.ts index e15b8a5348..db0a4dec4a 100644 --- a/packages/catalyst/src/cli/commands/deploy.spec.ts +++ b/packages/catalyst/src/cli/commands/deploy.spec.ts @@ -561,3 +561,84 @@ describe('--prebuilt flag', () => { await emptyDistCleanup(); }); }); + +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 482d06cfef..3f4ebd01b6 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 { 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,14 @@ Example: 'BigCommerce intrastructure project UUID. Can be found via the BigCommerce API (GET /v3/infrastructure/projects).', ).env('CATALYST_PROJECT_UUID'), ) + .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 ', @@ -479,5 +496,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 b59922b438..1aa1baac21 100644 --- a/packages/catalyst/src/cli/lib/auth.ts +++ b/packages/catalyst/src/cli/lib/auth.ts @@ -6,6 +6,7 @@ export const DEVICE_OAUTH_SCOPES = [ 'store_infrastructure_deployments_manage', 'store_infrastructure_logs_read_only', 'store_infrastructure_projects_manage', + 'store_channel_settings', ].join(' '); export const DEFAULT_LOGIN_URL = 'https://login.bigcommerce.com'; diff --git a/packages/catalyst/src/cli/lib/channels.spec.ts b/packages/catalyst/src/cli/lib/channels.spec.ts new file mode 100644 index 0000000000..474b330731 --- /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 new file mode 100644 index 0000000000..cf5872bbd1 --- /dev/null +++ b/packages/catalyst/src/cli/lib/channels.ts @@ -0,0 +1,54 @@ +import { z } from 'zod'; + +import { getTelemetry } from './telemetry'; + +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 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 c16ea054db..65fd8d81c3 100644 --- a/packages/catalyst/tests/mocks/handlers.ts +++ b/packages/catalyst/tests/mocks/handlers.ts @@ -138,4 +138,12 @@ export const handlers = [ }, }), ), + + // 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 }, + }), + ), ];