Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions packages/catalyst/src/cli/commands/deploy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
});
});
50 changes: 48 additions & 2 deletions packages/catalyst/src/cli/commands/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -255,7 +260,7 @@ export const getDeploymentStatus = async (
storeHash: string,
accessToken: string,
apiHost: string,
) => {
): Promise<string | undefined> => {
consola.info('Fetching deployment status...');

const spinner = yoctoSpinner().start('Fetching...');
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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 <channelId>',
"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 <value>',
Expand Down Expand Up @@ -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.',
);
}
});
1 change: 1 addition & 0 deletions packages/catalyst/src/cli/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
107 changes: 107 additions & 0 deletions packages/catalyst/src/cli/lib/channels.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
54 changes: 54 additions & 0 deletions packages/catalyst/src/cli/lib/channels.ts
Original file line number Diff line number Diff line change
@@ -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<ChannelSite> {
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 };
}
8 changes: 8 additions & 0 deletions packages/catalyst/tests/mocks/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
}),
),
];
Loading