From f023db4bedb7e79c661776da8053792e90d29187 Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Wed, 23 Sep 2026 17:24:58 -0500 Subject: [PATCH 01/15] LTRAC-1962: feat(cli) - Derive the checkout URL from the provisioned hostname MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `deploy --update-site-url --update-checkout-url` asked the merchant to type a checkout URL, defaulting to a `checkout.` subdomain that nothing provisions on a managed hosting zone. Derive the hostname native hosting actually creates and set it without prompting. The hostname is derived, not transported. ignition builds the same name from the same prefix, so carrying it over the API would add a field that can disagree with reality — and would have needed a `bc-interfaces` release, which would have blocked this on a cross-repo dependency. Wait for that hostname to serve a valid certificate before writing. The checkout-url endpoint does no certificate validation of its own: it accepts any hostname sharing a main domain with the storefront, whether or not anything answers there. Setting it early leaves checkout resolving without a certificate, which is worse for a shopper than the inherited URL it replaced. The wait matches how long BigCommerce keeps trying before giving up, and a hostname that never comes up leaves checkout alone rather than being written. `runChannelSiteUrlFlow` now reports which hostname it set, so the checkout step derives from that rather than guessing which of a project's hostnames was chosen. Without a site URL update there is nothing to derive from, so that path prompts exactly as before — as does a storefront on a custom domain. Refs LTRAC-1962 Co-Authored-By: Claude Opus 5 (1M context) --- .../ltrac-1962-derive-managed-checkout-url.md | 13 ++ .../catalyst/src/cli/commands/deploy.spec.ts | 18 ++- packages/catalyst/src/cli/commands/deploy.ts | 12 +- .../src/cli/lib/channel-site-flow.spec.ts | 2 +- .../catalyst/src/cli/lib/channel-site-flow.ts | 6 +- .../catalyst/src/cli/lib/checkout-url.spec.ts | 73 ++++++++++- packages/catalyst/src/cli/lib/checkout-url.ts | 122 ++++++++++++++++++ 7 files changed, 238 insertions(+), 8 deletions(-) create mode 100644 .changeset/ltrac-1962-derive-managed-checkout-url.md diff --git a/.changeset/ltrac-1962-derive-managed-checkout-url.md b/.changeset/ltrac-1962-derive-managed-checkout-url.md new file mode 100644 index 000000000..7a3ee858d --- /dev/null +++ b/.changeset/ltrac-1962-derive-managed-checkout-url.md @@ -0,0 +1,13 @@ +--- +"@bigcommerce/catalyst": patch +--- + +Set a channel's checkout URL to the checkout hostname native hosting provisions, instead of prompting for one. + +`catalyst deploy --update-site-url --update-checkout-url` asked the merchant to type a checkout URL, defaulting to a `checkout.` subdomain that nothing would ever provision on a managed hosting zone. It now derives the hostname that native hosting actually creates — the storefront hostname with a short prefix — and sets it without prompting. + +The hostname is derived rather than transported. ignition builds the same name from the same prefix, so carrying it over the API would only add a field that can disagree with reality, and would have required a `bc-interfaces` release. + +Before writing, the CLI waits for that hostname to serve a valid certificate. `PUT /v3/channels/:id/site/checkout-url` performs no certificate validation of its own — it accepts any hostname sharing a main domain with the storefront, whether or not anything answers there — so setting it too early would leave checkout resolving without a certificate, which is worse for a shopper than the inherited checkout URL it replaced. Provisioning takes up to six minutes, and the CLI gives up with a warning rather than writing a URL that would not work. + +Nothing changes for a storefront on a custom domain, or when `--update-checkout-url` is used without `--update-site-url`: both still prompt, since there is no provisioned hostname to derive from. diff --git a/packages/catalyst/src/cli/commands/deploy.spec.ts b/packages/catalyst/src/cli/commands/deploy.spec.ts index 858d390a1..de5c4ebcb 100644 --- a/packages/catalyst/src/cli/commands/deploy.spec.ts +++ b/packages/catalyst/src/cli/commands/deploy.spec.ts @@ -1081,9 +1081,11 @@ 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', () => @@ -1091,10 +1093,16 @@ describe('--update-site-url', () => { data: { id: 1, url: 'https://project-one.catalyst-sandbox.store', channel_id: 2 }, }), ), + // The provisioned checkout hostname answering at all means its + // certificate is live, which is what the readiness check looks for. + 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 }, @@ -1106,14 +1114,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: the hostname native hosting provisioned is not a guess. + expect(vi.mocked(input)).not.toHaveBeenCalled(); }); test('does not call the checkout URL API when the flag is omitted', async () => { diff --git a/packages/catalyst/src/cli/commands/deploy.ts b/packages/catalyst/src/cli/commands/deploy.ts index 93e76b0d4..b37cbdecf 100644 --- a/packages/catalyst/src/cli/commands/deploy.ts +++ b/packages/catalyst/src/cli/commands/deploy.ts @@ -11,6 +11,7 @@ import { assertAuthorized } from '../lib/auth-errors'; import { loadBuildEnv } from '../lib/build-env'; import { runChannelCheckoutUrlFlow } from '../lib/channel-checkout-url-flow'; import { runChannelSiteUrlFlow } from '../lib/channel-site-flow'; +import { resolveProvisionedCheckoutUrl } from '../lib/checkout-url'; import { cleanupCloudflareIncompatibilities, NoLinkedProjectError, @@ -610,9 +611,13 @@ Example: // Carried over so both flags don't ask which channel twice. let resolvedChannelId: number | undefined; + // Set when --update-site-url ran, so the checkout flow below can derive the + // checkout hostname that pairs with whichever hostname was actually used. + let siteHostname: string | undefined; + if (options.updateSiteUrl) { try { - ({ channelId: resolvedChannelId } = await runChannelSiteUrlFlow({ + ({ channelId: resolvedChannelId, hostname: siteHostname } = await runChannelSiteUrlFlow({ storeHash, accessToken, apiHost, @@ -626,11 +631,16 @@ Example: if (options.updateCheckoutUrl) { try { + // Without a site URL update there is no hostname to derive from, so the + // flow prompts as before. `url` skips the prompt when we do know it. + const url = siteHostname ? await resolveProvisionedCheckoutUrl(siteHostname) : undefined; + await runChannelCheckoutUrlFlow({ storeHash, accessToken, apiHost, channelId: resolvedChannelId, + url, }); } catch (error) { warnChannelFlowFailed('checkout URL', error); diff --git a/packages/catalyst/src/cli/lib/channel-site-flow.spec.ts b/packages/catalyst/src/cli/lib/channel-site-flow.spec.ts index 499b5cca4..b5bb5f843 100644 --- a/packages/catalyst/src/cli/lib/channel-site-flow.spec.ts +++ b/packages/catalyst/src/cli/lib/channel-site-flow.spec.ts @@ -371,7 +371,7 @@ describe('runChannelSiteUrlFlow', () => { channelId: 2, hostname: 'project-one.catalyst-sandbox.store', }), - ).resolves.toEqual({ channelId: 2 }); + ).resolves.toEqual({ channelId: 2, hostname: 'project-one.catalyst-sandbox.store' }); expect(consola.success).toHaveBeenCalledWith(expect.stringContaining('site URL')); }); diff --git a/packages/catalyst/src/cli/lib/channel-site-flow.ts b/packages/catalyst/src/cli/lib/channel-site-flow.ts index 4a4cfbd96..5b9d308e2 100644 --- a/packages/catalyst/src/cli/lib/channel-site-flow.ts +++ b/packages/catalyst/src/cli/lib/channel-site-flow.ts @@ -135,6 +135,10 @@ async function resolveHostname( export interface ChannelSiteFlowResult { channelId: number; + // The hostname the site URL was set to. Returned so a caller running the + // checkout flow next can derive the matching checkout hostname instead of + // guessing which of the project's hostnames was chosen. + hostname: string; } export async function runChannelSiteUrlFlow( @@ -191,5 +195,5 @@ export async function runChannelSiteUrlFlow( // Returned so a caller running several channel flows back to back — `channels // update --hostname --checkout-url`, or `deploy --update-site-url // --update-checkout-url` — reuses this channel instead of resolving it twice. - return { channelId: channel.id }; + return { channelId: channel.id, hostname }; } diff --git a/packages/catalyst/src/cli/lib/checkout-url.spec.ts b/packages/catalyst/src/cli/lib/checkout-url.spec.ts index a79e0d2bc..6321cc0b3 100644 --- a/packages/catalyst/src/cli/lib/checkout-url.spec.ts +++ b/packages/catalyst/src/cli/lib/checkout-url.spec.ts @@ -4,7 +4,13 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from 'vit import { server } from '../../../tests/mocks/node'; import { type ChannelSiteDetails } from './channels'; -import { sharesMainDomain, suggestCheckoutUrl, warnOnCrossDomainCheckout } from './checkout-url'; +import { + managedCheckoutHostname, + resolveProvisionedCheckoutUrl, + sharesMainDomain, + suggestCheckoutUrl, + warnOnCrossDomainCheckout, +} from './checkout-url'; import { consola } from './logger'; const storeHash = 'test-store'; @@ -389,3 +395,68 @@ describe('warnOnCrossDomainCheckout', () => { ); }); }); + +describe('managedCheckoutHostname', () => { + test('prefixes the storefront hostname', () => { + expect(managedCheckoutHostname('catalyst.catalyst-sandbox.store')).toBe( + 'c.catalyst.catalyst-sandbox.store', + ); + }); + + // ignition derives the same name from the same prefix, so this has to agree + // with it rather than be transported over the API. + test('shares a main domain with its storefront', () => { + const storefront = 'catalyst.catalyst-sandbox.store'; + const checkout = managedCheckoutHostname(storefront); + + if (!checkout) throw new Error('expected a hostname'); + + expect(sharesMainDomain(storefront, checkout)).toBe(true); + }); + + // Hostnames generated before ignition reserved room for the prefix can be too + // long, and those projects have no checkout hostname to point at. + test('returns undefined when the result exceeds the certificate name limit', () => { + const atLimit = `${'a'.repeat(62 - '.catalyst-sandbox.store'.length)}.catalyst-sandbox.store`; + + expect(managedCheckoutHostname(atLimit)).toBe(`c.${atLimit}`); + expect(managedCheckoutHostname(`a${atLimit}`)).toBeUndefined(); + }); +}); + +describe('resolveProvisionedCheckoutUrl', () => { + const storefront = 'catalyst.catalyst-sandbox.store'; + + test('returns the checkout URL once the hostname serves a certificate', async () => { + server.use( + http.head('https://c.catalyst.catalyst-sandbox.store/', () => + HttpResponse.json(null, { status: 302 }), + ), + ); + + await expect(resolveProvisionedCheckoutUrl(storefront)).resolves.toBe( + 'https://c.catalyst.catalyst-sandbox.store', + ); + }); + + // Setting a checkout URL whose certificate has not issued leaves checkout + // resolving without one, which is worse for a shopper than the inherited URL + // it would replace. So give up rather than write it. + test('gives up rather than set a URL that is not serving yet', async () => { + server.use(http.head('https://c.catalyst.catalyst-sandbox.store/', () => HttpResponse.error())); + + await expect( + resolveProvisionedCheckoutUrl(storefront, { timeoutMs: 0 }), + ).resolves.toBeUndefined(); + expect(consola.warn).toHaveBeenCalledWith(expect.stringContaining('not serving a certificate')); + }); + + test('gives up when the storefront hostname is too long to take a prefix', async () => { + const tooLong = `${'a'.repeat(63 - '.catalyst-sandbox.store'.length)}.catalyst-sandbox.store`; + + await expect(resolveProvisionedCheckoutUrl(tooLong)).resolves.toBeUndefined(); + expect(consola.warn).toHaveBeenCalledWith( + expect.stringContaining('too long to take a checkout prefix'), + ); + }); +}); diff --git a/packages/catalyst/src/cli/lib/checkout-url.ts b/packages/catalyst/src/cli/lib/checkout-url.ts index a2269759c..186580882 100644 --- a/packages/catalyst/src/cli/lib/checkout-url.ts +++ b/packages/catalyst/src/cli/lib/checkout-url.ts @@ -49,6 +49,128 @@ export function sharesMainDomain(a: string, b: string): boolean { // name, and every character here comes out of the project name's budget. export const MANAGED_ZONE_CHECKOUT_PREFIX = 'c.'; +// Cloudflare will not issue a certificate for a name longer than this, from +// the RFC 5280 limit on a certificate common name. +const MAX_HOSTNAME_LENGTH = 64; + +// How long native hosting can take to get a certificate onto a freshly +// provisioned checkout hostname. BigCommerce checks Cloudflare 60s after +// creating the custom hostname and then retries 10 times at 30s, so six +// minutes is the point past which it has given up rather than still working. +const CHECKOUT_HOSTNAME_READY_TIMEOUT_MS = 6 * 60 * 1000; +const CHECKOUT_HOSTNAME_POLL_INTERVAL_MS = 10 * 1000; + +// The checkout hostname native hosting provisions for a storefront on a managed +// zone. Derived rather than fetched: ignition builds the same name from the same +// prefix, so transporting it would only add a field that can disagree. +// +// Returns undefined when the result would exceed the certificate common-name +// limit. Hostnames generated before ignition reserved room for the prefix can be +// too long, and those projects have no checkout hostname to point at. +export function managedCheckoutHostname(storefrontHostname: string): string | undefined { + const hostname = MANAGED_ZONE_CHECKOUT_PREFIX + normalizeHostname(storefrontHostname); + + return hostname.length <= MAX_HOSTNAME_LENGTH ? hostname : undefined; +} + +// Whether the hostname terminates TLS with a certificate a client will accept. +// +// Any HTTP response means the handshake succeeded, which is the thing that +// matters; the status code is irrelevant, and checkout answers a bare GET with +// a redirect to the storefront when there is no cart. A rejected certificate or +// an unresolvable name throws, which is the signal we want. +async function checkoutHostnameIsServing(hostname: string): Promise { + try { + await fetch(`https://${hostname}/`, { + method: 'HEAD', + redirect: 'manual', + signal: AbortSignal.timeout(10_000), + }); + + return true; + } catch { + return false; + } +} + +// Waits for a freshly provisioned checkout hostname to serve a valid +// certificate. +// +// Worth the wait because `PUT .../site/checkout-url` does no certificate +// validation of its own: it accepts a hostname that shares a main domain with +// the storefront whether or not anything answers there. Setting it early leaves +// checkout resolving without a certificate, which is worse for a shopper than +// the inherited checkout URL it replaced. +export async function waitForCheckoutHostname( + hostname: string, + { timeoutMs = CHECKOUT_HOSTNAME_READY_TIMEOUT_MS, onWait }: CheckoutHostnameWaitOptions = {}, +): Promise { + const deadline = Date.now() + timeoutMs; + let waited = false; + + for (;;) { + // Sequential by nature: each probe asks whether the certificate has + // issued yet, so there is nothing to parallelise. + // eslint-disable-next-line no-await-in-loop + if (await checkoutHostnameIsServing(hostname)) return true; + + if (Date.now() + CHECKOUT_HOSTNAME_POLL_INTERVAL_MS >= deadline) return false; + + if (!waited) { + waited = true; + onWait?.(); + } + + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => setTimeout(resolve, CHECKOUT_HOSTNAME_POLL_INTERVAL_MS)); + } +} + +export interface CheckoutHostnameWaitOptions { + timeoutMs?: number; + // Called once, before the first sleep, so a caller can explain the pause + // rather than appearing to hang for minutes. + onWait?: () => void; +} + +// Resolves the checkout URL for a storefront on a managed hosting zone, waiting +// for native hosting to finish issuing its certificate. +// +// Returns undefined when there is nothing safe to set, leaving the caller on +// its existing path (a prompt, or no change) rather than writing a checkout URL +// that would not work. +export async function resolveProvisionedCheckoutUrl( + storefrontHostname: string, + { timeoutMs }: Pick = {}, +): Promise { + const hostname = managedCheckoutHostname(storefrontHostname); + + if (!hostname) { + consola.warn( + `${storefrontHostname} is too long to take a checkout prefix, so it has no checkout ` + + 'hostname. Checkout keeps its current URL.', + ); + + return undefined; + } + + const ready = await waitForCheckoutHostname(hostname, { + timeoutMs, + onWait: () => consola.info(`Waiting for ${hostname} to finish provisioning its certificate...`), + }); + + if (!ready) { + consola.warn( + `${hostname} is not serving a certificate yet, so checkout keeps its current URL. ` + + 'Re-run with `--update-checkout-url` once it is ready.', + ); + + return undefined; + } + + return `https://${hostname}`; +} + // The checkout subdomain a merchant most likely wants: // `https://www.example.com` → `https://checkout.example.com`. // From d7ee5812d7e447acca5bf25f5210231501710f91 Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Thu, 24 Sep 2026 14:34:15 -0500 Subject: [PATCH 02/15] LTRAC-1962: fix(cli) - Set the checkout URL first, then wait for its certificate The flow waited for the `c.` checkout hostname to serve a certificate and only then set it as the channel's checkout URL, expecting ignition to have provisioned the hostname. Testing on integration showed the checkout-url PUT is itself what provisions it: sites-service registers the hostname with bcserver, which creates the DNS record and Cloudflare custom hostname and issues the certificate. With ignition also registering it, the PUT failed with canonical-in-use; without ignition, waiting first could never finish. Now the flow writes the checkout URL, then waits up to six minutes for the certificate. If none issues it deletes the checkout URL, which also releases the hostname in bcserver, so checkout falls back to the default channel's rather than staying broken. An existing custom checkout URL is left alone, and a re-run that finds the hostname already set only waits. The managed-zone branch moves into runChannelCheckoutUrlFlow behind a `storefrontHostname` option, so deploy only passes the hostname through, and it only fires for hostnames on the zones native hosting generates. A merchant domain from `catalyst domains add` now prompts as before instead of having a `c.` subdomain registered for it. Refs LTRAC-1962 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ltrac-1962-derive-managed-checkout-url.md | 10 +- .../catalyst/src/cli/commands/deploy.spec.ts | 21 ++- packages/catalyst/src/cli/commands/deploy.ts | 9 +- .../cli/lib/channel-checkout-url-flow.spec.ts | 173 ++++++++++++++++++ .../src/cli/lib/channel-checkout-url-flow.ts | 118 +++++++++++- .../catalyst/src/cli/lib/checkout-url.spec.ts | 67 ++++--- packages/catalyst/src/cli/lib/checkout-url.ts | 59 +----- 7 files changed, 361 insertions(+), 96 deletions(-) diff --git a/.changeset/ltrac-1962-derive-managed-checkout-url.md b/.changeset/ltrac-1962-derive-managed-checkout-url.md index 7a3ee858d..9e73c9c35 100644 --- a/.changeset/ltrac-1962-derive-managed-checkout-url.md +++ b/.changeset/ltrac-1962-derive-managed-checkout-url.md @@ -2,12 +2,10 @@ "@bigcommerce/catalyst": patch --- -Set a channel's checkout URL to the checkout hostname native hosting provisions, instead of prompting for one. +Set a channel's checkout URL to its `c.` checkout hostname on a managed hosting zone, instead of prompting for one. -`catalyst deploy --update-site-url --update-checkout-url` asked the merchant to type a checkout URL, defaulting to a `checkout.` subdomain that nothing would ever provision on a managed hosting zone. It now derives the hostname that native hosting actually creates — the storefront hostname with a short prefix — and sets it without prompting. +`catalyst deploy --update-site-url --update-checkout-url` asked the merchant to type a checkout URL, defaulting to a `checkout.` subdomain that nothing would ever provision on a managed hosting zone. For a storefront on an auto-generated hostname (`.catalyst-sandbox.store`) it now sets `https://c..catalyst-sandbox.store` without prompting. -The hostname is derived rather than transported. ignition builds the same name from the same prefix, so carrying it over the API would only add a field that can disagree with reality, and would have required a `bc-interfaces` release. +Setting it is what provisions it: BigCommerce registers the hostname and issues its certificate in response to the write, usually within a couple of minutes. The CLI writes first, then waits for the certificate. If none issues within six minutes, when BigCommerce stops trying, it removes the checkout URL again so checkout falls back to the default channel's rather than staying broken. -Before writing, the CLI waits for that hostname to serve a valid certificate. `PUT /v3/channels/:id/site/checkout-url` performs no certificate validation of its own — it accepts any hostname sharing a main domain with the storefront, whether or not anything answers there — so setting it too early would leave checkout resolving without a certificate, which is worse for a shopper than the inherited checkout URL it replaced. Provisioning takes up to six minutes, and the CLI gives up with a warning rather than writing a URL that would not work. - -Nothing changes for a storefront on a custom domain, or when `--update-checkout-url` is used without `--update-site-url`: both still prompt, since there is no provisioned hostname to derive from. +It leaves an existing custom checkout URL alone, and on a re-run where the hostname is already set it only waits for the certificate. A storefront on a custom domain, or `--update-checkout-url` without `--update-site-url`, still prompts as before. diff --git a/packages/catalyst/src/cli/commands/deploy.spec.ts b/packages/catalyst/src/cli/commands/deploy.spec.ts index de5c4ebcb..4edc9a694 100644 --- a/packages/catalyst/src/cli/commands/deploy.spec.ts +++ b/packages/catalyst/src/cli/commands/deploy.spec.ts @@ -1093,8 +1093,23 @@ describe('--update-site-url', () => { data: { id: 1, url: 'https://project-one.catalyst-sandbox.store', channel_id: 2 }, }), ), - // The provisioned checkout hostname answering at all means its - // certificate is live, which is what the readiness check looks for. + // 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 }), ), @@ -1122,7 +1137,7 @@ describe('--update-site-url', () => { // 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: the hostname native hosting provisioned is not a guess. + // No prompt: on a managed zone the checkout hostname follows from the storefront. expect(vi.mocked(input)).not.toHaveBeenCalled(); }); diff --git a/packages/catalyst/src/cli/commands/deploy.ts b/packages/catalyst/src/cli/commands/deploy.ts index b37cbdecf..6e96d08c5 100644 --- a/packages/catalyst/src/cli/commands/deploy.ts +++ b/packages/catalyst/src/cli/commands/deploy.ts @@ -11,7 +11,6 @@ import { assertAuthorized } from '../lib/auth-errors'; import { loadBuildEnv } from '../lib/build-env'; import { runChannelCheckoutUrlFlow } from '../lib/channel-checkout-url-flow'; import { runChannelSiteUrlFlow } from '../lib/channel-site-flow'; -import { resolveProvisionedCheckoutUrl } from '../lib/checkout-url'; import { cleanupCloudflareIncompatibilities, NoLinkedProjectError, @@ -611,7 +610,7 @@ Example: // Carried over so both flags don't ask which channel twice. let resolvedChannelId: number | undefined; - // Set when --update-site-url ran, so the checkout flow below can derive the + // Set when --update-site-url ran, so the checkout flow below can use the // checkout hostname that pairs with whichever hostname was actually used. let siteHostname: string | undefined; @@ -631,16 +630,12 @@ Example: if (options.updateCheckoutUrl) { try { - // Without a site URL update there is no hostname to derive from, so the - // flow prompts as before. `url` skips the prompt when we do know it. - const url = siteHostname ? await resolveProvisionedCheckoutUrl(siteHostname) : undefined; - await runChannelCheckoutUrlFlow({ storeHash, accessToken, apiHost, channelId: resolvedChannelId, - url, + storefrontHostname: siteHostname, }); } catch (error) { warnChannelFlowFailed('checkout URL', error); diff --git a/packages/catalyst/src/cli/lib/channel-checkout-url-flow.spec.ts b/packages/catalyst/src/cli/lib/channel-checkout-url-flow.spec.ts index 9cf10b846..b13b51a02 100644 --- a/packages/catalyst/src/cli/lib/channel-checkout-url-flow.spec.ts +++ b/packages/catalyst/src/cli/lib/channel-checkout-url-flow.spec.ts @@ -169,3 +169,176 @@ describe('runChannelCheckoutUrlFlow', () => { expect(called).toBe(false); }); }); + +describe('runChannelCheckoutUrlFlow on a managed hosting zone', () => { + const storefrontHostname = 'project-one.catalyst-sandbox.store'; + const checkoutUrl = 'https://c.project-one.catalyst-sandbox.store'; + const probe = 'https://c.project-one.catalyst-sandbox.store/'; + + const siteWith = (isCheckoutUrlCustomized: boolean, checkout?: string) => + http.get(sitePath, () => + HttpResponse.json({ + data: { + id: 1, + url: `https://${storefrontHostname}`, + channel_id: 2, + ssl_status: null, + is_checkout_url_customized: isCheckoutUrlCustomized, + urls: [ + { url: `https://${storefrontHostname}`, type: 'primary' }, + ...(checkout ? [{ url: checkout, type: 'checkout' }] : []), + ], + }, + }), + ); + + const trackWrites = () => { + const writes: { put: unknown; deleted: boolean } = { put: undefined, deleted: false }; + + server.use( + http.put(checkoutPath, async ({ request }) => { + writes.put = await request.json(); + + return HttpResponse.json({ data: { id: 1, url: checkoutUrl, channel_id: 2 } }); + }), + http.delete(checkoutPath, () => { + writes.deleted = true; + + return new HttpResponse(null, { status: 204 }); + }), + ); + + return writes; + }; + + const run = (overrides: Partial[0]> = {}) => + runChannelCheckoutUrlFlow({ ...api, channelId: 2, storefrontHostname, ...overrides }); + + // The write is what provisions the hostname, so it can't wait for the + // certificate first — it writes, then waits. + test('sets the checkout hostname without prompting, then waits for its certificate', async () => { + const writes = trackWrites(); + + server.use( + siteWith(false, 'https://store-abc-1.mybigcommerce.com'), + http.head(probe, () => HttpResponse.json(null, { status: 302 })), + ); + + await run(); + + expect(writes.put).toEqual({ url: checkoutUrl }); + expect(inputMock).not.toHaveBeenCalled(); + expect(consola.success).toHaveBeenCalledWith( + 'c.project-one.catalyst-sandbox.store is serving checkout.', + ); + }); + + test('explains the wait while the certificate issues', async () => { + vi.useFakeTimers({ toFake: ['setTimeout'] }); + + let probes = 0; + + trackWrites(); + server.use( + siteWith(false), + http.head(probe, () => { + probes += 1; + + return probes === 1 ? HttpResponse.error() : HttpResponse.json(null, { status: 302 }); + }), + ); + + const done = run(); + + while (probes === 0 || vi.getTimerCount() === 0) { + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => setImmediate(resolve)); + } + + await vi.advanceTimersByTimeAsync(10_000); + await done; + + expect(consola.info).toHaveBeenCalledWith( + expect.stringContaining('Waiting for c.project-one.catalyst-sandbox.store'), + ); + + vi.useRealTimers(); + }); + + // Checkout on a hostname without a certificate is broken for shoppers, so + // falling back to the default channel's checkout is the better failure. + test('removes the checkout URL when the certificate never issues', async () => { + const writes = trackWrites(); + + server.use( + siteWith(false), + http.head(probe, () => HttpResponse.error()), + ); + + await run({ certificateTimeoutMs: 0 }); + + expect(writes.put).toEqual({ url: checkoutUrl }); + expect(writes.deleted).toBe(true); + expect(consola.warn).toHaveBeenCalledWith( + expect.stringContaining("wasn't issued a certificate in time"), + ); + }); + + // Writing it again would ask BigCommerce to register a hostname it holds. + test('only waits when the checkout hostname is already set', async () => { + const writes = trackWrites(); + + server.use( + siteWith(true, `${checkoutUrl}/`), + http.head(probe, () => HttpResponse.json(null, { status: 302 })), + ); + + await run(); + + expect(writes.put).toBeUndefined(); + expect(consola.success).toHaveBeenCalledWith( + 'c.project-one.catalyst-sandbox.store is serving checkout.', + ); + }); + + test('leaves a different custom checkout URL alone', async () => { + const writes = trackWrites(); + + server.use(siteWith(true, 'https://checkout.example.com')); + + await run({ channelName: 'Storefront' }); + + expect(writes.put).toBeUndefined(); + expect(consola.info).toHaveBeenCalledWith( + expect.stringContaining('Channel "Storefront" (2) already has a custom checkout URL'), + ); + }); + + test('leaves checkout alone when the hostname is too long to take a prefix', async () => { + const writes = trackWrites(); + const tooLong = `${'a'.repeat(63 - '.catalyst-sandbox.store'.length)}.catalyst-sandbox.store`; + + await run({ storefrontHostname: tooLong }); + + expect(writes.put).toBeUndefined(); + expect(inputMock).not.toHaveBeenCalled(); + expect(consola.warn).toHaveBeenCalledWith( + expect.stringContaining('too long to take a checkout prefix'), + ); + }); + + // Off the managed zone the `c.` subdomain is the merchant's DNS, so there is + // nothing to derive and the flow prompts as before. A merchant domain added + // with `catalyst domains add` is listed among the project's hostnames, which + // is exactly the case that must not be mistaken for the managed zone. + test('prompts for a merchant domain listed among the project hostnames', async () => { + const writes = trackWrites(); + + inputMock.mockResolvedValueOnce('https://checkout.project-one.example.com'); + + await run({ storefrontHostname: 'vanity.project-one.example.com' }); + + expect(inputMock).toHaveBeenCalled(); + expect(writes.put).toEqual({ url: 'https://checkout.project-one.example.com' }); + }); +}); diff --git a/packages/catalyst/src/cli/lib/channel-checkout-url-flow.ts b/packages/catalyst/src/cli/lib/channel-checkout-url-flow.ts index 2251f25ec..30e18b327 100644 --- a/packages/catalyst/src/cli/lib/channel-checkout-url-flow.ts +++ b/packages/catalyst/src/cli/lib/channel-checkout-url-flow.ts @@ -1,8 +1,19 @@ import { input } from '@inquirer/prompts'; import { resolveChannel } from './channel-site-flow'; -import { findChannelSiteUrl, getChannelSite, updateChannelCheckoutUrl } from './channels'; -import { normalizeCheckoutUrl, suggestCheckoutUrl } from './checkout-url'; +import { + deleteChannelCheckoutUrl, + findChannelSiteUrl, + getChannelSite, + updateChannelCheckoutUrl, +} from './channels'; +import { + isManagedHostingHostname, + managedCheckoutHostname, + normalizeCheckoutUrl, + suggestCheckoutUrl, + waitForCheckoutHostname, +} from './checkout-url'; import { consola } from './logger'; export interface ChannelCheckoutUrlFlowOptions { @@ -17,6 +28,12 @@ export interface ChannelCheckoutUrlFlowOptions { // the flow neither re-prompts nor re-reads. channelName?: string; storefrontUrl?: string; + // The hostname the storefront was just pointed at. On a managed hosting zone + // the checkout hostname follows from it, so the prompt is skipped. + storefrontHostname?: string; + // How long to wait for that hostname's certificate. Defaults to the point + // BigCommerce gives up issuing it. + certificateTimeoutMs?: number; } // Prompts for a checkout URL and writes it. @@ -34,6 +51,19 @@ export async function runChannelCheckoutUrlFlow( : await resolveChannel(options); const label = channel.name ? `"${channel.name}" (${channel.id})` : String(channel.id); + if ( + options.storefrontHostname !== undefined && + (await setManagedCheckoutUrl({ + ...options, + channelId: channel.id, + label, + storefrontHostname: options.storefrontHostname, + timeoutMs: options.certificateTimeoutMs, + })) + ) { + return; + } + let storefrontUrl = options.storefrontUrl; // The storefront URL only feeds the prompt default, so skip the read when the @@ -80,3 +110,87 @@ export async function runChannelCheckoutUrlFlow( consola.success(`Updated channel ${label} checkout URL to ${checkoutUrl}.`); } + +interface ManagedCheckoutUrlOptions { + storeHash: string; + accessToken: string; + apiHost: string; + channelId: number; + label: string; + storefrontHostname: string; + timeoutMs?: number; +} + +// Points a channel whose storefront is on a managed hosting zone at its `c.` +// checkout hostname. +// +// The write comes first because it is what provisions the hostname: BigCommerce +// registers it and issues its certificate in response. Checkout fails there +// until the certificate issues, usually within a couple of minutes, so if it +// never does the write is undone rather than leaving checkout broken. +// +// Resolves false when the storefront isn't on a managed zone, so the caller +// prompts instead. +async function setManagedCheckoutUrl(options: ManagedCheckoutUrlOptions): Promise { + const { storeHash, accessToken, apiHost, channelId, label, storefrontHostname } = options; + + // On a custom domain the `c.` subdomain is the merchant's DNS, not ours. + if (!isManagedHostingHostname(storefrontHostname)) return false; + + const hostname = managedCheckoutHostname(storefrontHostname); + + if (!hostname) { + consola.warn( + `${storefrontHostname} is too long to take a checkout prefix, so it has no checkout ` + + 'hostname. Checkout keeps its current URL.', + ); + + return true; + } + + const checkoutUrl = `https://${hostname}`; + const site = await getChannelSite(channelId, storeHash, accessToken, apiHost); + + if (site.isCheckoutUrlCustomized) { + // A re-run finds it already set; writing it again would ask BigCommerce to + // register a hostname it already holds. + const alreadySet = site.urls.some( + (entry) => entry.type === 'checkout' && entry.url.replace(/\/$/, '') === checkoutUrl, + ); + + if (!alreadySet) { + consola.info( + `Channel ${label} already has a custom checkout URL, so it was left alone. To replace ` + + `it, run \`catalyst channels update --channel-id ${channelId} --checkout-url ${checkoutUrl}\`.`, + ); + + return true; + } + } else { + await updateChannelCheckoutUrl(channelId, checkoutUrl, storeHash, accessToken, apiHost); + consola.success(`Updated channel ${label} checkout URL to ${checkoutUrl}.`); + } + + const ready = await waitForCheckoutHostname(hostname, { + timeoutMs: options.timeoutMs, + onWait: () => + consola.info( + `Waiting for ${hostname} to be issued a certificate. This usually takes a minute or two...`, + ), + }); + + if (ready) { + consola.success(`${hostname} is serving checkout.`); + + return true; + } + + await deleteChannelCheckoutUrl(channelId, storeHash, accessToken, apiHost); + consola.warn( + `${hostname} wasn't issued a certificate in time, so the checkout URL was removed and ` + + "checkout falls back to the default channel's. Re-run with `--update-checkout-url` to " + + 'try again.', + ); + + return true; +} diff --git a/packages/catalyst/src/cli/lib/checkout-url.spec.ts b/packages/catalyst/src/cli/lib/checkout-url.spec.ts index 82f133bc8..6995ac323 100644 --- a/packages/catalyst/src/cli/lib/checkout-url.spec.ts +++ b/packages/catalyst/src/cli/lib/checkout-url.spec.ts @@ -1,11 +1,14 @@ +import { http, HttpResponse } from 'msw'; import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; +import { server } from '../../../tests/mocks/node'; + import { type ChannelSiteDetails } from './channels'; import { managedCheckoutHostname, - resolveProvisionedCheckoutUrl, sharesMainDomain, suggestCheckoutUrl, + waitForCheckoutHostname, warnOnCrossDomainCheckout, } from './checkout-url'; import { consola } from './logger'; @@ -352,39 +355,51 @@ describe('managedCheckoutHostname', () => { }); }); -describe('resolveProvisionedCheckoutUrl', () => { - const storefront = 'catalyst.catalyst-sandbox.store'; +describe('waitForCheckoutHostname', () => { + const hostname = 'c.catalyst.catalyst-sandbox.store'; + const probe = `https://${hostname}/`; - test('returns the checkout URL once the hostname serves a certificate', async () => { - server.use( - http.head('https://c.catalyst.catalyst-sandbox.store/', () => - HttpResponse.json(null, { status: 302 }), - ), - ); + // Any response means the TLS handshake succeeded; checkout answers a bare + // request with a redirect. + test('resolves true once the hostname serves', async () => { + server.use(http.head(probe, () => HttpResponse.json(null, { status: 302 }))); - await expect(resolveProvisionedCheckoutUrl(storefront)).resolves.toBe( - 'https://c.catalyst.catalyst-sandbox.store', - ); + await expect(waitForCheckoutHostname(hostname)).resolves.toBe(true); }); - // Setting a checkout URL whose certificate has not issued leaves checkout - // resolving without one, which is worse for a shopper than the inherited URL - // it would replace. So give up rather than write it. - test('gives up rather than set a URL that is not serving yet', async () => { - server.use(http.head('https://c.catalyst.catalyst-sandbox.store/', () => HttpResponse.error())); + test('resolves false when the hostname never serves', async () => { + server.use(http.head(probe, () => HttpResponse.error())); - await expect( - resolveProvisionedCheckoutUrl(storefront, { timeoutMs: 0 }), - ).resolves.toBeUndefined(); - expect(consola.warn).toHaveBeenCalledWith(expect.stringContaining('not serving a certificate')); + await expect(waitForCheckoutHostname(hostname, { timeoutMs: 0 })).resolves.toBe(false); }); - test('gives up when the storefront hostname is too long to take a prefix', async () => { - const tooLong = `${'a'.repeat(63 - '.catalyst-sandbox.store'.length)}.catalyst-sandbox.store`; + test('explains the pause once, then retries', async () => { + vi.useFakeTimers({ toFake: ['setTimeout'] }); + + let probes = 0; - await expect(resolveProvisionedCheckoutUrl(tooLong)).resolves.toBeUndefined(); - expect(consola.warn).toHaveBeenCalledWith( - expect.stringContaining('too long to take a checkout prefix'), + server.use( + http.head(probe, () => { + probes += 1; + + return probes === 1 ? HttpResponse.error() : HttpResponse.json(null, { status: 302 }); + }), ); + + const onWait = vi.fn(); + const ready = waitForCheckoutHostname(hostname, { timeoutMs: 60_000, onWait }); + + while (onWait.mock.calls.length === 0) { + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => setImmediate(resolve)); + } + + await vi.advanceTimersByTimeAsync(10_000); + + await expect(ready).resolves.toBe(true); + expect(onWait).toHaveBeenCalledTimes(1); + expect(probes).toBe(2); + + vi.useRealTimers(); }); }); diff --git a/packages/catalyst/src/cli/lib/checkout-url.ts b/packages/catalyst/src/cli/lib/checkout-url.ts index 01caf7e60..731c5353e 100644 --- a/packages/catalyst/src/cli/lib/checkout-url.ts +++ b/packages/catalyst/src/cli/lib/checkout-url.ts @@ -44,16 +44,15 @@ export const MANAGED_ZONE_CHECKOUT_PREFIX = 'c.'; // the RFC 5280 limit on a certificate common name. const MAX_HOSTNAME_LENGTH = 64; -// How long native hosting can take to get a certificate onto a freshly -// provisioned checkout hostname. BigCommerce checks Cloudflare 60s after -// creating the custom hostname and then retries 10 times at 30s, so six -// minutes is the point past which it has given up rather than still working. +// How long BigCommerce can take to get a certificate onto a freshly provisioned +// checkout hostname. It checks Cloudflare 60s after creating the custom +// hostname and then retries 10 times at 30s, so six minutes is the point past +// which it has given up rather than still working. const CHECKOUT_HOSTNAME_READY_TIMEOUT_MS = 6 * 60 * 1000; const CHECKOUT_HOSTNAME_POLL_INTERVAL_MS = 10 * 1000; -// The checkout hostname native hosting provisions for a storefront on a managed -// zone. Derived rather than fetched: ignition builds the same name from the same -// prefix, so transporting it would only add a field that can disagree. +// The checkout hostname for a storefront on a managed zone. Nothing needs to +// exist beforehand: setting it as the checkout URL is what provisions it. // // Returns undefined when the result would exceed the certificate common-name // limit. Hostnames generated before ignition reserved room for the prefix can be @@ -85,13 +84,7 @@ async function checkoutHostnameIsServing(hostname: string): Promise { } // Waits for a freshly provisioned checkout hostname to serve a valid -// certificate. -// -// Worth the wait because `PUT .../site/checkout-url` does no certificate -// validation of its own: it accepts a hostname that shares a main domain with -// the storefront whether or not anything answers there. Setting it early leaves -// checkout resolving without a certificate, which is worse for a shopper than -// the inherited checkout URL it replaced. +// certificate. Until it does, checkout on that hostname fails for shoppers. export async function waitForCheckoutHostname( hostname: string, { timeoutMs = CHECKOUT_HOSTNAME_READY_TIMEOUT_MS, onWait }: CheckoutHostnameWaitOptions = {}, @@ -124,44 +117,6 @@ export interface CheckoutHostnameWaitOptions { onWait?: () => void; } -// Resolves the checkout URL for a storefront on a managed hosting zone, waiting -// for native hosting to finish issuing its certificate. -// -// Returns undefined when there is nothing safe to set, leaving the caller on -// its existing path (a prompt, or no change) rather than writing a checkout URL -// that would not work. -export async function resolveProvisionedCheckoutUrl( - storefrontHostname: string, - { timeoutMs }: Pick = {}, -): Promise { - const hostname = managedCheckoutHostname(storefrontHostname); - - if (!hostname) { - consola.warn( - `${storefrontHostname} is too long to take a checkout prefix, so it has no checkout ` + - 'hostname. Checkout keeps its current URL.', - ); - - return undefined; - } - - const ready = await waitForCheckoutHostname(hostname, { - timeoutMs, - onWait: () => consola.info(`Waiting for ${hostname} to finish provisioning its certificate...`), - }); - - if (!ready) { - consola.warn( - `${hostname} is not serving a certificate yet, so checkout keeps its current URL. ` + - 'Re-run with `--update-checkout-url` once it is ready.', - ); - - return undefined; - } - - return `https://${hostname}`; -} - // The checkout subdomain a merchant most likely wants: // `https://www.example.com` → `https://checkout.example.com`. // From 08979a474cb0ceb821fcbdc438a5ac4c3fd16e40 Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Thu, 24 Sep 2026 15:32:05 -0500 Subject: [PATCH 03/15] LTRAC-2014: feat(cli) - Offer to align the channel's URLs after deploy A deploy without --update-site-url or --update-checkout-url left the channel on its mybigcommerce.com storefront URL and the default channel's checkout, and said nothing, so the address bar changed domain at payment unless the merchant knew the flags. After an interactive deploy, if the channel the build targets doesn't already point at the project, ask whether to update its site URL, with that channel and the new hostname pre-selected. If the site then sits on an auto-generated hostname and checkout is elsewhere, ask whether to move checkout to c., using the same write-then-wait flow as the flag. Declining that prints the cross-domain warning instead. Declining the site URL is saved per channel in project.json so later deploys stay quiet. Checkout declines aren't saved, since the site URL prompt that leads to it doesn't come back. Explicit flags, channels update, and deploys without a TTY behave as before. Refs LTRAC-2014 Co-Authored-By: Claude Opus 5.5 (1M context) --- ...ac-2014-offer-channel-urls-after-deploy.md | 12 + packages/catalyst/src/cli/commands/deploy.ts | 23 ++ .../catalyst/src/cli/lib/channel-site-flow.ts | 11 + packages/catalyst/src/cli/lib/checkout-url.ts | 11 + .../src/cli/lib/deploy-channel-urls.spec.ts | 238 ++++++++++++++++++ .../src/cli/lib/deploy-channel-urls.ts | 127 ++++++++++ .../catalyst/src/cli/lib/project-config.ts | 7 + 7 files changed, 429 insertions(+) create mode 100644 .changeset/ltrac-2014-offer-channel-urls-after-deploy.md create mode 100644 packages/catalyst/src/cli/lib/deploy-channel-urls.spec.ts create mode 100644 packages/catalyst/src/cli/lib/deploy-channel-urls.ts diff --git a/.changeset/ltrac-2014-offer-channel-urls-after-deploy.md b/.changeset/ltrac-2014-offer-channel-urls-after-deploy.md new file mode 100644 index 000000000..5b7b792b9 --- /dev/null +++ b/.changeset/ltrac-2014-offer-channel-urls-after-deploy.md @@ -0,0 +1,12 @@ +--- +"@bigcommerce/catalyst": patch +--- + +After an interactive `catalyst deploy`, offer to point the deployed channel at the deployment. + +Without `--update-site-url` or `--update-checkout-url`, a deploy used to leave the channel on its `mybigcommerce.com` storefront URL and the default channel's checkout, and said nothing about it. Now, when the deploy runs in a terminal and the channel it was built for (`BIGCOMMERCE_CHANNEL_ID`) doesn't already point at the project: + +1. It asks whether to update the channel's site URL, then prompts for the channel (the deployed one is pre-selected) and the hostname (the new deployment is pre-selected). +2. If the site URL is now on an auto-generated hostname and checkout is on another domain, it asks whether to move checkout to `https://c..`, and does so the same way `--update-checkout-url` does. Declining prints the cross-domain checkout warning instead. + +Declining the site URL is saved per channel in `.bigcommerce/project.json` (`declinedSiteUrlChannels`), and later deploys don't ask again. `catalyst channels update` and the `--update-*` flags still work regardless. Scripted deploys without a TTY, and deploys that pass either flag, behave as before. diff --git a/packages/catalyst/src/cli/commands/deploy.ts b/packages/catalyst/src/cli/commands/deploy.ts index 6e96d08c5..dec2aa578 100644 --- a/packages/catalyst/src/cli/commands/deploy.ts +++ b/packages/catalyst/src/cli/commands/deploy.ts @@ -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 { @@ -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 once +per channel to point the channel's site URL at the deployment, then to move its checkout +onto the same domain. Declining is saved in .bigcommerce/project.json. + Example: $ catalyst deploy --secret BIGCOMMERCE_STORE_HASH= --secret BIGCOMMERCE_STOREFRONT_TOKEN=`, ) @@ -641,4 +646,22 @@ Example: warnChannelFlowFailed('checkout URL', error); } } + + // Neither flag: offer both, once per channel. 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); + } + } }); diff --git a/packages/catalyst/src/cli/lib/channel-site-flow.ts b/packages/catalyst/src/cli/lib/channel-site-flow.ts index ee1035583..0f76eff1c 100644 --- a/packages/catalyst/src/cli/lib/channel-site-flow.ts +++ b/packages/catalyst/src/cli/lib/channel-site-flow.ts @@ -28,6 +28,11 @@ export interface ChannelSiteFlowOptions { // `catalyst deploy --update-site-url` to default to the freshly-deployed // hostname. preferHostname?: string; + // Pre-selected in the channel prompt. + defaultChannelId?: number; + // Warn afterwards when checkout is left on another domain. Off for a caller + // that offers to fix it next, so the warning isn't printed before the offer. + diagnoseCheckout?: boolean; } async function resolveProject(options: ChannelSiteFlowOptions): Promise { @@ -58,6 +63,9 @@ export async function resolveChannel(options: { accessToken: string; apiHost: string; channelId?: number; + // Pre-selected in the picker, e.g. the channel `catalyst deploy` just + // deployed to. + defaultChannelId?: number; // Overrides the picker copy for callers that aren't about to write anything — // `channels info` only reads, so "to update" would misdescribe it. message?: string; @@ -89,6 +97,7 @@ export async function resolveChannel(options: { const id = await select({ message: options.message ?? 'Select the channel to update.', + default: options.defaultChannelId, choices: catalystChannels.map((c: Channel) => ({ name: c.name, value: c.id, @@ -164,6 +173,8 @@ export async function runChannelSiteUrlFlow( // Moving the site URL is when checkout is most likely left behind. The PUT // response above carries no `urls`, hence the re-fetch. Soft-failed: the write // already succeeded, so a failed diagnostic mustn't look like a failed write. + if (options.diagnoseCheckout === false) return { channelId: channel.id, hostname }; + try { const site = await getChannelSite( channel.id, diff --git a/packages/catalyst/src/cli/lib/checkout-url.ts b/packages/catalyst/src/cli/lib/checkout-url.ts index 731c5353e..a084b4312 100644 --- a/packages/catalyst/src/cli/lib/checkout-url.ts +++ b/packages/catalyst/src/cli/lib/checkout-url.ts @@ -194,6 +194,17 @@ export function isManagedHostingHostname(hostname: string): boolean { return NATIVE_HOSTING_ZONES.some((zone) => isSubdomainOf(host, zone)); } +// Whether a channel's checkout sits on a different main domain from its +// storefront. The same test `warnOnCrossDomainCheckout` applies, without the +// output, for a caller deciding whether to offer a fix. +export function isCrossDomainCheckout(site: ChannelSiteDetails): boolean { + const storefrontHost = hostnameOf(findChannelSiteUrl(site, 'primary') ?? site.url); + const checkoutUrl = findChannelSiteUrl(site, 'checkout'); + const checkoutHost = checkoutUrl ? hostnameOf(checkoutUrl) : undefined; + + return Boolean(storefrontHost && checkoutHost && !sharesMainDomain(storefrontHost, checkoutHost)); +} + export interface CheckoutDomainReport { // True only when both hostnames were readable and don't share a registrable // domain. A missing or unreadable checkout URL is not "cross domain". diff --git a/packages/catalyst/src/cli/lib/deploy-channel-urls.spec.ts b/packages/catalyst/src/cli/lib/deploy-channel-urls.spec.ts new file mode 100644 index 000000000..7303af143 --- /dev/null +++ b/packages/catalyst/src/cli/lib/deploy-channel-urls.spec.ts @@ -0,0 +1,238 @@ +import { confirm, select } from '@inquirer/prompts'; +import { http, HttpResponse } from 'msw'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { server } from '../../../tests/mocks/node'; + +import { deployedChannelId, offerChannelUrlUpdates } from './deploy-channel-urls'; +import { consola } from './logger'; +import { getProjectConfig } from './project-config'; + +vi.mock('@inquirer/prompts', () => ({ + select: vi.fn(), + confirm: vi.fn(), + input: vi.fn(), +})); + +const confirmMock = vi.mocked(confirm); +const selectMock = vi.mocked(select); + +const storeHash = 'test-store'; +const accessToken = 'test-token'; +const apiHost = 'api.bigcommerce.com'; +const projectUuid = 'a23f5785-fd99-4a94-9fb3-945551623923'; +const storefront = 'project-one.catalyst-sandbox.store'; + +const sitePath = 'https://:apiHost/stores/:storeHash/v3/channels/:channelId/site'; +const checkoutPath = `${sitePath}/checkout-url`; + +let dir: string; +let config: ReturnType; + +// A channel still on its canonical storefront URL and the default channel's +// checkout, as a fresh one is. Writing the site URL moves the primary, so the +// reads after it see the new one. +const freshChannel = () => { + const writes: { site?: unknown; checkout?: unknown } = {}; + let primary = 'https://store-abc-2.mybigcommerce.com'; + + server.use( + http.get(sitePath, () => + 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' }, + ], + }, + }), + ), + http.put(sitePath, async ({ request }) => { + const body: unknown = await request.json(); + + writes.site = body; + primary = + typeof body === 'object' && body !== null && 'url' in body && typeof body.url === 'string' + ? body.url + : primary; + + return HttpResponse.json({ data: { id: 1, url: primary, channel_id: 2 } }); + }), + http.put(checkoutPath, async ({ request }) => { + writes.checkout = await request.json(); + + return HttpResponse.json({ data: { id: 1, url: primary, channel_id: 2 } }); + }), + http.head(`https://c.${storefront}/`, () => HttpResponse.json(null, { status: 302 })), + ); + + return writes; +}; + +const run = (overrides: { channelId?: number } = { channelId: 2 }) => + offerChannelUrlUpdates({ + storeHash, + accessToken, + apiHost, + projectUuid, + config, + deploymentHostname: storefront, + ...overrides, + }); + +// The picker order in runChannelSiteUrlFlow: channel, then hostname. +const pickChannelAndHostname = () => + selectMock.mockResolvedValueOnce(2).mockResolvedValueOnce(storefront); + +beforeAll(() => { + consola.mockTypes(() => vi.fn()); +}); + +beforeEach(() => { + vi.clearAllMocks(); + dir = mkdtempSync(join(tmpdir(), 'deploy-channel-urls-')); + config = getProjectConfig(dir); + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); +}); + +afterEach(() => { + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + rmSync(dir, { recursive: true, force: true }); +}); + +describe('offerChannelUrlUpdates', () => { + test('points the site and checkout at the deployment when both are accepted', async () => { + const writes = freshChannel(); + + confirmMock.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + pickChannelAndHostname(); + + await run(); + + expect(writes.site).toEqual({ url: `https://${storefront}` }); + expect(writes.checkout).toEqual({ url: `https://c.${storefront}` }); + // The deployed channel is pre-selected, not assumed. + expect(selectMock).toHaveBeenCalledWith(expect.objectContaining({ default: 2 })); + }); + + // Declining is remembered for the channel so later deploys stay quiet. + test('saves a declined site URL and does not ask again', async () => { + const writes = freshChannel(); + + confirmMock.mockResolvedValueOnce(false); + + await run(); + + expect(writes.site).toBeUndefined(); + expect(config.get('declinedSiteUrlChannels')).toEqual([2]); + + vi.clearAllMocks(); + await run(); + + expect(confirmMock).not.toHaveBeenCalled(); + }); + + // Declining the checkout URL isn't saved: the site URL prompt that leads to + // it won't come back, so the warning is the last word. + test('warns instead of moving checkout when that is declined', async () => { + const writes = freshChannel(); + + confirmMock.mockResolvedValueOnce(true).mockResolvedValueOnce(false); + pickChannelAndHostname(); + + await run(); + + expect(writes.site).toEqual({ url: `https://${storefront}` }); + expect(writes.checkout).toBeUndefined(); + expect(consola.warn).toHaveBeenCalledWith(expect.stringContaining("default channel's domain")); + expect(config.get('declinedSiteUrlChannels')).toBeUndefined(); + }); + + test('does not offer when the channel already points at the project', async () => { + freshChannel(); + server.use( + http.get(sitePath, () => + HttpResponse.json({ + data: { + id: 1, + url: `https://${storefront}/`, + channel_id: 2, + ssl_status: null, + is_checkout_url_customized: false, + urls: [{ url: `https://${storefront}/`, type: 'primary' }], + }, + }), + ), + ); + + await run(); + + expect(confirmMock).not.toHaveBeenCalled(); + }); + + // A merchant domain's checkout is theirs to set up, so there is no `c.` + // offer; the diagnostic explains what to do instead. + test('warns without offering checkout for a merchant domain', async () => { + const writes = freshChannel(); + + confirmMock.mockResolvedValueOnce(true); + selectMock.mockResolvedValueOnce(2).mockResolvedValueOnce('vanity.project-one.example.com'); + + await run(); + + expect(confirmMock).toHaveBeenCalledTimes(1); + expect(writes.checkout).toBeUndefined(); + expect(consola.info).toHaveBeenCalledWith( + expect.stringContaining('checkout.vanity.project-one.example.com at BigCommerce'), + ); + }); + + test('stays quiet without a TTY or a known channel', async () => { + freshChannel(); + + await run({}); + + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + await run(); + + expect(confirmMock).not.toHaveBeenCalled(); + }); +}); + +describe('deployedChannelId', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + test('reads the build env first, then a stored secret', () => { + vi.stubEnv('BIGCOMMERCE_CHANNEL_ID', '7'); + + expect(deployedChannelId([{ type: 'secret', key: 'BIGCOMMERCE_CHANNEL_ID', value: '9' }])).toBe( + 7, + ); + + vi.unstubAllEnvs(); + delete process.env.BIGCOMMERCE_CHANNEL_ID; + + expect(deployedChannelId([{ type: 'secret', key: 'BIGCOMMERCE_CHANNEL_ID', value: '9' }])).toBe( + 9, + ); + }); + + test('ignores a missing or malformed value', () => { + delete process.env.BIGCOMMERCE_CHANNEL_ID; + + expect(deployedChannelId([])).toBeUndefined(); + expect( + deployedChannelId([{ type: 'secret', key: 'BIGCOMMERCE_CHANNEL_ID', value: 'abc' }]), + ).toBeUndefined(); + }); +}); diff --git a/packages/catalyst/src/cli/lib/deploy-channel-urls.ts b/packages/catalyst/src/cli/lib/deploy-channel-urls.ts new file mode 100644 index 000000000..31715df74 --- /dev/null +++ b/packages/catalyst/src/cli/lib/deploy-channel-urls.ts @@ -0,0 +1,127 @@ +import { confirm } from '@inquirer/prompts'; + +import { runChannelCheckoutUrlFlow } from './channel-checkout-url-flow'; +import { runChannelSiteUrlFlow } from './channel-site-flow'; +import { findChannelSiteUrl, getChannelSite } from './channels'; +import { + isCrossDomainCheckout, + isManagedHostingHostname, + MANAGED_ZONE_CHECKOUT_PREFIX, + warnOnCrossDomainCheckout, +} from './checkout-url'; +import { type DeploymentSecret } from './env-config'; +import { consola } from './logger'; +import { fetchProjects } from './project'; +import { type getProjectConfig } from './project-config'; + +export interface DeployChannelUrlOptions { + storeHash: string; + accessToken: string; + apiHost: string; + projectUuid: string; + config: ReturnType; + // The hostname the deploy just went live on. + deploymentHostname?: string; + // The channel the storefront was built for, if known. + channelId?: number; +} + +// The channel a deployment serves: the build reads `BIGCOMMERCE_CHANNEL_ID` +// from the env files, and a stored secret carries it when the build was skipped +// with `--prebuilt`. +export function deployedChannelId(secrets: DeploymentSecret[]): number | undefined { + const raw = + process.env.BIGCOMMERCE_CHANNEL_ID ?? + secrets.find((secret) => secret.key === 'BIGCOMMERCE_CHANNEL_ID')?.value; + const id = Number(raw); + + return Number.isInteger(id) && id > 0 ? id : undefined; +} + +// After an interactive deploy, offers to point the deployed channel at the new +// hostname, then to move checkout onto the same domain. +// +// Asked once per channel: declining the site URL is saved in project.json. +// Declining the checkout URL isn't, because the site URL prompt that leads to +// it won't come back either; the merchant is warned instead. +export async function offerChannelUrlUpdates(options: DeployChannelUrlOptions): Promise { + const { storeHash, accessToken, apiHost, projectUuid, config, channelId } = options; + + // No channel means nothing to key the opt-out on, and scripted deploys keep + // the flag-only behaviour. + if (!process.stdin.isTTY || channelId === undefined) return; + + const declined = config.get('declinedSiteUrlChannels') ?? []; + + if (declined.includes(channelId)) return; + + const [site, projects] = await Promise.all([ + getChannelSite(channelId, storeHash, accessToken, apiHost), + fetchProjects(storeHash, accessToken, apiHost), + ]); + const deploymentHostnames = + projects.find((project) => project.uuid === projectUuid)?.deployment_hostnames ?? []; + const storefrontUrl = (findChannelSiteUrl(site, 'primary') ?? site.url).replace(/\/$/, ''); + + // Already pointed at this project; nothing to offer. + if (deploymentHostnames.some((deployed) => storefrontUrl === `https://${deployed}`)) return; + + const shouldUpdate = await confirm({ + message: `Channel ${channelId}'s site URL is ${storefrontUrl}. Point it at this deployment?`, + default: true, + }); + + if (!shouldUpdate) { + config.set('declinedSiteUrlChannels', [...declined, channelId]); + consola.info( + `Won't ask again for channel ${channelId}. To change it later, run ` + + `\`catalyst channels update --channel-id ${channelId}\`.`, + ); + + return; + } + + const { channelId: updatedChannelId, hostname } = await runChannelSiteUrlFlow({ + storeHash, + accessToken, + apiHost, + projectUuid, + defaultChannelId: channelId, + preferHostname: options.deploymentHostname, + diagnoseCheckout: false, + }); + + const updated = await getChannelSite(updatedChannelId, storeHash, accessToken, apiHost); + + if (!isCrossDomainCheckout(updated)) return; + + // Off the managed zone, the checkout hostname is the merchant's to set up; + // the diagnostic explains how. + if (!isManagedHostingHostname(hostname)) { + warnOnCrossDomainCheckout(updated); + + return; + } + + const shouldMatchCheckout = await confirm({ + message: + `Checkout is still on ${findChannelSiteUrl(updated, 'checkout') ?? 'another domain'}. ` + + `Move it to https://${MANAGED_ZONE_CHECKOUT_PREFIX}${hostname} so shoppers stay on this ` + + 'domain through payment?', + default: true, + }); + + if (!shouldMatchCheckout) { + warnOnCrossDomainCheckout(updated); + + return; + } + + await runChannelCheckoutUrlFlow({ + storeHash, + accessToken, + apiHost, + channelId: updatedChannelId, + storefrontHostname: hostname, + }); +} diff --git a/packages/catalyst/src/cli/lib/project-config.ts b/packages/catalyst/src/cli/lib/project-config.ts index 2a6139b2d..43bab4979 100644 --- a/packages/catalyst/src/cli/lib/project-config.ts +++ b/packages/catalyst/src/cli/lib/project-config.ts @@ -13,6 +13,9 @@ export interface ProjectConfigSchema { // `catalyst env` commands. Lives here (gitignored .bigcommerce/project.json) // so users don't have to re-pass `--secret` on every deploy. env?: Record; + // Channels whose owner declined, after a deploy, to point the channel's site + // URL at the deployment. `catalyst deploy` doesn't offer again for these. + declinedSiteUrlChannels?: number[]; } // `cwd` defaults to the process working directory — the project the user is @@ -38,6 +41,10 @@ export function getProjectConfig(cwd: string = process.cwd()) { additionalProperties: { type: 'string' }, default: {}, }, + declinedSiteUrlChannels: { + type: 'array', + items: { type: 'number' }, + }, }, }); } From c96092b256ef25311c43febc4e0d6de2fd91c664 Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Thu, 24 Sep 2026 15:34:29 -0500 Subject: [PATCH 04/15] LTRAC-2014: fix(cli) - Use the deployed channel instead of asking for one The post-deploy site URL offer opened the channel picker with the deployed channel pre-selected. The deploy already knows which channel the build targets, so asking again only invited pointing a different channel at this storefront. Pass the channel through; only the hostname is prompted for. That leaves the picker's default-channel option unused, so it goes. Refs LTRAC-2014 Co-Authored-By: Claude Opus 5.5 (1M context) --- ...rac-2014-offer-channel-urls-after-deploy.md | 2 +- .../catalyst/src/cli/lib/channel-site-flow.ts | 6 ------ .../src/cli/lib/deploy-channel-urls.spec.ts | 18 ++++++++++-------- .../src/cli/lib/deploy-channel-urls.ts | 9 +++++---- 4 files changed, 16 insertions(+), 19 deletions(-) diff --git a/.changeset/ltrac-2014-offer-channel-urls-after-deploy.md b/.changeset/ltrac-2014-offer-channel-urls-after-deploy.md index 5b7b792b9..cfd528724 100644 --- a/.changeset/ltrac-2014-offer-channel-urls-after-deploy.md +++ b/.changeset/ltrac-2014-offer-channel-urls-after-deploy.md @@ -6,7 +6,7 @@ After an interactive `catalyst deploy`, offer to point the deployed channel at t Without `--update-site-url` or `--update-checkout-url`, a deploy used to leave the channel on its `mybigcommerce.com` storefront URL and the default channel's checkout, and said nothing about it. Now, when the deploy runs in a terminal and the channel it was built for (`BIGCOMMERCE_CHANNEL_ID`) doesn't already point at the project: -1. It asks whether to update the channel's site URL, then prompts for the channel (the deployed one is pre-selected) and the hostname (the new deployment is pre-selected). +1. It asks whether to update that channel's site URL, then prompts only for the hostname (the new deployment is pre-selected). The channel is the one the deploy targets, so it isn't asked for. 2. If the site URL is now on an auto-generated hostname and checkout is on another domain, it asks whether to move checkout to `https://c..`, and does so the same way `--update-checkout-url` does. Declining prints the cross-domain checkout warning instead. Declining the site URL is saved per channel in `.bigcommerce/project.json` (`declinedSiteUrlChannels`), and later deploys don't ask again. `catalyst channels update` and the `--update-*` flags still work regardless. Scripted deploys without a TTY, and deploys that pass either flag, behave as before. diff --git a/packages/catalyst/src/cli/lib/channel-site-flow.ts b/packages/catalyst/src/cli/lib/channel-site-flow.ts index 0f76eff1c..59572cc60 100644 --- a/packages/catalyst/src/cli/lib/channel-site-flow.ts +++ b/packages/catalyst/src/cli/lib/channel-site-flow.ts @@ -28,8 +28,6 @@ export interface ChannelSiteFlowOptions { // `catalyst deploy --update-site-url` to default to the freshly-deployed // hostname. preferHostname?: string; - // Pre-selected in the channel prompt. - defaultChannelId?: number; // Warn afterwards when checkout is left on another domain. Off for a caller // that offers to fix it next, so the warning isn't printed before the offer. diagnoseCheckout?: boolean; @@ -63,9 +61,6 @@ export async function resolveChannel(options: { accessToken: string; apiHost: string; channelId?: number; - // Pre-selected in the picker, e.g. the channel `catalyst deploy` just - // deployed to. - defaultChannelId?: number; // Overrides the picker copy for callers that aren't about to write anything — // `channels info` only reads, so "to update" would misdescribe it. message?: string; @@ -97,7 +92,6 @@ export async function resolveChannel(options: { const id = await select({ message: options.message ?? 'Select the channel to update.', - default: options.defaultChannelId, choices: catalystChannels.map((c: Channel) => ({ name: c.name, value: c.id, diff --git a/packages/catalyst/src/cli/lib/deploy-channel-urls.spec.ts b/packages/catalyst/src/cli/lib/deploy-channel-urls.spec.ts index 7303af143..6fd2d121f 100644 --- a/packages/catalyst/src/cli/lib/deploy-channel-urls.spec.ts +++ b/packages/catalyst/src/cli/lib/deploy-channel-urls.spec.ts @@ -88,9 +88,8 @@ const run = (overrides: { channelId?: number } = { channelId: 2 }) => ...overrides, }); -// The picker order in runChannelSiteUrlFlow: channel, then hostname. -const pickChannelAndHostname = () => - selectMock.mockResolvedValueOnce(2).mockResolvedValueOnce(storefront); +// The channel is the deployed one, so the hostname is the only pick. +const pickHostname = () => selectMock.mockResolvedValueOnce(storefront); beforeAll(() => { consola.mockTypes(() => vi.fn()); @@ -113,14 +112,17 @@ describe('offerChannelUrlUpdates', () => { const writes = freshChannel(); confirmMock.mockResolvedValueOnce(true).mockResolvedValueOnce(true); - pickChannelAndHostname(); + pickHostname(); await run(); expect(writes.site).toEqual({ url: `https://${storefront}` }); expect(writes.checkout).toEqual({ url: `https://c.${storefront}` }); - // The deployed channel is pre-selected, not assumed. - expect(selectMock).toHaveBeenCalledWith(expect.objectContaining({ default: 2 })); + // Only the hostname is asked for; the channel is the one deployed to. + expect(selectMock).toHaveBeenCalledTimes(1); + expect(selectMock).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Select the hostname to point the channel at.' }), + ); }); // Declining is remembered for the channel so later deploys stay quiet. @@ -146,7 +148,7 @@ describe('offerChannelUrlUpdates', () => { const writes = freshChannel(); confirmMock.mockResolvedValueOnce(true).mockResolvedValueOnce(false); - pickChannelAndHostname(); + pickHostname(); await run(); @@ -184,7 +186,7 @@ describe('offerChannelUrlUpdates', () => { const writes = freshChannel(); confirmMock.mockResolvedValueOnce(true); - selectMock.mockResolvedValueOnce(2).mockResolvedValueOnce('vanity.project-one.example.com'); + selectMock.mockResolvedValueOnce('vanity.project-one.example.com'); await run(); diff --git a/packages/catalyst/src/cli/lib/deploy-channel-urls.ts b/packages/catalyst/src/cli/lib/deploy-channel-urls.ts index 31715df74..196d48034 100644 --- a/packages/catalyst/src/cli/lib/deploy-channel-urls.ts +++ b/packages/catalyst/src/cli/lib/deploy-channel-urls.ts @@ -81,17 +81,18 @@ export async function offerChannelUrlUpdates(options: DeployChannelUrlOptions): return; } - const { channelId: updatedChannelId, hostname } = await runChannelSiteUrlFlow({ + const { hostname } = await runChannelSiteUrlFlow({ storeHash, accessToken, apiHost, projectUuid, - defaultChannelId: channelId, + // The channel the build targets; asking again would only invite a mismatch. + channelId, preferHostname: options.deploymentHostname, diagnoseCheckout: false, }); - const updated = await getChannelSite(updatedChannelId, storeHash, accessToken, apiHost); + const updated = await getChannelSite(channelId, storeHash, accessToken, apiHost); if (!isCrossDomainCheckout(updated)) return; @@ -121,7 +122,7 @@ export async function offerChannelUrlUpdates(options: DeployChannelUrlOptions): storeHash, accessToken, apiHost, - channelId: updatedChannelId, + channelId, storefrontHostname: hostname, }); } From 7cb4ae3549893618d51f9189d89bea10fba8c3e1 Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Thu, 24 Sep 2026 15:37:58 -0500 Subject: [PATCH 05/15] LTRAC-2014: fix(cli) - Prefer the deployment secret for the served channel The post-deploy offer took BIGCOMMERCE_CHANNEL_ID from the env files first and fell back to deployment secrets. At runtime it's the other way round: OpenNext copies the worker's bindings onto process.env first and only fills unset keys from the env files baked in at build time. So with both set, the offer could name a different channel from the one the storefront serves. Check the secrets (project.json env and --secret) first. Refs LTRAC-2014 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ltrac-2014-offer-channel-urls-after-deploy.md | 2 +- .../catalyst/src/cli/lib/deploy-channel-urls.spec.ts | 12 ++++-------- packages/catalyst/src/cli/lib/deploy-channel-urls.ts | 12 +++++++----- 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/.changeset/ltrac-2014-offer-channel-urls-after-deploy.md b/.changeset/ltrac-2014-offer-channel-urls-after-deploy.md index cfd528724..35d791741 100644 --- a/.changeset/ltrac-2014-offer-channel-urls-after-deploy.md +++ b/.changeset/ltrac-2014-offer-channel-urls-after-deploy.md @@ -4,7 +4,7 @@ After an interactive `catalyst deploy`, offer to point the deployed channel at the deployment. -Without `--update-site-url` or `--update-checkout-url`, a deploy used to leave the channel on its `mybigcommerce.com` storefront URL and the default channel's checkout, and said nothing about it. Now, when the deploy runs in a terminal and the channel it was built for (`BIGCOMMERCE_CHANNEL_ID`) doesn't already point at the project: +Without `--update-site-url` or `--update-checkout-url`, a deploy used to leave the channel on its `mybigcommerce.com` storefront URL and the default channel's checkout, and said nothing about it. Now, when the deploy runs in a terminal and the channel it serves (`BIGCOMMERCE_CHANNEL_ID`, from a stored or `--secret` deployment variable first, then the env files) doesn't already point at the project: 1. It asks whether to update that channel's site URL, then prompts only for the hostname (the new deployment is pre-selected). The channel is the one the deploy targets, so it isn't asked for. 2. If the site URL is now on an auto-generated hostname and checkout is on another domain, it asks whether to move checkout to `https://c..`, and does so the same way `--update-checkout-url` does. Declining prints the cross-domain checkout warning instead. diff --git a/packages/catalyst/src/cli/lib/deploy-channel-urls.spec.ts b/packages/catalyst/src/cli/lib/deploy-channel-urls.spec.ts index 6fd2d121f..7ccf80e7e 100644 --- a/packages/catalyst/src/cli/lib/deploy-channel-urls.spec.ts +++ b/packages/catalyst/src/cli/lib/deploy-channel-urls.spec.ts @@ -214,19 +214,15 @@ describe('deployedChannelId', () => { vi.unstubAllEnvs(); }); - test('reads the build env first, then a stored secret', () => { + // Matches what the storefront serves: OpenNext applies the worker's bindings + // first and only fills unset keys from the baked env files. + test('prefers a deployment secret over the env files', () => { vi.stubEnv('BIGCOMMERCE_CHANNEL_ID', '7'); - expect(deployedChannelId([{ type: 'secret', key: 'BIGCOMMERCE_CHANNEL_ID', value: '9' }])).toBe( - 7, - ); - - vi.unstubAllEnvs(); - delete process.env.BIGCOMMERCE_CHANNEL_ID; - expect(deployedChannelId([{ type: 'secret', key: 'BIGCOMMERCE_CHANNEL_ID', value: '9' }])).toBe( 9, ); + expect(deployedChannelId([])).toBe(7); }); test('ignores a missing or malformed value', () => { diff --git a/packages/catalyst/src/cli/lib/deploy-channel-urls.ts b/packages/catalyst/src/cli/lib/deploy-channel-urls.ts index 196d48034..dc8029a78 100644 --- a/packages/catalyst/src/cli/lib/deploy-channel-urls.ts +++ b/packages/catalyst/src/cli/lib/deploy-channel-urls.ts @@ -26,13 +26,15 @@ export interface DeployChannelUrlOptions { channelId?: number; } -// The channel a deployment serves: the build reads `BIGCOMMERCE_CHANNEL_ID` -// from the env files, and a stored secret carries it when the build was skipped -// with `--prebuilt`. +// The channel a deployment serves. A deployment secret (project.json `env` or +// `--secret`) wins: at runtime OpenNext copies the worker's bindings onto +// `process.env` first and only fills unset keys from the env files baked in at +// build time. The env files are the fallback, and aren't loaded at all with +// `--prebuilt`. export function deployedChannelId(secrets: DeploymentSecret[]): number | undefined { const raw = - process.env.BIGCOMMERCE_CHANNEL_ID ?? - secrets.find((secret) => secret.key === 'BIGCOMMERCE_CHANNEL_ID')?.value; + secrets.find((secret) => secret.key === 'BIGCOMMERCE_CHANNEL_ID')?.value ?? + process.env.BIGCOMMERCE_CHANNEL_ID; const id = Number(raw); return Number.isInteger(id) && id > 0 ? id : undefined; From f5a83a82baf6109e74e8bbce8735b41bf7518e0b Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Thu, 24 Sep 2026 15:48:47 -0500 Subject: [PATCH 06/15] LTRAC-2014: fix(cli) - Use the deployment hostname instead of asking for it After agreeing to point the channel at the deployment, the merchant still got a hostname picker with the new deployment pre-selected. The deploy already reports the hostname it went live on, so pass it through. The picker now only shows if the deploy status carried no hostname. Refs LTRAC-2014 Co-Authored-By: Claude Opus 5.5 (1M context) --- ...ac-2014-offer-channel-urls-after-deploy.md | 2 +- .../src/cli/lib/deploy-channel-urls.spec.ts | 30 +++++++++++-------- .../src/cli/lib/deploy-channel-urls.ts | 6 ++-- 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/.changeset/ltrac-2014-offer-channel-urls-after-deploy.md b/.changeset/ltrac-2014-offer-channel-urls-after-deploy.md index 35d791741..a4882ee9c 100644 --- a/.changeset/ltrac-2014-offer-channel-urls-after-deploy.md +++ b/.changeset/ltrac-2014-offer-channel-urls-after-deploy.md @@ -6,7 +6,7 @@ After an interactive `catalyst deploy`, offer to point the deployed channel at t Without `--update-site-url` or `--update-checkout-url`, a deploy used to leave the channel on its `mybigcommerce.com` storefront URL and the default channel's checkout, and said nothing about it. Now, when the deploy runs in a terminal and the channel it serves (`BIGCOMMERCE_CHANNEL_ID`, from a stored or `--secret` deployment variable first, then the env files) doesn't already point at the project: -1. It asks whether to update that channel's site URL, then prompts only for the hostname (the new deployment is pre-selected). The channel is the one the deploy targets, so it isn't asked for. +1. It asks whether to point that channel's site URL at the deployment, and sets it to the hostname the deploy just went live on. Neither the channel nor the hostname is asked for; the hostname picker only appears if the deploy didn't report one. 2. If the site URL is now on an auto-generated hostname and checkout is on another domain, it asks whether to move checkout to `https://c..`, and does so the same way `--update-checkout-url` does. Declining prints the cross-domain checkout warning instead. Declining the site URL is saved per channel in `.bigcommerce/project.json` (`declinedSiteUrlChannels`), and later deploys don't ask again. `catalyst channels update` and the `--update-*` flags still work regardless. Scripted deploys without a TTY, and deploys that pass either flag, behave as before. diff --git a/packages/catalyst/src/cli/lib/deploy-channel-urls.spec.ts b/packages/catalyst/src/cli/lib/deploy-channel-urls.spec.ts index 7ccf80e7e..89adf7139 100644 --- a/packages/catalyst/src/cli/lib/deploy-channel-urls.spec.ts +++ b/packages/catalyst/src/cli/lib/deploy-channel-urls.spec.ts @@ -77,7 +77,7 @@ const freshChannel = () => { return writes; }; -const run = (overrides: { channelId?: number } = { channelId: 2 }) => +const run = (overrides: Partial[0]> = { channelId: 2 }) => offerChannelUrlUpdates({ storeHash, accessToken, @@ -88,9 +88,6 @@ const run = (overrides: { channelId?: number } = { channelId: 2 }) => ...overrides, }); -// The channel is the deployed one, so the hostname is the only pick. -const pickHostname = () => selectMock.mockResolvedValueOnce(storefront); - beforeAll(() => { consola.mockTypes(() => vi.fn()); }); @@ -112,17 +109,13 @@ describe('offerChannelUrlUpdates', () => { const writes = freshChannel(); confirmMock.mockResolvedValueOnce(true).mockResolvedValueOnce(true); - pickHostname(); await run(); expect(writes.site).toEqual({ url: `https://${storefront}` }); expect(writes.checkout).toEqual({ url: `https://c.${storefront}` }); - // Only the hostname is asked for; the channel is the one deployed to. - expect(selectMock).toHaveBeenCalledTimes(1); - expect(selectMock).toHaveBeenCalledWith( - expect.objectContaining({ message: 'Select the hostname to point the channel at.' }), - ); + // Neither the channel nor the hostname is asked for; the deploy knows both. + expect(selectMock).not.toHaveBeenCalled(); }); // Declining is remembered for the channel so later deploys stay quiet. @@ -148,7 +141,6 @@ describe('offerChannelUrlUpdates', () => { const writes = freshChannel(); confirmMock.mockResolvedValueOnce(true).mockResolvedValueOnce(false); - pickHostname(); await run(); @@ -186,9 +178,8 @@ describe('offerChannelUrlUpdates', () => { const writes = freshChannel(); confirmMock.mockResolvedValueOnce(true); - selectMock.mockResolvedValueOnce('vanity.project-one.example.com'); - await run(); + await run({ channelId: 2, deploymentHostname: 'vanity.project-one.example.com' }); expect(confirmMock).toHaveBeenCalledTimes(1); expect(writes.checkout).toBeUndefined(); @@ -197,6 +188,19 @@ describe('offerChannelUrlUpdates', () => { ); }); + // The deploy normally reports its hostname; only without one is it asked for. + test('asks for the hostname when the deploy did not report one', async () => { + const writes = freshChannel(); + + confirmMock.mockResolvedValueOnce(true).mockResolvedValueOnce(false); + selectMock.mockResolvedValueOnce(storefront); + + await run({ channelId: 2, deploymentHostname: undefined }); + + expect(selectMock).toHaveBeenCalledTimes(1); + expect(writes.site).toEqual({ url: `https://${storefront}` }); + }); + test('stays quiet without a TTY or a known channel', async () => { freshChannel(); diff --git a/packages/catalyst/src/cli/lib/deploy-channel-urls.ts b/packages/catalyst/src/cli/lib/deploy-channel-urls.ts index dc8029a78..54f756ee4 100644 --- a/packages/catalyst/src/cli/lib/deploy-channel-urls.ts +++ b/packages/catalyst/src/cli/lib/deploy-channel-urls.ts @@ -88,9 +88,11 @@ export async function offerChannelUrlUpdates(options: DeployChannelUrlOptions): accessToken, apiHost, projectUuid, - // The channel the build targets; asking again would only invite a mismatch. + // Both are known: the channel the build targets and the hostname the deploy + // went live on. The hostname picker only shows if the deploy didn't report + // one. channelId, - preferHostname: options.deploymentHostname, + hostname: options.deploymentHostname, diagnoseCheckout: false, }); From 3273499c841b682e45656b945f725270ab9bab26 Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Thu, 24 Sep 2026 15:57:01 -0500 Subject: [PATCH 07/15] LTRAC-1962: fix(cli) - Skip re-sending an unchanged site URL sites-service deletes a channel's checkout URL on every site URL update, including an update to the same URL (WorkWithDomains.chooseDomainsForUpdate puts every non-canonical domain but the updated one up for deletion), and the deletion releases the hostname in bcserver. So a re-deploy with --update-site-url --update-checkout-url dropped the `c.` checkout hostname, then re-registered it, leaving checkout without a certificate for a minute or two on every deploy. Read the site first and skip the PUT when the primary already matches. The read is best-effort: if it fails, write as before. Refs LTRAC-1962 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ltrac-1962-derive-managed-checkout-url.md | 2 + .../src/cli/lib/channel-site-flow.spec.ts | 56 +++++++++++++++++++ .../catalyst/src/cli/lib/channel-site-flow.ts | 31 ++++++++-- 3 files changed, 83 insertions(+), 6 deletions(-) diff --git a/.changeset/ltrac-1962-derive-managed-checkout-url.md b/.changeset/ltrac-1962-derive-managed-checkout-url.md index 9e73c9c35..42c5b283d 100644 --- a/.changeset/ltrac-1962-derive-managed-checkout-url.md +++ b/.changeset/ltrac-1962-derive-managed-checkout-url.md @@ -9,3 +9,5 @@ Set a channel's checkout URL to its `c.` checkout hostname on a managed hosting Setting it is what provisions it: BigCommerce registers the hostname and issues its certificate in response to the write, usually within a couple of minutes. The CLI writes first, then waits for the certificate. If none issues within six minutes, when BigCommerce stops trying, it removes the checkout URL again so checkout falls back to the default channel's rather than staying broken. It leaves an existing custom checkout URL alone, and on a re-run where the hostname is already set it only waits for the certificate. A storefront on a custom domain, or `--update-checkout-url` without `--update-site-url`, still prompts as before. + +Re-running `--update-site-url` with a site URL that is already set no longer re-sends it. BigCommerce deletes a channel's checkout URL on every site URL update, even to the same URL, and releases its hostname, so each re-deploy with both flags used to drop and re-provision the `c.` checkout hostname, leaving checkout without a certificate for a minute or two. diff --git a/packages/catalyst/src/cli/lib/channel-site-flow.spec.ts b/packages/catalyst/src/cli/lib/channel-site-flow.spec.ts index b5bb5f843..12e89a972 100644 --- a/packages/catalyst/src/cli/lib/channel-site-flow.spec.ts +++ b/packages/catalyst/src/cli/lib/channel-site-flow.spec.ts @@ -322,6 +322,20 @@ describe('runChannelSiteUrlFlow', () => { // old domain, so the flow re-fetches the site and runs the diagnostic. test('warns when the updated site URL leaves checkout on another domain', async () => { server.use( + http.get( + 'https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', + () => + HttpResponse.json({ + data: { + id: 1, + url: 'https://store-abc-2.mybigcommerce.com', + channel_id: 2, + is_checkout_url_customized: false, + urls: [{ url: 'https://store-abc-2.mybigcommerce.com', type: 'primary' }], + }, + }), + { once: true }, + ), http.get('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () => HttpResponse.json({ data: { @@ -355,6 +369,48 @@ describe('runChannelSiteUrlFlow', () => { // The write already succeeded by this point, so a failing diagnostic must not // surface as a failed update. + // sites-service deletes the checkout URL on every site URL update, even to + // the same URL, so a re-deploy must not re-send an unchanged one. + test('skips the write when the site URL is already set', async () => { + let putCalled = false; + + server.use( + 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, + is_checkout_url_customized: true, + urls: [ + { url: 'https://project-one.catalyst-sandbox.store/', type: 'primary' }, + { url: 'https://c.project-one.catalyst-sandbox.store', type: 'checkout' }, + ], + }, + }), + ), + http.put('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () => { + putCalled = true; + + return HttpResponse.json({ data: {} }); + }), + ); + + await expect( + runChannelSiteUrlFlow({ + storeHash, + accessToken, + apiHost, + projectUuid: linkedProjectUuid, + channelId: 2, + hostname: 'project-one.catalyst-sandbox.store', + }), + ).resolves.toEqual({ channelId: 2, hostname: 'project-one.catalyst-sandbox.store' }); + + expect(putCalled).toBe(false); + expect(consola.info).toHaveBeenCalledWith(expect.stringContaining('site URL is already')); + }); + test('still reports success when the follow-up diagnostic fetch fails', async () => { server.use( http.get('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () => diff --git a/packages/catalyst/src/cli/lib/channel-site-flow.ts b/packages/catalyst/src/cli/lib/channel-site-flow.ts index ee1035583..314769d76 100644 --- a/packages/catalyst/src/cli/lib/channel-site-flow.ts +++ b/packages/catalyst/src/cli/lib/channel-site-flow.ts @@ -3,6 +3,7 @@ import { select } from '@inquirer/prompts'; import { type Channel, fetchAvailableChannels, + findChannelSiteUrl, getChannelSite, updateChannelSiteUrl, } from './channels'; @@ -148,18 +149,36 @@ export async function runChannelSiteUrlFlow( const channel = await resolveChannel(options); const hostname = await resolveHostname(project, options); const siteUrl = hostname.startsWith('https://') ? hostname : `https://${hostname}`; + const channelLabel = channel.name ? `"${channel.name}" (${channel.id})` : String(channel.id); - await updateChannelSiteUrl( + // Skip a write that changes nothing: sites-service deletes the channel's + // checkout URL on every site URL update, even to the same URL, and releases + // its hostname. A re-deploy would otherwise drop and re-provision a `c.` + // checkout hostname, leaving checkout without a certificate meanwhile. + // Best-effort: if the read fails, write as before. + const current = await getChannelSite( channel.id, - siteUrl, options.storeHash, options.accessToken, options.apiHost, - ); - - const channelLabel = channel.name ? `"${channel.name}" (${channel.id})` : String(channel.id); + ).catch(() => undefined); + + if ( + current && + (findChannelSiteUrl(current, 'primary') ?? current.url).replace(/\/$/, '') === siteUrl + ) { + consola.info(`Channel ${channelLabel} site URL is already ${siteUrl}.`); + } else { + await updateChannelSiteUrl( + channel.id, + siteUrl, + options.storeHash, + options.accessToken, + options.apiHost, + ); - consola.success(`Updated channel ${channelLabel} site URL to ${siteUrl}.`); + consola.success(`Updated channel ${channelLabel} site URL to ${siteUrl}.`); + } // Moving the site URL is when checkout is most likely left behind. The PUT // response above carries no `urls`, hence the re-fetch. Soft-failed: the write From 52a83bb81143e457de8e63b2f3f968c4555dd02c Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Thu, 24 Sep 2026 16:31:30 -0500 Subject: [PATCH 08/15] LTRAC-2014: feat(cli) - Offer the checkout URL on every deploy, and after channels update The checkout offer only existed as the second step of the site URL offer, and deploys skip that once the site points at the project. So checkout was never offered again after one decline, never offered at all when the site URL was set some other way, and a checkout later dropped by a site URL change went unnoticed. Check the two separately on every deploy. The checkout offer moves into a shared offerManagedCheckoutUrl: on the managed zone, when checkout is on another domain and wasn't customized there by the merchant, offer the `c.` hostname. A decline is now saved (declinedCheckoutUrlChannels), since re-checking every deploy would otherwise ask every time. channels update makes the same offer after it updates a site URL, because that update deletes the channel's checkout URL. As an explicit command it asks despite an earlier decline, and falls back to the cross-domain warning when there's nothing to offer. Refs LTRAC-2014 Co-Authored-By: Claude Opus 5.5 (1M context) --- ...ac-2014-offer-channel-urls-after-deploy.md | 14 ++- .../src/cli/commands/channels.spec.ts | 94 ++++++++++++++++++ .../catalyst/src/cli/commands/channels.ts | 30 +++++- packages/catalyst/src/cli/commands/deploy.ts | 6 +- .../src/cli/lib/channel-checkout-url-flow.ts | 74 +++++++++++++- .../src/cli/lib/deploy-channel-urls.spec.ts | 50 ++++++++-- .../src/cli/lib/deploy-channel-urls.ts | 96 ++++++++----------- .../catalyst/src/cli/lib/project-config.ts | 7 ++ 8 files changed, 299 insertions(+), 72 deletions(-) diff --git a/.changeset/ltrac-2014-offer-channel-urls-after-deploy.md b/.changeset/ltrac-2014-offer-channel-urls-after-deploy.md index a4882ee9c..a8f13bec8 100644 --- a/.changeset/ltrac-2014-offer-channel-urls-after-deploy.md +++ b/.changeset/ltrac-2014-offer-channel-urls-after-deploy.md @@ -2,11 +2,15 @@ "@bigcommerce/catalyst": patch --- -After an interactive `catalyst deploy`, offer to point the deployed channel at the deployment. +After an interactive `catalyst deploy`, offer to point the deployed channel at the deployment and to put its checkout on the same domain. -Without `--update-site-url` or `--update-checkout-url`, a deploy used to leave the channel on its `mybigcommerce.com` storefront URL and the default channel's checkout, and said nothing about it. Now, when the deploy runs in a terminal and the channel it serves (`BIGCOMMERCE_CHANNEL_ID`, from a stored or `--secret` deployment variable first, then the env files) doesn't already point at the project: +Without `--update-site-url` or `--update-checkout-url`, a deploy used to leave the channel on its `mybigcommerce.com` storefront URL and the default channel's checkout, and said nothing about it. Now, when the deploy runs in a terminal and knows the channel it serves (`BIGCOMMERCE_CHANNEL_ID`, from a stored or `--secret` deployment variable first, then the env files), it checks two things on every deploy: -1. It asks whether to point that channel's site URL at the deployment, and sets it to the hostname the deploy just went live on. Neither the channel nor the hostname is asked for; the hostname picker only appears if the deploy didn't report one. -2. If the site URL is now on an auto-generated hostname and checkout is on another domain, it asks whether to move checkout to `https://c..`, and does so the same way `--update-checkout-url` does. Declining prints the cross-domain checkout warning instead. +1. **Site URL.** If the channel doesn't point at the project, it asks whether to point it at the deployment, and sets it to the hostname the deploy just went live on. Neither the channel nor the hostname is asked for; the hostname picker only appears if the deploy didn't report one. +2. **Checkout URL.** If the channel's storefront is on an auto-generated hostname and checkout is on another domain, it asks whether to move checkout to `https://c..`, and does so the way `--update-checkout-url` does. This is checked on its own, so it's offered even when the site URL was set earlier or some other way. A custom checkout URL the merchant set on another domain is left alone. -Declining the site URL is saved per channel in `.bigcommerce/project.json` (`declinedSiteUrlChannels`), and later deploys don't ask again. `catalyst channels update` and the `--update-*` flags still work regardless. Scripted deploys without a TTY, and deploys that pass either flag, behave as before. +Declining either is saved per channel in `.bigcommerce/project.json` (`declinedSiteUrlChannels`, `declinedCheckoutUrlChannels`), and that offer isn't made again after deploys. Declining checkout also prints the cross-domain checkout warning and the command that sets it. + +`catalyst channels update` makes the same checkout offer after it updates a site URL, since that update deletes the channel's checkout URL. As an explicit command it asks even after an earlier decline. It falls back to the cross-domain warning when there's nothing to offer. + +Scripted runs without a TTY, and deploys or updates that pass a checkout flag, behave as before. diff --git a/packages/catalyst/src/cli/commands/channels.spec.ts b/packages/catalyst/src/cli/commands/channels.spec.ts index 3f57eff62..fc65e00c0 100644 --- a/packages/catalyst/src/cli/commands/channels.spec.ts +++ b/packages/catalyst/src/cli/commands/channels.spec.ts @@ -1100,3 +1100,97 @@ 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 }); + }); + + afterEach(() => { + 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}` }); + }); + + // 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")); + }); +}); diff --git a/packages/catalyst/src/cli/commands/channels.ts b/packages/catalyst/src/cli/commands/channels.ts index 93502778e..698649e1e 100644 --- a/packages/catalyst/src/cli/commands/channels.ts +++ b/packages/catalyst/src/cli/commands/channels.ts @@ -3,7 +3,10 @@ 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 { + offerManagedCheckoutUrl, + runChannelCheckoutUrlFlow, +} from '../lib/channel-checkout-url-flow'; import { resolveChannel, runChannelSiteUrlFlow } from '../lib/channel-site-flow'; import { channelPlatformLabel, @@ -166,17 +169,22 @@ Examples: // asked to change. const touchesCheckout = options.checkoutUrl !== undefined || options.removeCheckoutUrl === true; const updatesSiteUrl = options.hostname !== undefined || !touchesCheckout; + // A site URL update deletes the channel's checkout URL, so offer the + // managed-zone one straight after, when nothing was said about checkout. + const offersCheckout = updatesSiteUrl && !touchesCheckout && process.stdin.isTTY; 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) { @@ -193,6 +201,24 @@ Examples: } } + if (offersCheckout && channelId !== undefined && siteHostname !== undefined) { + const offered = await offerManagedCheckoutUrl({ + storeHash, + accessToken, + apiHost, + channelId, + storefrontHostname: siteHostname, + config, + // Asked for explicitly, so a decline after an earlier deploy doesn't + // silence it here. + respectOptOut: false, + }); + + if (!offered) { + warnOnCrossDomainCheckout(await getChannelSite(channelId, storeHash, accessToken, apiHost)); + } + } + if (touchesCheckout) { const channel = channelId === undefined diff --git a/packages/catalyst/src/cli/commands/deploy.ts b/packages/catalyst/src/cli/commands/deploy.ts index dec2aa578..05fb138f7 100644 --- a/packages/catalyst/src/cli/commands/deploy.ts +++ b/packages/catalyst/src/cli/commands/deploy.ts @@ -388,9 +388,9 @@ 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 once -per channel to point the channel's site URL at the deployment, then to move its checkout -onto the same domain. Declining is saved in .bigcommerce/project.json. +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= --secret BIGCOMMERCE_STOREFRONT_TOKEN=`, diff --git a/packages/catalyst/src/cli/lib/channel-checkout-url-flow.ts b/packages/catalyst/src/cli/lib/channel-checkout-url-flow.ts index 30e18b327..7dfbc1abc 100644 --- a/packages/catalyst/src/cli/lib/channel-checkout-url-flow.ts +++ b/packages/catalyst/src/cli/lib/channel-checkout-url-flow.ts @@ -1,4 +1,4 @@ -import { input } from '@inquirer/prompts'; +import { confirm, input } from '@inquirer/prompts'; import { resolveChannel } from './channel-site-flow'; import { @@ -8,13 +8,17 @@ import { updateChannelCheckoutUrl, } from './channels'; import { + isCrossDomainCheckout, isManagedHostingHostname, + MANAGED_ZONE_CHECKOUT_PREFIX, managedCheckoutHostname, normalizeCheckoutUrl, suggestCheckoutUrl, waitForCheckoutHostname, + warnOnCrossDomainCheckout, } from './checkout-url'; import { consola } from './logger'; +import { type getProjectConfig } from './project-config'; export interface ChannelCheckoutUrlFlowOptions { storeHash: string; @@ -194,3 +198,71 @@ async function setManagedCheckoutUrl(options: ManagedCheckoutUrlOptions): Promis return true; } + +export interface ManagedCheckoutOfferOptions { + storeHash: string; + accessToken: string; + apiHost: string; + channelId: number; + // The storefront hostname the channel's primary URL is on. + storefrontHostname: string; + config: ReturnType; + // Skip the offer for a channel whose owner already declined it. Off for an + // explicit command, which asks regardless. + respectOptOut: boolean; +} + +// Offers to move a managed-zone storefront's checkout onto its `c.` hostname, +// when checkout is on another domain. A decline is saved per channel. Resolves +// whether the offer was made, so an explicit command can fall back to the +// cross-domain warning when it wasn't. +// +// A custom checkout URL on another domain was the merchant's choice, so it's +// never offered for replacement. Off the managed zone there is no `c.` +// hostname to offer; that storefront's checkout is the merchant's to set up. +export async function offerManagedCheckoutUrl( + options: ManagedCheckoutOfferOptions, +): Promise { + const { storeHash, accessToken, apiHost, channelId, storefrontHostname, config } = options; + const declined = config.get('declinedCheckoutUrlChannels') ?? []; + + if (!isManagedHostingHostname(storefrontHostname)) return false; + if (options.respectOptOut && declined.includes(channelId)) return false; + + const site = await getChannelSite(channelId, storeHash, accessToken, apiHost); + + if (site.isCheckoutUrlCustomized || !isCrossDomainCheckout(site)) return false; + + const shouldMove = await confirm({ + message: + `Checkout is on ${findChannelSiteUrl(site, 'checkout') ?? 'another domain'}. Move it to ` + + `https://${MANAGED_ZONE_CHECKOUT_PREFIX}${storefrontHostname} so shoppers stay on this ` + + 'domain through payment?', + default: true, + }); + + if (!shouldMove) { + if (!declined.includes(channelId)) { + config.set('declinedCheckoutUrlChannels', [...declined, channelId]); + } + + warnOnCrossDomainCheckout(site); + consola.info( + `Won't ask again after deploys for channel ${channelId}. To set it later, run ` + + `\`catalyst channels update --channel-id ${channelId} --checkout-url ` + + `https://${MANAGED_ZONE_CHECKOUT_PREFIX}${storefrontHostname}\`.`, + ); + + return true; + } + + await runChannelCheckoutUrlFlow({ + storeHash, + accessToken, + apiHost, + channelId, + storefrontHostname, + }); + + return true; +} diff --git a/packages/catalyst/src/cli/lib/deploy-channel-urls.spec.ts b/packages/catalyst/src/cli/lib/deploy-channel-urls.spec.ts index 89adf7139..21d7a4a55 100644 --- a/packages/catalyst/src/cli/lib/deploy-channel-urls.spec.ts +++ b/packages/catalyst/src/cli/lib/deploy-channel-urls.spec.ts @@ -35,9 +35,13 @@ let config: ReturnType; // A channel still on its canonical storefront URL and the default channel's // checkout, as a fresh one is. Writing the site URL moves the primary, so the // reads after it see the new one. -const freshChannel = () => { +const freshChannel = ({ + primary: startingPrimary = 'https://store-abc-2.mybigcommerce.com', + checkout = 'https://store-abc-1.mybigcommerce.com', + customized = false, +}: { primary?: string; checkout?: string; customized?: boolean } = {}) => { const writes: { site?: unknown; checkout?: unknown } = {}; - let primary = 'https://store-abc-2.mybigcommerce.com'; + let primary = startingPrimary; server.use( http.get(sitePath, () => @@ -47,10 +51,10 @@ const freshChannel = () => { url: primary, channel_id: 2, ssl_status: null, - is_checkout_url_customized: false, + is_checkout_url_customized: customized, urls: [ { url: primary, type: 'primary' }, - { url: 'https://store-abc-1.mybigcommerce.com', type: 'checkout' }, + { url: checkout, type: 'checkout' }, ], }, }), @@ -135,9 +139,7 @@ describe('offerChannelUrlUpdates', () => { expect(confirmMock).not.toHaveBeenCalled(); }); - // Declining the checkout URL isn't saved: the site URL prompt that leads to - // it won't come back, so the warning is the last word. - test('warns instead of moving checkout when that is declined', async () => { + test('warns and saves the decline when moving checkout is declined', async () => { const writes = freshChannel(); confirmMock.mockResolvedValueOnce(true).mockResolvedValueOnce(false); @@ -148,6 +150,40 @@ describe('offerChannelUrlUpdates', () => { expect(writes.checkout).toBeUndefined(); expect(consola.warn).toHaveBeenCalledWith(expect.stringContaining("default channel's domain")); expect(config.get('declinedSiteUrlChannels')).toBeUndefined(); + expect(config.get('declinedCheckoutUrlChannels')).toEqual([2]); + + // The next deploy finds the site already pointed here and stays quiet. + vi.clearAllMocks(); + await run(); + + expect(confirmMock).not.toHaveBeenCalled(); + }); + + // Checked on its own, so a checkout left behind is offered even when the site + // URL was set some other way or on an earlier deploy. + test('offers checkout when the site already points at the project', async () => { + const writes = freshChannel({ primary: `https://${storefront}` }); + + confirmMock.mockResolvedValueOnce(true); + + await run(); + + expect(confirmMock).toHaveBeenCalledTimes(1); + expect(writes.site).toBeUndefined(); + expect(writes.checkout).toEqual({ url: `https://c.${storefront}` }); + }); + + // A custom checkout URL on another domain was the merchant's choice. + test('leaves a custom checkout URL on another domain alone', async () => { + freshChannel({ + primary: `https://${storefront}`, + checkout: 'https://checkout.example.com', + customized: true, + }); + + await run(); + + expect(confirmMock).not.toHaveBeenCalled(); }); test('does not offer when the channel already points at the project', async () => { diff --git a/packages/catalyst/src/cli/lib/deploy-channel-urls.ts b/packages/catalyst/src/cli/lib/deploy-channel-urls.ts index 54f756ee4..cf370b46f 100644 --- a/packages/catalyst/src/cli/lib/deploy-channel-urls.ts +++ b/packages/catalyst/src/cli/lib/deploy-channel-urls.ts @@ -1,14 +1,9 @@ import { confirm } from '@inquirer/prompts'; -import { runChannelCheckoutUrlFlow } from './channel-checkout-url-flow'; +import { offerManagedCheckoutUrl } from './channel-checkout-url-flow'; import { runChannelSiteUrlFlow } from './channel-site-flow'; import { findChannelSiteUrl, getChannelSite } from './channels'; -import { - isCrossDomainCheckout, - isManagedHostingHostname, - MANAGED_ZONE_CHECKOUT_PREFIX, - warnOnCrossDomainCheckout, -} from './checkout-url'; +import { isManagedHostingHostname } from './checkout-url'; import { type DeploymentSecret } from './env-config'; import { consola } from './logger'; import { fetchProjects } from './project'; @@ -41,22 +36,17 @@ export function deployedChannelId(secrets: DeploymentSecret[]): number | undefin } // After an interactive deploy, offers to point the deployed channel at the new -// hostname, then to move checkout onto the same domain. -// -// Asked once per channel: declining the site URL is saved in project.json. -// Declining the checkout URL isn't, because the site URL prompt that leads to -// it won't come back either; the merchant is warned instead. +// hostname, and to move its checkout onto the same domain. The two are checked +// separately on every deploy, so a checkout left behind is offered even when +// the site URL was set some other way, or earlier. Declining either is saved +// per channel in project.json, and that offer isn't made again. export async function offerChannelUrlUpdates(options: DeployChannelUrlOptions): Promise { const { storeHash, accessToken, apiHost, projectUuid, config, channelId } = options; - // No channel means nothing to key the opt-out on, and scripted deploys keep + // No channel means nothing to key the opt-outs on, and scripted deploys keep // the flag-only behaviour. if (!process.stdin.isTTY || channelId === undefined) return; - const declined = config.get('declinedSiteUrlChannels') ?? []; - - if (declined.includes(channelId)) return; - const [site, projects] = await Promise.all([ getChannelSite(channelId, storeHash, accessToken, apiHost), fetchProjects(storeHash, accessToken, apiHost), @@ -65,8 +55,36 @@ export async function offerChannelUrlUpdates(options: DeployChannelUrlOptions): projects.find((project) => project.uuid === projectUuid)?.deployment_hostnames ?? []; const storefrontUrl = (findChannelSiteUrl(site, 'primary') ?? site.url).replace(/\/$/, ''); - // Already pointed at this project; nothing to offer. - if (deploymentHostnames.some((deployed) => storefrontUrl === `https://${deployed}`)) return; + let storefrontHostname = deploymentHostnames.find( + (deployed) => storefrontUrl === `https://${deployed}`, + ); + + storefrontHostname ??= await offerSiteUrl({ ...options, channelId }, storefrontUrl); + + // Not pointed at this project, so a checkout hostname under it wouldn't + // share the storefront's domain. + if (storefrontHostname === undefined) return; + + await offerManagedCheckoutUrl({ + storeHash, + accessToken, + apiHost, + channelId, + storefrontHostname, + config, + respectOptOut: true, + }); +} + +// Resolves the hostname the site URL was set to, or undefined when it wasn't. +async function offerSiteUrl( + options: DeployChannelUrlOptions & { channelId: number }, + storefrontUrl: string, +): Promise { + const { storeHash, accessToken, apiHost, projectUuid, config, channelId } = options; + const declined = config.get('declinedSiteUrlChannels') ?? []; + + if (declined.includes(channelId)) return undefined; const shouldUpdate = await confirm({ message: `Channel ${channelId}'s site URL is ${storefrontUrl}. Point it at this deployment?`, @@ -80,7 +98,7 @@ export async function offerChannelUrlUpdates(options: DeployChannelUrlOptions): `\`catalyst channels update --channel-id ${channelId}\`.`, ); - return; + return undefined; } const { hostname } = await runChannelSiteUrlFlow({ @@ -93,40 +111,10 @@ export async function offerChannelUrlUpdates(options: DeployChannelUrlOptions): // one. channelId, hostname: options.deploymentHostname, - diagnoseCheckout: false, + // The checkout offer that follows covers the managed zone; elsewhere the + // diagnostic explains how to set up a checkout domain. + diagnoseCheckout: !isManagedHostingHostname(options.deploymentHostname ?? ''), }); - const updated = await getChannelSite(channelId, storeHash, accessToken, apiHost); - - if (!isCrossDomainCheckout(updated)) return; - - // Off the managed zone, the checkout hostname is the merchant's to set up; - // the diagnostic explains how. - if (!isManagedHostingHostname(hostname)) { - warnOnCrossDomainCheckout(updated); - - return; - } - - const shouldMatchCheckout = await confirm({ - message: - `Checkout is still on ${findChannelSiteUrl(updated, 'checkout') ?? 'another domain'}. ` + - `Move it to https://${MANAGED_ZONE_CHECKOUT_PREFIX}${hostname} so shoppers stay on this ` + - 'domain through payment?', - default: true, - }); - - if (!shouldMatchCheckout) { - warnOnCrossDomainCheckout(updated); - - return; - } - - await runChannelCheckoutUrlFlow({ - storeHash, - accessToken, - apiHost, - channelId, - storefrontHostname: hostname, - }); + return hostname; } diff --git a/packages/catalyst/src/cli/lib/project-config.ts b/packages/catalyst/src/cli/lib/project-config.ts index 43bab4979..fc5f048ed 100644 --- a/packages/catalyst/src/cli/lib/project-config.ts +++ b/packages/catalyst/src/cli/lib/project-config.ts @@ -16,6 +16,9 @@ export interface ProjectConfigSchema { // Channels whose owner declined, after a deploy, to point the channel's site // URL at the deployment. `catalyst deploy` doesn't offer again for these. declinedSiteUrlChannels?: number[]; + // Channels whose owner declined moving checkout onto the storefront's `c.` + // hostname. `catalyst deploy` doesn't offer again for these. + declinedCheckoutUrlChannels?: number[]; } // `cwd` defaults to the process working directory — the project the user is @@ -45,6 +48,10 @@ export function getProjectConfig(cwd: string = process.cwd()) { type: 'array', items: { type: 'number' }, }, + declinedCheckoutUrlChannels: { + type: 'array', + items: { type: 'number' }, + }, }, }); } From 48d6ec5e48208b9e1192f9088134b7d40c0c7026 Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Thu, 24 Sep 2026 16:34:41 -0500 Subject: [PATCH 09/15] LTRAC-2014: fix(cli) - Default the post-deploy URL prompts to No The site and checkout offers after a deploy defaulted to Yes, so pressing Enter past an unexpected question rewrote a live channel's URLs. Nothing asked for them, so default to No. channels update keeps Yes for its checkout follow-up, since the site URL change there was requested. Refs LTRAC-2014 Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/ltrac-2014-offer-channel-urls-after-deploy.md | 2 +- packages/catalyst/src/cli/commands/channels.spec.ts | 2 ++ packages/catalyst/src/cli/commands/channels.ts | 1 + packages/catalyst/src/cli/lib/channel-checkout-url-flow.ts | 4 +++- packages/catalyst/src/cli/lib/deploy-channel-urls.spec.ts | 3 +++ packages/catalyst/src/cli/lib/deploy-channel-urls.ts | 4 +++- 6 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.changeset/ltrac-2014-offer-channel-urls-after-deploy.md b/.changeset/ltrac-2014-offer-channel-urls-after-deploy.md index a8f13bec8..0b9a13133 100644 --- a/.changeset/ltrac-2014-offer-channel-urls-after-deploy.md +++ b/.changeset/ltrac-2014-offer-channel-urls-after-deploy.md @@ -9,7 +9,7 @@ Without `--update-site-url` or `--update-checkout-url`, a deploy used to leave t 1. **Site URL.** If the channel doesn't point at the project, it asks whether to point it at the deployment, and sets it to the hostname the deploy just went live on. Neither the channel nor the hostname is asked for; the hostname picker only appears if the deploy didn't report one. 2. **Checkout URL.** If the channel's storefront is on an auto-generated hostname and checkout is on another domain, it asks whether to move checkout to `https://c..`, and does so the way `--update-checkout-url` does. This is checked on its own, so it's offered even when the site URL was set earlier or some other way. A custom checkout URL the merchant set on another domain is left alone. -Declining either is saved per channel in `.bigcommerce/project.json` (`declinedSiteUrlChannels`, `declinedCheckoutUrlChannels`), and that offer isn't made again after deploys. Declining checkout also prints the cross-domain checkout warning and the command that sets it. +Both questions default to No, so pressing Enter never changes a live channel. Declining either is saved per channel in `.bigcommerce/project.json` (`declinedSiteUrlChannels`, `declinedCheckoutUrlChannels`), and that offer isn't made again after deploys. Declining checkout also prints the cross-domain checkout warning and the command that sets it. `catalyst channels update` makes the same checkout offer after it updates a site URL, since that update deletes the channel's checkout URL. As an explicit command it asks even after an earlier decline. It falls back to the cross-domain warning when there's nothing to offer. diff --git a/packages/catalyst/src/cli/commands/channels.spec.ts b/packages/catalyst/src/cli/commands/channels.spec.ts index fc65e00c0..e96b9103c 100644 --- a/packages/catalyst/src/cli/commands/channels.spec.ts +++ b/packages/catalyst/src/cli/commands/channels.spec.ts @@ -1176,6 +1176,8 @@ describe('channels update checkout offer', () => { 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 diff --git a/packages/catalyst/src/cli/commands/channels.ts b/packages/catalyst/src/cli/commands/channels.ts index 698649e1e..e83bf4635 100644 --- a/packages/catalyst/src/cli/commands/channels.ts +++ b/packages/catalyst/src/cli/commands/channels.ts @@ -212,6 +212,7 @@ Examples: // Asked for explicitly, so a decline after an earlier deploy doesn't // silence it here. respectOptOut: false, + defaultAnswer: true, }); if (!offered) { diff --git a/packages/catalyst/src/cli/lib/channel-checkout-url-flow.ts b/packages/catalyst/src/cli/lib/channel-checkout-url-flow.ts index 7dfbc1abc..6fb81e8c9 100644 --- a/packages/catalyst/src/cli/lib/channel-checkout-url-flow.ts +++ b/packages/catalyst/src/cli/lib/channel-checkout-url-flow.ts @@ -210,6 +210,8 @@ export interface ManagedCheckoutOfferOptions { // Skip the offer for a channel whose owner already declined it. Off for an // explicit command, which asks regardless. respectOptOut: boolean; + // What Enter answers. No after a deploy, where the offer wasn't asked for. + defaultAnswer: boolean; } // Offers to move a managed-zone storefront's checkout onto its `c.` hostname, @@ -238,7 +240,7 @@ export async function offerManagedCheckoutUrl( `Checkout is on ${findChannelSiteUrl(site, 'checkout') ?? 'another domain'}. Move it to ` + `https://${MANAGED_ZONE_CHECKOUT_PREFIX}${storefrontHostname} so shoppers stay on this ` + 'domain through payment?', - default: true, + default: options.defaultAnswer, }); if (!shouldMove) { diff --git a/packages/catalyst/src/cli/lib/deploy-channel-urls.spec.ts b/packages/catalyst/src/cli/lib/deploy-channel-urls.spec.ts index 21d7a4a55..e6abfb161 100644 --- a/packages/catalyst/src/cli/lib/deploy-channel-urls.spec.ts +++ b/packages/catalyst/src/cli/lib/deploy-channel-urls.spec.ts @@ -120,6 +120,9 @@ describe('offerChannelUrlUpdates', () => { expect(writes.checkout).toEqual({ url: `https://c.${storefront}` }); // Neither the channel nor the hostname is asked for; the deploy knows both. expect(selectMock).not.toHaveBeenCalled(); + // Unasked-for after a deploy, so Enter leaves both URLs alone. + expect(confirmMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ default: false })); + expect(confirmMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ default: false })); }); // Declining is remembered for the channel so later deploys stay quiet. diff --git a/packages/catalyst/src/cli/lib/deploy-channel-urls.ts b/packages/catalyst/src/cli/lib/deploy-channel-urls.ts index cf370b46f..2def06c86 100644 --- a/packages/catalyst/src/cli/lib/deploy-channel-urls.ts +++ b/packages/catalyst/src/cli/lib/deploy-channel-urls.ts @@ -73,6 +73,7 @@ export async function offerChannelUrlUpdates(options: DeployChannelUrlOptions): storefrontHostname, config, respectOptOut: true, + defaultAnswer: false, }); } @@ -88,7 +89,8 @@ async function offerSiteUrl( const shouldUpdate = await confirm({ message: `Channel ${channelId}'s site URL is ${storefrontUrl}. Point it at this deployment?`, - default: true, + // Unasked-for after a deploy, so Enter mustn't change a live channel. + default: false, }); if (!shouldUpdate) { From 401131da2901805432d341fab65211fe03395bdc Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Fri, 25 Sep 2026 10:49:13 -0500 Subject: [PATCH 10/15] LTRAC-2014: fix(cli) - Don't offer channel URL updates in CI The post-deploy offers and the channels update checkout follow-up only checked for a TTY. Some CI setups allocate a pseudo-terminal (docker run -t, script), where a prompt waits for input nobody gives and the job hangs. Also skip when CI is set, which CI systems define. Refs LTRAC-2014 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ltrac-2014-offer-channel-urls-after-deploy.md | 2 +- packages/catalyst/src/cli/commands/channels.spec.ts | 3 +++ packages/catalyst/src/cli/commands/channels.ts | 3 ++- packages/catalyst/src/cli/lib/can-prompt.ts | 7 +++++++ .../src/cli/lib/deploy-channel-urls.spec.ts | 13 +++++++++++++ .../catalyst/src/cli/lib/deploy-channel-urls.ts | 3 ++- 6 files changed, 28 insertions(+), 3 deletions(-) create mode 100644 packages/catalyst/src/cli/lib/can-prompt.ts diff --git a/.changeset/ltrac-2014-offer-channel-urls-after-deploy.md b/.changeset/ltrac-2014-offer-channel-urls-after-deploy.md index 0b9a13133..4e8117c48 100644 --- a/.changeset/ltrac-2014-offer-channel-urls-after-deploy.md +++ b/.changeset/ltrac-2014-offer-channel-urls-after-deploy.md @@ -13,4 +13,4 @@ Both questions default to No, so pressing Enter never changes a live channel. De `catalyst channels update` makes the same checkout offer after it updates a site URL, since that update deletes the channel's checkout URL. As an explicit command it asks even after an earlier decline. It falls back to the cross-domain warning when there's nothing to offer. -Scripted runs without a TTY, and deploys or updates that pass a checkout flag, behave as before. +Runs without a TTY or with `CI` set, and deploys or updates that pass a checkout flag, behave as before, so CI pipelines never wait on a prompt. diff --git a/packages/catalyst/src/cli/commands/channels.spec.ts b/packages/catalyst/src/cli/commands/channels.spec.ts index e96b9103c..0e6687991 100644 --- a/packages/catalyst/src/cli/commands/channels.spec.ts +++ b/packages/catalyst/src/cli/commands/channels.spec.ts @@ -1161,9 +1161,12 @@ describe('channels update checkout offer', () => { 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'); }); diff --git a/packages/catalyst/src/cli/commands/channels.ts b/packages/catalyst/src/cli/commands/channels.ts index e83bf4635..956c299c1 100644 --- a/packages/catalyst/src/cli/commands/channels.ts +++ b/packages/catalyst/src/cli/commands/channels.ts @@ -3,6 +3,7 @@ import { Command, InvalidArgumentError, Option } from 'commander'; import type Conf from 'conf'; import { colorize } from 'consola/utils'; +import { canPrompt } from '../lib/can-prompt'; import { offerManagedCheckoutUrl, runChannelCheckoutUrlFlow, @@ -171,7 +172,7 @@ Examples: const updatesSiteUrl = options.hostname !== undefined || !touchesCheckout; // A site URL update deletes the channel's checkout URL, so offer the // managed-zone one straight after, when nothing was said about checkout. - const offersCheckout = updatesSiteUrl && !touchesCheckout && process.stdin.isTTY; + const offersCheckout = updatesSiteUrl && !touchesCheckout && canPrompt(); let channelId = options.channelId; let siteHostname: string | undefined; diff --git a/packages/catalyst/src/cli/lib/can-prompt.ts b/packages/catalyst/src/cli/lib/can-prompt.ts new file mode 100644 index 000000000..7b6164c63 --- /dev/null +++ b/packages/catalyst/src/cli/lib/can-prompt.ts @@ -0,0 +1,7 @@ +// Whether an unrequested question can be asked. A TTY alone isn't enough: some +// CI setups allocate a pseudo-terminal (`docker run -t`, `script`), and a +// prompt there waits for input nobody will give, hanging the job. CI systems +// set `CI`, so honour it too. +export function canPrompt(): boolean { + return Boolean(process.stdin.isTTY) && !process.env.CI; +} diff --git a/packages/catalyst/src/cli/lib/deploy-channel-urls.spec.ts b/packages/catalyst/src/cli/lib/deploy-channel-urls.spec.ts index e6abfb161..2b43c4d6b 100644 --- a/packages/catalyst/src/cli/lib/deploy-channel-urls.spec.ts +++ b/packages/catalyst/src/cli/lib/deploy-channel-urls.spec.ts @@ -101,9 +101,12 @@ beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'deploy-channel-urls-')); config = getProjectConfig(dir); Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + // Our own CI sets it, and it silences the offers. + vi.stubEnv('CI', ''); }); afterEach(() => { + vi.unstubAllEnvs(); Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); rmSync(dir, { recursive: true, force: true }); }); @@ -240,6 +243,16 @@ describe('offerChannelUrlUpdates', () => { expect(writes.site).toEqual({ url: `https://${storefront}` }); }); + // Some CI setups allocate a pseudo-terminal; a prompt there would hang the job. + test('stays quiet in CI even with a TTY', async () => { + freshChannel(); + vi.stubEnv('CI', 'true'); + + await run(); + + expect(confirmMock).not.toHaveBeenCalled(); + }); + test('stays quiet without a TTY or a known channel', async () => { freshChannel(); diff --git a/packages/catalyst/src/cli/lib/deploy-channel-urls.ts b/packages/catalyst/src/cli/lib/deploy-channel-urls.ts index 2def06c86..1abfc053a 100644 --- a/packages/catalyst/src/cli/lib/deploy-channel-urls.ts +++ b/packages/catalyst/src/cli/lib/deploy-channel-urls.ts @@ -1,5 +1,6 @@ import { confirm } from '@inquirer/prompts'; +import { canPrompt } from './can-prompt'; import { offerManagedCheckoutUrl } from './channel-checkout-url-flow'; import { runChannelSiteUrlFlow } from './channel-site-flow'; import { findChannelSiteUrl, getChannelSite } from './channels'; @@ -45,7 +46,7 @@ export async function offerChannelUrlUpdates(options: DeployChannelUrlOptions): // No channel means nothing to key the opt-outs on, and scripted deploys keep // the flag-only behaviour. - if (!process.stdin.isTTY || channelId === undefined) return; + if (!canPrompt() || channelId === undefined) return; const [site, projects] = await Promise.all([ getChannelSite(channelId, storeHash, accessToken, apiHost), From 5f47723d900dba9279ace8821b272805aee3d177 Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Fri, 25 Sep 2026 11:05:13 -0500 Subject: [PATCH 11/15] LTRAC-1962: docs(cli) - Simplify the changeset Refs LTRAC-1962 Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/ltrac-1962-derive-managed-checkout-url.md | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/.changeset/ltrac-1962-derive-managed-checkout-url.md b/.changeset/ltrac-1962-derive-managed-checkout-url.md index 42c5b283d..9eaf6bf05 100644 --- a/.changeset/ltrac-1962-derive-managed-checkout-url.md +++ b/.changeset/ltrac-1962-derive-managed-checkout-url.md @@ -2,12 +2,4 @@ "@bigcommerce/catalyst": patch --- -Set a channel's checkout URL to its `c.` checkout hostname on a managed hosting zone, instead of prompting for one. - -`catalyst deploy --update-site-url --update-checkout-url` asked the merchant to type a checkout URL, defaulting to a `checkout.` subdomain that nothing would ever provision on a managed hosting zone. For a storefront on an auto-generated hostname (`.catalyst-sandbox.store`) it now sets `https://c..catalyst-sandbox.store` without prompting. - -Setting it is what provisions it: BigCommerce registers the hostname and issues its certificate in response to the write, usually within a couple of minutes. The CLI writes first, then waits for the certificate. If none issues within six minutes, when BigCommerce stops trying, it removes the checkout URL again so checkout falls back to the default channel's rather than staying broken. - -It leaves an existing custom checkout URL alone, and on a re-run where the hostname is already set it only waits for the certificate. A storefront on a custom domain, or `--update-checkout-url` without `--update-site-url`, still prompts as before. - -Re-running `--update-site-url` with a site URL that is already set no longer re-sends it. BigCommerce deletes a channel's checkout URL on every site URL update, even to the same URL, and releases its hostname, so each re-deploy with both flags used to drop and re-provision the `c.` checkout hostname, leaving checkout without a certificate for a minute or two. +`catalyst deploy --update-site-url --update-checkout-url` now sets the checkout URL of a storefront on an auto-generated hostname to `https://c..` without prompting, then waits for its certificate, which usually takes a minute or two. If no certificate is issued within six minutes, the checkout URL is removed again 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 channel's checkout URL. From d289a81b1b083ff71ed3ad1f4744a975a11670ce Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Fri, 25 Sep 2026 11:05:28 -0500 Subject: [PATCH 12/15] LTRAC-2014: docs(cli) - Simplify the changeset Refs LTRAC-2014 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ltrac-2014-offer-channel-urls-after-deploy.md | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/.changeset/ltrac-2014-offer-channel-urls-after-deploy.md b/.changeset/ltrac-2014-offer-channel-urls-after-deploy.md index 4e8117c48..f345db53b 100644 --- a/.changeset/ltrac-2014-offer-channel-urls-after-deploy.md +++ b/.changeset/ltrac-2014-offer-channel-urls-after-deploy.md @@ -2,15 +2,4 @@ "@bigcommerce/catalyst": patch --- -After an interactive `catalyst deploy`, offer to point the deployed channel at the deployment and to put its checkout on the same domain. - -Without `--update-site-url` or `--update-checkout-url`, a deploy used to leave the channel on its `mybigcommerce.com` storefront URL and the default channel's checkout, and said nothing about it. Now, when the deploy runs in a terminal and knows the channel it serves (`BIGCOMMERCE_CHANNEL_ID`, from a stored or `--secret` deployment variable first, then the env files), it checks two things on every deploy: - -1. **Site URL.** If the channel doesn't point at the project, it asks whether to point it at the deployment, and sets it to the hostname the deploy just went live on. Neither the channel nor the hostname is asked for; the hostname picker only appears if the deploy didn't report one. -2. **Checkout URL.** If the channel's storefront is on an auto-generated hostname and checkout is on another domain, it asks whether to move checkout to `https://c..`, and does so the way `--update-checkout-url` does. This is checked on its own, so it's offered even when the site URL was set earlier or some other way. A custom checkout URL the merchant set on another domain is left alone. - -Both questions default to No, so pressing Enter never changes a live channel. Declining either is saved per channel in `.bigcommerce/project.json` (`declinedSiteUrlChannels`, `declinedCheckoutUrlChannels`), and that offer isn't made again after deploys. Declining checkout also prints the cross-domain checkout warning and the command that sets it. - -`catalyst channels update` makes the same checkout offer after it updates a site URL, since that update deletes the channel's checkout URL. As an explicit command it asks even after an earlier decline. It falls back to the cross-domain warning when there's nothing to offer. - -Runs without a TTY or with `CI` set, and deploys or updates that pass a checkout flag, behave as before, so CI pipelines never wait on a prompt. +After an interactive `catalyst deploy`, offer to point the channel at the deployment and to move its checkout onto the same domain (`c..`). Both questions default to No, and declining is remembered per channel in `.bigcommerce/project.json`. `catalyst channels update` makes the same checkout offer after changing a site URL. Nothing is asked without a terminal, in CI, or when `--update-site-url` or `--update-checkout-url` is passed. From 48d7a58777271da6436c02847c3b8254be476831 Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Fri, 25 Sep 2026 11:17:38 -0500 Subject: [PATCH 13/15] LTRAC-1962: docs(cli) - Combine the checkout URL changesets LTRAC-2014's post-deploy offers ship in the same PR as the managed-zone checkout flow, so describe them in one changelog entry. Refs LTRAC-1962, LTRAC-2014 Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/ltrac-1962-derive-managed-checkout-url.md | 8 +++++++- .changeset/ltrac-2014-offer-channel-urls-after-deploy.md | 5 ----- 2 files changed, 7 insertions(+), 6 deletions(-) delete mode 100644 .changeset/ltrac-2014-offer-channel-urls-after-deploy.md diff --git a/.changeset/ltrac-1962-derive-managed-checkout-url.md b/.changeset/ltrac-1962-derive-managed-checkout-url.md index 9eaf6bf05..3aee82e27 100644 --- a/.changeset/ltrac-1962-derive-managed-checkout-url.md +++ b/.changeset/ltrac-1962-derive-managed-checkout-url.md @@ -2,4 +2,10 @@ "@bigcommerce/catalyst": patch --- -`catalyst deploy --update-site-url --update-checkout-url` now sets the checkout URL of a storefront on an auto-generated hostname to `https://c..` without prompting, then waits for its certificate, which usually takes a minute or two. If no certificate is issued within six minutes, the checkout URL is removed again 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 channel's checkout URL. +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..`: + +- `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. diff --git a/.changeset/ltrac-2014-offer-channel-urls-after-deploy.md b/.changeset/ltrac-2014-offer-channel-urls-after-deploy.md deleted file mode 100644 index f345db53b..000000000 --- a/.changeset/ltrac-2014-offer-channel-urls-after-deploy.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@bigcommerce/catalyst": patch ---- - -After an interactive `catalyst deploy`, offer to point the channel at the deployment and to move its checkout onto the same domain (`c..`). Both questions default to No, and declining is remembered per channel in `.bigcommerce/project.json`. `catalyst channels update` makes the same checkout offer after changing a site URL. Nothing is asked without a terminal, in CI, or when `--update-site-url` or `--update-checkout-url` is passed. From 17b5b0671a20587a6fff2c98043b19a0c6d94f0d Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Fri, 25 Sep 2026 12:22:26 -0500 Subject: [PATCH 14/15] LTRAC-1962: docs(cli) - Shorten the checkout URL comments Refs LTRAC-1962 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../catalyst/src/cli/commands/channels.ts | 6 +-- packages/catalyst/src/cli/commands/deploy.ts | 6 +-- packages/catalyst/src/cli/lib/can-prompt.ts | 6 +-- .../src/cli/lib/channel-checkout-url-flow.ts | 37 ++++++------------ .../catalyst/src/cli/lib/channel-site-flow.ts | 15 +++---- packages/catalyst/src/cli/lib/checkout-url.ts | 39 ++++++------------- .../src/cli/lib/deploy-channel-urls.ts | 28 +++++-------- .../catalyst/src/cli/lib/project-config.ts | 6 +-- 8 files changed, 45 insertions(+), 98 deletions(-) diff --git a/packages/catalyst/src/cli/commands/channels.ts b/packages/catalyst/src/cli/commands/channels.ts index 71b219607..1f9a13ce7 100644 --- a/packages/catalyst/src/cli/commands/channels.ts +++ b/packages/catalyst/src/cli/commands/channels.ts @@ -170,8 +170,7 @@ Examples: // asked to change. const touchesCheckout = options.checkoutUrl !== undefined || options.removeCheckoutUrl === true; const updatesSiteUrl = options.hostname !== undefined || !touchesCheckout; - // A site URL update deletes the channel's checkout URL, so offer the - // managed-zone one straight after, when nothing was said about checkout. + // 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; @@ -210,8 +209,7 @@ Examples: channelId, storefrontHostname: siteHostname, config, - // Asked for explicitly, so a decline after an earlier deploy doesn't - // silence it here. + // Explicit command, so an earlier decline doesn't silence it. respectOptOut: false, defaultAnswer: true, }); diff --git a/packages/catalyst/src/cli/commands/deploy.ts b/packages/catalyst/src/cli/commands/deploy.ts index 05fb138f7..52e85b82f 100644 --- a/packages/catalyst/src/cli/commands/deploy.ts +++ b/packages/catalyst/src/cli/commands/deploy.ts @@ -615,8 +615,7 @@ Example: // Carried over so both flags don't ask which channel twice. let resolvedChannelId: number | undefined; - // Set when --update-site-url ran, so the checkout flow below can use the - // checkout hostname that pairs with whichever hostname was actually used. + // Set by --update-site-url, so checkout pairs with the hostname actually used. let siteHostname: string | undefined; if (options.updateSiteUrl) { @@ -647,8 +646,7 @@ Example: } } - // Neither flag: offer both, once per channel. Explicit flags mean the - // caller already decided. + // Neither flag: offer both. Explicit flags mean the caller already decided. if (!options.updateSiteUrl && !options.updateCheckoutUrl) { try { await offerChannelUrlUpdates({ diff --git a/packages/catalyst/src/cli/lib/can-prompt.ts b/packages/catalyst/src/cli/lib/can-prompt.ts index 7b6164c63..7f7b4f89d 100644 --- a/packages/catalyst/src/cli/lib/can-prompt.ts +++ b/packages/catalyst/src/cli/lib/can-prompt.ts @@ -1,7 +1,5 @@ -// Whether an unrequested question can be asked. A TTY alone isn't enough: some -// CI setups allocate a pseudo-terminal (`docker run -t`, `script`), and a -// prompt there waits for input nobody will give, hanging the job. CI systems -// set `CI`, so honour it too. +// 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; } diff --git a/packages/catalyst/src/cli/lib/channel-checkout-url-flow.ts b/packages/catalyst/src/cli/lib/channel-checkout-url-flow.ts index 6fb81e8c9..ddef05220 100644 --- a/packages/catalyst/src/cli/lib/channel-checkout-url-flow.ts +++ b/packages/catalyst/src/cli/lib/channel-checkout-url-flow.ts @@ -32,11 +32,9 @@ export interface ChannelCheckoutUrlFlowOptions { // the flow neither re-prompts nor re-reads. channelName?: string; storefrontUrl?: string; - // The hostname the storefront was just pointed at. On a managed hosting zone - // the checkout hostname follows from it, so the prompt is skipped. + // On a managed zone the checkout hostname follows from this, so no prompt. storefrontHostname?: string; - // How long to wait for that hostname's certificate. Defaults to the point - // BigCommerce gives up issuing it. + // Defaults to when BigCommerce stops trying to issue the certificate. certificateTimeoutMs?: number; } @@ -125,16 +123,10 @@ interface ManagedCheckoutUrlOptions { timeoutMs?: number; } -// Points a channel whose storefront is on a managed hosting zone at its `c.` -// checkout hostname. -// -// The write comes first because it is what provisions the hostname: BigCommerce -// registers it and issues its certificate in response. Checkout fails there -// until the certificate issues, usually within a couple of minutes, so if it -// never does the write is undone rather than leaving checkout broken. -// -// Resolves false when the storefront isn't on a managed zone, so the caller -// prompts instead. +// Points a managed-zone storefront's channel at its `c.` checkout hostname. +// Writes first, since the write is what provisions the hostname, then waits +// for the certificate and undoes the write if none issues. Resolves false off +// the managed zone, so the caller prompts instead. async function setManagedCheckoutUrl(options: ManagedCheckoutUrlOptions): Promise { const { storeHash, accessToken, apiHost, channelId, label, storefrontHostname } = options; @@ -156,8 +148,7 @@ async function setManagedCheckoutUrl(options: ManagedCheckoutUrlOptions): Promis const site = await getChannelSite(channelId, storeHash, accessToken, apiHost); if (site.isCheckoutUrlCustomized) { - // A re-run finds it already set; writing it again would ask BigCommerce to - // register a hostname it already holds. + // Already set: writing it again would hit `canonical-in-use`. const alreadySet = site.urls.some( (entry) => entry.type === 'checkout' && entry.url.replace(/\/$/, '') === checkoutUrl, ); @@ -207,21 +198,15 @@ export interface ManagedCheckoutOfferOptions { // The storefront hostname the channel's primary URL is on. storefrontHostname: string; config: ReturnType; - // Skip the offer for a channel whose owner already declined it. Off for an - // explicit command, which asks regardless. + // Honour a saved decline. Off for explicit commands. respectOptOut: boolean; // What Enter answers. No after a deploy, where the offer wasn't asked for. defaultAnswer: boolean; } -// Offers to move a managed-zone storefront's checkout onto its `c.` hostname, -// when checkout is on another domain. A decline is saved per channel. Resolves -// whether the offer was made, so an explicit command can fall back to the -// cross-domain warning when it wasn't. -// -// A custom checkout URL on another domain was the merchant's choice, so it's -// never offered for replacement. Off the managed zone there is no `c.` -// hostname to offer; that storefront's checkout is the merchant's to set up. +// Offers to move a managed-zone storefront's checkout onto its `c.` hostname. +// Never replaces a merchant's own custom checkout URL. Resolves whether it +// asked, so an explicit command can fall back to the cross-domain warning. export async function offerManagedCheckoutUrl( options: ManagedCheckoutOfferOptions, ): Promise { diff --git a/packages/catalyst/src/cli/lib/channel-site-flow.ts b/packages/catalyst/src/cli/lib/channel-site-flow.ts index 663b4d420..60386a043 100644 --- a/packages/catalyst/src/cli/lib/channel-site-flow.ts +++ b/packages/catalyst/src/cli/lib/channel-site-flow.ts @@ -29,8 +29,7 @@ export interface ChannelSiteFlowOptions { // `catalyst deploy --update-site-url` to default to the freshly-deployed // hostname. preferHostname?: string; - // Warn afterwards when checkout is left on another domain. Off for a caller - // that offers to fix it next, so the warning isn't printed before the offer. + // Warn if checkout is left on another domain. Off when the caller offers a fix next. diagnoseCheckout?: boolean; } @@ -139,9 +138,7 @@ async function resolveHostname( export interface ChannelSiteFlowResult { channelId: number; - // The hostname the site URL was set to. Returned so a caller running the - // checkout flow next can derive the matching checkout hostname instead of - // guessing which of the project's hostnames was chosen. + // The hostname the site URL was set to, so a checkout step can pair with it. hostname: string; } @@ -154,11 +151,9 @@ export async function runChannelSiteUrlFlow( const siteUrl = hostname.startsWith('https://') ? hostname : `https://${hostname}`; const channelLabel = channel.name ? `"${channel.name}" (${channel.id})` : String(channel.id); - // Skip a write that changes nothing: sites-service deletes the channel's - // checkout URL on every site URL update, even to the same URL, and releases - // its hostname. A re-deploy would otherwise drop and re-provision a `c.` - // checkout hostname, leaving checkout without a certificate meanwhile. - // Best-effort: if the read fails, write as before. + // Skip an unchanged site URL: sites-service deletes the checkout URL on every + // site URL update, so re-sending it would drop the `c.` hostname. Best-effort: + // if the read fails, write anyway. const current = await getChannelSite( channel.id, options.storeHash, diff --git a/packages/catalyst/src/cli/lib/checkout-url.ts b/packages/catalyst/src/cli/lib/checkout-url.ts index b03cee19b..848d529a3 100644 --- a/packages/catalyst/src/cli/lib/checkout-url.ts +++ b/packages/catalyst/src/cli/lib/checkout-url.ts @@ -39,35 +39,24 @@ export function sharesMainDomain(a: string, b: string): boolean { // Cloudflare's 64-character certificate name limit. export const MANAGED_ZONE_CHECKOUT_PREFIX = 'c.'; -// Cloudflare will not issue a certificate for a name longer than this, from -// the RFC 5280 limit on a certificate common name. +// Longest name Cloudflare will issue a certificate for (RFC 5280). const MAX_HOSTNAME_LENGTH = 64; -// How long BigCommerce can take to get a certificate onto a freshly provisioned -// checkout hostname. It checks Cloudflare 60s after creating the custom -// hostname and then retries 10 times at 30s, so six minutes is the point past -// which it has given up rather than still working. +// BigCommerce stops trying to issue the certificate after about six minutes +// (a 60s delay, then 10 retries at 30s). const CHECKOUT_HOSTNAME_READY_TIMEOUT_MS = 6 * 60 * 1000; const CHECKOUT_HOSTNAME_POLL_INTERVAL_MS = 10 * 1000; -// The checkout hostname for a storefront on a managed zone. Nothing needs to -// exist beforehand: setting it as the checkout URL is what provisions it. -// -// Returns undefined when the result would exceed the certificate common-name -// limit. Hostnames generated before ignition reserved room for the prefix can be -// too long, and those projects have no checkout hostname to point at. +// The checkout hostname for a managed-zone storefront, or undefined when it +// would exceed the certificate name limit (older, longer hostnames). export function managedCheckoutHostname(storefrontHostname: string): string | undefined { const hostname = MANAGED_ZONE_CHECKOUT_PREFIX + normalizeHostname(storefrontHostname); return hostname.length <= MAX_HOSTNAME_LENGTH ? hostname : undefined; } -// Whether the hostname terminates TLS with a certificate a client will accept. -// -// Any HTTP response means the handshake succeeded, which is the thing that -// matters; the status code is irrelevant, and checkout answers a bare GET with -// a redirect to the storefront when there is no cart. A rejected certificate or -// an unresolvable name throws, which is the signal we want. +// Whether the hostname serves a certificate a client accepts. Any HTTP +// response means the handshake worked; a bad certificate or name throws. async function checkoutHostnameIsServing(hostname: string): Promise { try { await fetch(`https://${hostname}/`, { @@ -82,8 +71,7 @@ async function checkoutHostnameIsServing(hostname: string): Promise { } } -// Waits for a freshly provisioned checkout hostname to serve a valid -// certificate. Until it does, checkout on that hostname fails for shoppers. +// Waits for a newly provisioned checkout hostname's certificate. export async function waitForCheckoutHostname( hostname: string, { timeoutMs = CHECKOUT_HOSTNAME_READY_TIMEOUT_MS, onWait }: CheckoutHostnameWaitOptions = {}, @@ -92,8 +80,7 @@ export async function waitForCheckoutHostname( let waited = false; for (;;) { - // Sequential by nature: each probe asks whether the certificate has - // issued yet, so there is nothing to parallelise. + // One probe at a time: each asks whether the certificate has issued yet. // eslint-disable-next-line no-await-in-loop if (await checkoutHostnameIsServing(hostname)) return true; @@ -111,8 +98,7 @@ export async function waitForCheckoutHostname( export interface CheckoutHostnameWaitOptions { timeoutMs?: number; - // Called once, before the first sleep, so a caller can explain the pause - // rather than appearing to hang for minutes. + // Called once, before the first wait, so the caller can explain the pause. onWait?: () => void; } @@ -184,9 +170,8 @@ export function isManagedHostingHostname(hostname: string): boolean { return NATIVE_HOSTING_ZONES.some((zone) => isSubdomainOf(host, zone)); } -// Whether a channel's checkout sits on a different main domain from its -// storefront. The same test `warnOnCrossDomainCheckout` applies, without the -// output, for a caller deciding whether to offer a fix. +// Whether checkout is on a different main domain from the storefront: the +// test `warnOnCrossDomainCheckout` makes, without the output. export function isCrossDomainCheckout(site: ChannelSiteDetails): boolean { const storefrontHost = hostnameOf(findChannelSiteUrl(site, 'primary') ?? site.url); const checkoutUrl = findChannelSiteUrl(site, 'checkout'); diff --git a/packages/catalyst/src/cli/lib/deploy-channel-urls.ts b/packages/catalyst/src/cli/lib/deploy-channel-urls.ts index 1abfc053a..36d2ffc16 100644 --- a/packages/catalyst/src/cli/lib/deploy-channel-urls.ts +++ b/packages/catalyst/src/cli/lib/deploy-channel-urls.ts @@ -22,11 +22,8 @@ export interface DeployChannelUrlOptions { channelId?: number; } -// The channel a deployment serves. A deployment secret (project.json `env` or -// `--secret`) wins: at runtime OpenNext copies the worker's bindings onto -// `process.env` first and only fills unset keys from the env files baked in at -// build time. The env files are the fallback, and aren't loaded at all with -// `--prebuilt`. +// The channel a deployment serves. Deployment secrets win, as in OpenNext's +// runtime; env files are the fallback (not loaded with `--prebuilt`). export function deployedChannelId(secrets: DeploymentSecret[]): number | undefined { const raw = secrets.find((secret) => secret.key === 'BIGCOMMERCE_CHANNEL_ID')?.value ?? @@ -36,16 +33,13 @@ export function deployedChannelId(secrets: DeploymentSecret[]): number | undefin return Number.isInteger(id) && id > 0 ? id : undefined; } -// After an interactive deploy, offers to point the deployed channel at the new -// hostname, and to move its checkout onto the same domain. The two are checked -// separately on every deploy, so a checkout left behind is offered even when -// the site URL was set some other way, or earlier. Declining either is saved -// per channel in project.json, and that offer isn't made again. +// After an interactive deploy, offers to point the channel at the deployment +// and to move its checkout onto the same domain. Each is checked on every +// deploy; a decline is saved per channel. export async function offerChannelUrlUpdates(options: DeployChannelUrlOptions): Promise { const { storeHash, accessToken, apiHost, projectUuid, config, channelId } = options; - // No channel means nothing to key the opt-outs on, and scripted deploys keep - // the flag-only behaviour. + // No channel means nothing to key the opt-outs on. if (!canPrompt() || channelId === undefined) return; const [site, projects] = await Promise.all([ @@ -62,8 +56,7 @@ export async function offerChannelUrlUpdates(options: DeployChannelUrlOptions): storefrontHostname ??= await offerSiteUrl({ ...options, channelId }, storefrontUrl); - // Not pointed at this project, so a checkout hostname under it wouldn't - // share the storefront's domain. + // Not on this project, so a `c.` hostname under it wouldn't match. if (storefrontHostname === undefined) return; await offerManagedCheckoutUrl({ @@ -109,13 +102,10 @@ async function offerSiteUrl( accessToken, apiHost, projectUuid, - // Both are known: the channel the build targets and the hostname the deploy - // went live on. The hostname picker only shows if the deploy didn't report - // one. + // The deploy knows both; the hostname picker only shows if it reported none. channelId, hostname: options.deploymentHostname, - // The checkout offer that follows covers the managed zone; elsewhere the - // diagnostic explains how to set up a checkout domain. + // On the managed zone the checkout offer follows; elsewhere, warn instead. diagnoseCheckout: !isManagedHostingHostname(options.deploymentHostname ?? ''), }); diff --git a/packages/catalyst/src/cli/lib/project-config.ts b/packages/catalyst/src/cli/lib/project-config.ts index fc5f048ed..7c206c95f 100644 --- a/packages/catalyst/src/cli/lib/project-config.ts +++ b/packages/catalyst/src/cli/lib/project-config.ts @@ -13,11 +13,9 @@ export interface ProjectConfigSchema { // `catalyst env` commands. Lives here (gitignored .bigcommerce/project.json) // so users don't have to re-pass `--secret` on every deploy. env?: Record; - // Channels whose owner declined, after a deploy, to point the channel's site - // URL at the deployment. `catalyst deploy` doesn't offer again for these. + // Channels that declined the post-deploy site URL offer. declinedSiteUrlChannels?: number[]; - // Channels whose owner declined moving checkout onto the storefront's `c.` - // hostname. `catalyst deploy` doesn't offer again for these. + // Channels that declined the post-deploy checkout URL offer. declinedCheckoutUrlChannels?: number[]; } From 7a1c68a7c3f203c72c62ed095d94474b1e90c349 Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Fri, 25 Sep 2026 13:29:35 -0500 Subject: [PATCH 15/15] LTRAC-1962: fix(cli) - Set the c. checkout hostname with --update-checkout-url alone Without --update-site-url the checkout flow had no storefront hostname, so a managed-zone channel was prompted with a checkout. default and never waited for the certificate. That is also the command the certificate-timeout message tells people to re-run. Derive the hostname from the channel's primary URL instead. An explicit --checkout-url is still written as given. Refs LTRAC-1962 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cli/lib/channel-checkout-url-flow.spec.ts | 26 +++++++++++++++++++ .../src/cli/lib/channel-checkout-url-flow.ts | 21 +++++++++++++++ packages/catalyst/src/cli/lib/checkout-url.ts | 2 +- 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/packages/catalyst/src/cli/lib/channel-checkout-url-flow.spec.ts b/packages/catalyst/src/cli/lib/channel-checkout-url-flow.spec.ts index b13b51a02..815c5cc43 100644 --- a/packages/catalyst/src/cli/lib/channel-checkout-url-flow.spec.ts +++ b/packages/catalyst/src/cli/lib/channel-checkout-url-flow.spec.ts @@ -233,6 +233,32 @@ describe('runChannelCheckoutUrlFlow on a managed hosting zone', () => { ); }); + // `deploy --update-checkout-url` alone doesn't pass the hostname. + test('derives the checkout hostname from the channel when not given one', async () => { + const writes = trackWrites(); + + server.use( + siteWith(false, 'https://store-abc-1.mybigcommerce.com'), + http.head(probe, () => HttpResponse.json(null, { status: 302 })), + ); + + await run({ storefrontHostname: undefined }); + + expect(writes.put).toEqual({ url: checkoutUrl }); + expect(inputMock).not.toHaveBeenCalled(); + }); + + test('writes an explicit URL as given', async () => { + const writes = trackWrites(); + + server.use(siteWith(false)); + + await run({ storefrontHostname: undefined, url: 'checkout.example.com' }); + + expect(writes.put).toEqual({ url: 'https://checkout.example.com' }); + expect(consola.success).not.toHaveBeenCalledWith(expect.stringContaining('serving checkout')); + }); + test('explains the wait while the certificate issues', async () => { vi.useFakeTimers({ toFake: ['setTimeout'] }); diff --git a/packages/catalyst/src/cli/lib/channel-checkout-url-flow.ts b/packages/catalyst/src/cli/lib/channel-checkout-url-flow.ts index ddef05220..4738a5e50 100644 --- a/packages/catalyst/src/cli/lib/channel-checkout-url-flow.ts +++ b/packages/catalyst/src/cli/lib/channel-checkout-url-flow.ts @@ -8,6 +8,7 @@ import { updateChannelCheckoutUrl, } from './channels'; import { + hostnameOf, isCrossDomainCheckout, isManagedHostingHostname, MANAGED_ZONE_CHECKOUT_PREFIX, @@ -93,6 +94,26 @@ export async function runChannelCheckoutUrlFlow( } } + // Called without the hostname, e.g. `deploy --update-checkout-url` alone, a + // managed-zone storefront still has nothing to ask. An explicit URL is + // written as given. + const storefrontHostname = hostnameOf(storefrontUrl); + + if ( + options.url === undefined && + options.storefrontHostname === undefined && + storefrontHostname !== undefined && + (await setManagedCheckoutUrl({ + ...options, + channelId: channel.id, + label, + storefrontHostname, + timeoutMs: options.certificateTimeoutMs, + })) + ) { + return; + } + const answer = options.url ?? (await input({ diff --git a/packages/catalyst/src/cli/lib/checkout-url.ts b/packages/catalyst/src/cli/lib/checkout-url.ts index 848d529a3..8fab9d37a 100644 --- a/packages/catalyst/src/cli/lib/checkout-url.ts +++ b/packages/catalyst/src/cli/lib/checkout-url.ts @@ -8,7 +8,7 @@ const normalizeHostname = (hostname: string) => hostname.toLowerCase().replace(/ const isSubdomainOf = (hostname: string, parent: string) => hostname.endsWith(`.${parent}`); -function hostnameOf(url: string): string | undefined { +export function hostnameOf(url: string): string | undefined { try { return new URL(url).hostname; } catch {