Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
f023db4
LTRAC-1962: feat(cli) - Derive the checkout URL from the provisioned …
jorgemoya Sep 23, 2026
12a1014
Merge LTRAC-1946 into LTRAC-1962
jorgemoya Sep 24, 2026
d7ee581
LTRAC-1962: fix(cli) - Set the checkout URL first, then wait for its …
jorgemoya Sep 24, 2026
08979a4
LTRAC-2014: feat(cli) - Offer to align the channel's URLs after deploy
jorgemoya Sep 24, 2026
c96092b
LTRAC-2014: fix(cli) - Use the deployed channel instead of asking for…
jorgemoya Sep 24, 2026
7cb4ae3
LTRAC-2014: fix(cli) - Prefer the deployment secret for the served ch…
jorgemoya Sep 24, 2026
f5a83a8
LTRAC-2014: fix(cli) - Use the deployment hostname instead of asking …
jorgemoya Sep 24, 2026
3273499
LTRAC-1962: fix(cli) - Skip re-sending an unchanged site URL
jorgemoya Sep 24, 2026
45bce94
Merge LTRAC-1962 into LTRAC-2014
jorgemoya Sep 24, 2026
a8063fc
Merge LTRAC-1946 into LTRAC-1962
jorgemoya Sep 24, 2026
4dd5c32
Merge LTRAC-1962 into LTRAC-2014
jorgemoya Sep 24, 2026
52a83bb
LTRAC-2014: feat(cli) - Offer the checkout URL on every deploy, and a…
jorgemoya Sep 24, 2026
48d6ec5
LTRAC-2014: fix(cli) - Default the post-deploy URL prompts to No
jorgemoya Sep 24, 2026
401131d
LTRAC-2014: fix(cli) - Don't offer channel URL updates in CI
jorgemoya Sep 25, 2026
95f8154
Merge LTRAC-1946 into LTRAC-1962
jorgemoya Sep 25, 2026
5f47723
LTRAC-1962: docs(cli) - Simplify the changeset
jorgemoya Sep 25, 2026
3360450
Merge LTRAC-1962 into LTRAC-2014
jorgemoya Sep 25, 2026
d289a81
LTRAC-2014: docs(cli) - Simplify the changeset
jorgemoya Sep 25, 2026
48d7a58
LTRAC-1962: docs(cli) - Combine the checkout URL changesets
jorgemoya Sep 25, 2026
10a4a23
Merge LTRAC-1946 into LTRAC-1962
jorgemoya Sep 25, 2026
17b5b06
LTRAC-1962: docs(cli) - Shorten the checkout URL comments
jorgemoya Sep 25, 2026
7a1c68a
LTRAC-1962: fix(cli) - Set the c. checkout hostname with --update-che…
jorgemoya Sep 25, 2026
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
11 changes: 11 additions & 0 deletions .changeset/ltrac-1962-derive-managed-checkout-url.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@bigcommerce/catalyst": patch
---

Put checkout on the same domain as a native-hosted storefront. For a storefront on an auto-generated hostname, the checkout URL is `https://c.<project>.<zone>`:

- `catalyst deploy --update-site-url --update-checkout-url` sets it without prompting.
- An interactive `catalyst deploy` without those flags offers to point the channel at the deployment and to move its checkout there. Both questions default to No, and declining is remembered per channel in `.bigcommerce/project.json`.
- `catalyst channels update` offers it after changing a site URL.

After setting it, the CLI waits for the certificate, which usually takes a minute or two. If none is issued within six minutes, the checkout URL is removed so checkout keeps working on the default domain. An existing custom checkout URL is left alone, and re-running with an unchanged site URL no longer resets the checkout URL. Nothing is asked without a terminal or in CI.
99 changes: 99 additions & 0 deletions packages/catalyst/src/cli/commands/channels.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1100,3 +1100,102 @@ describe('channels checkout URLs', () => {
});
});
});

// A site URL update deletes the channel's checkout URL, so on the managed zone
// `channels update` offers the `c.` one straight after.
describe('channels update checkout offer', () => {
const sitePath = 'https://:apiHost/stores/:storeHash/v3/channels/:channelId/site';
const checkoutPath = `${sitePath}/checkout-url`;
const storefront = 'project-one.catalyst-sandbox.store';

const run = () =>
program.parseAsync([
'node',
'catalyst',
'channels',
'update',
'--channel-id',
'2',
'--hostname',
storefront,
'--project-uuid',
linkedProjectUuid,
'--store-hash',
storeHash,
'--access-token',
accessToken,
]);

const site = (primary: string) =>
HttpResponse.json({
data: {
id: 1,
url: primary,
channel_id: 2,
ssl_status: null,
is_checkout_url_customized: false,
urls: [
{ url: primary, type: 'primary' },
{ url: 'https://store-abc-1.mybigcommerce.com', type: 'checkout' },
],
},
});

// The first read is the pre-write check, so it sees the old primary.
const channelMovingToManagedZone = () => {
const writes: { checkout?: unknown } = {};

server.use(
http.get(sitePath, () => site('https://store-abc-2.mybigcommerce.com'), { once: true }),
http.get(sitePath, () => site(`https://${storefront}`)),
http.put(checkoutPath, async ({ request }) => {
writes.checkout = await request.json();

return HttpResponse.json({ data: { id: 1, url: `https://${storefront}`, channel_id: 2 } });
}),
http.head(`https://c.${storefront}/`, () => HttpResponse.json(null, { status: 302 })),
);

return writes;
};

beforeEach(() => {
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
// Our own CI sets it, and it silences the offer.
vi.stubEnv('CI', '');
});

afterEach(() => {
vi.unstubAllEnvs();
Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true });
config.delete('declinedCheckoutUrlChannels');
});

test('offers the managed-zone checkout URL after updating the site URL', async () => {
const writes = channelMovingToManagedZone();

mockConfirm.mockResolvedValueOnce(true);

await run();

expect(writes.checkout).toEqual({ url: `https://c.${storefront}` });
// The site URL change was asked for, so Enter accepts the follow-up.
expect(mockConfirm).toHaveBeenCalledWith(expect.objectContaining({ default: true }));
});

// Asked for explicitly, so an earlier decline after a deploy doesn't silence
// it; declining here is saved so deploys stay quiet.
test('asks despite an earlier decline, and saves a new one', async () => {
const writes = channelMovingToManagedZone();

config.set('declinedCheckoutUrlChannels', [2]);
mockConfirm.mockResolvedValueOnce(false);

await run();

expect(mockConfirm).toHaveBeenCalledTimes(1);
expect(writes.checkout).toBeUndefined();
expect(config.get('declinedCheckoutUrlChannels')).toEqual([2]);
expect(consola.warn).toHaveBeenCalledWith(expect.stringContaining("default channel's domain"));
});
});
30 changes: 28 additions & 2 deletions packages/catalyst/src/cli/commands/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import { Command, InvalidArgumentError, Option } from 'commander';
import type Conf from 'conf';
import { colorize } from 'consola/utils';

import { runChannelCheckoutUrlFlow } from '../lib/channel-checkout-url-flow';
import { canPrompt } from '../lib/can-prompt';
import {
offerManagedCheckoutUrl,
runChannelCheckoutUrlFlow,
} from '../lib/channel-checkout-url-flow';
import { resolveChannel, runChannelSiteUrlFlow } from '../lib/channel-site-flow';
import {
channelPlatformLabel,
Expand Down Expand Up @@ -166,17 +170,21 @@ Examples:
// asked to change.
const touchesCheckout = options.checkoutUrl !== undefined || options.removeCheckoutUrl === true;
const updatesSiteUrl = options.hostname !== undefined || !touchesCheckout;
// A site URL update deletes the checkout URL, so offer the managed-zone one next.
const offersCheckout = updatesSiteUrl && !touchesCheckout && canPrompt();
let channelId = options.channelId;
let siteHostname: string | undefined;

if (updatesSiteUrl) {
try {
({ channelId } = await runChannelSiteUrlFlow({
({ channelId, hostname: siteHostname } = await runChannelSiteUrlFlow({
storeHash,
accessToken,
apiHost,
projectUuid: options.projectUuid ?? config.get('projectUuid'),
channelId: options.channelId,
hostname: options.hostname,
diagnoseCheckout: !offersCheckout,
}));
} catch (error) {
if (error instanceof NoLinkedProjectError) {
Expand All @@ -193,6 +201,24 @@ Examples:
}
}

if (offersCheckout && channelId !== undefined && siteHostname !== undefined) {
const offered = await offerManagedCheckoutUrl({
storeHash,
accessToken,
apiHost,
channelId,
storefrontHostname: siteHostname,
config,
// Explicit command, so an earlier decline doesn't silence it.
respectOptOut: false,
defaultAnswer: true,
});

if (!offered) {
warnOnCrossDomainCheckout(await getChannelSite(channelId, storeHash, accessToken, apiHost));
}
}

if (touchesCheckout) {
const channel =
channelId === undefined
Expand Down
33 changes: 29 additions & 4 deletions packages/catalyst/src/cli/commands/deploy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1081,20 +1081,43 @@ describe('--update-site-url', () => {
});

// Running both flows must not ask which channel twice — the checkout flow
// reuses the channel the site-URL flow already resolved.
test('reuses the resolved channel when both update flags are passed', async () => {
// reuses the channel the site-URL flow already resolved. And with the site
// hostname in hand the checkout URL is derived rather than prompted for.
test('derives the checkout URL from the site hostname when both flags are passed', async () => {
let checkoutChannelId: string | undefined;
let checkoutBody: unknown;

server.use(
http.put('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () =>
HttpResponse.json({
data: { id: 1, url: 'https://project-one.catalyst-sandbox.store', channel_id: 2 },
}),
),
// A channel still on the inherited checkout, so the flow writes.
http.get('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () =>
HttpResponse.json({
data: {
id: 1,
url: 'https://project-one.catalyst-sandbox.store',
channel_id: 2,
ssl_status: null,
is_checkout_url_customized: false,
urls: [
{ url: 'https://project-one.catalyst-sandbox.store', type: 'primary' },
{ url: 'https://store-abc-1.mybigcommerce.com', type: 'checkout' },
],
},
}),
),
// Answering at all means the certificate issued after the write.
http.head('https://c.project-one.catalyst-sandbox.store/', () =>
HttpResponse.json(null, { status: 302 }),
),
http.put(
'https://:apiHost/stores/:storeHash/v3/channels/:channelId/site/checkout-url',
({ params }) => {
async ({ params, request }) => {
checkoutChannelId = String(params.channelId);
checkoutBody = await request.json();

return HttpResponse.json({
data: { id: 1, url: 'https://example.com', channel_id: 2 },
Expand All @@ -1106,14 +1129,16 @@ describe('--update-site-url', () => {
vi.mocked(select)
.mockResolvedValueOnce(2) // channel, asked once by the site-URL flow
.mockResolvedValueOnce('project-one.catalyst-sandbox.store'); // hostname
vi.mocked(input).mockResolvedValueOnce('https://checkout.example.com');

await program.parseAsync(deployArgs(['--update-site-url', '--update-checkout-url']));

expect(checkoutChannelId).toBe('2');
expect(checkoutBody).toEqual({ url: 'https://c.project-one.catalyst-sandbox.store' });
// Two selects total: channel + hostname. A third would mean the checkout
// flow re-prompted for the channel.
expect(vi.mocked(select)).toHaveBeenCalledTimes(2);
// No prompt: on a managed zone the checkout hostname follows from the storefront.
expect(vi.mocked(input)).not.toHaveBeenCalled();
});

test('does not call the checkout URL API when the flag is omitted', async () => {
Expand Down
28 changes: 27 additions & 1 deletion packages/catalyst/src/cli/commands/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
selectOrCreateInfrastructureProject,
setupCommerceHosting,
} from '../lib/commerce-hosting';
import { deployedChannelId, offerChannelUrlUpdates } from '../lib/deploy-channel-urls';
import { getDeploymentErrorMessage } from '../lib/deployment-errors';
import { detectProjectPackageManager } from '../lib/detect-package-manager';
import {
Expand Down Expand Up @@ -387,6 +388,10 @@ export const deploy = new Command('deploy')
Environment variables saved with \`catalyst env add\` are sent automatically on every deploy.
Use \`--secret\` to set or override a variable for a single run.

Without \`--update-site-url\` or \`--update-checkout-url\`, an interactive deploy offers to
point the channel's site URL at the deployment and to move its checkout onto the same domain.
Each is checked on every deploy; declining either is saved in .bigcommerce/project.json.

Example:
$ catalyst deploy --secret BIGCOMMERCE_STORE_HASH=<YOUR_STORE_HASH> --secret BIGCOMMERCE_STOREFRONT_TOKEN=<YOUR_STOREFRONT_TOKEN>`,
)
Expand Down Expand Up @@ -610,9 +615,12 @@ Example:
// Carried over so both flags don't ask which channel twice.
let resolvedChannelId: number | undefined;

// Set by --update-site-url, so checkout pairs with the hostname actually used.
let siteHostname: string | undefined;

if (options.updateSiteUrl) {
try {
({ channelId: resolvedChannelId } = await runChannelSiteUrlFlow({
({ channelId: resolvedChannelId, hostname: siteHostname } = await runChannelSiteUrlFlow({
storeHash,
accessToken,
apiHost,
Expand All @@ -631,9 +639,27 @@ Example:
accessToken,
apiHost,
channelId: resolvedChannelId,
storefrontHostname: siteHostname,
});
} catch (error) {
warnChannelFlowFailed('checkout URL', error);
}
}

// Neither flag: offer both. Explicit flags mean the caller already decided.
if (!options.updateSiteUrl && !options.updateCheckoutUrl) {
try {
await offerChannelUrlUpdates({
storeHash,
accessToken,
apiHost,
projectUuid,
config,
deploymentHostname,
channelId: deployedChannelId(mergedSecrets),
});
} catch (error) {
warnChannelFlowFailed('URLs', error);
}
}
});
5 changes: 5 additions & 0 deletions packages/catalyst/src/cli/lib/can-prompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Whether an unrequested question can be asked. Also checks `CI`: some CI
// setups allocate a pseudo-terminal, where a prompt would hang the job.
export function canPrompt(): boolean {
return Boolean(process.stdin.isTTY) && !process.env.CI;
}
Loading
Loading