Skip to content

Commit e49f807

Browse files
jorgemoyaclaude
andcommitted
LTRAC-446: feat(cli) - Auto-update BC channel site URL on deploy
Add `--update-site-url <channelId>` (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 <noreply@anthropic.com>
1 parent 24f35a4 commit e49f807

6 files changed

Lines changed: 295 additions & 5 deletions

File tree

packages/catalyst/src/cli/commands/deploy.spec.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -817,3 +817,84 @@ describe('transformation guard', () => {
817817
expect(installDependencies).not.toHaveBeenCalled();
818818
});
819819
});
820+
821+
describe('--update-site-url', () => {
822+
const channelId = 7;
823+
824+
function deployArgs(extra: string[] = []) {
825+
return [
826+
'node',
827+
'catalyst',
828+
'deploy',
829+
'--store-hash',
830+
storeHash,
831+
'--access-token',
832+
accessToken,
833+
'--api-host',
834+
apiHost,
835+
'--project-uuid',
836+
projectUuid,
837+
'--prebuilt',
838+
...extra,
839+
];
840+
}
841+
842+
test('PUTs the deployment URL to the given channel', async () => {
843+
let putBody: unknown;
844+
let receivedChannelId: string | undefined;
845+
846+
server.use(
847+
http.put(
848+
'https://:apiHost/stores/:storeHash/v3/channels/:channelId/site',
849+
async ({ request, params }) => {
850+
putBody = await request.json();
851+
receivedChannelId = String(params.channelId);
852+
853+
return HttpResponse.json({
854+
data: { id: 1, url: 'https://example.com', channel_id: channelId },
855+
});
856+
},
857+
),
858+
);
859+
860+
await program.parseAsync(deployArgs(['--update-site-url', String(channelId)]));
861+
862+
expect(receivedChannelId).toBe(String(channelId));
863+
expect(putBody).toEqual({ url: 'https://example.com' });
864+
expect(consola.success).toHaveBeenCalledWith(
865+
`Updated channel ${channelId} site URL to https://example.com.`,
866+
);
867+
});
868+
869+
test('does not call the channel site API when the flag is omitted', async () => {
870+
let putCalled = false;
871+
872+
server.use(
873+
http.put('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () => {
874+
putCalled = true;
875+
876+
return HttpResponse.json({}, { status: 200 });
877+
}),
878+
);
879+
880+
await program.parseAsync(deployArgs());
881+
882+
expect(putCalled).toBe(false);
883+
expect(consola.success).not.toHaveBeenCalledWith(expect.stringContaining('Updated channel'));
884+
});
885+
886+
test('soft-fails with a warning when the update API returns an error', async () => {
887+
server.use(
888+
http.put('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () =>
889+
HttpResponse.json({}, { status: 401 }),
890+
),
891+
);
892+
893+
await program.parseAsync(deployArgs(['--update-site-url', String(channelId)]));
894+
895+
expect(consola.warn).toHaveBeenCalledWith(
896+
expect.stringContaining('Failed to update channel site URL'),
897+
);
898+
expect(consola.info).toHaveBeenCalledWith(expect.stringContaining('catalyst auth login'));
899+
});
900+
});

packages/catalyst/src/cli/commands/deploy.ts

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { dirname, join } from 'node:path';
66
import yoctoSpinner from 'yocto-spinner';
77
import { z } from 'zod';
88

9+
import { updateChannelSiteUrl } from '../lib/channels';
910
import {
1011
NoLinkedProjectError,
1112
selectOrCreateInfrastructureProject,
@@ -268,7 +269,7 @@ export const getDeploymentStatus = async (
268269
storeHash: string,
269270
accessToken: string,
270271
apiHost: string,
271-
) => {
272+
): Promise<string | undefined> => {
272273
consola.info('Fetching deployment status...');
273274

274275
const spinner = yoctoSpinner().start('Fetching...');
@@ -346,10 +347,14 @@ export const getDeploymentStatus = async (
346347
spinner.success('Deployment completed successfully.');
347348

348349
if (deploymentHostname) {
349-
consola.success(
350-
`View your deployment at: ${colorize('blue', `https://${deploymentHostname}`)}`,
351-
);
350+
const url = `https://${deploymentHostname}`;
351+
352+
consola.success(`View your deployment at: ${colorize('blue', url)}`);
353+
354+
return url;
352355
}
356+
357+
return undefined;
353358
};
354359

355360
export const fetchProject = async (
@@ -394,6 +399,14 @@ Example:
394399
.addOption(accessTokenOption())
395400
.addOption(apiHostOption())
396401
.addOption(projectUuidOption())
402+
.addOption(
403+
new Option(
404+
'--update-site-url <channelId>',
405+
"BigCommerce channel ID whose site URL should be updated with this deployment's URL. When omitted, no channel is updated.",
406+
)
407+
.env('CATALYST_UPDATE_SITE_URL')
408+
.argParser((value: string) => Number(value)),
409+
)
397410
.addOption(
398411
new Option(
399412
'--secret <value>',
@@ -545,5 +558,34 @@ Example:
545558
environmentVariables,
546559
);
547560

548-
await getDeploymentStatus(deploymentUuid, storeHash, accessToken, options.apiHost);
561+
const deploymentUrl = await getDeploymentStatus(
562+
deploymentUuid,
563+
storeHash,
564+
accessToken,
565+
options.apiHost,
566+
);
567+
568+
const channelId: number | undefined = options.updateSiteUrl;
569+
570+
if (!channelId) {
571+
return;
572+
}
573+
574+
if (!deploymentUrl) {
575+
consola.warn('Skipping channel site URL update: deployment did not return a URL.');
576+
577+
return;
578+
}
579+
580+
try {
581+
await updateChannelSiteUrl(channelId, deploymentUrl, storeHash, accessToken, options.apiHost);
582+
consola.success(`Updated channel ${channelId} site URL to ${deploymentUrl}.`);
583+
} catch (error) {
584+
consola.warn(
585+
`Failed to update channel site URL: ${error instanceof Error ? error.message : String(error)}`,
586+
);
587+
consola.info(
588+
'Update it manually in the control panel, or re-run after `catalyst auth login` if the token is missing the store_channel_settings scope.',
589+
);
590+
}
549591
});

packages/catalyst/src/cli/lib/auth.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export const DEVICE_OAUTH_SCOPES = [
66
'store_infrastructure_deployments_manage',
77
'store_infrastructure_logs_read_only',
88
'store_infrastructure_projects_manage',
9+
'store_channel_settings',
910
].join(' ');
1011
export const DEFAULT_LOGIN_URL = 'https://login.bigcommerce.com';
1112

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { http, HttpResponse } from 'msw';
2+
import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest';
3+
4+
import { server } from '../../../tests/mocks/node';
5+
6+
import { updateChannelSiteUrl } from './channels';
7+
8+
const storeHash = 'test-store';
9+
const accessToken = 'test-token';
10+
const apiHost = 'api.bigcommerce.com';
11+
const channelId = 1;
12+
13+
beforeAll(() => {
14+
vi.mock('./telemetry', () => {
15+
const instance = {
16+
identify: vi.fn(),
17+
isEnabled: vi.fn(() => true),
18+
track: vi.fn(),
19+
correlationId: 'test-session-uuid',
20+
commandName: 'unknown',
21+
durationMs: vi.fn().mockReturnValue(0),
22+
analytics: {
23+
closeAndFlush: vi.fn().mockResolvedValue(undefined),
24+
},
25+
};
26+
27+
return {
28+
Telemetry: vi.fn().mockImplementation(() => instance),
29+
getTelemetry: vi.fn(() => instance),
30+
resetTelemetry: vi.fn(),
31+
};
32+
});
33+
});
34+
35+
afterAll(() => {
36+
vi.restoreAllMocks();
37+
});
38+
39+
describe('updateChannelSiteUrl', () => {
40+
test('PUTs the URL and returns parsed channel site', async () => {
41+
let receivedBody: unknown;
42+
43+
server.use(
44+
http.put(
45+
'https://:apiHost/stores/:storeHash/v3/channels/:channelId/site',
46+
async ({ request }) => {
47+
receivedBody = await request.json();
48+
49+
return HttpResponse.json({
50+
data: {
51+
id: 42,
52+
url: 'https://new.example.com',
53+
channel_id: channelId,
54+
},
55+
});
56+
},
57+
),
58+
);
59+
60+
const result = await updateChannelSiteUrl(
61+
channelId,
62+
'https://new.example.com',
63+
storeHash,
64+
accessToken,
65+
apiHost,
66+
);
67+
68+
expect(receivedBody).toEqual({ url: 'https://new.example.com' });
69+
expect(result).toEqual({ id: 42, url: 'https://new.example.com', channelId });
70+
});
71+
72+
test('throws with re-auth hint on 401', async () => {
73+
server.use(
74+
http.put('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () =>
75+
HttpResponse.json({}, { status: 401 }),
76+
),
77+
);
78+
79+
await expect(
80+
updateChannelSiteUrl(channelId, 'https://x.example', storeHash, accessToken, apiHost),
81+
).rejects.toThrow('Re-run `catalyst auth login`');
82+
});
83+
84+
test('throws with re-auth hint on 403', async () => {
85+
server.use(
86+
http.put('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () =>
87+
HttpResponse.json({}, { status: 403 }),
88+
),
89+
);
90+
91+
await expect(
92+
updateChannelSiteUrl(channelId, 'https://x.example', storeHash, accessToken, apiHost),
93+
).rejects.toThrow('Re-run `catalyst auth login`');
94+
});
95+
96+
test('throws with status on other errors', async () => {
97+
server.use(
98+
http.put('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () =>
99+
HttpResponse.json({}, { status: 500 }),
100+
),
101+
);
102+
103+
await expect(
104+
updateChannelSiteUrl(channelId, 'https://x.example', storeHash, accessToken, apiHost),
105+
).rejects.toThrow('Failed to update channel site: 500');
106+
});
107+
});

packages/catalyst/src/cli/lib/channels.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,14 @@ const eligibilityResponseSchema = z.object({
5858
}),
5959
});
6060

61+
const channelSiteSchema = z.object({
62+
data: z.object({
63+
id: z.number(),
64+
url: z.string(),
65+
channel_id: z.number(),
66+
}),
67+
});
68+
6169
export interface ChannelInit {
6270
storefrontToken: string;
6371
envVars: Record<string, string>;
@@ -173,3 +181,46 @@ export async function fetchAvailableChannels(
173181

174182
return channelsResponseSchema.parse(await response.json()).data;
175183
}
184+
185+
export interface ChannelSite {
186+
id: number;
187+
url: string;
188+
channelId: number;
189+
}
190+
191+
export async function updateChannelSiteUrl(
192+
channelId: number,
193+
siteUrl: string,
194+
storeHash: string,
195+
accessToken: string,
196+
apiHost: string,
197+
): Promise<ChannelSite> {
198+
const response = await fetch(
199+
`https://${apiHost}/stores/${storeHash}/v3/channels/${channelId}/site`,
200+
{
201+
method: 'PUT',
202+
headers: {
203+
'X-Auth-Token': accessToken,
204+
'Content-Type': 'application/json',
205+
Accept: 'application/json',
206+
'X-Correlation-Id': getTelemetry().correlationId,
207+
},
208+
body: JSON.stringify({ url: siteUrl }),
209+
},
210+
);
211+
212+
if (response.status === 401 || response.status === 403) {
213+
throw new Error(
214+
`Failed to update channel site (${response.status}). Re-run \`catalyst auth login\` to refresh your access token with the store_channel_settings scope.`,
215+
);
216+
}
217+
218+
if (!response.ok) {
219+
throw new Error(`Failed to update channel site: ${response.status} ${response.statusText}`);
220+
}
221+
222+
const res: unknown = await response.json();
223+
const { data } = channelSiteSchema.parse(res);
224+
225+
return { id: data.id, url: data.url, channelId: data.channel_id };
226+
}

packages/catalyst/tests/mocks/handlers.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,4 +155,12 @@ export const handlers = [
155155
'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid',
156156
() => new HttpResponse(null, { status: 204 }),
157157
),
158+
159+
// Default handler for updateChannelSiteUrl — succeeds with a generic
160+
// payload. Tests that need to assert error handling should override.
161+
http.put('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () =>
162+
HttpResponse.json({
163+
data: { id: 1, url: 'https://example.com', channel_id: 1 },
164+
}),
165+
),
158166
];

0 commit comments

Comments
 (0)