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
146 changes: 146 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,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'));
});
});
71 changes: 69 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 { fetchChannelSite, 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,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 <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 <value>',
Expand Down Expand Up @@ -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.',
);
}
});
Loading
Loading