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..3aee82e27 --- /dev/null +++ b/.changeset/ltrac-1962-derive-managed-checkout-url.md @@ -0,0 +1,11 @@ +--- +"@bigcommerce/catalyst": patch +--- + +Put checkout on the same domain as a native-hosted storefront. For a storefront on an auto-generated hostname, the checkout URL is `https://c..`: + +- `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/packages/catalyst/src/cli/commands/channels.spec.ts b/packages/catalyst/src/cli/commands/channels.spec.ts index 3f57eff62..0e6687991 100644 --- a/packages/catalyst/src/cli/commands/channels.spec.ts +++ b/packages/catalyst/src/cli/commands/channels.spec.ts @@ -1100,3 +1100,102 @@ describe('channels checkout URLs', () => { }); }); }); + +// A site URL update deletes the channel's checkout URL, so on the managed zone +// `channels update` offers the `c.` one straight after. +describe('channels update checkout offer', () => { + const sitePath = 'https://:apiHost/stores/:storeHash/v3/channels/:channelId/site'; + const checkoutPath = `${sitePath}/checkout-url`; + const storefront = 'project-one.catalyst-sandbox.store'; + + const run = () => + program.parseAsync([ + 'node', + 'catalyst', + 'channels', + 'update', + '--channel-id', + '2', + '--hostname', + storefront, + '--project-uuid', + linkedProjectUuid, + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]); + + const site = (primary: string) => + HttpResponse.json({ + data: { + id: 1, + url: primary, + channel_id: 2, + ssl_status: null, + is_checkout_url_customized: false, + urls: [ + { url: primary, type: 'primary' }, + { url: 'https://store-abc-1.mybigcommerce.com', type: 'checkout' }, + ], + }, + }); + + // The first read is the pre-write check, so it sees the old primary. + const channelMovingToManagedZone = () => { + const writes: { checkout?: unknown } = {}; + + server.use( + http.get(sitePath, () => site('https://store-abc-2.mybigcommerce.com'), { once: true }), + http.get(sitePath, () => site(`https://${storefront}`)), + http.put(checkoutPath, async ({ request }) => { + writes.checkout = await request.json(); + + return HttpResponse.json({ data: { id: 1, url: `https://${storefront}`, channel_id: 2 } }); + }), + http.head(`https://c.${storefront}/`, () => HttpResponse.json(null, { status: 302 })), + ); + + return writes; + }; + + beforeEach(() => { + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + // Our own CI sets it, and it silences the offer. + vi.stubEnv('CI', ''); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + config.delete('declinedCheckoutUrlChannels'); + }); + + test('offers the managed-zone checkout URL after updating the site URL', async () => { + const writes = channelMovingToManagedZone(); + + mockConfirm.mockResolvedValueOnce(true); + + await run(); + + expect(writes.checkout).toEqual({ url: `https://c.${storefront}` }); + // The site URL change was asked for, so Enter accepts the follow-up. + expect(mockConfirm).toHaveBeenCalledWith(expect.objectContaining({ default: true })); + }); + + // Asked for explicitly, so an earlier decline after a deploy doesn't silence + // it; declining here is saved so deploys stay quiet. + test('asks despite an earlier decline, and saves a new one', async () => { + const writes = channelMovingToManagedZone(); + + config.set('declinedCheckoutUrlChannels', [2]); + mockConfirm.mockResolvedValueOnce(false); + + await run(); + + expect(mockConfirm).toHaveBeenCalledTimes(1); + expect(writes.checkout).toBeUndefined(); + expect(config.get('declinedCheckoutUrlChannels')).toEqual([2]); + expect(consola.warn).toHaveBeenCalledWith(expect.stringContaining("default channel's domain")); + }); +}); diff --git a/packages/catalyst/src/cli/commands/channels.ts b/packages/catalyst/src/cli/commands/channels.ts index 65252cc3c..1f9a13ce7 100644 --- a/packages/catalyst/src/cli/commands/channels.ts +++ b/packages/catalyst/src/cli/commands/channels.ts @@ -3,7 +3,11 @@ import { Command, InvalidArgumentError, Option } from 'commander'; import type Conf from 'conf'; import { colorize } from 'consola/utils'; -import { runChannelCheckoutUrlFlow } from '../lib/channel-checkout-url-flow'; +import { canPrompt } from '../lib/can-prompt'; +import { + offerManagedCheckoutUrl, + runChannelCheckoutUrlFlow, +} from '../lib/channel-checkout-url-flow'; import { resolveChannel, runChannelSiteUrlFlow } from '../lib/channel-site-flow'; import { channelPlatformLabel, @@ -166,17 +170,21 @@ Examples: // asked to change. const touchesCheckout = options.checkoutUrl !== undefined || options.removeCheckoutUrl === true; const updatesSiteUrl = options.hostname !== undefined || !touchesCheckout; + // A site URL update deletes the checkout URL, so offer the managed-zone one next. + const offersCheckout = updatesSiteUrl && !touchesCheckout && canPrompt(); let channelId = options.channelId; + let siteHostname: string | undefined; if (updatesSiteUrl) { try { - ({ channelId } = await runChannelSiteUrlFlow({ + ({ channelId, hostname: siteHostname } = await runChannelSiteUrlFlow({ storeHash, accessToken, apiHost, projectUuid: options.projectUuid ?? config.get('projectUuid'), channelId: options.channelId, hostname: options.hostname, + diagnoseCheckout: !offersCheckout, })); } catch (error) { if (error instanceof NoLinkedProjectError) { @@ -193,6 +201,24 @@ Examples: } } + if (offersCheckout && channelId !== undefined && siteHostname !== undefined) { + const offered = await offerManagedCheckoutUrl({ + storeHash, + accessToken, + apiHost, + channelId, + storefrontHostname: siteHostname, + config, + // Explicit command, so an earlier decline doesn't silence it. + respectOptOut: false, + defaultAnswer: true, + }); + + if (!offered) { + warnOnCrossDomainCheckout(await getChannelSite(channelId, storeHash, accessToken, apiHost)); + } + } + if (touchesCheckout) { const channel = channelId === undefined diff --git a/packages/catalyst/src/cli/commands/deploy.spec.ts b/packages/catalyst/src/cli/commands/deploy.spec.ts index 858d390a1..4edc9a694 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,31 @@ describe('--update-site-url', () => { data: { id: 1, url: 'https://project-one.catalyst-sandbox.store', channel_id: 2 }, }), ), + // A channel still on the inherited checkout, so the flow writes. + http.get('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () => + HttpResponse.json({ + data: { + id: 1, + url: 'https://project-one.catalyst-sandbox.store', + channel_id: 2, + ssl_status: null, + is_checkout_url_customized: false, + urls: [ + { url: 'https://project-one.catalyst-sandbox.store', type: 'primary' }, + { url: 'https://store-abc-1.mybigcommerce.com', type: 'checkout' }, + ], + }, + }), + ), + // Answering at all means the certificate issued after the write. + http.head('https://c.project-one.catalyst-sandbox.store/', () => + HttpResponse.json(null, { status: 302 }), + ), http.put( 'https://:apiHost/stores/:storeHash/v3/channels/:channelId/site/checkout-url', - ({ params }) => { + async ({ params, request }) => { checkoutChannelId = String(params.channelId); + checkoutBody = await request.json(); return HttpResponse.json({ data: { id: 1, url: 'https://example.com', channel_id: 2 }, @@ -1106,14 +1129,16 @@ describe('--update-site-url', () => { vi.mocked(select) .mockResolvedValueOnce(2) // channel, asked once by the site-URL flow .mockResolvedValueOnce('project-one.catalyst-sandbox.store'); // hostname - vi.mocked(input).mockResolvedValueOnce('https://checkout.example.com'); await program.parseAsync(deployArgs(['--update-site-url', '--update-checkout-url'])); expect(checkoutChannelId).toBe('2'); + expect(checkoutBody).toEqual({ url: 'https://c.project-one.catalyst-sandbox.store' }); // Two selects total: channel + hostname. A third would mean the checkout // flow re-prompted for the channel. expect(vi.mocked(select)).toHaveBeenCalledTimes(2); + // No prompt: on a managed zone the checkout hostname follows from the storefront. + expect(vi.mocked(input)).not.toHaveBeenCalled(); }); test('does not call the checkout URL API when the flag is omitted', async () => { diff --git a/packages/catalyst/src/cli/commands/deploy.ts b/packages/catalyst/src/cli/commands/deploy.ts index 93e76b0d4..52e85b82f 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 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=`, ) @@ -610,9 +615,12 @@ Example: // Carried over so both flags don't ask which channel twice. let resolvedChannelId: number | undefined; + // Set by --update-site-url, so checkout pairs with the hostname actually used. + let siteHostname: string | undefined; + if (options.updateSiteUrl) { try { - ({ channelId: resolvedChannelId } = await runChannelSiteUrlFlow({ + ({ channelId: resolvedChannelId, hostname: siteHostname } = await runChannelSiteUrlFlow({ storeHash, accessToken, apiHost, @@ -631,9 +639,27 @@ Example: accessToken, apiHost, channelId: resolvedChannelId, + storefrontHostname: siteHostname, }); } catch (error) { warnChannelFlowFailed('checkout URL', error); } } + + // Neither flag: offer both. Explicit flags mean the caller already decided. + if (!options.updateSiteUrl && !options.updateCheckoutUrl) { + try { + await offerChannelUrlUpdates({ + storeHash, + accessToken, + apiHost, + projectUuid, + config, + deploymentHostname, + channelId: deployedChannelId(mergedSecrets), + }); + } catch (error) { + warnChannelFlowFailed('URLs', error); + } + } }); 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..7f7b4f89d --- /dev/null +++ b/packages/catalyst/src/cli/lib/can-prompt.ts @@ -0,0 +1,5 @@ +// Whether an unrequested question can be asked. Also checks `CI`: some CI +// setups allocate a pseudo-terminal, where a prompt would hang the job. +export function canPrompt(): boolean { + return Boolean(process.stdin.isTTY) && !process.env.CI; +} 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..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 @@ -169,3 +169,202 @@ 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.', + ); + }); + + // `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'] }); + + 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..4738a5e50 100644 --- a/packages/catalyst/src/cli/lib/channel-checkout-url-flow.ts +++ b/packages/catalyst/src/cli/lib/channel-checkout-url-flow.ts @@ -1,9 +1,25 @@ -import { input } from '@inquirer/prompts'; +import { confirm, 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 { + hostnameOf, + 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; @@ -17,6 +33,10 @@ export interface ChannelCheckoutUrlFlowOptions { // the flow neither re-prompts nor re-reads. channelName?: string; storefrontUrl?: string; + // On a managed zone the checkout hostname follows from this, so no prompt. + storefrontHostname?: string; + // Defaults to when BigCommerce stops trying to issue the certificate. + certificateTimeoutMs?: number; } // Prompts for a checkout URL and writes it. @@ -34,6 +54,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 @@ -61,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({ @@ -80,3 +133,144 @@ 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 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; + + // 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) { + // Already set: writing it again would hit `canonical-in-use`. + 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; +} + +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; + // 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. +// 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 { + 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: options.defaultAnswer, + }); + + 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/channel-site-flow.spec.ts b/packages/catalyst/src/cli/lib/channel-site-flow.spec.ts index 499b5cca4..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', () => @@ -371,7 +427,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 97073ba71..60386a043 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'; @@ -28,6 +29,8 @@ export interface ChannelSiteFlowOptions { // `catalyst deploy --update-site-url` to default to the freshly-deployed // hostname. preferHostname?: string; + // Warn if checkout is left on another domain. Off when the caller offers a fix next. + diagnoseCheckout?: boolean; } async function resolveProject(options: ChannelSiteFlowOptions): Promise { @@ -135,6 +138,8 @@ async function resolveHostname( export interface ChannelSiteFlowResult { channelId: number; + // The hostname the site URL was set to, so a checkout step can pair with it. + hostname: string; } export async function runChannelSiteUrlFlow( @@ -144,22 +149,40 @@ 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 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, - 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 // 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, @@ -186,5 +209,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 86505d514..6995ac323 100644 --- a/packages/catalyst/src/cli/lib/checkout-url.spec.ts +++ b/packages/catalyst/src/cli/lib/checkout-url.spec.ts @@ -1,7 +1,16 @@ +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 { sharesMainDomain, suggestCheckoutUrl, warnOnCrossDomainCheckout } from './checkout-url'; +import { + managedCheckoutHostname, + sharesMainDomain, + suggestCheckoutUrl, + waitForCheckoutHostname, + warnOnCrossDomainCheckout, +} from './checkout-url'; import { consola } from './logger'; const site = (urls: Array<{ url: string; type: string }>): ChannelSiteDetails => ({ @@ -317,3 +326,80 @@ describe('warnOnCrossDomainCheckout', () => { expect(messages).not.toContain('cannot be issued a certificate'); }); }); + +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('waitForCheckoutHostname', () => { + const hostname = 'c.catalyst.catalyst-sandbox.store'; + const probe = `https://${hostname}/`; + + // 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(waitForCheckoutHostname(hostname)).resolves.toBe(true); + }); + + test('resolves false when the hostname never serves', async () => { + server.use(http.head(probe, () => HttpResponse.error())); + + await expect(waitForCheckoutHostname(hostname, { timeoutMs: 0 })).resolves.toBe(false); + }); + + test('explains the pause once, then retries', async () => { + vi.useFakeTimers({ toFake: ['setTimeout'] }); + + let probes = 0; + + 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 164d0f350..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 { @@ -39,6 +39,69 @@ export function sharesMainDomain(a: string, b: string): boolean { // Cloudflare's 64-character certificate name limit. export const MANAGED_ZONE_CHECKOUT_PREFIX = 'c.'; +// Longest name Cloudflare will issue a certificate for (RFC 5280). +const MAX_HOSTNAME_LENGTH = 64; + +// 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 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 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}/`, { + method: 'HEAD', + redirect: 'manual', + signal: AbortSignal.timeout(10_000), + }); + + return true; + } catch { + return false; + } +} + +// Waits for a newly provisioned checkout hostname's certificate. +export async function waitForCheckoutHostname( + hostname: string, + { timeoutMs = CHECKOUT_HOSTNAME_READY_TIMEOUT_MS, onWait }: CheckoutHostnameWaitOptions = {}, +): Promise { + const deadline = Date.now() + timeoutMs; + let waited = false; + + for (;;) { + // 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; + + 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 wait, so the caller can explain the pause. + onWait?: () => void; +} + // The checkout subdomain a merchant most likely wants: // `https://www.example.com` → `https://checkout.example.com`. // @@ -107,6 +170,16 @@ export function isManagedHostingHostname(hostname: string): boolean { return NATIVE_HOSTING_ZONES.some((zone) => isSubdomainOf(host, zone)); } +// 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'); + 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..2b43c4d6b --- /dev/null +++ b/packages/catalyst/src/cli/lib/deploy-channel-urls.spec.ts @@ -0,0 +1,292 @@ +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 = ({ + 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 = startingPrimary; + + server.use( + http.get(sitePath, () => + HttpResponse.json({ + data: { + id: 1, + url: primary, + channel_id: 2, + ssl_status: null, + is_checkout_url_customized: customized, + urls: [ + { url: primary, type: 'primary' }, + { url: checkout, 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: Partial[0]> = { channelId: 2 }) => + offerChannelUrlUpdates({ + storeHash, + accessToken, + apiHost, + projectUuid, + config, + deploymentHostname: storefront, + ...overrides, + }); + +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 }); + // 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 }); +}); + +describe('offerChannelUrlUpdates', () => { + test('points the site and checkout at the deployment when both are accepted', async () => { + const writes = freshChannel(); + + confirmMock.mockResolvedValueOnce(true).mockResolvedValueOnce(true); + + await run(); + + expect(writes.site).toEqual({ url: `https://${storefront}` }); + 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. + 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(); + }); + + test('warns and saves the decline when moving checkout is declined', async () => { + const writes = freshChannel(); + + confirmMock.mockResolvedValueOnce(true).mockResolvedValueOnce(false); + + 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(); + 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 () => { + 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); + + await run({ channelId: 2, deploymentHostname: 'vanity.project-one.example.com' }); + + expect(confirmMock).toHaveBeenCalledTimes(1); + expect(writes.checkout).toBeUndefined(); + expect(consola.info).toHaveBeenCalledWith( + expect.stringContaining('checkout.vanity.project-one.example.com at BigCommerce'), + ); + }); + + // 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}` }); + }); + + // 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(); + + await run({}); + + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + await run(); + + expect(confirmMock).not.toHaveBeenCalled(); + }); +}); + +describe('deployedChannelId', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + // 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( + 9, + ); + expect(deployedChannelId([])).toBe(7); + }); + + 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..36d2ffc16 --- /dev/null +++ b/packages/catalyst/src/cli/lib/deploy-channel-urls.ts @@ -0,0 +1,113 @@ +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'; +import { isManagedHostingHostname } 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. 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 ?? + process.env.BIGCOMMERCE_CHANNEL_ID; + const id = Number(raw); + + return Number.isInteger(id) && id > 0 ? id : undefined; +} + +// 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. + if (!canPrompt() || channelId === undefined) 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(/\/$/, ''); + + let storefrontHostname = deploymentHostnames.find( + (deployed) => storefrontUrl === `https://${deployed}`, + ); + + storefrontHostname ??= await offerSiteUrl({ ...options, channelId }, storefrontUrl); + + // Not on this project, so a `c.` hostname under it wouldn't match. + if (storefrontHostname === undefined) return; + + await offerManagedCheckoutUrl({ + storeHash, + accessToken, + apiHost, + channelId, + storefrontHostname, + config, + respectOptOut: true, + defaultAnswer: false, + }); +} + +// 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?`, + // Unasked-for after a deploy, so Enter mustn't change a live channel. + default: false, + }); + + 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 undefined; + } + + const { hostname } = await runChannelSiteUrlFlow({ + storeHash, + accessToken, + apiHost, + projectUuid, + // The deploy knows both; the hostname picker only shows if it reported none. + channelId, + hostname: options.deploymentHostname, + // On the managed zone the checkout offer follows; elsewhere, warn instead. + diagnoseCheckout: !isManagedHostingHostname(options.deploymentHostname ?? ''), + }); + + return hostname; +} diff --git a/packages/catalyst/src/cli/lib/project-config.ts b/packages/catalyst/src/cli/lib/project-config.ts index 2a6139b2d..7c206c95f 100644 --- a/packages/catalyst/src/cli/lib/project-config.ts +++ b/packages/catalyst/src/cli/lib/project-config.ts @@ -13,6 +13,10 @@ 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 that declined the post-deploy site URL offer. + declinedSiteUrlChannels?: number[]; + // Channels that declined the post-deploy checkout URL offer. + declinedCheckoutUrlChannels?: number[]; } // `cwd` defaults to the process working directory — the project the user is @@ -38,6 +42,14 @@ export function getProjectConfig(cwd: string = process.cwd()) { additionalProperties: { type: 'string' }, default: {}, }, + declinedSiteUrlChannels: { + type: 'array', + items: { type: 'number' }, + }, + declinedCheckoutUrlChannels: { + type: 'array', + items: { type: 'number' }, + }, }, }); }