diff --git a/.changeset/sync-makeswift-1-9-0.md b/.changeset/sync-makeswift-1-9-0.md new file mode 100644 index 0000000000..480e96c25c --- /dev/null +++ b/.changeset/sync-makeswift-1-9-0.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-b2b-makeswift": minor +--- + +Pulls in changes from the `@bigcommerce/catalyst-makeswift@1.9.0` release. For more information, see the [changelog entry](https://github.com/bigcommerce/catalyst/blob/da09e6304d1467ac6193f4d5eb07107a8933e0dc/core/CHANGELOG.md#190). diff --git a/core/.gitignore b/core/.gitignore index f4086e9252..12fd47d6ed 100644 --- a/core/.gitignore +++ b/core/.gitignore @@ -54,3 +54,6 @@ build-config.json # OpenNext .open-next .wrangler + +# Catalyst CLI config +.bigcommerce diff --git a/core/app/[locale]/(default)/(faceted)/category/[slug]/page-data.ts b/core/app/[locale]/(default)/(faceted)/category/[slug]/page-data.ts index ac42d67c47..f48f9c83c6 100644 --- a/core/app/[locale]/(default)/(faceted)/category/[slug]/page-data.ts +++ b/core/app/[locale]/(default)/(faceted)/category/[slug]/page-data.ts @@ -13,6 +13,7 @@ const CategoryPageQuery = graphql( entityId name path + defaultProductSort ...BreadcrumbsFragment seo { pageTitle diff --git a/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx b/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx index 14ec86b128..fc25177749 100644 --- a/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx +++ b/core/app/[locale]/(default)/(faceted)/category/[slug]/page.tsx @@ -133,6 +133,11 @@ export default async function Category(props: Props) { const taxDisplay = settings?.tax?.plp; + const categoryDefaultSort = + category.defaultProductSort && category.defaultProductSort !== 'DEFAULT' + ? category.defaultProductSort.toLowerCase() + : 'featured'; + const streamableFacetedSearch = Streamable.from(async () => { const searchParams = await props.searchParams; const currencyCode = await getPreferredCurrencyCode(); @@ -142,12 +147,14 @@ export default async function Category(props: Props) { customerAccessToken, ); const parsedSearchParams = loadSearchParams?.(searchParams) ?? {}; + const sort = typeof searchParams.sort === 'string' ? searchParams.sort : categoryDefaultSort; const search = await fetchFacetedSearch( { ...searchParams, ...parsedSearchParams, category: categoryId, + sort, }, currencyCode, customerAccessToken, @@ -279,7 +286,7 @@ export default async function Category(props: Props) { resetFiltersLabel={t('FacetedSearch.resetFilters')} showCompare={productComparisonsEnabled} showRating={showRating} - sortDefaultValue="featured" + sortDefaultValue={categoryDefaultSort} sortLabel={t('SortBy.sortBy')} sortOptions={[ { value: 'featured', label: t('SortBy.featuredItems') }, diff --git a/core/app/[locale]/(default)/(faceted)/fetch-faceted-search.ts b/core/app/[locale]/(default)/(faceted)/fetch-faceted-search.ts index 115d639d1c..49de0bc818 100644 --- a/core/app/[locale]/(default)/(faceted)/fetch-faceted-search.ts +++ b/core/app/[locale]/(default)/(faceted)/fetch-faceted-search.ts @@ -189,7 +189,11 @@ const getProductSearchResults = cache( const response = await client.fetch({ document: GetProductSearchResultsQuery, - variables: { ...filterArgs, ...paginationArgs, currencyCode }, + variables: { + ...filterArgs, + ...paginationArgs, + currencyCode, + }, customerAccessToken, fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate: 300 } }, }); diff --git a/core/app/[locale]/(default)/(faceted)/search/page-data.ts b/core/app/[locale]/(default)/(faceted)/search/page-data.ts index d81561a54a..60a650b59e 100644 --- a/core/app/[locale]/(default)/(faceted)/search/page-data.ts +++ b/core/app/[locale]/(default)/(faceted)/search/page-data.ts @@ -27,6 +27,9 @@ const SearchPageQuery = graphql(` tax { plp } + search { + defaultSearchProductSort + } } } } diff --git a/core/app/[locale]/(default)/(faceted)/search/page.tsx b/core/app/[locale]/(default)/(faceted)/search/page.tsx index a6192437d4..41aed16879 100644 --- a/core/app/[locale]/(default)/(faceted)/search/page.tsx +++ b/core/app/[locale]/(default)/(faceted)/search/page.tsx @@ -88,6 +88,8 @@ export default async function Search(props: Props) { const taxDisplay = settings?.tax?.plp; + const defaultProductSort = settings?.search.defaultSearchProductSort; + const streamableFacetedSearch = Streamable.from(async () => { const searchParams = await props.searchParams; const customerAccessToken = await getSessionCustomerAccessToken(); @@ -98,11 +100,13 @@ export default async function Search(props: Props) { customerAccessToken, ); const parsedSearchParams = loadSearchParams?.(searchParams) ?? {}; + const sort = typeof searchParams.sort === 'string' ? searchParams.sort : defaultProductSort; const search = await fetchFacetedSearch( { ...searchParams, ...parsedSearchParams, + sort, }, currencyCode, customerAccessToken, @@ -261,7 +265,7 @@ export default async function Search(props: Props) { resetFiltersLabel={t('FacetedSearch.resetFilters')} showCompare={productComparisonsEnabled} showRating={showRating} - sortDefaultValue="featured" + sortDefaultValue={defaultProductSort?.toLowerCase() ?? 'featured'} sortLabel={t('SortBy.sortBy')} sortOptions={[ { value: 'featured', label: t('SortBy.featuredItems') }, diff --git a/core/app/[locale]/(default)/account/settings/_actions/update-customer.ts b/core/app/[locale]/(default)/account/settings/_actions/update-customer.ts index 90f673bd55..e29148c9ed 100644 --- a/core/app/[locale]/(default)/account/settings/_actions/update-customer.ts +++ b/core/app/[locale]/(default)/account/settings/_actions/update-customer.ts @@ -5,11 +5,12 @@ import { parseWithZod } from '@conform-to/zod'; import { revalidateTag } from 'next/cache'; import { getTranslations } from 'next-intl/server'; +import { Field, FieldGroup } from '@/vibes/soul/form/dynamic-form/schema'; import { updateAccountSchema } from '@/vibes/soul/sections/account-settings/schema'; -import { UpdateAccountAction } from '@/vibes/soul/sections/account-settings/update-account-form'; +import { State } from '@/vibes/soul/sections/account-settings/update-account-form'; import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; -import { graphql } from '~/client/graphql'; +import { graphql, VariablesOf } from '~/client/graphql'; import { TAGS } from '~/client/tags'; const UpdateCustomerMutation = graphql(` @@ -19,6 +20,8 @@ const UpdateCustomerMutation = graphql(` customer { firstName lastName + email + company } errors { __typename @@ -43,11 +46,81 @@ const UpdateCustomerMutation = graphql(` } `); -export const updateCustomer: UpdateAccountAction = async (prevState, formData) => { +type FormFieldsInput = NonNullable< + VariablesOf['input']['formFields'] +>; + +/* + * BigCommerce re-validates every required custom customer field on every updateCustomer call, + * even ones already satisfied by a prior save (see issue #3074) - so any required custom field + * rendered in the form must always be resent here, not just the ones the user actually changed. + */ +function buildFormFieldsInput( + fields: Array>, + value: Record, +): FormFieldsInput { + const flatFields = fields.flatMap((field) => (Array.isArray(field) ? field : [field])); + + return { + checkboxes: flatFields + .filter((field) => field.type === 'checkbox-group') + .filter((field) => Boolean(value[field.name])) + .map((field) => { + const rawValue = value[field.name]; + const rawValues = Array.isArray(rawValue) ? rawValue : [rawValue]; + + return { + fieldEntityId: Number(field.id), + fieldValueEntityIds: rawValues.map(Number), + }; + }), + multipleChoices: flatFields + .filter((field) => ['radio-group', 'button-radio-group', 'select'].includes(field.type)) + .filter((field) => Boolean(value[field.name])) + .map((field) => ({ + fieldEntityId: Number(field.id), + fieldValueEntityId: Number(value[field.name]), + })), + numbers: flatFields + .filter((field) => field.type === 'number') + .filter((field) => Boolean(value[field.name])) + .map((field) => ({ + fieldEntityId: Number(field.id), + number: Number(value[field.name]), + })), + dates: flatFields + .filter((field) => field.type === 'date') + .filter((field) => Boolean(value[field.name])) + .map((field) => ({ + fieldEntityId: Number(field.id), + date: new Date(String(value[field.name])).toISOString(), + })), + multilineTexts: flatFields + .filter((field) => field.type === 'textarea') + .filter((field) => Boolean(value[field.name])) + .map((field) => ({ + fieldEntityId: Number(field.id), + multilineText: String(value[field.name]), + })), + texts: flatFields + .filter((field) => field.type === 'text' || field.type === 'email') + .filter((field) => Boolean(value[field.name])) + .map((field) => ({ + fieldEntityId: Number(field.id), + text: String(value[field.name]), + })), + }; +} + +export async function updateCustomer( + { fields }: { fields: Array> }, + prevState: Awaited, + formData: FormData, +): Promise { const t = await getTranslations('Account.Settings'); const customerAccessToken = await getSessionCustomerAccessToken(); - const submission = parseWithZod(formData, { schema: updateAccountSchema }); + const submission = parseWithZod(formData, { schema: updateAccountSchema(fields) }); if (submission.status !== 'success') { return { @@ -56,12 +129,20 @@ export const updateCustomer: UpdateAccountAction = async (prevState, formData) = }; } + const { firstName, lastName, email, company, ...customFieldValues } = submission.value; + try { const response = await client.fetch({ document: UpdateCustomerMutation, customerAccessToken, variables: { - input: submission.value, + input: { + firstName, + lastName, + email, + company, + formFields: buildFormFieldsInput(fields, customFieldValues), + }, }, fetchOptions: { cache: 'no-store' }, }); @@ -107,4 +188,4 @@ export const updateCustomer: UpdateAccountAction = async (prevState, formData) = lastResult: submission.reply({ formErrors: [t('somethingWentWrong')] }), }; } -}; +} diff --git a/core/app/[locale]/(default)/account/settings/page-data.tsx b/core/app/[locale]/(default)/account/settings/page-data.tsx index 136ef5c991..737dfc0d16 100644 --- a/core/app/[locale]/(default)/account/settings/page-data.tsx +++ b/core/app/[locale]/(default)/account/settings/page-data.tsx @@ -4,7 +4,10 @@ import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { graphql, VariablesOf } from '~/client/graphql'; import { TAGS } from '~/client/tags'; -import { FormFieldsFragment } from '~/data-transformers/form-field-transformer/fragment'; +import { + FormFieldsFragment, + FormFieldValuesFragment, +} from '~/data-transformers/form-field-transformer/fragment'; const AccountSettingsQuery = graphql( ` @@ -21,6 +24,9 @@ const AccountSettingsQuery = graphql( lastName company isSubscribedToNewsletter + formFields { + ...FormFieldValuesFragment + } } site { settings { @@ -50,7 +56,7 @@ const AccountSettingsQuery = graphql( } } `, - [FormFieldsFragment], + [FormFieldsFragment, FormFieldValuesFragment], ); type Variables = VariablesOf; @@ -96,6 +102,7 @@ export const getAccountSettingsQuery = cache(async ({ address, customer }: Props return { addressFields, customerFields, + customerFormFieldValues: customerInfo.formFields, customerInfo, newsletterSettings, passwordComplexitySettings, diff --git a/core/app/[locale]/(default)/account/settings/page.tsx b/core/app/[locale]/(default)/account/settings/page.tsx index cad145dc6f..25c3fefe9e 100644 --- a/core/app/[locale]/(default)/account/settings/page.tsx +++ b/core/app/[locale]/(default)/account/settings/page.tsx @@ -3,13 +3,49 @@ import { Metadata } from 'next'; import { notFound } from 'next/navigation'; import { getTranslations, setRequestLocale } from 'next-intl/server'; +import { Field } from '@/vibes/soul/form/dynamic-form/schema'; import { AccountSettingsSection } from '@/vibes/soul/sections/account-settings'; +import { formFieldTransformer } from '~/data-transformers/form-field-transformer'; +import { + ACCOUNT_SETTINGS_FIELDS_TO_EXCLUDE, + mapFormFieldValueToName, +} from '~/data-transformers/form-field-transformer/utils'; +import { exists } from '~/lib/utils'; import { changePassword } from './_actions/change-password'; import { updateCustomer } from './_actions/update-customer'; import { updateNewsletterSubscription } from './_actions/update-newsletter-subscription'; import { getAccountSettingsQuery } from './page-data'; +function withDefaultValue(field: Field, value: unknown): Field { + if (field.type === 'checkbox-group') { + return { ...field, defaultValue: Array.isArray(value) ? value.map(String) : undefined }; + } + + if (typeof value !== 'string') { + return field; + } + + switch (field.type) { + case 'radio-group': + case 'select': + case 'swatch-radio-group': + case 'card-radio-group': + case 'button-radio-group': + case 'checkbox': + case 'number': + case 'text': + case 'textarea': + case 'date': + case 'email': + case 'hidden': + return { ...field, defaultValue: value }; + + default: + return field; + } +} + interface Props { params: Promise<{ locale: string }>; } @@ -47,6 +83,21 @@ export default async function Settings({ params }: Props) { }, ); + const customFieldValues = accountSettings.customerFormFieldValues.reduce>( + (acc, field) => ({ ...acc, ...mapFormFieldValueToName(field) }), + {}, + ); + + const updateAccountCustomFields = accountSettings.customerFields + .filter((field) => !ACCOUNT_SETTINGS_FIELDS_TO_EXCLUDE.includes(field.entityId)) + .map(formFieldTransformer) + .filter(exists) + .map((field) => withDefaultValue(field, customFieldValues[field.name])); + + const updateCustomerWithFields = updateCustomer.bind(null, { + fields: updateAccountCustomFields, + }); + return ( diff --git a/core/app/[locale]/(default)/cart/_actions/update-line-item.ts b/core/app/[locale]/(default)/cart/_actions/update-line-item.ts index 2d01a290a8..a2d10c3d88 100644 --- a/core/app/[locale]/(default)/cart/_actions/update-line-item.ts +++ b/core/app/[locale]/(default)/cart/_actions/update-line-item.ts @@ -53,161 +53,9 @@ export const updateLineItem = async ( } switch (submission.value.intent) { - case 'increment': { - const parsedSelectedOptions = cartLineItem.selectedOptions.reduce( - (accum, option) => { - let multipleChoicesOptionInput; - let checkboxOptionInput; - let numberFieldOptionInput; - let textFieldOptionInput; - let multiLineTextFieldOptionInput; - let dateFieldOptionInput; - - switch (option.__typename) { - case 'CartSelectedMultipleChoiceOption': - multipleChoicesOptionInput = { - optionEntityId: option.entityId, - optionValueEntityId: option.valueEntityId, - }; - - if (accum.multipleChoices) { - return { - ...accum, - multipleChoices: [...accum.multipleChoices, multipleChoicesOptionInput], - }; - } - - return { - ...accum, - multipleChoices: [multipleChoicesOptionInput], - }; - - case 'CartSelectedCheckboxOption': - checkboxOptionInput = { - optionEntityId: option.entityId, - optionValueEntityId: option.valueEntityId, - }; - - if (accum.checkboxes) { - return { - ...accum, - checkboxes: [...accum.checkboxes, checkboxOptionInput], - }; - } - - return { ...accum, checkboxes: [checkboxOptionInput] }; - - case 'CartSelectedNumberFieldOption': - numberFieldOptionInput = { - optionEntityId: option.entityId, - number: option.number, - }; - - if (accum.numberFields) { - return { - ...accum, - numberFields: [...accum.numberFields, numberFieldOptionInput], - }; - } - - return { ...accum, numberFields: [numberFieldOptionInput] }; - - case 'CartSelectedTextFieldOption': - textFieldOptionInput = { - optionEntityId: option.entityId, - text: option.text, - }; - - if (accum.textFields) { - return { - ...accum, - textFields: [...accum.textFields, textFieldOptionInput], - }; - } - - return { ...accum, textFields: [textFieldOptionInput] }; - - case 'CartSelectedMultiLineTextFieldOption': - multiLineTextFieldOptionInput = { - optionEntityId: option.entityId, - text: option.text, - }; - - if (accum.multiLineTextFields) { - return { - ...accum, - multiLineTextFields: [ - ...accum.multiLineTextFields, - multiLineTextFieldOptionInput, - ], - }; - } - - return { - ...accum, - multiLineTextFields: [multiLineTextFieldOptionInput], - }; - - case 'CartSelectedDateFieldOption': - dateFieldOptionInput = { - optionEntityId: option.entityId, - date: new Date(String(option.date.utc)).toISOString(), - }; + case 'update': { + const { quantity } = submission.value; - if (accum.dateFields) { - return { - ...accum, - dateFields: [...accum.dateFields, dateFieldOptionInput], - }; - } - - return { ...accum, dateFields: [dateFieldOptionInput] }; - } - - return accum; - }, - {}, - ); - - try { - await updateQuantity({ - lineItemEntityId: cartLineItem.id, - productEntityId: cartLineItem.productEntityId, - variantEntityId: cartLineItem.variantEntityId, - selectedOptions: parsedSelectedOptions, - quantity: cartLineItem.quantity + 1, - }); - } catch (error) { - // eslint-disable-next-line no-console - console.error(error); - - if (error instanceof BigCommerceGQLError) { - return { - ...prevState, - lastResult: submission.reply({ - formErrors: error.errors.map(({ message }) => message), - }), - }; - } - - if (error instanceof Error) { - return { ...prevState, lastResult: submission.reply({ formErrors: [error.message] }) }; - } - - return { ...prevState, lastResult: submission.reply({ formErrors: [String(error)] }) }; - } - - const item = submission.value; - - return { - lineItems: prevState.lineItems.map((lineItem) => - lineItem.id === item.id ? { ...lineItem, quantity: lineItem.quantity + 1 } : lineItem, - ), - lastResult: submission.reply({ resetForm: true }), - }; - } - - case 'decrement': { const parsedSelectedOptions = cartLineItem.selectedOptions.reduce( (accum, option) => { let multipleChoicesOptionInput; @@ -329,7 +177,7 @@ export const updateLineItem = async ( productEntityId: cartLineItem.productEntityId, variantEntityId: cartLineItem.variantEntityId, selectedOptions: parsedSelectedOptions, - quantity: cartLineItem.quantity - 1, + quantity, }); } catch (error) { // eslint-disable-next-line no-console @@ -351,11 +199,9 @@ export const updateLineItem = async ( return { ...prevState, lastResult: submission.reply({ formErrors: [String(error)] }) }; } - const item = submission.value; - return { lineItems: prevState.lineItems.map((lineItem) => - lineItem.id === item.id ? { ...lineItem, quantity: lineItem.quantity - 1 } : lineItem, + lineItem.id === cartLineItem.id ? { ...lineItem, quantity } : lineItem, ), lastResult: submission.reply({ resetForm: true }), }; diff --git a/core/app/[locale]/(default)/cart/_actions/update-quantity.ts b/core/app/[locale]/(default)/cart/_actions/update-quantity.ts index ebae4492ed..32eb7de4ad 100644 --- a/core/app/[locale]/(default)/cart/_actions/update-quantity.ts +++ b/core/app/[locale]/(default)/cart/_actions/update-quantity.ts @@ -1,11 +1,12 @@ 'use server'; -import { revalidatePath } from 'next/cache'; +import { revalidateTag } from 'next/cache'; import { getTranslations } from 'next-intl/server'; import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { graphql, VariablesOf } from '~/client/graphql'; +import { TAGS } from '~/client/tags'; import { getCartId } from '~/lib/cart'; import { removeItem } from './remove-item'; @@ -87,7 +88,7 @@ export const updateQuantity = async ({ throw new Error(t('failedToUpdateQuantity')); } - revalidatePath('/cart'); + revalidateTag(TAGS.cart, { expire: 0 }); return cart; }; diff --git a/core/app/[locale]/(default)/cart/page.tsx b/core/app/[locale]/(default)/cart/page.tsx index e6ea1232ff..1a4965377a 100644 --- a/core/app/[locale]/(default)/cart/page.tsx +++ b/core/app/[locale]/(default)/cart/page.tsx @@ -370,7 +370,9 @@ export default async function Cart({ params }: Props) { : undefined } incrementLineItemLabel={t('increment')} - key={`${cart.entityId}-${cart.version}`} + // Keyed by entityId only; keying by version too would remount the section on + // every mutation (see the pending-intent dispatcher notes in the Cart section). + key={cart.entityId} lineItemAction={updateLineItem} lineItemActionPendingLabel={t('cartUpdateInProgress')} shipping={{ diff --git a/core/app/[locale]/(default)/product/[slug]/page-data.ts b/core/app/[locale]/(default)/product/[slug]/page-data.ts index b81698b2f1..a42eb4a38c 100644 --- a/core/app/[locale]/(default)/product/[slug]/page-data.ts +++ b/core/app/[locale]/(default)/product/[slug]/page-data.ts @@ -196,6 +196,14 @@ const ProductQuery = graphql( numberOfReviews } description + featuredPromotions { + edges { + node { + entityId + text + } + } + } ...ProductOptionsFragment } } @@ -297,6 +305,18 @@ const StreamableProductQuery = graphql( altText url: urlTemplate(lossy: true) } + # Product videos. The Storefront GraphQL API only returns the video + # title and url (a YouTube watch URL); the dedicated PDP Videos section + # renders them via lite-youtube-embed. 25 covers realistic product + # video counts without needing pagination. + videos(first: 25) { + edges { + node { + title + url + } + } + } sku weight { value diff --git a/core/app/[locale]/(default)/product/[slug]/page.tsx b/core/app/[locale]/(default)/product/[slug]/page.tsx index 81457201b2..93fc234233 100644 --- a/core/app/[locale]/(default)/product/[slug]/page.tsx +++ b/core/app/[locale]/(default)/product/[slug]/page.tsx @@ -6,6 +6,7 @@ import { SearchParams } from 'nuqs/server'; import { Stream, Streamable } from '@/vibes/soul/lib/streamable'; import { FeaturedProductCarousel } from '@/vibes/soul/sections/featured-product-carousel'; +import { ProductVideos } from '@/vibes/soul/sections/product-detail/product-videos'; import { auth, getSessionCustomerAccessToken } from '~/auth'; import { rewriteWysiwygContentUrls } from '~/data-transformers/html-content-transformer'; import { pricesTransformer } from '~/data-transformers/prices-transformer'; @@ -202,6 +203,18 @@ export default async function Product({ params, searchParams }: Props) { }; }); + // Product videos render in their own section below the primary content, so + // they're streamed independently of the gallery images. The Storefront + // GraphQL API returns each video as { title, url } (a YouTube watch URL). + const streamableVideos = Streamable.from(async () => { + const product = await streamableProduct; + + return removeEdgesAndNodes(product.videos).map((video) => ({ + url: video.url, + title: video.title, + })); + }); + const streameableCtaLabel = Streamable.from(async () => { const product = await streamableProductInventory; @@ -532,6 +545,11 @@ export default async function Product({ params, searchParams }: Props) { }; }); + const promotionCallouts = removeEdgesAndNodes(baseProduct.featuredPromotions).map((p) => ({ + id: p.entityId.toString(), + text: p.text, + })); + const streamableUser = Streamable.from(async () => { const session = await auth(); const firstName = session?.user?.firstName ?? ''; @@ -593,6 +611,7 @@ export default async function Product({ params, searchParams }: Props) { backorderDisplayData: streamableBackorderDisplayData, }} productId={baseProduct.entityId} + promotionCallouts={promotionCallouts} quantityLabel={t('ProductDetails.quantity')} recaptchaSiteKey={recaptchaSiteKey} reviewFormAction={submitReview} @@ -601,6 +620,10 @@ export default async function Product({ params, searchParams }: Props) { /> + + {(videos) => videos.length > 0 && } + + => { - const { data } = await client.fetch({ - document: WebPageChildrenQuery, - variables: { id: decodeURIComponent(id) }, - fetchOptions: { next: { revalidate } }, - }); +const getWebPageChildren = cache( + async (id: string, customerAccessToken?: string): Promise => { + const { data } = await client.fetch({ + document: WebPageChildrenQuery, + variables: { id: decodeURIComponent(id) }, + customerAccessToken, + fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, + }); - if (!data.node) { - return []; - } + if (!data.node) { + return []; + } - if (!('children' in data.node)) { - return []; - } + if (!('children' in data.node)) { + return []; + } - const { children } = data.node; + const { children } = data.node; - return removeEdgesAndNodes(children).reduce((acc: PageLink[], child) => { - if ('path' in child) { - return [...acc, { label: child.name, href: child.path }]; - } + return removeEdgesAndNodes(children).reduce((acc: PageLink[], child) => { + if ('path' in child) { + return [...acc, { label: child.name, href: child.path }]; + } - if ('link' in child) { - return [...acc, { label: child.name, href: child.link }]; - } + if ('link' in child) { + return [...acc, { label: child.name, href: child.link }]; + } - return acc; - }, []); -}); + return acc; + }, []); + }, +); export default async function WebPageLayout({ params, children }: Props) { const { locale, id } = await params; + const customerAccessToken = await getSessionCustomerAccessToken(); setRequestLocale(locale); return ( } + sidebar={} sidebarSize="small" > {children} diff --git a/core/components/footer/fragment.ts b/core/components/footer/fragment.ts index fd7b4fda39..740684114f 100644 --- a/core/components/footer/fragment.ts +++ b/core/components/footer/fragment.ts @@ -42,6 +42,7 @@ export const FooterSectionsFragment = graphql(` node { __typename name + isVisibleInNavigation ... on RawHtmlPage { path } diff --git a/core/components/footer/index.tsx b/core/components/footer/index.tsx index cf9d490541..c9480ed575 100644 --- a/core/components/footer/index.tsx +++ b/core/components/footer/index.tsx @@ -124,10 +124,12 @@ export const Footer = async () => { }, ] : []), - ...removeEdgesAndNodes(sectionsData.content.pages).map((page) => ({ - label: page.name, - href: page.__typename === 'ExternalLinkPage' ? page.link : page.path, - })), + ...removeEdgesAndNodes(sectionsData.content.pages) + .filter((page) => page.isVisibleInNavigation) + .map((page) => ({ + label: page.name, + href: page.__typename === 'ExternalLinkPage' ? page.link : page.path, + })), ], }, ]; diff --git a/core/components/header/_actions/switch-locale.ts b/core/components/header/_actions/switch-locale.ts new file mode 100644 index 0000000000..9a766c8ffb --- /dev/null +++ b/core/components/header/_actions/switch-locale.ts @@ -0,0 +1,66 @@ +'use server'; + +import { revalidateTag } from 'next/cache'; + +import { client } from '~/client'; +import { graphql } from '~/client/graphql'; +import { TAGS } from '~/client/tags'; +import { getCartId } from '~/lib/cart'; + +const UpdateCartLocaleMutation = graphql(` + mutation UpdateCartLocaleMutation($input: UpdateCartLocaleInput!) { + cart { + updateCartLocale(input: $input) { + cart { + entityId + } + errors { + __typename + ... on Error { + message + } + } + } + } + } +`); + +// Keeps the cart's locale in sync when the shopper switches their storefront +// locale. Unlike currency, updating the locale mutates the cart in place, so the +// cart ID does not change. This is best-effort: a failure here should not block +// the locale navigation, so errors are logged rather than surfaced to the user. +export const switchLocale = async (locale: string): Promise => { + const cartId = await getCartId(); + + if (!cartId) { + return; + } + + try { + const result = await client.fetch({ + document: UpdateCartLocaleMutation, + variables: { input: { cartEntityId: cartId, data: { locale } } }, + }); + + const updateCartLocale = result.data.cart.updateCartLocale; + + // `updateCartLocale` is null when the feature is disabled for this store. + if (!updateCartLocale) { + return; + } + + const { errors } = updateCartLocale; + + if (errors.length > 0) { + // eslint-disable-next-line no-console + console.error('Error updating cart locale', errors); + + return; + } + + revalidateTag(TAGS.cart, { expire: 0 }); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error updating cart locale', error); + } +}; diff --git a/core/components/header/index.tsx b/core/components/header/index.tsx index 123881a80d..975ac027d9 100644 --- a/core/components/header/index.tsx +++ b/core/components/header/index.tsx @@ -16,6 +16,7 @@ import { SiteHeader as HeaderSection } from '~/lib/makeswift/components/site-hea import { search } from './_actions/search'; import { switchCurrency } from './_actions/switch-currency'; +import { switchLocale } from './_actions/switch-locale'; import { CurrencyCode, HeaderFragment, HeaderLinksFragment } from './fragment'; const GetCartCountQuery = graphql(` @@ -176,6 +177,7 @@ export const Header = async () => { cartCount: streamableCartCount, activeLocaleId: locale, locales, + localeAction: switchLocale, currencies, activeCurrencyId: streamableActiveCurrencyId, currencyAction: switchCurrency, diff --git a/core/components/product-card/fragment.ts b/core/components/product-card/fragment.ts index 4c5d18e7af..5bda83f945 100644 --- a/core/components/product-card/fragment.ts +++ b/core/components/product-card/fragment.ts @@ -46,6 +46,14 @@ export const ProductCardFragment = graphql( } } } + featuredPromotions { + edges { + node { + entityId + text + } + } + } ...PricingFragment } `, diff --git a/core/data-transformers/form-field-transformer/utils.ts b/core/data-transformers/form-field-transformer/utils.ts index 398dc43b01..c37a02dacc 100644 --- a/core/data-transformers/form-field-transformer/utils.ts +++ b/core/data-transformers/form-field-transformer/utils.ts @@ -25,6 +25,20 @@ export enum FieldNameToFieldId { export const CUSTOMER_FIELDS_TO_EXCLUDE = [FieldNameToFieldId.currentPassword]; +/* Account Settings only manages the fields below directly; anything else returned by + site.settings.formFields.customer is a genuinely merchant-defined custom field. */ +export const ACCOUNT_SETTINGS_FIELDS_TO_EXCLUDE = [ + FieldNameToFieldId.email, + FieldNameToFieldId.password, + FieldNameToFieldId.confirmPassword, + FieldNameToFieldId.currentPassword, + FieldNameToFieldId.firstName, + FieldNameToFieldId.lastName, + FieldNameToFieldId.company, + FieldNameToFieldId.phone, + FieldNameToFieldId.exclusiveOffers, +]; + export const REGISTER_CUSTOMER_FORM_LAYOUT = [ [FieldNameToFieldId.firstName, FieldNameToFieldId.lastName], FieldNameToFieldId.email, diff --git a/core/data-transformers/product-card-transformer.ts b/core/data-transformers/product-card-transformer.ts index 2b57e100ad..7ae3ba9a87 100644 --- a/core/data-transformers/product-card-transformer.ts +++ b/core/data-transformers/product-card-transformer.ts @@ -68,6 +68,13 @@ export const singleProductCardTransformer = ( 'variants' in product ? getInventoryMessage(product, outOfStockMessage, showBackorderMessage) : undefined, + promotions: + 'featuredPromotions' in product + ? removeEdgesAndNodes(product.featuredPromotions).map((p) => ({ + id: p.entityId.toString(), + text: p.text, + })) + : undefined, }; }; diff --git a/core/messages/da.json b/core/messages/da.json index c068677a1a..3dce0754d3 100644 --- a/core/messages/da.json +++ b/core/messages/da.json @@ -526,6 +526,11 @@ "backorderQuantity": "{antal, number} vil være i restordre", "loadingMoreImages": "Indlæser flere billeder", "imagesLoaded": "{count, plural, =1 {1 yderligere billede indlæst} other {# yderligere billeder indlæst}}", + "playVideo": "Afspil video", + "viewVideo": "Se video", + "videosTitle": "Videoer", + "hideVideos": "Skjul videoer", + "showVideos": "Vis videoer", "Submit": { "addToCart": "Føj til kurv", "outOfStock": "Udsolgt", diff --git a/core/messages/de.json b/core/messages/de.json index 60850a8223..9c85785833 100644 --- a/core/messages/de.json +++ b/core/messages/de.json @@ -526,6 +526,11 @@ "backorderQuantity": "{quantity, number} wird nachbestellt", "loadingMoreImages": "Weitere Bilder werden geladen", "imagesLoaded": "{count, plural, =1 {1 weiteres Bild geladen, } other {# weitere Bilder geladen, }}", + "playVideo": "Video abspielen", + "viewVideo": "Video ansehen", + "videosTitle": "Videos", + "hideVideos": "Videos ausblenden", + "showVideos": "Videos anzeigen", "Submit": { "addToCart": "Zum Warenkorb hinzufügen", "outOfStock": "Kein Lagerbestand", diff --git a/core/messages/en.json b/core/messages/en.json index 1d4b8908a9..514d1a9396 100644 --- a/core/messages/en.json +++ b/core/messages/en.json @@ -529,6 +529,11 @@ "backorderQuantity": "{quantity, number} will be on backorder", "loadingMoreImages": "Loading more images", "imagesLoaded": "{count, plural, =1 {1 more image loaded} other {# more images loaded}}", + "playVideo": "Play video", + "viewVideo": "View video", + "videosTitle": "Videos", + "hideVideos": "Hide videos", + "showVideos": "Show videos", "Submit": { "addToCart": "Add to cart", "outOfStock": "Out of stock", diff --git a/core/messages/es-419.json b/core/messages/es-419.json index 1c81487dde..12b770b68b 100644 --- a/core/messages/es-419.json +++ b/core/messages/es-419.json @@ -526,6 +526,11 @@ "backorderQuantity": "{cantidad, número} estará en espera", "loadingMoreImages": "Cargando más imágenes", "imagesLoaded": "{count, plural, =1 {1 imagen más cargada} other {# más imágenes cargadas}}", + "playVideo": "Reproducir video", + "viewVideo": "Ver video", + "videosTitle": "Videos", + "hideVideos": "Ocultar videos", + "showVideos": "Mostrar videos", "Submit": { "addToCart": "Agregar al carrito", "outOfStock": "Agotado/a", diff --git a/core/messages/es-AR.json b/core/messages/es-AR.json index 1c81487dde..12b770b68b 100644 --- a/core/messages/es-AR.json +++ b/core/messages/es-AR.json @@ -526,6 +526,11 @@ "backorderQuantity": "{cantidad, número} estará en espera", "loadingMoreImages": "Cargando más imágenes", "imagesLoaded": "{count, plural, =1 {1 imagen más cargada} other {# más imágenes cargadas}}", + "playVideo": "Reproducir video", + "viewVideo": "Ver video", + "videosTitle": "Videos", + "hideVideos": "Ocultar videos", + "showVideos": "Mostrar videos", "Submit": { "addToCart": "Agregar al carrito", "outOfStock": "Agotado/a", diff --git a/core/messages/es-CL.json b/core/messages/es-CL.json index 1c81487dde..12b770b68b 100644 --- a/core/messages/es-CL.json +++ b/core/messages/es-CL.json @@ -526,6 +526,11 @@ "backorderQuantity": "{cantidad, número} estará en espera", "loadingMoreImages": "Cargando más imágenes", "imagesLoaded": "{count, plural, =1 {1 imagen más cargada} other {# más imágenes cargadas}}", + "playVideo": "Reproducir video", + "viewVideo": "Ver video", + "videosTitle": "Videos", + "hideVideos": "Ocultar videos", + "showVideos": "Mostrar videos", "Submit": { "addToCart": "Agregar al carrito", "outOfStock": "Agotado/a", diff --git a/core/messages/es-CO.json b/core/messages/es-CO.json index 1c81487dde..12b770b68b 100644 --- a/core/messages/es-CO.json +++ b/core/messages/es-CO.json @@ -526,6 +526,11 @@ "backorderQuantity": "{cantidad, número} estará en espera", "loadingMoreImages": "Cargando más imágenes", "imagesLoaded": "{count, plural, =1 {1 imagen más cargada} other {# más imágenes cargadas}}", + "playVideo": "Reproducir video", + "viewVideo": "Ver video", + "videosTitle": "Videos", + "hideVideos": "Ocultar videos", + "showVideos": "Mostrar videos", "Submit": { "addToCart": "Agregar al carrito", "outOfStock": "Agotado/a", diff --git a/core/messages/es-LA.json b/core/messages/es-LA.json index 1c81487dde..12b770b68b 100644 --- a/core/messages/es-LA.json +++ b/core/messages/es-LA.json @@ -526,6 +526,11 @@ "backorderQuantity": "{cantidad, número} estará en espera", "loadingMoreImages": "Cargando más imágenes", "imagesLoaded": "{count, plural, =1 {1 imagen más cargada} other {# más imágenes cargadas}}", + "playVideo": "Reproducir video", + "viewVideo": "Ver video", + "videosTitle": "Videos", + "hideVideos": "Ocultar videos", + "showVideos": "Mostrar videos", "Submit": { "addToCart": "Agregar al carrito", "outOfStock": "Agotado/a", diff --git a/core/messages/es-MX.json b/core/messages/es-MX.json index 1c81487dde..12b770b68b 100644 --- a/core/messages/es-MX.json +++ b/core/messages/es-MX.json @@ -526,6 +526,11 @@ "backorderQuantity": "{cantidad, número} estará en espera", "loadingMoreImages": "Cargando más imágenes", "imagesLoaded": "{count, plural, =1 {1 imagen más cargada} other {# más imágenes cargadas}}", + "playVideo": "Reproducir video", + "viewVideo": "Ver video", + "videosTitle": "Videos", + "hideVideos": "Ocultar videos", + "showVideos": "Mostrar videos", "Submit": { "addToCart": "Agregar al carrito", "outOfStock": "Agotado/a", diff --git a/core/messages/es-PE.json b/core/messages/es-PE.json index 1c81487dde..12b770b68b 100644 --- a/core/messages/es-PE.json +++ b/core/messages/es-PE.json @@ -526,6 +526,11 @@ "backorderQuantity": "{cantidad, número} estará en espera", "loadingMoreImages": "Cargando más imágenes", "imagesLoaded": "{count, plural, =1 {1 imagen más cargada} other {# más imágenes cargadas}}", + "playVideo": "Reproducir video", + "viewVideo": "Ver video", + "videosTitle": "Videos", + "hideVideos": "Ocultar videos", + "showVideos": "Mostrar videos", "Submit": { "addToCart": "Agregar al carrito", "outOfStock": "Agotado/a", diff --git a/core/messages/es.json b/core/messages/es.json index 92c04e3690..cb9453a698 100644 --- a/core/messages/es.json +++ b/core/messages/es.json @@ -526,6 +526,11 @@ "backorderQuantity": "{cantidad, número} en pedidos pendientes", "loadingMoreImages": "Cargando más imágenes", "imagesLoaded": "{count, plural, =1 {1 imagen más cargada} other {# imágenes más cargadas}}", + "playVideo": "Ver vídeo", + "viewVideo": "Ver vídeo", + "videosTitle": "Vídeos", + "hideVideos": "Ocultar vídeos", + "showVideos": "Mostrar vídeos", "Submit": { "addToCart": "Añadir al carrito", "outOfStock": "Sin existencias", diff --git a/core/messages/fr.json b/core/messages/fr.json index 6f07af7786..22502a8c61 100644 --- a/core/messages/fr.json +++ b/core/messages/fr.json @@ -526,6 +526,11 @@ "backorderQuantity": "{quantité, nombre} en attente de réapprovisionnement", "loadingMoreImages": "Chargement de plus d'images", "imagesLoaded": "{count, plural, =1 {1 image supplémentaire chargée} other {# images supplémentaires chargées}}", + "playVideo": "Lancer la vidéo", + "viewVideo": "Voir la vidéo", + "videosTitle": "Vidéos", + "hideVideos": "Masquer les vidéos", + "showVideos": "Afficher les vidéos", "Submit": { "addToCart": "Ajouter au panier", "outOfStock": "En rupture de stock", diff --git a/core/messages/it.json b/core/messages/it.json index 4b08bd3029..2d883dc017 100644 --- a/core/messages/it.json +++ b/core/messages/it.json @@ -526,6 +526,11 @@ "backorderQuantity": "{quantity, number} sarà in arretrato", "loadingMoreImages": "Caricamento di altre immagini", "imagesLoaded": "{count, plural, =1 {1 altra immagine caricata} other {# altre immagini caricate}}", + "playVideo": "Riproduci video", + "viewVideo": "Guarda il video", + "videosTitle": "Video", + "hideVideos": "Nascondi i video", + "showVideos": "Mostra i video", "Submit": { "addToCart": "Aggiungi al carrello", "outOfStock": "Esaurito", diff --git a/core/messages/ja.json b/core/messages/ja.json index f303527124..b5900488df 100644 --- a/core/messages/ja.json +++ b/core/messages/ja.json @@ -526,6 +526,11 @@ "backorderQuantity": "{quantity, number} はバックオーダーになります", "loadingMoreImages": "さらに画像を読み込んでいます", "imagesLoaded": "{count, plural, =1 {1 枚の画像が読み込まれました} other {#枚の画像が読み込まれました}}", + "playVideo": "動画を再生", + "viewVideo": "動画を視聴", + "videosTitle": "ビデオ", + "hideVideos": "動画を非表示", + "showVideos": "動画を表示", "Submit": { "addToCart": "カートに追加", "outOfStock": "品切れ", diff --git a/core/messages/nl.json b/core/messages/nl.json index 5a791406df..edc79e06fd 100644 --- a/core/messages/nl.json +++ b/core/messages/nl.json @@ -526,6 +526,11 @@ "backorderQuantity": "{hoeveelheid, aantal} staat in backorder", "loadingMoreImages": "Meer afbeeldingen laden", "imagesLoaded": "{count, plural, =1 {1 afbeelding geladen} other {# afbeeldingen geladen}}", + "playVideo": "Video afspelen", + "viewVideo": "Bekijk video", + "videosTitle": "Video's", + "hideVideos": "Video's verbergen", + "showVideos": "Video's weergeven", "Submit": { "addToCart": "Toevoegen aan winkelmandje", "outOfStock": "Niet op voorraad", diff --git a/core/messages/no.json b/core/messages/no.json index c59e680609..803b26cc29 100644 --- a/core/messages/no.json +++ b/core/messages/no.json @@ -526,6 +526,11 @@ "backorderQuantity": "{antall, nummer} vil være restordre", "loadingMoreImages": "Laster inn flere bilder", "imagesLoaded": "{count, plural, =1 {1 bilde til ble lastet inn} other {# flere bilder ble lastet inn}}", + "playVideo": "Spill av video", + "viewVideo": "Vis video", + "videosTitle": "Videoer", + "hideVideos": "Skjul videoer", + "showVideos": "Vis videoer", "Submit": { "addToCart": "Legg i handlekurv", "outOfStock": "Utsolgt", diff --git a/core/messages/pl.json b/core/messages/pl.json index 7eff0388c5..d137a911ce 100644 --- a/core/messages/pl.json +++ b/core/messages/pl.json @@ -526,6 +526,11 @@ "backorderQuantity": "{quantity, number} will be on backorder", "loadingMoreImages": "Loading more images", "imagesLoaded": "{count, plural, =1 {1 more image loaded} other {# more images loaded}}", + "playVideo": "Play video", + "viewVideo": "View video", + "videosTitle": "Wideo", + "hideVideos": "Hide videos", + "showVideos": "Show videos", "Submit": { "addToCart": "Dodaj do koszyka", "outOfStock": "Brak w magazynie", diff --git a/core/messages/pt-BR.json b/core/messages/pt-BR.json index 27c3bf66ea..57b9598ad5 100644 --- a/core/messages/pt-BR.json +++ b/core/messages/pt-BR.json @@ -526,6 +526,11 @@ "backorderQuantity": "{quantity, number} will be on backorder", "loadingMoreImages": "Loading more images", "imagesLoaded": "{count, plural, =1 {1 more image loaded} other {# more images loaded}}", + "playVideo": "Play video", + "viewVideo": "View video", + "videosTitle": "Vídeos", + "hideVideos": "Hide videos", + "showVideos": "Show videos", "Submit": { "addToCart": "Adicionar ao carrinho", "outOfStock": "Fora do estoque", diff --git a/core/messages/pt.json b/core/messages/pt.json index 27c3bf66ea..57b9598ad5 100644 --- a/core/messages/pt.json +++ b/core/messages/pt.json @@ -526,6 +526,11 @@ "backorderQuantity": "{quantity, number} will be on backorder", "loadingMoreImages": "Loading more images", "imagesLoaded": "{count, plural, =1 {1 more image loaded} other {# more images loaded}}", + "playVideo": "Play video", + "viewVideo": "View video", + "videosTitle": "Vídeos", + "hideVideos": "Hide videos", + "showVideos": "Show videos", "Submit": { "addToCart": "Adicionar ao carrinho", "outOfStock": "Fora do estoque", diff --git a/core/messages/sv.json b/core/messages/sv.json index 77237e38fd..95c8642572 100644 --- a/core/messages/sv.json +++ b/core/messages/sv.json @@ -526,6 +526,11 @@ "backorderQuantity": "{quantity, number} kommer att vara restnoterade", "loadingMoreImages": "Laddar fler bilder", "imagesLoaded": "{count, plural, =1 {1 till bild har laddats} other {# till bilder har laddats}}", + "playVideo": "Spela upp video", + "viewVideo": "Visa video", + "videosTitle": "Videor", + "hideVideos": "Dölj videor", + "showVideos": "Visa videor", "Submit": { "addToCart": "Lägg till i kundvagn", "outOfStock": "Ej i lager", diff --git a/core/next.config.ts b/core/next.config.ts index 1cb12064ab..4739b0365b 100644 --- a/core/next.config.ts +++ b/core/next.config.ts @@ -79,6 +79,10 @@ export default async (): Promise => { experimental: { optimizePackageImports: ['@icons-pack/react-simple-icons'], }, + images: { + // Allow product-video poster thumbnails (YouTube) through next/image. + remotePatterns: [{ protocol: 'https', hostname: 'i.ytimg.com', pathname: '/vi/**' }], + }, typescript: { ignoreBuildErrors: !!process.env.CI, }, diff --git a/core/package.json b/core/package.json index ccd49e68dd..7652327274 100644 --- a/core/package.json +++ b/core/package.json @@ -17,6 +17,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@base-ui/react": "^1.6.0", "@bigcommerce/catalyst-client": "workspace:^", "@c15t/nextjs": "^1.8.2", "@conform-to/react": "^1.6.1", @@ -50,14 +51,15 @@ "clsx": "^2.1.1", "content-security-policy-builder": "^2.3.0", "deepmerge": "^4.3.1", + "dompurify": "^3.3.1", "embla-carousel": "9.0.0-rc01", "embla-carousel-autoplay": "9.0.0-rc01", "embla-carousel-fade": "9.0.0-rc01", "embla-carousel-react": "9.0.0-rc01", "gql.tada": "^1.8.10", "graphql": "^16.11.0", - "dompurify": "^3.3.1", "jose": "^5.10.0", + "lite-youtube-embed": "^0.3.4", "lodash.debounce": "^4.0.8", "lru-cache": "^11.1.0", "lucide-react": "^0.474.0", @@ -76,6 +78,7 @@ "set-cookie-parser": "^2.7.1", "sharp": "^0.33.5", "sonner": "^1.7.4", + "storefront-kit": "^0.24.0", "swr": "^2.2.5", "tailwindcss-radix": "^3.0.5", "uuid": "^11.1.0", diff --git a/core/proxies/with-routes.ts b/core/proxies/with-routes.ts index f8a0dcbae7..bdde3cebb5 100644 --- a/core/proxies/with-routes.ts +++ b/core/proxies/with-routes.ts @@ -71,7 +71,7 @@ const getRoute = async (path: string, channelId?: string, customerAccessToken?: document: GetRouteQuery, variables: { path }, customerAccessToken, - fetchOptions: { next: { revalidate } }, + fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, channelId, }); @@ -93,7 +93,7 @@ const getRawWebPageContent = async (id: string, customerAccessToken?: string) => const response = await client.fetch({ document: getRawWebPageContentQuery, variables: { id }, - fetchOptions: { next: { revalidate } }, + fetchOptions: customerAccessToken ? { cache: 'no-store' } : { next: { revalidate } }, customerAccessToken, }); diff --git a/core/vibes/soul/form/dynamic-form/index.tsx b/core/vibes/soul/form/dynamic-form/index.tsx index 65321d0e51..feea46e8f5 100644 --- a/core/vibes/soul/form/dynamic-form/index.tsx +++ b/core/vibes/soul/form/dynamic-form/index.tsx @@ -241,7 +241,7 @@ function SubmitButton({ ); } -function DynamicFormField({ +export function DynamicFormField({ field, formField, }: { diff --git a/core/vibes/soul/form/dynamic-form/schema.ts b/core/vibes/soul/form/dynamic-form/schema.ts index 4a0048d33f..3675f2e63d 100644 --- a/core/vibes/soul/form/dynamic-form/schema.ts +++ b/core/vibes/soul/form/dynamic-form/schema.ts @@ -328,31 +328,36 @@ function getFieldSchema( return fieldSchema; } -export function schema( +export function getFieldsShape( fields: Array>, passwordComplexity?: PasswordComplexitySettings | null, errorTranslations?: FormErrorTranslationMap, -) { +): SchemaRawShape { const shape: SchemaRawShape = {}; - let passwordFieldName: string | undefined; - let confirmPasswordFieldName: string | undefined; fields.forEach((field) => { if (Array.isArray(field)) { field.forEach((f) => { shape[f.name] = getFieldSchema(f, passwordComplexity, errorTranslations); - - if (f.type === 'password') passwordFieldName = f.name; - if (f.type === 'confirm-password') confirmPasswordFieldName = f.name; }); } else { shape[field.name] = getFieldSchema(field, passwordComplexity, errorTranslations); - - if (field.type === 'password') passwordFieldName = field.name; - if (field.type === 'confirm-password') confirmPasswordFieldName = field.name; } }); + return shape; +} + +export function schema( + fields: Array>, + passwordComplexity?: PasswordComplexitySettings | null, + errorTranslations?: FormErrorTranslationMap, +) { + const shape = getFieldsShape(fields, passwordComplexity, errorTranslations); + const flatFields = fields.flatMap((field) => (Array.isArray(field) ? field : [field])); + const passwordFieldName = flatFields.find((f) => f.type === 'password')?.name; + const confirmPasswordFieldName = flatFields.find((f) => f.type === 'confirm-password')?.name; + return z.object(shape).superRefine((data, ctx) => { if ( passwordFieldName != null && diff --git a/core/vibes/soul/primitives/navigation/index.tsx b/core/vibes/soul/primitives/navigation/index.tsx index ab5b28aa43..1b3eee52ad 100644 --- a/core/vibes/soul/primitives/navigation/index.tsx +++ b/core/vibes/soul/primitives/navigation/index.tsx @@ -87,6 +87,7 @@ export type SearchResult = }; type CurrencyAction = Action; +type LocaleAction = (locale: string) => Promise | void; type SearchAction = Action< { searchResults: S[] | null; @@ -107,6 +108,7 @@ interface Props { linksPosition?: 'center' | 'left' | 'right'; locales?: Locale[]; activeLocaleId?: string; + localeAction?: LocaleAction; currencies?: Currency[]; activeCurrencyId?: Streamable; currencyAction?: CurrencyAction; @@ -285,6 +287,7 @@ export const Navigation = forwardRef(function Navigation linksPosition = 'center', activeLocaleId, locales, + localeAction, currencies: streamableCurrencies, activeCurrencyId: streamableActiveCurrencyId, currencyAction, @@ -410,6 +413,7 @@ export const Navigation = forwardRef(function Navigation {/* Locale / Language Dropdown */} {locales.length > 1 ? ( {/* Locale / Language Dropdown */} {locales && locales.length > 1 ? ( startTransition(() => switchLocale(id))} + onSelect={() => + startTransition(async () => { + // Sync the cart's locale first so the shopper lands on the + // new locale with an already-updated cart. + await action?.(id); + await switchLocale(id); + }) + } > {label} diff --git a/core/vibes/soul/primitives/product-card/index.tsx b/core/vibes/soul/primitives/product-card/index.tsx index 4e89c9314f..458ee66c4f 100644 --- a/core/vibes/soul/primitives/product-card/index.tsx +++ b/core/vibes/soul/primitives/product-card/index.tsx @@ -1,4 +1,11 @@ import { clsx } from 'clsx'; +import { + Content as CalloutContent, + Description as CalloutDescription, + Header as CalloutHeader, + Root as CalloutRoot, + Title as CalloutTitle, +} from 'storefront-kit/callout'; import { Badge } from '@/vibes/soul/primitives/badge'; import { Price, PriceLabel } from '@/vibes/soul/primitives/price-label'; @@ -21,6 +28,7 @@ export interface Product { rating?: number; inventoryMessage?: string; numberOfReviews?: number; + promotions?: Array<{ id: string; text: string }>; } export interface ProductCardProps { @@ -32,6 +40,7 @@ export interface ProductCardProps { imageSizes?: string; compareLabel?: string; compareParamName?: string; + moreOffersLabel?: string; product: Product; showRating?: boolean; } @@ -70,6 +79,7 @@ export function ProductCard({ inventoryMessage, rating, numberOfReviews, + promotions, }, showRating = false, colorScheme = 'light', @@ -78,6 +88,7 @@ export function ProductCard({ aspectRatio = '5:6', compareLabel, compareParamName, + moreOffersLabel = 'more offers', imagePriority = false, imageSizes = '(min-width: 80rem) 20vw, (min-width: 64rem) 25vw, (min-width: 42rem) 33vw, (min-width: 24rem) 50vw, 100vw', }: ProductCardProps) { @@ -171,6 +182,28 @@ export function ProductCard({ price={price} /> )} + {promotions != null && promotions.length > 0 && ( +
+ + + + + {promotions[0]?.text ?? ''} + + {promotions.length > 1 && ( + + +{promotions.length - 1} {moreOffersLabel} + + )} + + + +
+ )} {showRating && typeof rating === 'number' && rating > 0 && ( )} diff --git a/core/vibes/soul/sections/account-settings/index.tsx b/core/vibes/soul/sections/account-settings/index.tsx index d8e52712c4..d01b61de53 100644 --- a/core/vibes/soul/sections/account-settings/index.tsx +++ b/core/vibes/soul/sections/account-settings/index.tsx @@ -1,4 +1,8 @@ -import { PasswordComplexitySettings } from '@/vibes/soul/form/dynamic-form/schema'; +import { + Field, + FieldGroup, + PasswordComplexitySettings, +} from '@/vibes/soul/form/dynamic-form/schema'; import { ChangePasswordAction, ChangePasswordForm } from './change-password-form'; import { @@ -11,6 +15,7 @@ export interface AccountSettingsSectionProps { title?: string; account: Account; updateAccountAction: UpdateAccountAction; + updateAccountCustomFields?: Array>; updateAccountSubmitLabel?: string; changePasswordTitle?: string; changePasswordAction: ChangePasswordAction; @@ -45,6 +50,7 @@ export function AccountSettingsSection({ title = 'Account Settings', account, updateAccountAction, + updateAccountCustomFields, updateAccountSubmitLabel, changePasswordTitle = 'Change Password', changePasswordAction, @@ -73,6 +79,7 @@ export function AccountSettingsSection({ diff --git a/core/vibes/soul/sections/account-settings/schema.ts b/core/vibes/soul/sections/account-settings/schema.ts index 5fe66feea2..4ec5b97392 100644 --- a/core/vibes/soul/sections/account-settings/schema.ts +++ b/core/vibes/soul/sections/account-settings/schema.ts @@ -2,18 +2,23 @@ import { getTranslations } from 'next-intl/server'; import { z } from 'zod'; import { + Field, + FieldGroup, FormErrorTranslationMap, + getFieldsShape, getPasswordSchema, PasswordComplexitySettings, } from '@/vibes/soul/form/dynamic-form/schema'; import { ExistingResultType } from '~/client/util'; -export const updateAccountSchema = z.object({ - firstName: z.string().min(2).trim(), - lastName: z.string().min(2).trim(), - email: z.string().email().trim(), - company: z.string().trim().optional(), -}); +export const updateAccountSchema = (customFields: Array> = []) => + z.object({ + firstName: z.string().min(2).trim(), + lastName: z.string().min(2).trim(), + email: z.string().email().trim(), + company: z.string().trim().optional(), + ...getFieldsShape(customFields), + }); export const updateAccountErrorTranslations = ( t: ExistingResultType>, diff --git a/core/vibes/soul/sections/account-settings/update-account-form.tsx b/core/vibes/soul/sections/account-settings/update-account-form.tsx index 44bfb6bd39..67e77fef86 100644 --- a/core/vibes/soul/sections/account-settings/update-account-form.tsx +++ b/core/vibes/soul/sections/account-settings/update-account-form.tsx @@ -1,11 +1,20 @@ 'use client'; -import { getFormProps, getInputProps, SubmissionResult, useForm } from '@conform-to/react'; +import { + FieldMetadata, + FormProvider, + getFormProps, + getInputProps, + SubmissionResult, + useForm, +} from '@conform-to/react'; import { getZodConstraint } from '@conform-to/zod'; import { useTranslations } from 'next-intl'; import { useActionState, useEffect, useOptimistic, useTransition } from 'react'; import { z } from 'zod'; +import { DynamicFormField } from '@/vibes/soul/form/dynamic-form'; +import { Field, FieldGroup } from '@/vibes/soul/form/dynamic-form/schema'; import { Input } from '@/vibes/soul/form/input'; import { Button } from '@/vibes/soul/primitives/button'; import { toast } from '@/vibes/soul/primitives/toaster'; @@ -17,17 +26,31 @@ type Action = (state: Awaited, payload: P) => S | Promise; export type UpdateAccountAction = Action; -export type Account = z.infer; +export type Account = z.infer>; -interface State { +export interface State { account: Account; successMessage?: string; lastResult: SubmissionResult | null; } +function getDynamicFormField( + fields: object, + name: string, +): FieldMetadata | undefined { + // `fields` is conform's static, schema-derived shape; custom fields are looked up dynamically + // by name, which has no static index signature to type against. + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return Reflect.get(fields, name); +} + export interface UpdateAccountFormProps { action: UpdateAccountAction; account: Account; + /* Merchant-defined required/custom customer fields (e.g. a "Terms and Conditions" checkbox + added after this account existed). BigCommerce re-validates these on every updateCustomer + call, so they must be rendered and submitted in this same form, not a separate one. */ + customFields?: Array>; firstNameLabel?: string; lastNameLabel?: string; emailLabel?: string; @@ -38,6 +61,7 @@ export interface UpdateAccountFormProps { export function UpdateAccountForm({ action, account, + customFields = [], firstNameLabel = 'First name', lastNameLabel = 'Last name', emailLabel = 'Email', @@ -46,6 +70,7 @@ export function UpdateAccountForm({ }: UpdateAccountFormProps) { const t = useTranslations('Account.Settings'); const errorTranslations = updateAccountErrorTranslations(t); + const schema = updateAccountSchema(customFields); const [state, formAction] = useActionState(action, { account, lastResult: null }); const [pending, startTransition] = useTransition(); @@ -54,7 +79,7 @@ export function UpdateAccountForm({ (prevState, formData) => { const intent = formData.get('intent'); const submission = parseWithZodTranslatedErrors(formData, { - schema: updateAccountSchema, + schema, errorTranslations, }); @@ -74,15 +99,21 @@ export function UpdateAccountForm({ }, ); + const customFieldsDefaultValue = customFields + .flatMap((f) => (Array.isArray(f) ? f : [f])) + .reduce< + Record + >((acc, f) => ({ ...acc, [f.name]: 'defaultValue' in f ? f.defaultValue : '' }), {}); + const [form, fields] = useForm({ lastResult: state.lastResult, - defaultValue: optimisticState.account, - constraint: getZodConstraint(updateAccountSchema), + defaultValue: { ...optimisticState.account, ...customFieldsDefaultValue }, + constraint: getZodConstraint(schema), shouldValidate: 'onBlur', shouldRevalidate: 'onInput', onValidate({ formData }) { return parseWithZodTranslatedErrors(formData, { - schema: updateAccountSchema, + schema, errorTranslations, }); }, @@ -95,52 +126,81 @@ export function UpdateAccountForm({ }, [state]); return ( -
{ - startTransition(() => { - formAction(formData); - setOptimisticState(formData); - }); - }} - className="space-y-5" - > -
+ + { + startTransition(() => { + formAction(formData); + setOptimisticState(formData); + }); + }} + className="space-y-5" + > +
+ + +
-
- - - -
+ {customFields.map((field, index) => { + if (Array.isArray(field)) { + return ( +
+ {field.map((f) => { + const groupFormField = getDynamicFormField(fields, f.name); + + if (!groupFormField) return null; + + return ( + + ); + })} +
+ ); + } + + const formField = getDynamicFormField(fields, field.name); + + if (!formField) return null; + + return ; + })} + + + ); } diff --git a/core/vibes/soul/sections/cart/client.tsx b/core/vibes/soul/sections/cart/client.tsx index fc5b8ffb8d..99c07850c6 100644 --- a/core/vibes/soul/sections/cart/client.tsx +++ b/core/vibes/soul/sections/cart/client.tsx @@ -1,16 +1,18 @@ 'use client'; -import { getFormProps, getInputProps, SubmissionResult, useForm } from '@conform-to/react'; -import { parseWithZod } from '@conform-to/zod'; +import { SubmissionResult, useForm } from '@conform-to/react'; import { clsx } from 'clsx'; +import debounce from 'lodash.debounce'; import { ArrowRight, GiftIcon, Minus, Plus, Trash2 } from 'lucide-react'; import { ComponentPropsWithoutRef, + FormEvent, startTransition, useActionState, useEffect, useMemo, - useOptimistic, + useRef, + useState, } from 'react'; import { useFormStatus } from 'react-dom'; @@ -29,7 +31,6 @@ import { Image } from '~/components/image'; import { AddCartToQuoteButton } from './add-cart-to-quote-button'; import { CouponCodeForm, CouponCodeFormState } from './coupon-code-form'; -import { cartLineItemActionFormDataSchema } from './schema'; import { ShippingForm, ShippingFormState } from './shipping-form'; import { CartEmptyState } from '.'; @@ -181,6 +182,8 @@ const defaultEmptyState = { cta: { label: 'Continue shopping', href: '#' }, }; +type PendingLineItemIntent = { intent: 'update'; quantity: number } | { intent: 'delete' }; + // eslint-disable-next-line valid-jsdoc /** * This component supports various CSS variables for theming. Here's a comprehensive list, along @@ -232,6 +235,20 @@ export function CartClient({ const [form] = useForm({ lastResult: state.lastResult }); + // Server actions run strictly serially, so rapid clicks coalesce into pending intents + // (one per line item, newer clicks overwrite) flushed at most one action at a time. + // Instance-level on purpose: if a consumer remounts this section mid-flight (e.g. by + // keying it on cart version), intents die with the instance and the UI snaps back to + // server truth, instead of a longer-lived dispatcher deadlocking on an action React + // silently dropped at unmount. + const [pendingLineItemIntents] = useState(() => new Map()); + const inFlightIntentRef = useRef<{ id: string; entry: PendingLineItemIntent } | null>(null); + + // Bumped whenever pendingLineItemIntents changes so renders re-read the mutable map. + const [, setPendingIntentsRevision] = useState(0); + + const isCartMutationPending = isLineItemActionPending || pendingLineItemIntents.size > 0; + useEffect(() => { if (form.errors) { form.errors.forEach((error) => { @@ -240,10 +257,131 @@ export function CartClient({ } }, [form.errors]); - // Prevent page unload when line item action is pending + const flushNextIntent = () => { + if (inFlightIntentRef.current !== null) return; + + // Resolve each intent against server truth once: an intent is stale if its row is + // gone or already matches the confirmed quantity. + const resolved = Array.from(pendingLineItemIntents.entries()).map(([intentId, intentEntry]) => { + const item = cart.lineItems.find((lineItem) => lineItem.id === intentId); + + return { + id: intentId, + entry: intentEntry, + item, + isStale: + !item || (intentEntry.intent === 'update' && intentEntry.quantity === item.quantity), + }; + }); + + const staleEntries = resolved.filter(({ isStale }) => isStale); + + if (staleEntries.length > 0) { + staleEntries.forEach(({ id: staleId }) => pendingLineItemIntents.delete(staleId)); + setPendingIntentsRevision((revision) => revision + 1); + } + + const next = resolved.find(({ isStale }) => !isStale); + + if (!next?.item) return; + + const { id, entry, item: confirmedItem } = next; + + inFlightIntentRef.current = { id, entry }; + + const formData = new FormData(); + + formData.append('id', id); + formData.append('intent', entry.intent); + + if (entry.intent === 'update') { + formData.append('quantity', entry.quantity.toString()); + } + + // Analytics fire once per flush with the net change, not once per click. + const analyticsFormData = new FormData(); + + analyticsFormData.append('id', id); + analyticsFormData.append('intent', entry.intent); + + if (entry.intent === 'update') { + const netChange = entry.quantity - confirmedItem.quantity; + + analyticsFormData.append('quantity', Math.abs(netChange).toString()); + + if (netChange > 0) events.onAddToCart?.(analyticsFormData); + if (netChange < 0) events.onRemoveFromCart?.(analyticsFormData); + } else { + analyticsFormData.append('quantity', confirmedItem.quantity.toString()); + events.onRemoveFromCart?.(analyticsFormData); + } + + startTransition(() => { + formAction(formData); + }); + }; + + const flushRef = useRef(flushNextIntent); + + useEffect(() => { + flushRef.current = flushNextIntent; + }); + + // Trailing debounce with no maxWait: a mid-burst flush would spend a request on a + // quantity the user is still changing, and the final absolute value supersedes it + // anyway. The pagehide and nav-guard flushes cover leaving before the timer fires. + const debouncedFlush = useMemo(() => debounce(() => flushRef.current(), 400), []); + + useEffect(() => () => debouncedFlush.cancel(), [debouncedFlush]); + + const queueLineItemIntent = (id: string, entry: PendingLineItemIntent) => { + pendingLineItemIntents.set(id, entry); + setPendingIntentsRevision((revision) => revision + 1); + debouncedFlush(); + + // Quantity clicks coalesce inside the debounce window, but removal flushes right away. + if (entry.intent === 'delete') { + debouncedFlush.flush(); + } + }; + + // Settle observer: when a flush completes, clear its intent and flush any backlog + // queued while it was in flight (flushNextIntent prunes intents the settled action + // already confirmed). The section is keyed stably, so this instance survives the + // revalidation and dispatching from here is safe. + useEffect(() => { + if (isLineItemActionPending) return; + if (inFlightIntentRef.current === null) return; + + const { id, entry } = inFlightIntentRef.current; + + inFlightIntentRef.current = null; + + // Clear the intent we just flushed unless newer clicks replaced it mid-flight; if + // the action failed this reverts the row to server truth (the error toast explains why). + if (pendingLineItemIntents.get(id) === entry) { + pendingLineItemIntents.delete(id); + setPendingIntentsRevision((revision) => revision + 1); + } + + if (pendingLineItemIntents.size > 0) { + flushRef.current(); + } + }, [isLineItemActionPending, state, pendingLineItemIntents]); + + // A full page unload can't wait on the debounce timer. + useEffect(() => { + const flushPendingIntents = () => debouncedFlush.flush(); + + window.addEventListener('pagehide', flushPendingIntents); + + return () => window.removeEventListener('pagehide', flushPendingIntents); + }, [debouncedFlush]); + + // Prevent page unload when a cart mutation is pending useEffect(() => { const handleBeforeUnload = (event: BeforeUnloadEvent) => { - if (isLineItemActionPending) { + if (isCartMutationPending) { event.preventDefault(); // eslint-disable-next-line @typescript-eslint/no-deprecated event.returnValue = ''; // Chrome requires returnValue to be set @@ -252,19 +390,19 @@ export function CartClient({ } }; - if (isLineItemActionPending) { + if (isCartMutationPending) { window.addEventListener('beforeunload', handleBeforeUnload); } return () => { window.removeEventListener('beforeunload', handleBeforeUnload); }; - }, [isLineItemActionPending]); + }, [isCartMutationPending]); - // Prevent client-side navigation when line item action is pending + // Prevent client-side navigation when a cart mutation is pending useEffect(() => { const handleClick = (event: MouseEvent) => { - if (isLineItemActionPending && event.target instanceof HTMLElement) { + if (isCartMutationPending && event.target instanceof HTMLElement) { const link = event.target.closest('a[href]'); if ( @@ -276,7 +414,10 @@ export function CartClient({ // eslint-disable-next-line no-alert const shouldNavigate = window.confirm(lineItemActionPendingLabel); - if (!shouldNavigate) { + if (shouldNavigate) { + // Dispatch any debounced intent now, while this instance can still send it. + debouncedFlush.flush(); + } else { event.preventDefault(); event.stopPropagation(); } @@ -286,7 +427,7 @@ export function CartClient({ const handleKeyDown = (event: KeyboardEvent) => { if ( - isLineItemActionPending && + isCartMutationPending && (event.key === 'Enter' || event.key === ' ') && event.target instanceof HTMLElement ) { @@ -301,7 +442,10 @@ export function CartClient({ // eslint-disable-next-line no-alert const shouldNavigate = window.confirm(lineItemActionPendingLabel); - if (!shouldNavigate) { + if (shouldNavigate) { + // Dispatch any debounced intent now, while this instance can still send it. + debouncedFlush.flush(); + } else { event.preventDefault(); event.stopPropagation(); } @@ -309,7 +453,7 @@ export function CartClient({ } }; - if (isLineItemActionPending) { + if (isCartMutationPending) { document.addEventListener('click', handleClick, true); document.addEventListener('keydown', handleKeyDown, true); } @@ -318,50 +462,21 @@ export function CartClient({ document.removeEventListener('click', handleClick, true); document.removeEventListener('keydown', handleKeyDown, true); }; - }, [isLineItemActionPending, lineItemActionPendingLabel]); - - const [optimisticLineItems, setOptimisticLineItems] = useOptimistic( - state.lineItems, - (prevState, formData) => { - const submission = parseWithZod(formData, { schema: cartLineItemActionFormDataSchema }); + }, [isCartMutationPending, lineItemActionPendingLabel, debouncedFlush]); - if (submission.status !== 'success') return prevState; + // Server truth (the revalidation-refreshed cart prop, not useActionState's mount-frozen + // state) overlaid with pending intents; pendingIntentsRevision invalidates this on change. + const displayLineItems: CartLineItem[] = cart.lineItems + .filter((item) => pendingLineItemIntents.get(item.id)?.intent !== 'delete') + .map((item) => { + const pending = pendingLineItemIntents.get(item.id); - switch (submission.value.intent) { - case 'increment': { - const { id } = submission.value; + return pending?.intent === 'update' ? { ...item, quantity: pending.quantity } : item; + }); - return prevState.map((item) => - item.id === id ? { ...item, quantity: item.quantity + 1 } : item, - ); - } - - case 'decrement': { - const { id } = submission.value; - - return prevState.map((item) => - item.id === id ? { ...item, quantity: item.quantity - 1 } : item, - ); - } - - case 'delete': { - const { id } = submission.value; - - return prevState.filter((item) => item.id !== id); - } - - default: - return prevState; - } - }, - ); - - const optimisticQuantity = useMemo( - () => optimisticLineItems.reduce((total, item) => total + item.quantity, 0), - [optimisticLineItems], - ); + const displayTotalQuantity = displayLineItems.reduce((total, item) => total + item.quantity, 0); - if (optimisticQuantity === 0) { + if (displayTotalQuantity === 0) { return ; } @@ -378,7 +493,7 @@ export function CartClient({ {cart.summaryItems.map((summaryItem, index) => (
{summaryItem.label}
- {isLineItemActionPending ? ( + {isCartMutationPending ? ( ) : (
{summaryItem.value}
@@ -413,7 +528,7 @@ export function CartClient({
{cart.totalLabel ?? 'Total'}
- {isLineItemActionPending ? ( + {isCartMutationPending ? ( ) : (
{cart.total}
@@ -430,7 +545,7 @@ export function CartClient({ {checkoutLabel} @@ -448,12 +563,12 @@ export function CartClient({

{title} - {optimisticQuantity} + {displayTotalQuantity}

{/* Cart Items */}
    - {optimisticLineItems.map((lineItem) => ( + {displayLineItems.map((lineItem) => (
  • ({ deleteLabel={deleteLineItemLabel} incrementLabel={incrementLineItemLabel} lineItem={lineItem} - onSubmit={(formData) => { - startTransition(() => { - formAction(formData); - setOptimisticLineItems(formData); - - const intent = formData.get('intent'); - - if (intent === 'increment') { - formData.set('quantity', '1'); - - events.onAddToCart?.(formData); - } - - if (intent === 'decrement') { - formData.set('quantity', '1'); - - events.onRemoveFromCart?.(formData); - } - - if (intent === 'delete') { - formData.set('quantity', lineItem.quantity.toString()); - - events.onRemoveFromCart?.(formData); - } - }); - }} + onDelete={() => queueLineItemIntent(lineItem.id, { intent: 'delete' })} + onUpdateQuantity={(quantity) => + queueLineItemIntent(lineItem.id, { intent: 'update', quantity }) + } />
@@ -527,7 +620,8 @@ export function CartClient({ function CounterForm({ lineItem, action, - onSubmit, + onDelete, + onUpdateQuantity, incrementLabel = 'Increase count', decrementLabel = 'Decrease count', deleteLabel = 'Remove item', @@ -537,26 +631,30 @@ function CounterForm({ decrementLabel?: string; deleteLabel?: string; action: (payload: FormData) => void; - onSubmit: (formData: FormData) => void; + onDelete: () => void; + onUpdateQuantity: (quantity: number) => void; }) { - const [form, fields] = useForm({ - defaultValue: { id: lineItem.id }, - shouldValidate: 'onBlur', - shouldRevalidate: 'onInput', - onValidate({ formData }) { - return parseWithZod(formData, { schema: cartLineItemActionFormDataSchema }); - }, - onSubmit(event, { formData }) { - event.preventDefault(); + // Every control is its own micro-form (`display: contents`, so layout is unaffected) + // rather than one form per line item, because the quantity buttons each need their own + // hidden `quantity` field and forms can't nest. Without JS, each posts its pre-computed + // absolute quantity (or delete) straight to the server action. With JS, onSubmit + // intercepts and routes through the coalescing dispatcher instead, so rapid clicks + // debounce into one request rather than one per click. + const handleDeleteSubmit = (event: FormEvent) => { + event.preventDefault(); + onDelete(); + }; - onSubmit(formData); - }, - }); + const handleUpdateQuantitySubmit = (quantity: number) => (event: FormEvent) => { + event.preventDefault(); + onUpdateQuantity(quantity); + }; if (lineItem.typename === 'CartGiftCertificate') { return ( -
- + + +
{typeof lineItem.price === 'string' && ( {lineItem.price} @@ -569,9 +667,7 @@ function CounterForm({ - - {lineItem.quantity} - + + + {lineItem.quantity} + +
+ + + -
+ +
+
+ + -
- {lineItem.inventoryMessages?.outOfStockMessage != null && ( - - {lineItem.inventoryMessages.outOfStockMessage} - - )} - {lineItem.inventoryMessages?.quantityOutOfStockMessage != null && ( - - {lineItem.inventoryMessages.quantityOutOfStockMessage} - - )} - {lineItem.inventoryMessages?.quantityReadyToShipMessage != null && ( - - {lineItem.inventoryMessages.quantityReadyToShipMessage} - - )} - {lineItem.inventoryMessages?.quantityBackorderedMessage != null && ( - - {lineItem.inventoryMessages.quantityBackorderedMessage} - - )} - {lineItem.inventoryMessages?.backorderMessage != null && ( - - {lineItem.inventoryMessages.backorderMessage} - - )} + + {lineItem.inventoryMessages?.outOfStockMessage != null && ( + + {lineItem.inventoryMessages.outOfStockMessage} + + )} + {lineItem.inventoryMessages?.quantityOutOfStockMessage != null && ( + + {lineItem.inventoryMessages.quantityOutOfStockMessage} + + )} + {lineItem.inventoryMessages?.quantityReadyToShipMessage != null && ( + + {lineItem.inventoryMessages.quantityReadyToShipMessage} + + )} + {lineItem.inventoryMessages?.quantityBackorderedMessage != null && ( + + {lineItem.inventoryMessages.quantityBackorderedMessage} + + )} + {lineItem.inventoryMessages?.backorderMessage != null && ( + + {lineItem.inventoryMessages.backorderMessage} + + )} - + ); } @@ -698,8 +807,24 @@ function CheckoutButton({ const [lastResult, formAction] = useActionState( async (state: SubmissionResult | null, formData: FormData) => { if (typeof action === 'string') { - await new Promise(() => { - window.location.assign(action); + window.location.assign(action); + + // The page normally unloads before this settles. If the navigation is + // cancelled instead (Esc, "Stay" on the beforeunload prompt, a stalled + // redirect) or the page is restored from bfcache, settle so the button + // re-enables as a retry, rather than spinning forever with subsequent + // clicks queued behind a never-resolving action. + await new Promise((resolve) => { + let fallbackTimer: ReturnType; + + const settle = () => { + clearTimeout(fallbackTimer); + window.removeEventListener('pageshow', settle); + resolve(); + }; + + fallbackTimer = setTimeout(settle, 8000); + window.addEventListener('pageshow', settle); }); return null; diff --git a/core/vibes/soul/sections/cart/schema.ts b/core/vibes/soul/sections/cart/schema.ts index 50d35712e1..cec899a8b8 100644 --- a/core/vibes/soul/sections/cart/schema.ts +++ b/core/vibes/soul/sections/cart/schema.ts @@ -2,12 +2,11 @@ import { z } from 'zod'; export const cartLineItemActionFormDataSchema = z.discriminatedUnion('intent', [ z.object({ - intent: z.literal('increment'), - id: z.string(), - }), - z.object({ - intent: z.literal('decrement'), + intent: z.literal('update'), id: z.string(), + // Quantity is absolute so rapid clicks can coalesce into a single request; + // 0 is never sent because removal is a separate 'delete' intent. + quantity: z.coerce.number().int().min(1), }), z.object({ intent: z.literal('delete'), diff --git a/core/vibes/soul/sections/product-detail/index.tsx b/core/vibes/soul/sections/product-detail/index.tsx index de0afc1c6d..0f018be749 100644 --- a/core/vibes/soul/sections/product-detail/index.tsx +++ b/core/vibes/soul/sections/product-detail/index.tsx @@ -1,4 +1,10 @@ import { ReactNode } from 'react'; +import { + Content as CalloutContent, + Header as CalloutHeader, + Root as CalloutRoot, + Title as CalloutTitle, +} from 'storefront-kit/callout'; import { Stream, Streamable } from '@/vibes/soul/lib/streamable'; import { Accordion, AccordionItem } from '@/vibes/soul/primitives/accordion'; @@ -52,6 +58,7 @@ interface ProductDetailProduct { export interface ProductDetailProps { breadcrumbs?: Streamable; + promotionCallouts?: Streamable>; product: Streamable; action: ProductDetailFormAction; fields: Streamable; @@ -98,6 +105,7 @@ export function ProductDetail({ action, fields: streamableFields, breadcrumbs, + promotionCallouts, quantityLabel, incrementLabel, decrementLabel, @@ -201,6 +209,34 @@ export function ProductDetail({ )} + {promotionCallouts != null && ( +
+ + {(callouts) => + callouts.length > 0 ? ( +
+ {callouts.map((callout) => ( + + + + + {callout.text} + + + + + ))} +
+ ) : null + } +
+
+ )}
} value={product.images}> {(imagesData) => ( diff --git a/core/vibes/soul/sections/product-detail/lite-youtube-embed.d.ts b/core/vibes/soul/sections/product-detail/lite-youtube-embed.d.ts new file mode 100644 index 0000000000..4fa68430ba --- /dev/null +++ b/core/vibes/soul/sections/product-detail/lite-youtube-embed.d.ts @@ -0,0 +1,5 @@ +// `lite-youtube-embed` ships no TypeScript declarations. It is imported only for +// its side effect — registering the custom element — so an ambient +// module declaration (implicit `any`) is sufficient and keeps the production +// `next build` / `tsc` typecheck passing. +declare module 'lite-youtube-embed'; diff --git a/core/vibes/soul/sections/product-detail/lite-youtube.tsx b/core/vibes/soul/sections/product-detail/lite-youtube.tsx new file mode 100644 index 0000000000..740751f457 --- /dev/null +++ b/core/vibes/soul/sections/product-detail/lite-youtube.tsx @@ -0,0 +1,47 @@ +'use client'; + +// lite-youtube-embed is a tiny, dependency-free custom element that renders a +// YouTube facade (poster + play button) and only injects the real iframe on +// click — the standard "third-party facade" pattern. The CSS styles the element. +import 'lite-youtube-embed/src/lite-yt-embed.css'; + +import type { CSSProperties } from 'react'; + +// Register the custom element on the client only: the package +// subclasses HTMLElement at import time, which isn't defined during SSR. The +// element still renders as inert markup on the server and upgrades on hydration. +if (typeof window !== 'undefined') { + void import('lite-youtube-embed'); +} + +declare module 'react' { + namespace JSX { + interface IntrinsicElements { + 'lite-youtube': React.DetailedHTMLProps, HTMLElement> & { + videoid: string; + playlabel?: string; + params?: string; + }; + } + } +} + +export interface LiteYouTubeProps { + videoId: string; + /** Visually-hidden label for the play button (accessibility). */ + playLabel: string; + className?: string; + style?: CSSProperties; +} + +export function LiteYouTube({ videoId, playLabel, className, style }: LiteYouTubeProps) { + return ( + + ); +} diff --git a/core/vibes/soul/sections/product-detail/product-videos.tsx b/core/vibes/soul/sections/product-detail/product-videos.tsx new file mode 100644 index 0000000000..109fdd1069 --- /dev/null +++ b/core/vibes/soul/sections/product-detail/product-videos.tsx @@ -0,0 +1,153 @@ +'use client'; + +import { clsx } from 'clsx'; +import { useTranslations } from 'next-intl'; +import { useId, useState } from 'react'; + +import { Image } from '~/components/image'; + +import { LiteYouTube } from './lite-youtube'; +import { getYouTubeId, getYouTubePosterUrl } from './video-embed'; + +export interface ProductVideo { + url: string; + title: string; +} + +export interface ProductVideosProps { + videos: ProductVideo[]; + className?: string; +} + +// eslint-disable-next-line valid-jsdoc +/** + * Renders product videos as a dedicated section below the primary product + * content (mirroring the Stencil/Cornerstone `product_below_content` layout) + * rather than inside the image gallery. A single featured player is shown with + * a horizontal strip of thumbnails; clicking a thumbnail swaps the featured + * video. Product videos are YouTube-only, matching BigCommerce's supported + * provider, so non-YouTube URLs are dropped. + * + * Theming CSS variables (shared with the gallery): + * + * ```css + * :root { + * --product-gallery-focus: hsl(var(--primary)); + * --product-gallery-image-background: hsl(var(--contrast-100)); + * --product-gallery-image-border-active: hsl(var(--foreground)); + * --product-detail-primary-text: hsl(var(--foreground)); + * --product-detail-title-font-family: var(--font-family-heading); + * } + * ``` + */ +export function ProductVideos({ videos, className }: ProductVideosProps) { + const t = useTranslations('Product.ProductDetails'); + + // Resolve to YouTube ids up front; non-YouTube URLs are skipped (YouTube is + // the only supported product-video provider). + const youtubeVideos = videos + .map((video) => ({ id: getYouTubeId(video.url), title: video.title })) + .filter((video): video is { id: string; title: string } => video.id !== null); + + const [selectedIndex, setSelectedIndex] = useState(0); + const [isOpen, setIsOpen] = useState(true); + const panelId = useId(); + + const featured = youtubeVideos[Math.min(selectedIndex, youtubeVideos.length - 1)]; + + // No renderable (YouTube) videos — render nothing. The `!featured` check also + // narrows the indexed access to a defined value for the rest of the render. + if (!featured) return null; + + return ( +
+
+
+

+ {t('videosTitle')} +

+ +
+ + {isOpen && ( +
+ {/* Featured player */} +
+ +
+ {Boolean(featured.title) && ( +

+ {featured.title} +

+ )} + + {/* Thumbnail strip — only when there's more than one video to choose from. */} + {youtubeVideos.length > 1 && ( +
+ {youtubeVideos.map((video, index) => ( + + ))} +
+ )} +
+ )} +
+
+ ); +} diff --git a/core/vibes/soul/sections/product-detail/video-embed.ts b/core/vibes/soul/sections/product-detail/video-embed.ts new file mode 100644 index 0000000000..c77050f692 --- /dev/null +++ b/core/vibes/soul/sections/product-detail/video-embed.ts @@ -0,0 +1,56 @@ +/** + * Product videos come from the Storefront GraphQL API as a YouTube watch URL + * (e.g. `https://www.youtube.com/watch?v=...`). The `` player + * (see ./lite-youtube) needs the bare video id, and the Videos section thumbnail + * needs a poster image — both are derived here. The embedding itself (iframe, facade, + * autoplay) is handled by the lite-youtube-embed library. + * + * Product videos are YouTube-only; any other URL yields a null id and is skipped. + */ + +// YouTube ids are short alphanumeric tokens; reject anything else so a malformed +// path tail or encoded query material can't leak through. +const YOUTUBE_ID = /^[\w-]{6,}$/; +const YOUTUBE_HOSTS = new Set(['youtube.com', 'm.youtube.com', 'youtu.be']); + +// Extract the YouTube video id from a watch/share URL, or null if it isn't a +// (safe http/https) YouTube URL. +export function getYouTubeId(rawUrl: string): string | null { + let url: URL; + + try { + url = new URL(rawUrl); + } catch { + return null; + } + + if (url.protocol !== 'http:' && url.protocol !== 'https:') return null; + + const host = url.hostname.replace(/^www\./, ''); + + if (!YOUTUBE_HOSTS.has(host)) return null; + + // https://www.youtube.com/watch?v=ID + const vParam = url.searchParams.get('v'); + + if (vParam && YOUTUBE_ID.test(vParam)) return vParam; + + // https://www.youtube.com/embed/ID, /shorts/ID, /v/ID + const match = /\/(?:embed|shorts|v)\/([\w-]+)/.exec(url.pathname); + + if (match?.[1] && YOUTUBE_ID.test(match[1])) return match[1]; + + // https://youtu.be/ID — first path segment only, ignoring any tail. + if (host === 'youtu.be') { + const [, first] = url.pathname.split('/'); + + if (first && YOUTUBE_ID.test(first)) return first; + } + + return null; +} + +// Poster/thumbnail image URL for a YouTube video id (used by the gallery thumbnail). +export function getYouTubePosterUrl(videoId: string): string { + return `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`; +} diff --git a/packages/catalyst/AGENTS.md b/packages/catalyst/AGENTS.md new file mode 100644 index 0000000000..1b968d9392 --- /dev/null +++ b/packages/catalyst/AGENTS.md @@ -0,0 +1,96 @@ +# Catalyst CLI (`@bigcommerce/catalyst`) + +CLI tool for Catalyst development and deployment — handles build, dev server, and deployment to Cloudflare Workers. + +## Directory Structure + +``` +src/cli/ +├── index.ts # Entry point (#!/usr/bin/env node) +├── program.ts # Commander program setup, registers all commands +├── commands/ # CLI command implementations (auth, build, deploy, logs, project, start, telemetry, version) +├── hooks/ # Pre/post action hooks (telemetry) +└── lib/ # Utilities (auth, logger, project config, credentials, wrangler config, telemetry, deployment errors) +templates/ # OpenNext config and public_headers template +tests/mocks/ # MSW handlers and test mocks +dist/cli.js # Bundled output (single ESM file) +``` + +## CLI Commands + +| Command | Description | +|---------|-------------| +| `auth whoami/login/logout` | Manage authentication (device code OAuth flow, credential storage) | +| `build` | Build Catalyst project using OpenNext/Cloudflare adapter | +| `deploy` | Deploy to Cloudflare with bundle upload | +| `logs` | View logs (`tail` default, `query` planned). Supports `--format` (default/json/pretty/short/request) | +| `project create/list/link` | Manage BigCommerce infrastructure projects | +| `start` | Start local preview using OpenNext Cloudflare adapter | +| `telemetry` | Enable/disable/check telemetry | +| `version` | Display version and platform info | + +## Development + +```bash +pnpm dev # Watch mode (rebuilds dist/cli.js on changes) +pnpm build # Production build via tsup +pnpm test # Run tests (vitest) +pnpm test:watch # Watch mode tests +pnpm typecheck # tsc --noEmit +pnpm lint # eslint with 0 warnings threshold +``` + +## Testing Changes + +To test CLI changes directly without a full publish cycle, build the CLI package first, then run the compiled output from inside the `core/` directory using its absolute path: + +```bash +# From packages/catalyst — rebuild the CLI +pnpm build + +# From core/ — run the CLI using the absolute path to the built output +pnpm exec /packages/catalyst/dist/cli.js +``` + +For example: `pnpm exec /packages/catalyst/dist/cli.js project list`. + +## Build + +- **Bundler**: tsup (`tsup.config.ts`) +- **Entry**: `src/cli/index.ts` → `dist/cli.js` (single ESM bundle with source maps) +- **Environment variables** injected at build time: `CLI_SEGMENT_WRITE_KEY`, `CONSOLA_LEVEL` + +## Testing + +- **Framework**: Vitest (`vitest.config.ts`) +- **Coverage threshold**: 100% (strict) +- **Mocking**: MSW for BigCommerce API calls; telemetry always disabled in tests (`vitest.setup.ts`) +- **Test files**: co-located as `*.spec.ts` next to source files + +## Key Dependencies + +- `commander` — CLI framework +- `execa` — process execution (next, pnpm, wrangler) +- `consola` — logging +- `zod` — API response validation +- `conf` — persistent config (`.bigcommerce/project.json`) +- `@segment/analytics-node` — telemetry +- `adm-zip` — bundle zipping for deploy + +## After Making Changes + +Always run `pnpm typecheck` and `pnpm lint` after making code changes, and fix any errors or warnings before considering the work done. The lint config enforces zero warnings (`--max-warnings 0`). Use `pnpm lint --fix` to auto-fix formatting and fixable lint issues before manually editing. + +## Code Style Notes + +- **No `let` when avoidable** — prefer `const` with `while (true)` + `break` over mutable flags. +- **No iterators/generators** — eslint `no-restricted-syntax` disallows `for...of` and generator functions. Use `.forEach()`, `.map()`, etc. +- **Consola for logging** — use `consola` (from `../lib/logger`) instead of `console`. Use `colorize` from `consola/utils` for colored output. +- **Shared CLI options** — commands that need `--store-hash`, `--access-token`, `--api-host`, and `--project-uuid` should use the shared option factories from `lib/shared-options.ts`. Chain `.makeOptionMandatory()` inline where needed to preserve commander's extra-typings inference. +- **SSE stream pattern** — the fetch API's `ReadableStreamDefaultReader` doesn't support async iteration, so `while (true)` + `reader.read()` + `break` is the standard pattern. For long-lived streams, implement a TTL-based reconnect to free connection pool resources, and distinguish server-side disconnects (`TypeError: terminated`) from actual failures for retry logic. + +## Integration Points + +- **BigCommerce Infrastructure API**: `https://api.bigcommerce.com/stores/{storeHash}/v3/infrastructure/` +- **Next.js**: dev/build/start +- **OpenNext + Cloudflare**: serverless build and deploy via Wrangler diff --git a/packages/catalyst/CHANGELOG.md b/packages/catalyst/CHANGELOG.md new file mode 100644 index 0000000000..c74b12620c --- /dev/null +++ b/packages/catalyst/CHANGELOG.md @@ -0,0 +1,176 @@ +# @bigcommerce/catalyst + +## 1.1.0 + +### Minor Changes + +- [#3111](https://github.com/bigcommerce/catalyst/pull/3111) [`9565d76`](https://github.com/bigcommerce/catalyst/commit/9565d7636de95327651af0a90ced37a352241be7) Thanks [@jorgemoya](https://github.com/jorgemoya)! - `catalyst` now reads the API host from `CATALYST_API_HOST` (renamed from `BIGCOMMERCE_API_HOST`) and resolves it with the same precedence as other credentials: `--api-host` flag > `CATALYST_API_HOST` > `.bigcommerce/project.json` `apiHost` > default `api.bigcommerce.com`. **Breaking:** the `BIGCOMMERCE_API_HOST` environment variable is no longer read — set `CATALYST_API_HOST` instead. + +- [#3110](https://github.com/bigcommerce/catalyst/pull/3110) [`5ca4c61`](https://github.com/bigcommerce/catalyst/commit/5ca4c615ead79bc3bf1b7b1bc3ce1743fddeb12b) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Add the `catalyst debug` command, which prints a diagnostic report (CLI version, runtime, the project's package manager, project/config state, telemetry correlation ID, and which key files exist) to include when filing a bug report. Credentials and environment variables are resolved across the same chain a build uses (`process.env` > `.env.local` > `.env` > `.bigcommerce/project.json`) and reported by name and source only — secret values are never printed. Use `--json` for machine-readable output. + +- [#3084](https://github.com/bigcommerce/catalyst/pull/3084) [`aadaf27`](https://github.com/bigcommerce/catalyst/commit/aadaf27b72cbbf0889262a26ca44daec54bdbbb4) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Add the `catalyst domains claim` command, which claims ownership of a custom domain that is already in use on another store. When you try to add a domain bound to a different store, `catalyst domains add` now prints the ownership-verification TXT record to publish; after publishing it, run `catalyst domains claim ` to release the domain from the other store and bind it to your project. + +- [#3089](https://github.com/bigcommerce/catalyst/pull/3089) [`8d9f9a9`](https://github.com/bigcommerce/catalyst/commit/8d9f9a92da07d65ec8c9cc9a45ec1ffb582d96c7) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Add the `catalyst domains transfer` command, which moves a custom domain from one project to another project in the same store (the same-store counterpart to `domains claim`). Pass `--to-project-uuid ` to target a specific project, or omit it to pick the destination interactively from your store's projects. When `catalyst domains add` fails because the domain is already bound to another project in the store, it now points you at the exact `domains transfer` command to move it instead of surfacing the raw API error. + +- [#3098](https://github.com/bigcommerce/catalyst/pull/3098) [`05f600a`](https://github.com/bigcommerce/catalyst/commit/05f600a5fcee8c17927b2b56343f942a2e6d4b2c) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Env-file loading is now scoped to `catalyst build` and `catalyst deploy` — the only commands that need storefront environment variables (for the build). Previously `.env.local` was auto-loaded globally from the current working directory (but `.env` was not), which was surprising, inconsistent, and affected commands that don't use those variables. Now `build` and `deploy` auto-load `.env.local` and `.env` from the current directory (with `.env.local` taking precedence, and neither overriding your real environment), and you can point at a specific file with `--env-path `. No other command reads env files. + + Migration: if you relied on env vars being auto-loaded for a command other than `build`/`deploy`, set them in your shell environment or pass the relevant flags instead. For a build with an env file outside the project directory, use `catalyst deploy --env-path ../.env.local`. + +### Patch Changes + +- [#3105](https://github.com/bigcommerce/catalyst/pull/3105) [`552cfb5`](https://github.com/bigcommerce/catalyst/commit/552cfb5c59a1e69b92f7018c2ddc3c9b3c5f0c61) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Add a `--wrangler-version ` flag to `catalyst build` and `catalyst deploy` so developers can build against a Wrangler version or dist-tag other than the pinned default. When omitted, the build uses the pinned default as before (and the flag is ignored by `deploy --prebuilt`, which skips the build). The value is validated to look like a version or dist-tag before it's interpolated into the `wrangler@` spec. + +- [#3102](https://github.com/bigcommerce/catalyst/pull/3102) [`fea7b30`](https://github.com/bigcommerce/catalyst/commit/fea7b30bab0d1b09dc5bddbe3884317968264760) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Add the `@bigcommerce/catalyst` CLI to a newly created project's `devDependencies` instead of `dependencies`. The CLI is a build-time tool (it only backs the `build`/`start`/`deploy` npm scripts) and is never imported at runtime, so it doesn't belong in runtime dependencies. + +- [#3095](https://github.com/bigcommerce/catalyst/pull/3095) [`27373e8`](https://github.com/bigcommerce/catalyst/commit/27373e8a50c9bd0703f474364a7c269947d56179) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Validate channel names before creating a channel. `catalyst create` and `catalyst channel create` now reject names containing unsupported characters (such as an apostrophe in "Bob's Store") with a clear message that names the offending input and lists the allowed characters — letters, numbers, spaces, hyphens, and underscores — instead of surfacing an opaque API error. The interactive prompt validates as you type, and an invalid `--name` flag fails fast. + +- [#3099](https://github.com/bigcommerce/catalyst/pull/3099) [`b4e5952`](https://github.com/bigcommerce/catalyst/commit/b4e595210b2a38e24aac98c656581dfbd67a1c8f) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Unify the device-code login UX across `auth login`, `create`, and the channel commands. The CLI now waits for you to press Enter before opening the browser and best-effort copies the one-time code to your clipboard so you can paste it directly (the code is still printed as a fallback). Non-interactive/CI runs skip the prompt and open directly. + +- [#3097](https://github.com/bigcommerce/catalyst/pull/3097) [`e3e5ab2`](https://github.com/bigcommerce/catalyst/commit/e3e5ab2e3cae414e6067017b1a8939f472fa1456) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Fail `catalyst build`/`catalyst deploy` fast with a clear, actionable error when required environment variables (`BIGCOMMERCE_STORE_HASH`, `BIGCOMMERCE_STOREFRONT_TOKEN`, `BIGCOMMERCE_CHANNEL_ID`, `AUTH_SECRET`) aren't loaded, instead of surfacing a raw OpenNext/Next.js build stack trace. The check also runs on the plain `next build` fallthrough (non-Commerce-Hosting projects), not just the Commerce Hosting pipeline. The message names the missing variables and explains that the build auto-loads `.env.local` and `.env` from the current directory (or pass `--env-path ` to load a file from elsewhere). + +- [#3107](https://github.com/bigcommerce/catalyst/pull/3107) [`0d42b12`](https://github.com/bigcommerce/catalyst/commit/0d42b12a59157eceba1397411de504767d7a309d) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Fix the dependency install step hanging indefinitely during `catalyst project link` and the `catalyst deploy` first-run setup. nypm runs the install through tinyexec, which in silent mode drains the child's stdout to completion before it reads stderr; pnpm floods stderr with Node warnings (on Node 26, thousands of `File descriptor N opened in unmanaged mode` lines), filling the stderr pipe buffer while tinyexec is still on stdout, so pnpm blocks writing and the install deadlocks. The install now runs the child with `NODE_NO_WARNINGS` (and pins `COREPACK_ENABLE_DOWNLOAD_PROMPT` off) so stderr never fills. The `link`/`deploy` setup paths also now detect the project's actual package manager from its lockfile instead of always forcing pnpm. + +- [#3096](https://github.com/bigcommerce/catalyst/pull/3096) [`c1d0f3d`](https://github.com/bigcommerce/catalyst/commit/c1d0f3df789f304f8ce1aa07225cdcc5a110dfa3) Thanks [@jorgemoya](https://github.com/jorgemoya)! - `project create` now shows the actionable "Infrastructure Projects API not enabled — contact support to join the beta" guidance when the API responds `404`, matching the existing `403` handling, instead of a cryptic `Failed to create project: Not Found`. + +- [#3094](https://github.com/bigcommerce/catalyst/pull/3094) [`bf58b13`](https://github.com/bigcommerce/catalyst/commit/bf58b134cd870e04ec585eec465e93ce9481d13b) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Give the CLI readable, actionable errors when a BigCommerce API returns a 4xx or 5xx. A shared HTTP-error helper now turns a failed response into a clear message: it prefers the API's own reason (`detail`/`title`/field errors) and, when the body is empty or unparseable, falls back to curated copy for the status class. Client (4xx) errors are treated as user-actionable and print without the "share this Correlation ID with BigCommerce support" framing, while server (5xx) errors keep it. This replaces the raw `... failed: ` throws across the `channel`, `project`, `deploy`, `logs`, and `auth` API paths. + +- [#3088](https://github.com/bigcommerce/catalyst/pull/3088) [`ce03afb`](https://github.com/bigcommerce/catalyst/commit/ce03afb8e7ef27e4821e99f8fd4e4d94a7654922) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Stop framing clear, user-actionable CLI errors as bugs to report. Validation errors, not-found and not-enabled responses, conflicts, and bad command input now print just the message and exit, instead of appending a Correlation ID and a "share this with BigCommerce support" prompt. That framing is now reserved for genuine server-side (5xx) failures and unexpected errors. Applies across the `domains`, `project`, `channel`, `logs`, and `auth` commands via a shared `UserActionableError` type. + +- [#3100](https://github.com/bigcommerce/catalyst/pull/3100) [`07cd41c`](https://github.com/bigcommerce/catalyst/commit/07cd41cd5867fd3fc520a7a3fda752f059bf0c5a) Thanks [@jorgemoya](https://github.com/jorgemoya)! - The CLI now follows `.env.example` as the source of truth when writing `.env.local`. Generated env files preserve the documented ordering and per-key comment blocks, render documented-but-unsupplied keys as blank/default active keys, and append any CLI-only variables in a clearly separated trailing section. Existing `.env.local` values are reconciled rather than clobbered on re-runs (e.g. `channel link`), so user-set values are preserved while newly documented keys are added in their canonical position. + +## 1.0.0 + +### Major Changes + +- [#3077](https://github.com/bigcommerce/catalyst/pull/3077) [`a45ab43`](https://github.com/bigcommerce/catalyst/commit/a45ab4346c27e8cc60d6ce64fb597f22dbde2243) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Introducing the Catalyst CLI (`@bigcommerce/catalyst`) — a single command-line tool for scaffolding, building, and deploying your Catalyst storefront to BigCommerce's Native Hosting infrastructure. + + ### Highlights + - **Scaffold a storefront** — `catalyst create` downloads a clean, standalone project (flattened from `core/`, `workspace:` dependencies resolved to published versions, fresh git repo) via tarball extraction and connects it to your BigCommerce store. The package manager is auto-detected from `npm_config_user_agent`. + - **Browser-based authentication** — Run `catalyst auth login` to authenticate via an OAuth device code flow. Credentials are stored locally in `.bigcommerce/project.json` for use by all subsequent commands. CI/CD environments can use `--store-hash` and `--access-token` flags or environment variables instead. + - **Project & channel management** — Create, link, and list BigCommerce infrastructure projects with `catalyst project`, and connect storefront channels with `catalyst channel`. + - **Build & deploy** — `catalyst build` runs the OpenNext Cloudflare build pipeline (deriving the Wrangler `compatibility_date` dynamically) and generates deployment artifacts. `catalyst deploy` bundles, uploads, and deploys your storefront with real-time progress streaming. Pass runtime secrets with `--secret KEY=VALUE`; environment variables are auto-detected as deploy secrets. + - **Persisted deployment env vars** — Manage deployment environment variables across deploys with `catalyst env` (list and remove; values are masked). + - **Custom domains** — Add, list, check the status of, and remove custom domains for a Native Hosting project with `catalyst domains`. + - **Local preview** — `catalyst start` launches a local Cloudflare Workers preview of your built storefront via the OpenNext adapter. + - **Live & historical logs** — `catalyst logs tail` streams real-time application logs with color-coded levels and auto-reconnect; `catalyst logs query` retrieves historical logs. + - **In-place upgrades** — `catalyst upgrade` upgrades a project to a newer version via a resilient 3-way merge (`git merge-tree`, falling back to per-file `git merge-file`), producing resolvable conflict markers instead of ever aborting. + - **Smart credential resolution** — Configuration is resolved in priority order: CLI flags → `--env-file` → process environment variables → `.bigcommerce/project.json`. + - **Telemetry** — Anonymous usage telemetry with session and correlation IDs for support. Opt out anytime with `catalyst telemetry disable`. + + ### Commands + + | Command | Description | + | -------------------- | -------------------------------------------------------------------- | + | `catalyst create` | Scaffold and connect a Catalyst storefront to your BigCommerce store | + | `catalyst auth` | Authenticate, sign out, and verify stored credentials | + | `catalyst project` | Create, link, and list infrastructure projects | + | `catalyst channel` | Connect a storefront channel to your project | + | `catalyst build` | Build your Catalyst project for deployment | + | `catalyst deploy` | Build and deploy to BigCommerce Native Hosting | + | `catalyst env` | Manage persisted deployment environment variables | + | `catalyst domains` | Manage custom domains for a Native Hosting project | + | `catalyst start` | Start a local Cloudflare Workers preview | + | `catalyst logs` | Stream live logs and query historical logs | + | `catalyst upgrade` | Upgrade a project to a newer version via 3-way merge | + | `catalyst version` | Display CLI, Node.js, and platform info | + | `catalyst telemetry` | View or change telemetry collection status | + + ### Getting started + + ```bash + cd core + pnpm add @bigcommerce/catalyst@latest @opennextjs/cloudflare@1.17.3 + pnpm catalyst auth login + pnpm catalyst project create + pnpm catalyst deploy --secret BIGCOMMERCE_STORE_HASH= --secret BIGCOMMERCE_STOREFRONT_TOKEN= + ``` + + For full documentation, see the [Native Hosting Overview](https://developer.bigcommerce.com/docs/storefront/catalyst/deployment/native-hosting/overview) and [CLI Reference](https://developer.bigcommerce.com/docs/storefront/catalyst/reference/cli). + +## 1.0.0-alpha.6 + +### Patch Changes + +- [#3061](https://github.com/bigcommerce/catalyst/pull/3061) [`eea1355`](https://github.com/bigcommerce/catalyst/commit/eea135543423dc0d50d6bff68d93f1548e54e096) Thanks [@jorgemoya](https://github.com/jorgemoya)! - `catalyst build` now derives the Cloudflare Workers `compatibility_date` dynamically (current date minus one month) instead of using a pinned date, keeping the build-time runtime semantics aligned with what the deployment service applies at deploy time. + +## 1.0.0-alpha.5 + +### Minor Changes + +- [#2988](https://github.com/bigcommerce/catalyst/pull/2988) [`24f35a4`](https://github.com/bigcommerce/catalyst/commit/24f35a4cc60d73036c264a896e816b98aa47bfba) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Show every deployed URL for each project in `catalyst project list` output (the canonical hostname plus any vanity hostnames) so users can recover the hosted storefront URLs without having to redeploy. + +### Patch Changes + +- [#3028](https://github.com/bigcommerce/catalyst/pull/3028) [`bdc6e0b`](https://github.com/bigcommerce/catalyst/commit/bdc6e0bf055262e1440bcc1ebcc55597256b424a) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Remove `core/instrumentation.ts` and the `@vercel/otel` dependency during Commerce Hosting setup. The hook isn't compatible with the OpenNext + Cloudflare Workers bundling path and caused a "Failed to prepare server" error on every cold start in `catalyst logs tail`. Self-hosted (non-Commerce Hosting) deployments are unaffected. + +## 1.0.0-alpha.4 + +### Patch Changes + +- [`de04f42`](https://github.com/bigcommerce/catalyst/commit/de04f42696b30b675bec2625eefa1e825b4a97ba) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Add built-in help text to all CLI commands so `catalyst --help` is the canonical reference. + +- [`e740158`](https://github.com/bigcommerce/catalyst/commit/e7401583603aae85d4adef23b4b72eeb96e11907) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Show the global `--env-path` option in every subcommand's `--help` output. + +- [`e5b4dee`](https://github.com/bigcommerce/catalyst/commit/e5b4dee1fc108d648b6110707b572c1fc9bc2e7c) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Update the CLI with the new client id. + +## 1.0.0-alpha.3 + +### Minor Changes + +- [#2972](https://github.com/bigcommerce/catalyst/pull/2972) [`e681933`](https://github.com/bigcommerce/catalyst/commit/e681933ebbe798198e4c1b8f6f20f67dc4ec36ad) Thanks [@jorgemoya](https://github.com/jorgemoya)! - Auto-detect environment variables as deploy secrets. + +## 1.0.0-alpha.2 + +### Patch Changes + +- Fix CLI environment variable resolution for `deploy`, `build`, and `project` commands. The published dist was using stale `BIGCOMMERCE_*` env var names instead of the correct `CATALYST_*` names (`CATALYST_STORE_HASH`, `CATALYST_ACCESS_TOKEN`, `CATALYST_PROJECT_UUID`). + +## 1.0.0-alpha.1 + +### Major Changes + +- Introducing the Catalyst CLI (`@bigcommerce/catalyst`) — a command-line tool for building and deploying your Catalyst storefront to BigCommerce's Native Hosting infrastructure. + + ### Highlights + - **Browser-based authentication** — Run `catalyst auth login` to authenticate via an OAuth device code flow. Credentials are stored locally in `.bigcommerce/project.json` for use by all subsequent commands. CI/CD environments can use `--store-hash` and `--access-token` flags or environment variables instead. + - **Project management** — Create, link, and list BigCommerce infrastructure projects with `catalyst project create`, `catalyst project link`, and `catalyst project list`. + - **Build & deploy** — `catalyst build` runs the OpenNext Cloudflare build pipeline and generates deployment artifacts. `catalyst deploy` bundles, uploads, and deploys your storefront with real-time progress streaming. Pass runtime secrets with `--secret KEY=VALUE`. + - **Local preview** — `catalyst start` launches a local Cloudflare Workers preview of your built storefront via the OpenNext adapter. + - **Live log tailing** — `catalyst logs tail` streams real-time application logs from your deployed storefront with color-coded log levels and multiple output formats. + - **Smart credential resolution** — Configuration is resolved in priority order: CLI flags → `--env-file` → process environment variables → `.bigcommerce/project.json`. + - **Telemetry** — Anonymous usage telemetry with session and correlation IDs for support. Opt out anytime with `catalyst telemetry disable`. + + ### Commands + + | Command | Description | + | ------------------------- | ------------------------------------------------- | + | `catalyst auth login` | Authenticate via browser OAuth flow | + | `catalyst auth logout` | Remove stored credentials | + | `catalyst auth whoami` | Verify credentials and display store/project info | + | `catalyst project create` | Create a new infrastructure project | + | `catalyst project link` | Link to an existing infrastructure project | + | `catalyst project list` | List infrastructure projects for your store | + | `catalyst build` | Build your Catalyst project for deployment | + | `catalyst deploy` | Build and deploy to BigCommerce Native Hosting | + | `catalyst start` | Start a local Cloudflare Workers preview | + | `catalyst logs tail` | Stream live logs from your deployment | + | `catalyst version` | Display CLI, Node.js, and platform info | + | `catalyst telemetry` | View or change telemetry collection status | + + ### Getting started + + ```bash + cd core + pnpm add @bigcommerce/catalyst@alpha @opennextjs/cloudflare@1.17.3 + pnpm catalyst auth login + pnpm catalyst project create + pnpm catalyst deploy --secret BIGCOMMERCE_STORE_HASH= --secret BIGCOMMERCE_STOREFRONT_TOKEN= + ``` + + For full documentation, see the [Native Hosting Overview](https://developer.bigcommerce.com/docs/storefront/catalyst/deployment/native-hosting/overview) and [CLI Reference](https://developer.bigcommerce.com/docs/storefront/catalyst/reference/cli). + +## 1.0.0-alpha.0 + +### Major Changes + +- [`acee114`](https://github.com/bigcommerce/catalyst/commit/acee114ca0ee7428e33b1db28a5b3b18914cde4b) Thanks [@chanceaclark](https://github.com/chanceaclark)! - Alpha version of the CLI diff --git a/packages/catalyst/README.md b/packages/catalyst/README.md index ed8f949d92..5ecfc6f561 100644 --- a/packages/catalyst/README.md +++ b/packages/catalyst/README.md @@ -1,3 +1,36 @@ # @bigcommerce/catalyst -CLI +CLI tool for Catalyst development and deployment. + +## Developing the CLI + +You'll need two terminal windows: + +### Terminal 1 — Watch mode (rebuilds on changes) + +```bash +cd packages/catalyst +pnpm dev +``` + +This runs `tsup --watch` and rebuilds `dist/cli.js` on every source change. + +### Terminal 2 — Run the CLI + +From the `core/` directory, run the CLI using the absolute path to the built executable: + +```bash +cd core +pnpm exec /packages/catalyst/dist/cli.js +``` + +For example: + +```bash +pnpm exec /packages/catalyst/dist/cli.js project list +pnpm exec /packages/catalyst/dist/cli.js logs tail +pnpm exec /packages/catalyst/dist/cli.js logs query --start 2026-06-01T00:00:00Z --end 2026-06-02T00:00:00Z +pnpm exec /packages/catalyst/dist/cli.js deploy +``` + +Replace `` with the absolute path to your local clone of the `catalyst` repository. diff --git a/packages/catalyst/package.json b/packages/catalyst/package.json index 9697588b0e..3d6544f824 100644 --- a/packages/catalyst/package.json +++ b/packages/catalyst/package.json @@ -1,7 +1,12 @@ { "name": "@bigcommerce/catalyst", - "version": "0.1.0", + "version": "1.1.0", "type": "module", + "repository": { + "type": "git", + "url": "https://github.com/bigcommerce/catalyst", + "directory": "packages/catalyst" + }, "bin": { "catalyst": "dist/cli.js" }, @@ -9,7 +14,6 @@ "dist", "templates" ], - "private": true, "scripts": { "dev": "tsup --watch", "typecheck": "tsc --noEmit", @@ -23,22 +27,35 @@ "node": "^20.0.0 || ^22.0.0 || ^24.0.0" }, "dependencies": { + "@commander-js/extra-typings": "^14.0.0", + "@inquirer/prompts": "^7.5.3", "@segment/analytics-node": "^2.2.1", "adm-zip": "^0.5.16", "commander": "^14.0.0", "conf": "^13.1.0", "consola": "^3.4.2", + "cross-spawn": "^7.0.6", "dotenv": "^16.5.0", "execa": "^9.6.0", + "fs-extra": "^11.3.0", + "lodash.kebabcase": "^4.1.1", + "nypm": "^0.5.4", + "open": "^10.1.0", + "semver": "^7.7.4", + "std-env": "^3.9.0", + "tar": "^7.5.7", "yocto-spinner": "^1.0.0", "zod": "^4.0.5" }, "devDependencies": { "@bigcommerce/eslint-config": "^2.11.0", "@bigcommerce/eslint-config-catalyst": "workspace:^", - "@commander-js/extra-typings": "^14.0.0", "@types/adm-zip": "^0.5.7", + "@types/cross-spawn": "^6.0.6", + "@types/fs-extra": "^11.0.4", + "@types/lodash.kebabcase": "^4.1.9", "@types/node": "^22.15.30", + "@types/semver": "^7.7.0", "@vitest/coverage-v8": "^3.2.4", "@vitest/ui": "^3.2.4", "eslint": "^8.57.1", @@ -49,6 +66,6 @@ "vitest": "^3.2.4" }, "peerDependencies": { - "@opennextjs/cloudflare": "^1.8.0" + "@opennextjs/cloudflare": "1.17.3" } } diff --git a/packages/catalyst/src/cli/commands/auth.spec.ts b/packages/catalyst/src/cli/commands/auth.spec.ts new file mode 100644 index 0000000000..314dfc4f27 --- /dev/null +++ b/packages/catalyst/src/cli/commands/auth.spec.ts @@ -0,0 +1,313 @@ +import { confirm, input, password } from '@inquirer/prompts'; +import { Command } from 'commander'; +import { http, HttpResponse } from 'msw'; +import { realpath } from 'node:fs/promises'; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + MockInstance, + test, + vi, +} from 'vitest'; + +import { server } from '../../../tests/mocks/node'; +import { textHistory } from '../../../tests/mocks/spinner'; +import { consola } from '../lib/logger'; +import { mkTempDir } from '../lib/mk-temp-dir'; +import { getProjectConfig } from '../lib/project-config'; +import { program } from '../program'; + +import { auth } from './auth'; + +// eslint-disable-next-line import/dynamic-import-chunkname +vi.mock('yocto-spinner', () => import('../../../tests/mocks/spinner')); +vi.mock('open', () => ({ default: vi.fn().mockResolvedValue(undefined) })); +vi.mock('@inquirer/prompts', () => ({ + confirm: vi.fn(), + input: vi.fn(), + password: vi.fn(), +})); + +const confirmMock = vi.mocked(confirm); +const inputMock = vi.mocked(input); +const passwordMock = vi.mocked(password); + +let exitMock: MockInstance; +let tmpDir: string; +let cleanup: () => Promise; + +beforeAll(async () => { + consola.mockTypes(() => vi.fn()); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + exitMock = vi.spyOn(process, 'exit').mockImplementation(() => null as never); + + [tmpDir, cleanup] = await mkTempDir(); + tmpDir = await realpath(tmpDir); +}); + +beforeEach(() => { + process.chdir(tmpDir); +}); + +afterEach(() => { + vi.clearAllMocks(); + textHistory.length = 0; + + // Clean up config between tests + try { + const config = getProjectConfig(); + + config.delete('storeHash'); + config.delete('accessToken'); + } catch { + // ignore if config doesn't exist + } +}); + +afterAll(async () => { + await cleanup(); +}); + +test('auth is a properly configured Command instance', () => { + expect(auth).toBeInstanceOf(Command); + expect(auth.name()).toBe('auth'); + expect(auth.description()).toBe('Manage authentication for the BigCommerce CLI.'); + + const subcommands = auth.commands.map((cmd) => cmd.name()); + + expect(subcommands).toContain('whoami'); + expect(subcommands).toContain('login'); + expect(subcommands).toContain('logout'); +}); + +describe('whoami', () => { + test('displays store info when credentials are valid', async () => { + const config = getProjectConfig(); + + config.set('storeHash', 'test-store'); + config.set('accessToken', 'test-token'); + + await program.parseAsync(['node', 'catalyst', 'auth', 'whoami']); + + expect(consola.info).toHaveBeenCalledWith('Logged in to Test Store (test-store)'); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + test('reports no credentials found', async () => { + await program.parseAsync(['node', 'catalyst', 'auth', 'whoami']); + + expect(consola.info).toHaveBeenCalledWith('Not logged in: no credentials found.'); + expect(consola.info).toHaveBeenCalledWith( + 'Run `catalyst auth login`, or provide --store-hash and --access-token flags (or set CATALYST_STORE_HASH and CATALYST_ACCESS_TOKEN environment variables).', + ); + expect(exitMock).toHaveBeenCalledWith(1); + }); + + test('reports an invalid or expired token on 401', async () => { + const config = getProjectConfig(); + + config.set('storeHash', 'test-store'); + config.set('accessToken', 'bad-token'); + + server.use( + http.get( + 'https://:apiHost/stores/:storeHash/v3/settings/store/profile', + () => new HttpResponse(null, { status: 401, statusText: 'Unauthorized' }), + ), + ); + + await program.parseAsync(['node', 'catalyst', 'auth', 'whoami']); + + expect(consola.error).toHaveBeenCalledWith( + 'Not logged in: your access token is invalid or has expired. Run `catalyst auth login`.', + ); + expect(exitMock).toHaveBeenCalledWith(1); + }); +}); + +describe('login', () => { + test('completes OAuth device flow and stores credentials', async () => { + await program.parseAsync(['node', 'catalyst', 'auth', 'login']); + + expect(consola.info).toHaveBeenCalledWith(expect.stringContaining('MOCK-CODE')); + expect(consola.success).toHaveBeenCalledWith('Logged in to store mock-store-hash.'); + expect(exitMock).toHaveBeenCalledWith(0); + + // Verify credentials were stored + const config = getProjectConfig(); + + expect(config.get('storeHash')).toBe('mock-store-hash'); + expect(config.get('accessToken')).toBe('mock-access-token'); + }); + + test('exits early when already logged in', async () => { + const config = getProjectConfig(); + + config.set('storeHash', 'existing-store'); + config.set('accessToken', 'existing-token'); + + await program.parseAsync(['node', 'catalyst', 'auth', 'login']); + + expect(consola.info).toHaveBeenCalledWith('Already logged in to store existing-store.'); + expect(consola.info).toHaveBeenCalledWith( + 'Run `catalyst auth logout` first to re-authenticate.', + ); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + test('prompts to fall back to manual login when device code request fails', async () => { + server.use( + http.post( + 'https://login.bigcommerce.com/device/token', + () => new HttpResponse(null, { status: 404, statusText: 'Not Found' }), + ), + ); + + confirmMock.mockResolvedValueOnce(true); + inputMock.mockResolvedValueOnce('manual-store-hash'); + passwordMock.mockResolvedValueOnce('manual-access-token'); + + await program.parseAsync(['node', 'catalyst', 'auth', 'login']); + + expect(confirmMock).toHaveBeenCalledOnce(); + expect(confirmMock.mock.calls[0]?.[0].message).toContain('Try logging in manually'); + expect(inputMock).toHaveBeenCalledWith(expect.objectContaining({ message: 'Store hash:' })); + + expect(consola.warn).toHaveBeenCalledWith(expect.stringContaining("Browser login didn't work")); + expect(consola.success).toHaveBeenCalledWith('Logged in to store manual-store-hash.'); + expect(exitMock).toHaveBeenCalledWith(0); + + const config = getProjectConfig(); + + expect(config.get('storeHash')).toBe('manual-store-hash'); + expect(config.get('accessToken')).toBe('manual-access-token'); + }); + + test('exits cleanly when user declines manual login fallback', async () => { + server.use( + http.post( + 'https://login.bigcommerce.com/device/token', + () => new HttpResponse(null, { status: 404, statusText: 'Not Found' }), + ), + ); + + confirmMock.mockResolvedValueOnce(false); + + await program.parseAsync(['node', 'catalyst', 'auth', 'login']); + + expect(consola.warn).toHaveBeenCalledWith(expect.stringContaining("Browser login didn't work")); + expect(consola.info).toHaveBeenCalledWith( + 'Login aborted. Re-run `catalyst auth login` when you have your credentials ready.', + ); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + test('fails when manual credentials cannot be validated', async () => { + server.use( + http.post( + 'https://login.bigcommerce.com/device/token', + () => new HttpResponse(null, { status: 404, statusText: 'Not Found' }), + ), + http.get( + 'https://:apiHost/stores/:storeHash/v3/settings/store/profile', + () => new HttpResponse(null, { status: 401, statusText: 'Unauthorized' }), + ), + ); + + confirmMock.mockResolvedValueOnce(true); + inputMock.mockResolvedValueOnce('manual-store-hash'); + passwordMock.mockResolvedValueOnce('bad-token'); + + await program.parseAsync(['node', 'catalyst', 'auth', 'login']); + + expect(consola.error).toHaveBeenCalledWith( + expect.stringContaining('Could not validate credentials'), + ); + expect(exitMock).toHaveBeenCalledWith(1); + }); + + test('rejects empty store hash during manual login', async () => { + server.use( + http.post( + 'https://login.bigcommerce.com/device/token', + () => new HttpResponse(null, { status: 404, statusText: 'Not Found' }), + ), + ); + + confirmMock.mockResolvedValueOnce(true); + inputMock.mockResolvedValueOnce(' '); + + await program.parseAsync(['node', 'catalyst', 'auth', 'login']); + + expect(consola.error).toHaveBeenCalledWith(expect.stringContaining('Store hash is required')); + expect(exitMock).toHaveBeenCalledWith(1); + }); + + test('rejects empty access token during manual login', async () => { + server.use( + http.post( + 'https://login.bigcommerce.com/device/token', + () => new HttpResponse(null, { status: 404, statusText: 'Not Found' }), + ), + ); + + confirmMock.mockResolvedValueOnce(true); + inputMock.mockResolvedValueOnce('manual-store-hash'); + passwordMock.mockResolvedValueOnce(' '); + + await program.parseAsync(['node', 'catalyst', 'auth', 'login']); + + expect(consola.error).toHaveBeenCalledWith(expect.stringContaining('Access token is required')); + expect(exitMock).toHaveBeenCalledWith(1); + }); + + test('handles browser open failure gracefully', async () => { + // eslint-disable-next-line import/dynamic-import-chunkname + const openMock = await import('open'); + + vi.mocked(openMock.default).mockRejectedValueOnce(new Error('No browser')); + + await program.parseAsync(['node', 'catalyst', 'auth', 'login']); + + expect(consola.info).toHaveBeenCalledWith( + expect.stringContaining('Open https://login.bigcommerce.com/device in your browser'), + ); + expect(consola.success).toHaveBeenCalledWith('Logged in to store mock-store-hash.'); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + test('shows spinner during authentication polling', async () => { + await program.parseAsync(['node', 'catalyst', 'auth', 'login']); + + expect(textHistory).toContain('Waiting for authentication...'); + expect(textHistory).toContain('Authentication complete.'); + }); +}); + +describe('logout', () => { + test('clears stored credentials', async () => { + const config = getProjectConfig(); + + config.set('storeHash', 'test-store'); + config.set('accessToken', 'test-token'); + + await program.parseAsync(['node', 'catalyst', 'auth', 'logout']); + + expect(consola.success).toHaveBeenCalledWith('Logged out from store test-store.'); + expect(exitMock).toHaveBeenCalledWith(0); + + expect(config.get('storeHash')).toBeUndefined(); + expect(config.get('accessToken')).toBeUndefined(); + }); + + test('reports not logged in when no credentials exist', async () => { + await program.parseAsync(['node', 'catalyst', 'auth', 'logout']); + + expect(consola.info).toHaveBeenCalledWith('Not logged in: no credentials found.'); + expect(exitMock).toHaveBeenCalledWith(0); + }); +}); diff --git a/packages/catalyst/src/cli/commands/auth.ts b/packages/catalyst/src/cli/commands/auth.ts new file mode 100644 index 0000000000..4d259b4b76 --- /dev/null +++ b/packages/catalyst/src/cli/commands/auth.ts @@ -0,0 +1,219 @@ +import { Command } from 'commander'; +import { z } from 'zod'; + +import { assertAuthorized, UnauthorizedError } from '../lib/auth-errors'; +import { consola } from '../lib/logger'; +import { LoginAbortedError, login as runInteractiveLogin } from '../lib/login'; +import { fetchProjects } from '../lib/project'; +import { getProjectConfig } from '../lib/project-config'; +import { + accessTokenOption, + apiHostOption, + loginUrlOption, + resolveApiHost, + storeHashOption, +} from '../lib/shared-options'; + +const StoreProfileSchema = z.object({ + data: z.object({ + store_name: z.string(), + }), +}); + +async function fetchStoreProfile(storeHash: string, accessToken: string, apiHost: string) { + const response = await fetch(`https://${apiHost}/stores/${storeHash}/v3/settings/store/profile`, { + method: 'GET', + headers: { + 'X-Auth-Token': accessToken, + Accept: 'application/json', + }, + }); + + assertAuthorized(response); + + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}`); + } + + const res: unknown = await response.json(); + const result = StoreProfileSchema.safeParse(res); + + if (!result.success) { + throw new Error('Unexpected response from store profile API'); + } + + return result.data.data; +} + +const whoami = new Command('whoami') + .configureHelp({ showGlobalOptions: true }) + .description('Verify stored credentials and display store/project info.') + .addHelpText( + 'after', + ` +Example: + $ catalyst auth whoami + + Logged in to My Store (abc123), connected to project my-project (43eba682-0c48-11f1-9bd5-827a48b0ce1e)`, + ) + .addOption(storeHashOption()) + .addOption(accessTokenOption()) + .addOption(apiHostOption()) + .action(async (options) => { + try { + const config = getProjectConfig(); + const apiHost = resolveApiHost(options, config); + + const storeHash = options.storeHash ?? config.get('storeHash'); + const accessToken = options.accessToken ?? config.get('accessToken'); + + if (!storeHash || !accessToken) { + consola.info('Not logged in: no credentials found.'); + consola.info( + 'Run `catalyst auth login`, or provide --store-hash and --access-token flags (or set CATALYST_STORE_HASH and CATALYST_ACCESS_TOKEN environment variables).', + ); + process.exit(1); + + return; + } + + const store = await fetchStoreProfile(storeHash, accessToken, apiHost); + + const projectUuid = config.get('projectUuid'); + + if (projectUuid) { + const projects = await fetchProjects(storeHash, accessToken, apiHost); + const linkedProject = projects.find((p) => p.uuid === projectUuid); + + if (linkedProject) { + consola.info( + `Logged in to ${store.store_name} (${storeHash}), connected to project ${linkedProject.name} (${projectUuid})`, + ); + } else { + consola.info( + `Logged in to ${store.store_name} (${storeHash}), project ${projectUuid} not found`, + ); + } + } else { + consola.info(`Logged in to ${store.store_name} (${storeHash})`); + } + + process.exit(0); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + + if (error instanceof UnauthorizedError) { + consola.error( + 'Not logged in: your access token is invalid or has expired. Run `catalyst auth login`.', + ); + } else if (message.includes('401') || message.includes('403')) { + consola.error(`Not logged in: invalid credentials (${message})`); + } else { + consola.error(`Failed to verify credentials: ${message}`); + } + + process.exit(1); + } + }); + +const login = new Command('login') + .configureHelp({ showGlobalOptions: true }) + .description( + 'Authenticate via browser using the OAuth device code flow. Falls back to an interactive store hash + access token prompt if the browser flow is unavailable. If already logged in, displays current credentials and suggests running `catalyst auth logout` to re-authenticate.', + ) + .addHelpText( + 'after', + ` +Examples: + # Login interactively (browser, with manual fallback) + $ catalyst auth login + + # Login with existing credentials (skips interactive flow) + $ catalyst auth login --store-hash --access-token `, + ) + .addOption(storeHashOption()) + .addOption(accessTokenOption()) + .addOption(apiHostOption()) + .addOption(loginUrlOption()) + .action(async (options) => { + try { + const config = getProjectConfig(); + const apiHost = resolveApiHost(options, config); + + const storeHash = options.storeHash ?? config.get('storeHash'); + const accessToken = options.accessToken ?? config.get('accessToken'); + + if (storeHash && accessToken) { + consola.info(`Already logged in to store ${storeHash}.`); + consola.info('Run `catalyst auth logout` first to re-authenticate.'); + process.exit(0); + + return; + } + + const credentials = await runInteractiveLogin(options.loginUrl, apiHost); + + config.set('storeHash', credentials.storeHash); + config.set('accessToken', credentials.accessToken); + + consola.success(`Logged in to store ${credentials.storeHash}.`); + process.exit(0); + } catch (error) { + if (error instanceof LoginAbortedError) { + consola.info( + 'Login aborted. Re-run `catalyst auth login` when you have your credentials ready.', + ); + process.exit(0); + + return; + } + + const message = error instanceof Error ? error.message : String(error); + + consola.error(`Login failed: ${message}`); + process.exit(1); + } + }); + +const logout = new Command('logout') + .configureHelp({ showGlobalOptions: true }) + .description('Remove stored credentials for the current project.') + .addHelpText( + 'after', + ` +Example: + $ catalyst auth logout`, + ) + .action(() => { + try { + const config = getProjectConfig(); + + const storeHash = config.get('storeHash'); + const accessToken = config.get('accessToken'); + + if (!storeHash && !accessToken) { + consola.info('Not logged in: no credentials found.'); + process.exit(0); + + return; + } + + config.delete('storeHash'); + config.delete('accessToken'); + + consola.success(`Logged out from store ${storeHash ?? 'unknown'}.`); + process.exit(0); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + + consola.error(`Logout failed: ${message}`); + process.exit(1); + } + }); + +export const auth = new Command('auth') + .configureHelp({ showGlobalOptions: true }) + .description('Manage authentication for the BigCommerce CLI.') + .addCommand(whoami) + .addCommand(login) + .addCommand(logout); diff --git a/packages/catalyst/src/cli/commands/build.spec.ts b/packages/catalyst/src/cli/commands/build.spec.ts index 06f5525693..0e6cb0597c 100644 --- a/packages/catalyst/src/cli/commands/build.spec.ts +++ b/packages/catalyst/src/cli/commands/build.spec.ts @@ -1,44 +1,148 @@ import { Command } from 'commander'; import { execa } from 'execa'; -import { join } from 'node:path'; -import { expect, test, vi } from 'vitest'; +import { afterEach, beforeAll, beforeEach, expect, test, vi } from 'vitest'; +import { consola } from '../lib/logger'; +import { getProjectState } from '../lib/project-state'; import { program } from '../program'; import { build } from './build'; -// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -vi.spyOn(process, 'exit').mockImplementation(() => null as never); +vi.mock('execa', () => ({ + execa: vi.fn(() => Promise.resolve({})), + __esModule: true, +})); -vi.mock('node:fs', () => ({ - existsSync: vi.fn(() => true), +vi.mock('../lib/project-state', () => ({ + getProjectState: vi.fn(), })); -vi.mock('execa', () => ({ - execa: vi.fn(() => Promise.resolve({ stdout: '' })), - __esModule: true, +// The transformed build path shells out and touches the filesystem; stub the +// side effects so the tests can focus on how options are threaded through. +vi.mock('node:fs/promises', () => ({ + copyFile: vi.fn(() => Promise.resolve()), + cp: vi.fn(() => Promise.resolve()), + rm: vi.fn(() => Promise.resolve()), + writeFile: vi.fn(() => Promise.resolve()), +})); + +vi.mock('../lib/build-env', () => ({ + loadBuildEnv: vi.fn(), +})); + +vi.mock('../lib/wrangler-config', () => ({ + getWranglerConfig: vi.fn(() => ({})), +})); + +vi.mock('../lib/get-module-cli-path', () => ({ + getModuleCliPath: vi.fn(() => '/module/cli'), +})); + +vi.mock('../lib/project-config', () => ({ + getProjectConfig: vi.fn(() => ({ get: vi.fn(() => 'mock-uuid') })), })); +// The required-env check has its own unit tests; here we only care about +// command routing, so keep it a no-op regardless of the ambient environment. +vi.mock('../lib/required-build-env', () => ({ + assertRequiredBuildEnv: vi.fn(), +})); + +const untransformedState = { + projectUuid: undefined, + hasMiddleware: false, + hasProxy: true, + hasOpenNextDep: false, + isLinked: false, + isTransformed: false, + isFullySetUp: false, +}; + +const transformedState = { + projectUuid: 'mock-uuid', + hasMiddleware: true, + hasProxy: false, + hasOpenNextDep: true, + isLinked: true, + isTransformed: true, + isFullySetUp: true, +}; + +const DEFAULT_WRANGLER_VERSION = '4.90.0'; + +// eslint-disable-next-line @typescript-eslint/consistent-type-assertions +vi.spyOn(process, 'exit').mockImplementation(() => null as never); + +beforeAll(() => { + consola.wrapAll(); +}); + +beforeEach(() => { + consola.mockTypes(() => vi.fn()); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + test('properly configured Command instance', () => { expect(build).toBeInstanceOf(Command); expect(build.name()).toBe('build'); expect(build.options).toEqual( expect.arrayContaining([ - expect.objectContaining({ long: '--framework' }), expect.objectContaining({ long: '--project-uuid' }), + expect.objectContaining({ long: '--wrangler-version' }), ]), ); }); -test('calls execa with Next.js build if framework is nextjs', async () => { - await program.parseAsync(['node', 'catalyst', 'build', '--framework', 'nextjs', '--debug']); +test('falls through to `next build` when project is not transformed', async () => { + vi.mocked(getProjectState).mockReturnValue(untransformedState); + + await program.parseAsync(['node', 'catalyst', 'build']); + + expect(execa).toHaveBeenCalledWith( + 'pnpm', + ['exec', 'next', 'build'], + expect.objectContaining({ stdio: 'inherit', cwd: process.cwd() }), + ); +}); + +test('uses the pinned default Wrangler version when the flag is absent', async () => { + vi.mocked(getProjectState).mockReturnValue(transformedState); + + await program.parseAsync(['node', 'catalyst', 'build']); expect(execa).toHaveBeenCalledWith( - join('node_modules', '.bin', 'next'), - ['build', '--debug'], - expect.objectContaining({ - stdio: 'inherit', - cwd: process.cwd(), - }), + 'pnpm', + expect.arrayContaining(['dlx', `wrangler@${DEFAULT_WRANGLER_VERSION}`]), + expect.anything(), ); }); + +test('threads --wrangler-version into the wrangler invocation', async () => { + vi.mocked(getProjectState).mockReturnValue(transformedState); + + await program.parseAsync(['node', 'catalyst', 'build', '--wrangler-version', '4.24.3']); + + expect(execa).toHaveBeenCalledWith( + 'pnpm', + expect.arrayContaining(['dlx', 'wrangler@4.24.3']), + expect.anything(), + ); + expect(execa).not.toHaveBeenCalledWith( + 'pnpm', + expect.arrayContaining(['dlx', `wrangler@${DEFAULT_WRANGLER_VERSION}`]), + expect.anything(), + ); +}); + +test('rejects an invalid --wrangler-version value', async () => { + vi.mocked(getProjectState).mockReturnValue(transformedState); + + await expect( + program.parseAsync(['node', 'catalyst', 'build', '--wrangler-version', 'foo; rm -rf /']), + ).rejects.toThrow(/not a valid Wrangler version/); + + expect(execa).not.toHaveBeenCalled(); +}); diff --git a/packages/catalyst/src/cli/commands/build.ts b/packages/catalyst/src/cli/commands/build.ts index 94898ba888..57ee68c604 100644 --- a/packages/catalyst/src/cli/commands/build.ts +++ b/packages/catalyst/src/cli/commands/build.ts @@ -1,130 +1,180 @@ -import { Command, Option } from 'commander'; +import { Command, InvalidArgumentError, Option } from 'commander'; import { execa } from 'execa'; -import { existsSync } from 'node:fs'; -import { copyFile, cp, writeFile } from 'node:fs/promises'; +import { copyFile, cp, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; +import { valid as validSemver } from 'semver'; +import { loadBuildEnv } from '../lib/build-env'; import { getModuleCliPath } from '../lib/get-module-cli-path'; import { consola } from '../lib/logger'; import { getProjectConfig } from '../lib/project-config'; +import { getProjectState } from '../lib/project-state'; +import { assertRequiredBuildEnv } from '../lib/required-build-env'; +import { envPathOption } from '../lib/shared-options'; import { getWranglerConfig } from '../lib/wrangler-config'; -const WRANGLER_VERSION = '4.24.3'; +export const WRANGLER_VERSION = '4.90.0'; + +// npm dist-tags (e.g. latest, beta) aren't valid semver, so they're allowed +// through a narrow character allowlist. This also guards the value before +// it's interpolated into the `wrangler@` spec passed to `pnpm dlx`, +// rejecting anything that could smuggle extra args or shell metacharacters in. +const DIST_TAG_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/; + +export const parseWranglerVersion = (value: string): string => { + if (!validSemver(value) && !DIST_TAG_PATTERN.test(value)) { + throw new InvalidArgumentError( + `"${value}" is not a valid Wrangler version or dist-tag (e.g. 4.90.0 or latest).`, + ); + } + + return value; +}; + +export async function buildCatalystProject( + projectUuid: string, + wranglerVersion: string = WRANGLER_VERSION, +): Promise { + // Fail fast with an actionable message if the vars the build reads aren't + // loaded — otherwise the missing values surface as a raw stack trace deep in + // the OpenNext/Next.js prerender. + assertRequiredBuildEnv(); + + const coreDir = process.cwd(); + const openNextOutDir = join(coreDir, '.open-next'); + const bigcommerceDistDir = join(coreDir, '.bigcommerce', 'dist'); + + const wranglerConfig = getWranglerConfig(projectUuid); + + // Wrangler's --outdir writes alongside existing files instead of replacing + // the directory. Stale artifacts (e.g. wasm modules named by an older + // Wrangler version) end up in the bundle and break the Cloudflare upload. + await rm(bigcommerceDistDir, { recursive: true, force: true }); + + consola.start('Copying templates...'); + + await copyFile( + join(getModuleCliPath(), 'templates', 'open-next.config.ts'), + join(coreDir, '.bigcommerce', 'open-next.config.ts'), + ); + await writeFile( + join(coreDir, '.bigcommerce', 'wrangler.jsonc'), + JSON.stringify(wranglerConfig, null, 2), + ); + + consola.success('Templates copied'); + + consola.start('Building project...'); + + await execa( + 'pnpm', + [ + 'exec', + 'opennextjs-cloudflare', + 'build', + '--skipWranglerConfigCheck', + '--openNextConfigPath', + join(coreDir, '.bigcommerce', 'open-next.config.ts'), + ], + { + stdout: ['pipe', 'inherit'], + cwd: coreDir, + }, + ); + + await execa( + 'pnpm', + [ + 'dlx', + `wrangler@${wranglerVersion}`, + 'deploy', + '--config', + join(coreDir, '.bigcommerce', 'wrangler.jsonc'), + '--keep-vars', + '--outdir', + bigcommerceDistDir, + '--dry-run', + ], + { + stdout: ['pipe', 'inherit'], + cwd: coreDir, + }, + ); + + consola.success('Project built'); + + await cp(join(openNextOutDir, 'assets'), join(bigcommerceDistDir, 'assets'), { + recursive: true, + force: true, + }); +} export const build = new Command('build') - .allowUnknownOption() - // The unknown options end up in program.args, not in program.opts(). Commander does not take a guess at how to interpret the unknown options. - .argument( - '[next-build-options...]', - 'Next.js `build` options (see: https://nextjs.org/docs/app/api-reference/cli/next#next-build-options)', + .configureHelp({ showGlobalOptions: true }) + .description( + 'Build your Catalyst project using the OpenNext/Cloudflare build pipeline. Also runs a Wrangler dry-run to generate deployment artifacts.', + ) + .addHelpText( + 'after', + ` +Examples: + $ catalyst build + + # Include project UUID + $ catalyst build --project-uuid + + # Build with a specific Wrangler version + $ catalyst build --wrangler-version 4.24.3`, ) .addOption( new Option( '--project-uuid ', 'Project UUID to be included in the deployment configuration.', - ).env('BIGCOMMERCE_PROJECT_UUID'), + ).env('CATALYST_PROJECT_UUID'), ) .addOption( - new Option('--framework ', 'The framework to use for the build.').choices([ - 'nextjs', - 'catalyst', - ]), + new Option( + '--wrangler-version ', + `Wrangler version or dist-tag to build with. Defaults to ${WRANGLER_VERSION}.`, + ).argParser(parseWranglerVersion), ) - .action(async (nextBuildOptions, options) => { - const coreDir = process.cwd(); - - try { - const config = getProjectConfig(); - const framework = options.framework ?? config.get('framework'); - - if (framework === 'nextjs') { - const nextBin = join('node_modules', '.bin', 'next'); - - if (!existsSync(nextBin)) { - throw new Error( - `Next.js is not installed in ${coreDir}. Are you in a valid Next.js project?`, - ); - } - - await execa(nextBin, ['build', ...nextBuildOptions], { - stdio: 'inherit', - cwd: coreDir, - }); - } - - if (framework === 'catalyst') { - const openNextOutDir = join(coreDir, '.open-next'); - const bigcommerceDistDir = join(coreDir, '.bigcommerce', 'dist'); - - const projectUuid = options.projectUuid ?? config.get('projectUuid'); - - if (!projectUuid) { - throw new Error( - 'Project UUID is required. Please run `catalyst project create` or `catalyst project link` or this command again with --project-uuid .', - ); - } - - const wranglerConfig = getWranglerConfig(projectUuid, 'PLACEHOLDER_KV_ID'); - - consola.start('Copying templates...'); - - await copyFile( - join(getModuleCliPath(), 'templates', 'open-next.config.ts'), - join(coreDir, '.bigcommerce', 'open-next.config.ts'), - ); - await writeFile( - join(coreDir, '.bigcommerce', 'wrangler.jsonc'), - JSON.stringify(wranglerConfig, null, 2), - ); - - consola.success('Templates copied'); - - consola.start('Building project...'); - - await execa( - 'pnpm', - [ - 'exec', - 'opennextjs-cloudflare', - 'build', - '--skipWranglerConfigCheck', - '--openNextConfigPath', - join(coreDir, '.bigcommerce', 'open-next.config.ts'), - ], - { - stdout: ['pipe', 'inherit'], - cwd: coreDir, - }, - ); - - await execa( - 'pnpm', - [ - 'dlx', - `wrangler@${WRANGLER_VERSION}`, - 'deploy', - '--config', - join(coreDir, '.bigcommerce', 'wrangler.jsonc'), - '--keep-vars', - '--outdir', - bigcommerceDistDir, - '--dry-run', - ], - { - stdout: ['pipe', 'inherit'], - cwd: coreDir, - }, - ); - - consola.success('Project built'); - - await cp(join(openNextOutDir, 'assets'), join(bigcommerceDistDir, 'assets'), { - recursive: true, - force: true, - }); - } - } catch (error) { - consola.error(error); - process.exit(1); + .addOption(envPathOption()) + .action(async (options) => { + // The build reads storefront env vars (BIGCOMMERCE_*). Load them from the + // env file(s) before building so both the build and any pre-build checks + // see them. + loadBuildEnv({ envPath: options.envPath }); + + // Project must be transformed (middleware swapped in, OpenNext dep installed) + // before the OpenNext build pipeline can run. If it isn't, fall through to + // `next build` so this command works for self-hosted Catalyst projects too. + const state = getProjectState(); + + if (!state.isTransformed) { + consola.info('Project is not set up for Commerce Hosting — running `next build`.'); + consola.info('To deploy to Commerce Hosting, run `catalyst deploy`.'); + + // `next build` reads the same storefront env vars; fail fast with an + // actionable message here too, since this path doesn't go through + // buildCatalystProject where the check normally runs. + assertRequiredBuildEnv(); + + await execa('pnpm', ['exec', 'next', 'build'], { + stdio: 'inherit', + cwd: process.cwd(), + }); + + return; } + + const config = getProjectConfig(); + const projectUuid = options.projectUuid ?? config.get('projectUuid'); + + if (!projectUuid) { + throw new Error( + 'Project UUID is required. Please run `catalyst project create` or `catalyst project link` or this command again with --project-uuid .', + ); + } + + await buildCatalystProject(projectUuid, options.wranglerVersion); }); diff --git a/packages/catalyst/src/cli/commands/channel.spec.ts b/packages/catalyst/src/cli/commands/channel.spec.ts new file mode 100644 index 0000000000..87abde74f2 --- /dev/null +++ b/packages/catalyst/src/cli/commands/channel.spec.ts @@ -0,0 +1,631 @@ +import { checkbox, confirm, input, select } from '@inquirer/prompts'; +import { Command } from 'commander'; +import Conf from 'conf'; +import { http, HttpResponse } from 'msw'; +import { existsSync, readFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { afterAll, afterEach, beforeAll, describe, expect, MockInstance, test, vi } from 'vitest'; + +import { server } from '../../../tests/mocks/node'; +import { consola } from '../lib/logger'; +import { mkTempDir } from '../lib/mk-temp-dir'; +import { getProjectConfig, ProjectConfigSchema } from '../lib/project-config'; +import { program } from '../program'; + +import { channel } from './channel'; + +vi.mock('@inquirer/prompts', () => ({ + select: vi.fn(), + confirm: vi.fn(), + input: vi.fn(), + checkbox: vi.fn(), +})); +// `channel link` can trigger the interactive device-code login (browser + +// spinner); stub both so the no-credentials path runs headless in tests. +vi.mock('open', () => ({ default: vi.fn().mockResolvedValue(undefined) })); +// eslint-disable-next-line import/dynamic-import-chunkname +vi.mock('yocto-spinner', () => import('../../../tests/mocks/spinner')); + +const mockSelect = vi.mocked(select); +const mockConfirm = vi.mocked(confirm); +const mockInput = vi.mocked(input); +const mockCheckbox = vi.mocked(checkbox); + +let exitMock: MockInstance; + +let tmpDir: string; +let cleanup: () => Promise; +let config: Conf; + +const { mockIdentify } = vi.hoisted(() => ({ + mockIdentify: vi.fn(), +})); + +const linkedProjectUuid = 'a23f5785-fd99-4a94-9fb3-945551623923'; +const storeHash = 'test-store'; +const accessToken = 'test-token'; + +beforeAll(async () => { + consola.mockTypes(() => vi.fn()); + + vi.mock('../lib/telemetry', () => { + const instance = { + identify: mockIdentify, + isEnabled: vi.fn(() => true), + track: vi.fn(), + correlationId: 'test-session-uuid', + commandName: 'unknown', + durationMs: vi.fn().mockReturnValue(0), + analytics: { + closeAndFlush: vi.fn().mockResolvedValue(undefined), + }, + }; + + return { + Telemetry: vi.fn().mockImplementation(() => instance), + getTelemetry: vi.fn(() => instance), + resetTelemetry: vi.fn(), + }; + }); + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + exitMock = vi.spyOn(process, 'exit').mockImplementation(() => null as never); + + [tmpDir, cleanup] = await mkTempDir(); + + vi.spyOn(process, 'cwd').mockReturnValue(tmpDir); + + config = getProjectConfig(); +}); + +afterEach(() => { + vi.clearAllMocks(); + config.delete('storeHash'); + config.delete('accessToken'); + config.delete('projectUuid'); +}); + +afterAll(async () => { + vi.restoreAllMocks(); + exitMock.mockRestore(); + + await cleanup(); +}); + +describe('channel', () => { + test('has the update subcommand', () => { + expect(channel).toBeInstanceOf(Command); + expect(channel.name()).toBe('channel'); + + const update = channel.commands.find((cmd) => cmd.name() === 'update'); + + expect(update).toBeDefined(); + expect(update?.description()).toContain('Update a BigCommerce channel'); + }); + + test('has the link subcommand', () => { + const link = channel.commands.find((cmd) => cmd.name() === 'link'); + + expect(link).toBeDefined(); + expect(link?.description()).toContain('Link this Catalyst project to a BigCommerce channel'); + }); + + test('has the create subcommand', () => { + const create = channel.commands.find((cmd) => cmd.name() === 'create'); + + expect(create).toBeDefined(); + expect(create?.description()).toContain('Create a new Catalyst storefront channel'); + }); +}); + +describe('channel update', () => { + test('happy path: prompts for channel and hostname, then PUTs', async () => { + let putBody: unknown; + let putChannelId: string | undefined; + + server.use( + http.put( + 'https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', + async ({ request, params }) => { + putBody = await request.json(); + putChannelId = String(params.channelId); + + return HttpResponse.json({ + data: { + id: 1, + url: 'https://project-one.catalyst-sandbox.store', + channel_id: 2, + }, + }); + }, + ), + ); + + mockSelect.mockResolvedValueOnce(2).mockResolvedValueOnce('project-one.catalyst-sandbox.store'); + + await program.parseAsync([ + 'node', + 'catalyst', + 'channel', + 'update', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--project-uuid', + linkedProjectUuid, + ]); + + expect(mockSelect).toHaveBeenCalledTimes(2); + expect(putChannelId).toBe('2'); + expect(putBody).toEqual({ url: 'https://project-one.catalyst-sandbox.store' }); + expect(consola.success).toHaveBeenCalledWith( + expect.stringContaining('Updated channel "Catalyst Storefront" (2) site URL'), + ); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + test('reads project UUID from .bigcommerce/project.json when no flag is passed', async () => { + config.set('projectUuid', linkedProjectUuid); + + mockSelect.mockResolvedValueOnce(2).mockResolvedValueOnce('vanity.project-one.example.com'); + + await program.parseAsync([ + 'node', + 'catalyst', + 'channel', + 'update', + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]); + + expect(mockSelect).toHaveBeenCalledTimes(2); + expect(consola.success).toHaveBeenCalledWith( + expect.stringContaining('https://vanity.project-one.example.com'), + ); + }); + + test('--channel-id and --hostname skip both prompts', async () => { + let putBody: unknown; + let putChannelId: string | undefined; + + server.use( + http.put( + 'https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', + async ({ request, params }) => { + putBody = await request.json(); + putChannelId = String(params.channelId); + + return HttpResponse.json({ + data: { id: 1, url: 'https://override.example', channel_id: 5 }, + }); + }, + ), + ); + + await program.parseAsync([ + 'node', + 'catalyst', + 'channel', + 'update', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--project-uuid', + linkedProjectUuid, + '--channel-id', + '5', + '--hostname', + 'override.example', + ]); + + expect(mockSelect).not.toHaveBeenCalled(); + expect(putChannelId).toBe('5'); + expect(putBody).toEqual({ url: 'https://override.example' }); + }); + + test('exits gracefully when no projects exist and user declines to create one', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => + HttpResponse.json({ data: [] }), + ), + ); + + // First prompt: "Would you like to create one?" — user says no + mockConfirm.mockResolvedValueOnce(false); + + await program.parseAsync([ + 'node', + 'catalyst', + 'channel', + 'update', + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]); + + expect(consola.info).toHaveBeenCalledWith(expect.stringContaining('catalyst project create')); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + test('propagates errors from the channel-site PUT', async () => { + server.use( + http.put('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () => + HttpResponse.json({}, { status: 401 }), + ), + ); + + mockSelect.mockResolvedValueOnce(2).mockResolvedValueOnce('project-one.catalyst-sandbox.store'); + + await expect( + program.parseAsync([ + 'node', + 'catalyst', + 'channel', + 'update', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--project-uuid', + linkedProjectUuid, + ]), + ).rejects.toThrow('Re-run `catalyst auth login`'); + }); +}); + +describe('channel link', () => { + const initUrl = + 'https://cxm-prd.bigcommerceapp.com/stores/:storeHash/cli-api/v3/channels/:channelId/init'; + + test('links a channel by id and writes .env.local', async () => { + let initChannelId: string | undefined; + + server.use( + http.get(initUrl, ({ params }) => { + initChannelId = String(params.channelId); + + return HttpResponse.json({ + data: { + storefront_api_token: 'sft-token', + envVars: { + BIGCOMMERCE_STORE_HASH: storeHash, + BIGCOMMERCE_CHANNEL_ID: '2', + BIGCOMMERCE_STOREFRONT_TOKEN: 'sft-token', + }, + }, + }); + }), + ); + + await program.parseAsync([ + 'node', + 'catalyst', + 'channel', + 'link', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--channel-id', + '2', + ]); + + expect(initChannelId).toBe('2'); + expect(mockIdentify).toHaveBeenCalledWith(storeHash); + + const envLocal = readFileSync(join(tmpDir, '.env.local'), 'utf8'); + + expect(envLocal).toContain(`BIGCOMMERCE_STORE_HASH=${storeHash}`); + expect(envLocal).toContain('BIGCOMMERCE_STOREFRONT_TOKEN=sft-token'); + expect(consola.success).toHaveBeenCalledWith(expect.stringContaining('Linked to channel 2')); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + test('prompts for a channel when --channel-id is omitted', async () => { + let initChannelId: string | undefined; + + server.use( + http.get(initUrl, ({ params }) => { + initChannelId = String(params.channelId); + + return HttpResponse.json({ + data: { storefront_api_token: 'sft-token', envVars: { BIGCOMMERCE_CHANNEL_ID: '2' } }, + }); + }), + ); + + mockSelect.mockResolvedValueOnce(2); + + await program.parseAsync([ + 'node', + 'catalyst', + 'channel', + 'link', + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]); + + expect(mockSelect).toHaveBeenCalledTimes(1); + expect(initChannelId).toBe('2'); + // id 2 in the default channels handler is "Catalyst Storefront". + expect(consola.success).toHaveBeenCalledWith( + expect.stringContaining('Linked to channel "Catalyst Storefront" (2)'), + ); + }); + + test('merges --env overrides into .env.local', async () => { + server.use( + http.get(initUrl, () => + HttpResponse.json({ + data: { + storefront_api_token: 'sft-token', + envVars: { BIGCOMMERCE_STORE_HASH: storeHash }, + }, + }), + ), + ); + + await program.parseAsync([ + 'node', + 'catalyst', + 'channel', + 'link', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--channel-id', + '2', + '--env', + 'EXTRA_FLAG=on', + '--env', + 'BIGCOMMERCE_STORE_HASH=overridden', + ]); + + const envLocal = readFileSync(join(tmpDir, '.env.local'), 'utf8'); + + expect(envLocal).toContain('EXTRA_FLAG=on'); + expect(envLocal).toContain('BIGCOMMERCE_STORE_HASH=overridden'); + }); + + test('exits when the store has no storefront channels', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/channels', () => + HttpResponse.json({ data: [] }), + ), + ); + + await program.parseAsync([ + 'node', + 'catalyst', + 'channel', + 'link', + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]); + + expect(consola.info).toHaveBeenCalledWith( + expect.stringContaining('No storefront channels found'), + ); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + test('logs in and persists credentials when none are available', async () => { + server.use( + http.get(initUrl, () => + HttpResponse.json({ + data: { storefront_api_token: 'sft-token', envVars: { BIGCOMMERCE_CHANNEL_ID: '2' } }, + }), + ), + ); + + await program.parseAsync(['node', 'catalyst', 'channel', 'link', '--channel-id', '2']); + + expect(config.get('storeHash')).toBe('mock-store-hash'); + expect(config.get('accessToken')).toBe('mock-access-token'); + expect(mockIdentify).toHaveBeenCalledWith('mock-store-hash'); + }); +}); + +describe('channel create', () => { + const eligibilityUrl = + 'https://cxm-prd.bigcommerceapp.com/stores/:storeHash/cli-api/v3/channels/catalyst/eligibility'; + const createUrl = + 'https://cxm-prd.bigcommerceapp.com/stores/:storeHash/cli-api/v3/channels/catalyst'; + + test('creates a channel non-interactively and links it with --link', async () => { + let createBody: unknown; + + server.use( + http.post(createUrl, async ({ request }) => { + createBody = await request.json(); + + return HttpResponse.json({ + data: { + id: 42, + storefront_api_token: 'new-sft-token', + envVars: { + BIGCOMMERCE_STORE_HASH: storeHash, + BIGCOMMERCE_CHANNEL_ID: '42', + BIGCOMMERCE_STOREFRONT_TOKEN: 'new-sft-token', + }, + }, + }); + }), + ); + + await program.parseAsync([ + 'node', + 'catalyst', + 'channel', + 'create', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--name', + 'My Store', + '--locale', + 'en', + '--additional-locales', + 'es', + 'fr', + '--no-sample-data', + '--link', + ]); + + // No prompts when every input is flag-provided (including --link). + expect(mockInput).not.toHaveBeenCalled(); + expect(mockSelect).not.toHaveBeenCalled(); + expect(mockCheckbox).not.toHaveBeenCalled(); + + expect(createBody).toEqual({ + name: 'My Store', + initialData: { type: 'none' }, + deployStorefront: true, + devOrigin: 'http://localhost:3000', + storefrontLanguage: 'en', + additionalLocales: ['es', 'fr'], + }); + + expect(mockIdentify).toHaveBeenCalledWith(storeHash); + expect(consola.success).toHaveBeenCalledWith(expect.stringContaining('Created channel 42')); + + const envLocal = readFileSync(join(tmpDir, '.env.local'), 'utf8'); + + expect(envLocal).toContain('BIGCOMMERCE_CHANNEL_ID=42'); + expect(envLocal).toContain('BIGCOMMERCE_STOREFRONT_TOKEN=new-sft-token'); + expect(consola.success).toHaveBeenCalledWith(expect.stringContaining('Linked to channel 42')); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + test('aborts cleanly when the store is not eligible', async () => { + let createCalled = false; + + server.use( + http.get(eligibilityUrl, () => + HttpResponse.json({ + data: { eligible: false, message: 'Your plan does not support new channels.' }, + }), + ), + http.post(createUrl, () => { + createCalled = true; + + return HttpResponse.json({ data: {} }); + }), + ); + + await program.parseAsync([ + 'node', + 'catalyst', + 'channel', + 'create', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--name', + 'My Store', + '--locale', + 'en', + '--additional-locales', + 'es', + ]); + + expect(consola.warn).toHaveBeenCalledWith('Your plan does not support new channels.'); + expect(createCalled).toBe(false); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + test('prompts before linking and skips the .env.local write when declined', async () => { + const envPath = join(tmpDir, '.env.local'); + + rmSync(envPath, { force: true }); + + // Only the post-create link prompt should fire (create inputs are all flags). + mockSelect.mockResolvedValueOnce(false); + + await program.parseAsync([ + 'node', + 'catalyst', + 'channel', + 'create', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--name', + 'My Store', + '--locale', + 'en', + '--additional-locales', + 'es', + '--no-sample-data', + ]); + + expect(mockSelect).toHaveBeenCalledTimes(1); + expect(consola.success).toHaveBeenCalledWith(expect.stringContaining('Created channel 42')); + expect(consola.success).not.toHaveBeenCalledWith(expect.stringContaining('Linked to channel')); + expect(existsSync(envPath)).toBe(false); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + test('interactive path prompts for name, locale, languages, and sample data', async () => { + let createBody: unknown; + + server.use( + http.post(createUrl, async ({ request }) => { + createBody = await request.json(); + + return HttpResponse.json({ + data: { + id: 42, + storefront_api_token: 'new-sft-token', + envVars: { BIGCOMMERCE_CHANNEL_ID: '42' }, + }, + }); + }), + ); + + mockInput.mockResolvedValueOnce('Interactive Channel'); + // select order: default locale, "add languages?", "sample data?", link prompt + mockSelect + .mockResolvedValueOnce('en') + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false); + mockCheckbox.mockResolvedValueOnce(['es']); + + await program.parseAsync([ + 'node', + 'catalyst', + 'channel', + 'create', + '--store-hash', + storeHash, + '--access-token', + accessToken, + ]); + + expect(mockInput).toHaveBeenCalledTimes(1); + expect(mockCheckbox).toHaveBeenCalledTimes(1); + expect(createBody).toEqual({ + name: 'Interactive Channel', + initialData: { type: 'none' }, + deployStorefront: true, + devOrigin: 'http://localhost:3000', + storefrontLanguage: 'en', + additionalLocales: ['es'], + }); + expect(consola.success).toHaveBeenCalledWith(expect.stringContaining('Created channel 42')); + }); +}); diff --git a/packages/catalyst/src/cli/commands/channel.ts b/packages/catalyst/src/cli/commands/channel.ts new file mode 100644 index 0000000000..4f9e50a48a --- /dev/null +++ b/packages/catalyst/src/cli/commands/channel.ts @@ -0,0 +1,369 @@ +import { select } from '@inquirer/prompts'; +import { Command, InvalidArgumentError, Option } from 'commander'; +import type Conf from 'conf'; +import { colorize } from 'consola/utils'; + +import { runChannelSiteUrlFlow } from '../lib/channel-site-flow'; +import { + channelPlatformLabel, + checkChannelEligibility, + fetchAvailableChannels, + getChannelInit, + sortChannelsByPlatform, +} from '../lib/channels'; +import { NoLinkedProjectError } from '../lib/commerce-hosting'; +import { runCreateChannelFlow } from '../lib/create-channel-flow'; +import { parseEnvAssignment } from '../lib/env-config'; +import { consola } from '../lib/logger'; +import { LoginAbortedError, login as runInteractiveLogin } from '../lib/login'; +import { getProjectConfig, type ProjectConfigSchema } from '../lib/project-config'; +import { resolveCredentials } from '../lib/resolve-credentials'; +import { + accessTokenOption, + apiHostOption, + loginUrlOption, + projectUuidOption, + resolveApiHost, + storeHashOption, +} from '../lib/shared-options'; +import { getTelemetry } from '../lib/telemetry'; +import { writeEnv } from '../lib/write-env'; + +const parseChannelId = (value: string): number => { + const parsed = Number.parseInt(value, 10); + + if (Number.isNaN(parsed)) { + throw new InvalidArgumentError(`"${value}" is not a valid channel ID (expected a number).`); + } + + return parsed; +}; + +// Resolve credentials from flags/env → persisted project config → interactive +// login (persisting on success). Returns null when the user aborts login. +// `channel link` is an onboarding command — a fresh clone has neither +// .env.local nor .bigcommerce/project.json — so it logs the user in like +// `catalyst project create`, rather than erroring like the operational commands. +async function resolveCredentialsWithLogin( + options: { storeHash?: string; accessToken?: string; loginUrl: string }, + config: Conf, + apiHost: string, +): Promise<{ storeHash: string; accessToken: string } | null> { + const storeHash = options.storeHash ?? config.get('storeHash'); + const accessToken = options.accessToken ?? config.get('accessToken'); + + if (storeHash && accessToken) { + return { storeHash, accessToken }; + } + + try { + const credentials = await runInteractiveLogin(options.loginUrl, apiHost); + + config.set('storeHash', credentials.storeHash); + config.set('accessToken', credentials.accessToken); + + return credentials; + } catch (error) { + if (error instanceof LoginAbortedError) { + return null; + } + + throw error; + } +} + +const update = new Command('update') + .configureHelp({ showGlobalOptions: true }) + .description( + "Update a BigCommerce channel's site URL to point at one of your project's deployment hostnames.", + ) + .addHelpText( + 'after', + ` +Examples: + # Pick a channel and hostname interactively + $ catalyst channel update + + # Skip both prompts + $ catalyst channel update --channel-id 123 --hostname my-storefront.example.com`, + ) + .addOption(storeHashOption()) + .addOption(accessTokenOption()) + .addOption(apiHostOption()) + .addOption(projectUuidOption()) + .addOption( + new Option( + '--channel-id ', + 'Skip the channel prompt and target this channel directly.', + ).argParser((value: string) => Number(value)), + ) + .addOption( + new Option( + '--hostname ', + "Skip the hostname prompt and use this hostname directly. Must be one of the project's deployment_hostnames.", + ), + ) + .action(async (options) => { + const config = getProjectConfig(); + const apiHost = resolveApiHost(options, config); + const { storeHash, accessToken } = resolveCredentials(options, config); + + await getTelemetry().identify(storeHash); + + try { + await runChannelSiteUrlFlow({ + storeHash, + accessToken, + apiHost, + projectUuid: options.projectUuid ?? config.get('projectUuid'), + channelId: options.channelId, + hostname: options.hostname, + }); + } catch (error) { + if (error instanceof NoLinkedProjectError) { + consola.info( + "When you're ready to create a project, run `catalyst project create` or re-run `catalyst channel update`.", + ); + process.exit(0); + + // Unreachable in production; prevents continuation when process.exit is mocked in tests. + return; + } + + throw error; + } + + process.exit(0); + }); + +const link = new Command('link') + .configureHelp({ showGlobalOptions: true }) + .description( + 'Link this Catalyst project to a BigCommerce channel and write its credentials to .env.local.', + ) + .addHelpText( + 'after', + ` +Examples: + # Pick a channel interactively (logs you in if needed) + $ catalyst channel link + + # Non-interactive + $ catalyst channel link --store-hash --access-token --channel-id 123 + + # Append extra environment variables to .env.local + $ catalyst channel link --channel-id 123 --env MY_FLAG=1`, + ) + .addOption(storeHashOption()) + .addOption(accessTokenOption()) + .addOption(apiHostOption()) + .addOption(loginUrlOption()) + .addOption( + new Option('--channel-id ', 'Link this channel directly, skipping the picker.').argParser( + parseChannelId, + ), + ) + .option( + '--env ', + 'Arbitrary environment variables to set in .env.local. Format: KEY=VALUE (repeatable).', + ) + .addOption( + new Option('--cli-api-origin ', 'Catalyst CLI API origin') + .default('https://cxm-prd.bigcommerceapp.com') + .hideHelp(), + ) + .action(async (options) => { + const config = getProjectConfig(); + const apiHost = resolveApiHost(options, config); + + const credentials = await resolveCredentialsWithLogin(options, config, apiHost); + + if (!credentials) { + consola.info( + 'Login aborted. Re-run `catalyst channel link` when you have your credentials ready.', + ); + process.exit(0); + + return; + } + + const { storeHash, accessToken } = credentials; + + await getTelemetry().identify(storeHash); + + let channelId = options.channelId; + let channelName: string | undefined; + + if (channelId === undefined) { + const channels = await fetchAvailableChannels(storeHash, accessToken, apiHost); + + if (channels.length === 0) { + consola.info( + 'No storefront channels found on this store. Create one with `catalyst create` and try again.', + ); + process.exit(0); + + return; + } + + channelId = await select({ + message: 'Which channel would you like to link?', + choices: sortChannelsByPlatform(channels).map((c) => ({ + name: c.name, + value: c.id, + description: channelPlatformLabel(c.platform), + })), + }); + + channelName = channels.find((c) => c.id === channelId)?.name; + } + + const initData = await getChannelInit(channelId, storeHash, accessToken, options.cliApiOrigin); + + const envVars: Record = { ...initData.envVars }; + + // Inline `--env KEY=VALUE` overrides win over the channel-provided values. + if (options.env) { + options.env.forEach((entry) => { + const { key, value } = parseEnvAssignment(entry); + + envVars[key] = value; + }); + } + + // Writes .env.local in the current working directory — `channel link` + // runs from inside `core/`, the same place `dev`/`build`/`deploy` run. + writeEnv(process.cwd(), envVars); + + const label = channelName ? `"${channelName}" (${channelId})` : `${channelId}`; + + consola.success( + `Linked to channel ${label} and wrote ${colorize('cyanBright', '.env.local')}.`, + ); + consola.log(`Next steps:\n\n ${colorize('yellow', 'pnpm run dev')}`); + + process.exit(0); + }); + +const create = new Command('create') + .configureHelp({ showGlobalOptions: true }) + .description('Create a new Catalyst storefront channel on your BigCommerce store.') + .addHelpText( + 'after', + ` +Examples: + # Create a channel interactively (logs you in if needed) + $ catalyst channel create + + # Non-interactive, and link it to this project afterwards + $ catalyst channel create --name "My Store" --locale en --no-sample-data --link + + # Add additional storefront languages (max 4) + $ catalyst channel create --name "My Store" --additional-locales es fr`, + ) + .addOption(storeHashOption()) + .addOption(accessTokenOption()) + .addOption(apiHostOption()) + .addOption(loginUrlOption()) + .option('--name ', 'Name for the new channel. Skips the name prompt.') + .option( + '--locale ', + 'Default storefront locale (e.g. "en"). Skips the default-language prompt.', + ) + .option( + '--additional-locales ', + 'Additional storefront locales, max 4 (e.g. --additional-locales es fr). Skips the additional-languages prompt.', + ) + .addOption(new Option('--sample-data', 'Install sample data on the new channel.')) + .addOption(new Option('--no-sample-data', 'Create the channel without sample data.')) + .option( + '--link', + 'Link the new channel to this project — write its credentials to .env.local without prompting.', + ) + .addOption( + new Option('--cli-api-origin ', 'Catalyst CLI API origin') + .default('https://cxm-prd.bigcommerceapp.com') + .hideHelp(), + ) + .action(async (options) => { + if (options.additionalLocales && options.additionalLocales.length > 4) { + consola.error('You can only set up to 4 additional locales.'); + process.exit(1); + + return; + } + + const config = getProjectConfig(); + const apiHost = resolveApiHost(options, config); + + const credentials = await resolveCredentialsWithLogin(options, config, apiHost); + + if (!credentials) { + consola.info( + 'Login aborted. Re-run `catalyst channel create` when you have your credentials ready.', + ); + process.exit(0); + + return; + } + + const { storeHash, accessToken } = credentials; + + await getTelemetry().identify(storeHash); + + const eligibility = await checkChannelEligibility(storeHash, accessToken, options.cliApiOrigin); + + if (!eligibility.eligible) { + consola.warn(eligibility.message); + process.exit(0); + + return; + } + + const channelData = await runCreateChannelFlow({ + storeHash, + accessToken, + apiHost, + cliApiOrigin: options.cliApiOrigin, + name: options.name, + locale: options.locale, + additionalLocales: options.additionalLocales, + sampleData: options.sampleData, + }); + + consola.success(`Created channel ${channelData.channelId}.`); + consola.warn( + 'A preview storefront has been deployed in your BigCommerce control panel. This preview may look different from your local environment as it may be running different code. Additionally, it may take a few minutes for the channel storefront to be accessible.', + ); + + // `--link` opts in non-interactively; otherwise ask before touching .env.local. + const shouldLink = options.link + ? true + : await select({ + message: 'Would you like to link this channel to your project?', + choices: [ + { name: 'Yes', value: true }, + { name: 'No', value: false }, + ], + }); + + if (shouldLink) { + // Same write path as `channel link`: write the channel's credentials to + // .env.local in the current working directory (where `dev`/`build`/`deploy` run). + writeEnv(process.cwd(), channelData.envVars); + + consola.success( + `Linked to channel ${channelData.channelId} and wrote ${colorize('cyanBright', '.env.local')}.`, + ); + consola.log(`Next steps:\n\n ${colorize('yellow', 'pnpm run dev')}`); + } + + process.exit(0); + }); + +export const channel = new Command('channel') + .configureHelp({ showGlobalOptions: true }) + .description('Manage BigCommerce channels.') + .addCommand(create) + .addCommand(link) + .addCommand(update); diff --git a/packages/catalyst/src/cli/commands/create.spec.ts b/packages/catalyst/src/cli/commands/create.spec.ts new file mode 100644 index 0000000000..596d0df93e --- /dev/null +++ b/packages/catalyst/src/cli/commands/create.spec.ts @@ -0,0 +1,531 @@ +import { Command } from 'commander'; +import { mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { afterAll, afterEach, beforeAll, describe, expect, MockInstance, test, vi } from 'vitest'; + +import { promptForCommerceHostingProject, setupCommerceHosting } from '../lib/commerce-hosting'; +import { detectPackageManager } from '../lib/detect-package-manager'; +import { extractCatalyst } from '../lib/extract-catalyst'; +import { initGitRepo } from '../lib/init-git-repo'; +import { installDependencies } from '../lib/install-dependencies'; +import { consola } from '../lib/logger'; +import { login, LoginAbortedError } from '../lib/login'; +import { mkTempDir } from '../lib/mk-temp-dir'; +import { hasProjectsAccess } from '../lib/project'; +import { rewriteCorePackage } from '../lib/rewrite-core-package'; +import { setupCoreProject } from '../lib/setup-core-project'; +import { writeEnv } from '../lib/write-env'; +import { program } from '../program'; + +import { create } from './create'; + +// Mock all side-effecting modules so the action runs end-to-end without +// actually cloning, installing, writing files, or hitting the network. +vi.mock('child_process', () => ({ execSync: vi.fn() })); + +vi.mock('@inquirer/prompts', () => ({ + checkbox: vi.fn(), + input: vi.fn(), + select: vi.fn(), +})); + +const { MockLoginAbortedError } = vi.hoisted(() => ({ + MockLoginAbortedError: class extends Error { + constructor() { + super('Login aborted by user.'); + this.name = 'LoginAbortedError'; + } + }, +})); + +vi.mock('../lib/login', () => ({ + login: vi.fn().mockResolvedValue({ + storeHash: 'login-store-hash', + accessToken: 'login-access-token', + }), + LoginAbortedError: MockLoginAbortedError, +})); + +vi.mock('../lib/extract-catalyst', () => ({ + extractCatalyst: vi.fn().mockResolvedValue(undefined), +})); +vi.mock('../lib/rewrite-core-package', () => ({ + rewriteCorePackage: vi.fn().mockResolvedValue(undefined), +})); +vi.mock('../lib/detect-package-manager', () => ({ detectPackageManager: vi.fn(() => 'pnpm') })); +vi.mock('../lib/init-git-repo', () => ({ initGitRepo: vi.fn() })); +vi.mock('../lib/setup-core-project', () => ({ setupCoreProject: vi.fn() })); +vi.mock('../lib/install-dependencies', () => ({ + installDependencies: vi.fn().mockResolvedValue(undefined), +})); +vi.mock('../lib/write-env', () => ({ writeEnv: vi.fn() })); + +vi.mock('../lib/commerce-hosting', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + setupCommerceHosting: vi.fn(), + promptForCommerceHostingProject: vi.fn().mockResolvedValue({ + uuid: 'commerce-project-uuid', + name: 'commerce-project', + }), + }; +}); + +vi.mock('../lib/project', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + hasProjectsAccess: vi.fn().mockResolvedValue(true), + }; +}); + +vi.mock('../lib/localization', () => ({ + getAvailableLocales: vi.fn().mockResolvedValue([ + { name: 'English', value: 'en' }, + { name: 'Spanish', value: 'es' }, + ]), +})); + +const { mockIdentify } = vi.hoisted(() => ({ mockIdentify: vi.fn() })); + +vi.mock('../lib/telemetry', () => { + const instance = { + identify: mockIdentify, + isEnabled: vi.fn(() => true), + track: vi.fn(), + correlationId: 'test-session-uuid', + commandName: 'create', + durationMs: vi.fn().mockReturnValue(0), + analytics: { closeAndFlush: vi.fn().mockResolvedValue(undefined) }, + }; + + return { + Telemetry: vi.fn().mockImplementation(() => instance), + getTelemetry: vi.fn(() => instance), + resetTelemetry: vi.fn(), + }; +}); + +let exitMock: MockInstance; +let tmpDir: string; +let cleanup: () => Promise; +let testCounter = 0; + +const storeHash = 'flag-store-hash'; +const accessToken = 'flag-access-token'; + +// Each test gets a unique --project-name so the computed projectDir +// (`${tmpDir}/${name}`) doesn't collide with prior tests' directories +// when extractCatalyst's mock creates them. +const uniqueProjectName = () => `test-project-${(testCounter += 1)}`; + +beforeAll(async () => { + consola.mockTypes(() => vi.fn()); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + exitMock = vi.spyOn(process, 'exit').mockImplementation(() => null as never); + + [tmpDir, cleanup] = await mkTempDir(); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +afterAll(async () => { + vi.restoreAllMocks(); + exitMock.mockRestore(); + + await cleanup(); +}); + +test('properly configured Command instance', () => { + expect(create).toBeInstanceOf(Command); + expect(create.name()).toBe('create'); + expect(create.description()).toBe( + 'Scaffold and connect a Catalyst storefront to your BigCommerce store.', + ); +}); + +describe('happy paths', () => { + test('scaffolds with full creds + flag-provided channel info (no commerce hosting)', async () => { + const projectName = uniqueProjectName(); + + await program.parseAsync([ + 'node', + 'catalyst', + 'create', + '--project-name', + projectName, + '--project-dir', + tmpDir, + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--channel-id', + '42', + '--storefront-token', + 'flag-storefront-token', + ]); + + expect(login).not.toHaveBeenCalled(); + expect(mockIdentify).toHaveBeenCalledWith(storeHash); + expect(extractCatalyst).toHaveBeenCalled(); + expect(rewriteCorePackage).toHaveBeenCalled(); + expect(detectPackageManager).toHaveBeenCalled(); + expect(setupCoreProject).toHaveBeenCalled(); + expect(setupCommerceHosting).not.toHaveBeenCalled(); + expect(installDependencies).toHaveBeenCalled(); + expect(initGitRepo).toHaveBeenCalled(); + expect(writeEnv).toHaveBeenCalledWith( + join(tmpDir, projectName), + expect.objectContaining({ + BIGCOMMERCE_STORE_HASH: storeHash, + BIGCOMMERCE_CHANNEL_ID: '42', + BIGCOMMERCE_STOREFRONT_TOKEN: 'flag-storefront-token', + CATALYST_ACCESS_TOKEN: accessToken, + }), + ); + }); + + test('--hosting commerce sets up commerce hosting and writes BIGCOMMERCE_ACCESS_TOKEN', async () => { + const projectName = uniqueProjectName(); + + await program.parseAsync([ + 'node', + 'catalyst', + 'create', + '--project-name', + projectName, + '--project-dir', + tmpDir, + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--channel-id', + '42', + '--storefront-token', + 'flag-storefront-token', + '--hosting', + 'commerce', + ]); + + expect(hasProjectsAccess).toHaveBeenCalledWith(storeHash, accessToken, 'api.bigcommerce.com'); + expect(promptForCommerceHostingProject).toHaveBeenCalled(); + expect(setupCommerceHosting).toHaveBeenCalledWith({ + projectDir: join(tmpDir, projectName), + projectUuid: 'commerce-project-uuid', + storeHash, + accessToken, + }); + expect(writeEnv).toHaveBeenCalledWith( + join(tmpDir, projectName), + expect.objectContaining({ BIGCOMMERCE_ACCESS_TOKEN: accessToken }), + ); + }); + + test('login is invoked when creds are missing — channel info alone is insufficient', async () => { + // Regression test for edge case #1: previously, providing channel info via + // flags caused the login gate to be skipped, leaving BIGCOMMERCE_STORE_HASH + // unset and the storefront unable to start. + const projectName = uniqueProjectName(); + + await program.parseAsync([ + 'node', + 'catalyst', + 'create', + '--project-name', + projectName, + '--project-dir', + tmpDir, + '--channel-id', + '42', + '--storefront-token', + 'flag-storefront-token', + ]); + + expect(login).toHaveBeenCalled(); + expect(writeEnv).toHaveBeenCalledWith( + join(tmpDir, projectName), + expect.objectContaining({ + BIGCOMMERCE_STORE_HASH: 'login-store-hash', + BIGCOMMERCE_CHANNEL_ID: '42', + BIGCOMMERCE_STOREFRONT_TOKEN: 'flag-storefront-token', + }), + ); + }); + + test('warns when --use-existing is passed without --hosting commerce', async () => { + await program.parseAsync([ + 'node', + 'catalyst', + 'create', + '--project-name', + uniqueProjectName(), + '--project-dir', + tmpDir, + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--channel-id', + '42', + '--storefront-token', + 'flag-storefront-token', + '--use-existing', + ]); + + expect(consola.warn).toHaveBeenCalledWith( + '--use-existing has no effect without --hosting commerce. Ignoring.', + ); + }); +}); + +describe('parser validation', () => { + test('--channel-id with non-numeric value throws InvalidArgumentError', async () => { + await expect( + program.parseAsync([ + 'node', + 'catalyst', + 'create', + '--project-name', + uniqueProjectName(), + '--project-dir', + tmpDir, + '--channel-id', + 'abc', + ]), + ).rejects.toThrow(/not a valid channel ID/); + }); + + test('--env without = throws InvalidArgumentError', async () => { + await expect( + program.parseAsync([ + 'node', + 'catalyst', + 'create', + '--project-name', + uniqueProjectName(), + '--project-dir', + tmpDir, + '--env', + 'BAD_VALUE', + ]), + ).rejects.toThrow(/Expected KEY=VALUE/); + }); + + test('--env with KEY=VAL=UE preserves the full value past the first =', async () => { + const projectName = uniqueProjectName(); + + await program.parseAsync([ + 'node', + 'catalyst', + 'create', + '--project-name', + projectName, + '--project-dir', + tmpDir, + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--channel-id', + '42', + '--storefront-token', + 'flag-storefront-token', + '--env', + 'CONNECTION_STRING=postgres://user:pass@host/db?ssl=true', + ]); + + expect(writeEnv).toHaveBeenCalledWith( + join(tmpDir, projectName), + expect.objectContaining({ + CONNECTION_STRING: 'postgres://user:pass@host/db?ssl=true', + }), + ); + }); +}); + +describe('ordering invariants', () => { + test('writeEnv runs before install, and git init runs after install', async () => { + // Env vars must be written before install (postinstall scripts may read + // them), and the git repo is initialized only after the project is complete. + await program.parseAsync([ + 'node', + 'catalyst', + 'create', + '--project-name', + uniqueProjectName(), + '--project-dir', + tmpDir, + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--channel-id', + '42', + '--storefront-token', + 'flag-storefront-token', + ]); + + const [writeEnvOrder] = vi.mocked(writeEnv).mock.invocationCallOrder; + const [installOrder] = vi.mocked(installDependencies).mock.invocationCallOrder; + const [gitInitOrder] = vi.mocked(initGitRepo).mock.invocationCallOrder; + + expect(writeEnvOrder).toBeLessThan(installOrder); + expect(installOrder).toBeLessThan(gitInitOrder); + }); +}); + +describe('failure handling', () => { + test('mid-flow failure surfaces cleanup warning when projectDir exists', async () => { + // Regression test for edge case #5. extractCatalyst's mock creates the dir + // so the cleanup-warning's pathExistsSync check passes; installDependencies + // then throws to simulate a mid-flow failure. + const projectName = uniqueProjectName(); + const projectDir = join(tmpDir, projectName); + + vi.mocked(extractCatalyst).mockImplementationOnce(() => { + mkdirSync(projectDir, { recursive: true }); + + return Promise.resolve(); + }); + vi.mocked(installDependencies).mockRejectedValueOnce(new Error('install failed')); + + await expect( + program.parseAsync([ + 'node', + 'catalyst', + 'create', + '--project-name', + projectName, + '--project-dir', + tmpDir, + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--channel-id', + '42', + '--storefront-token', + 'flag-storefront-token', + ]), + ).rejects.toThrow('install failed'); + + expect(consola.warn).toHaveBeenCalledWith( + expect.stringContaining(`'${projectDir}' may be in a partial state`), + ); + }); + + test('mid-flow failure does not log cleanup warning if projectDir does not exist', async () => { + // extractCatalyst is mocked to reject WITHOUT creating the dir, so + // pathExistsSync returns false and the cleanup warning is suppressed. + vi.mocked(extractCatalyst).mockRejectedValueOnce( + new Error('extract failed before creating directory'), + ); + + await expect( + program.parseAsync([ + 'node', + 'catalyst', + 'create', + '--project-name', + uniqueProjectName(), + '--project-dir', + tmpDir, + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--channel-id', + '42', + '--storefront-token', + 'flag-storefront-token', + ]), + ).rejects.toThrow('extract failed before creating directory'); + + expect(consola.warn).not.toHaveBeenCalledWith(expect.stringContaining('partial state')); + }); + + test('exits cleanly when the user aborts the interactive login', async () => { + vi.mocked(login).mockRejectedValueOnce(new LoginAbortedError()); + + await program.parseAsync([ + 'node', + 'catalyst', + 'create', + '--project-name', + uniqueProjectName(), + '--project-dir', + tmpDir, + ]); + + expect(consola.info).toHaveBeenCalledWith( + 'Login aborted. Re-run `catalyst create` when you have your credentials ready.', + ); + expect(exitMock).toHaveBeenCalledWith(0); + expect(extractCatalyst).not.toHaveBeenCalled(); + }); + + test('propagates non-LoginAbortedError login failures', async () => { + vi.mocked(login).mockRejectedValueOnce(new Error('network down')); + + await expect( + program.parseAsync([ + 'node', + 'catalyst', + 'create', + '--project-name', + uniqueProjectName(), + '--project-dir', + tmpDir, + ]), + ).rejects.toThrow('network down'); + + expect(extractCatalyst).not.toHaveBeenCalled(); + }); +}); + +describe('--hosting commerce preconditions', () => { + test('exits with error when hasProjectsAccess returns false', async () => { + vi.mocked(hasProjectsAccess).mockResolvedValueOnce(false); + + // The promptForCommerceHostingProject mock would normally return a project, + // but after process.exit (mocked) the action falls through. Make it throw + // so we can verify the precondition fired before reaching the prompt. + vi.mocked(promptForCommerceHostingProject).mockRejectedValueOnce( + new Error('should not have prompted after access denied'), + ); + + await expect( + program.parseAsync([ + 'node', + 'catalyst', + 'create', + '--project-name', + uniqueProjectName(), + '--project-dir', + tmpDir, + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--channel-id', + '42', + '--storefront-token', + 'flag-storefront-token', + '--hosting', + 'commerce', + ]), + ).rejects.toThrow(/should not have prompted/); + + expect(consola.error).toHaveBeenCalledWith( + expect.stringContaining('does not have access to the Infrastructure Projects API'), + ); + expect(exitMock).toHaveBeenCalledWith(1); + }); +}); diff --git a/packages/catalyst/src/cli/commands/create.ts b/packages/catalyst/src/cli/commands/create.ts new file mode 100644 index 0000000000..6e335f0397 --- /dev/null +++ b/packages/catalyst/src/cli/commands/create.ts @@ -0,0 +1,418 @@ +import { Command, InvalidArgumentError, Option } from '@commander-js/extra-typings'; +import { input, select } from '@inquirer/prompts'; +import { execSync } from 'child_process'; +import { colorize } from 'consola/utils'; +import { pathExistsSync } from 'fs-extra/esm'; +import kebabCase from 'lodash.kebabcase'; +import { join } from 'path'; + +import { DEFAULT_LOGIN_URL } from '../lib/auth'; +import { + channelPlatformLabel, + checkChannelEligibility, + fetchAvailableChannels, + getChannelInit, + sortChannelsByPlatform, +} from '../lib/channels'; +import { promptForCommerceHostingProject, setupCommerceHosting } from '../lib/commerce-hosting'; +import { runCreateChannelFlow } from '../lib/create-channel-flow'; +import { detectPackageManager } from '../lib/detect-package-manager'; +import { extractCatalyst } from '../lib/extract-catalyst'; +import { initGitRepo } from '../lib/init-git-repo'; +import { installDependencies } from '../lib/install-dependencies'; +import { consola } from '../lib/logger'; +import { login, LoginAbortedError } from '../lib/login'; +import { hasProjectsAccess, type ProjectListItem } from '../lib/project'; +import { rewriteCorePackage } from '../lib/rewrite-core-package'; +import { setupCoreProject } from '../lib/setup-core-project'; +import { accessTokenOption, storeHashOption } from '../lib/shared-options'; +import { getTelemetry } from '../lib/telemetry'; +import { writeEnv } from '../lib/write-env'; + +function getPlatformCheckCommand(command: string): string { + const isWindows = process.platform === 'win32'; + + return isWindows ? `where.exe ${command}` : `which ${command}`; +} + +function parseChannelId(value: string): number { + const parsed = parseInt(value, 10); + + if (Number.isNaN(parsed)) { + throw new InvalidArgumentError(`"${value}" is not a valid channel ID (expected a number).`); + } + + return parsed; +} + +// Variadic argParser: called once per `--env KEY=VALUE`, accumulating into a +// merged record. Splits on the first `=` so values containing `=` are preserved. +function parseEnvFlag( + value: string, + previous: Record = {}, +): Record { + const eqIdx = value.indexOf('='); + + if (eqIdx <= 0) { + throw new InvalidArgumentError(`Expected KEY=VALUE, got "${value}".`); + } + + const key = value.substring(0, eqIdx); + const val = value.substring(eqIdx + 1); + + if (!val) { + throw new InvalidArgumentError(`Expected KEY=VALUE with non-empty value, got "${value}".`); + } + + return { ...previous, [key]: val }; +} + +async function handleChannelSelection(storeHash: string, accessToken: string, apiHost: string) { + const channels = await fetchAvailableChannels(storeHash, accessToken, apiHost); + + const existingChannel = await select({ + message: 'Which channel would you like to use?', + choices: sortChannelsByPlatform(channels).map((ch) => ({ + name: ch.name, + value: ch, + description: `Channel Platform: ${channelPlatformLabel(ch.platform)}`, + })), + }); + + return existingChannel.id; +} + +async function setupProject(options: { + projectName?: string; + projectDir: string; +}): Promise<{ projectName: string; projectDir: string }> { + let { projectName, projectDir } = options; + + if (!pathExistsSync(projectDir)) { + consola.error(`--project-dir ${projectDir} is not a valid path`); + process.exit(1); + } + + if (projectName) { + projectName = kebabCase(projectName); + projectDir = join(options.projectDir, projectName); + + if (pathExistsSync(projectDir)) { + consola.error(`${projectDir} already exists`); + process.exit(1); + } + } + + if (!projectName) { + const validateProjectName = (i: string) => { + const formatted = kebabCase(i); + + if (!formatted) return 'Project name is required'; + + const targetDir = join(options.projectDir, formatted); + + if (pathExistsSync(targetDir)) return `Destination '${targetDir}' already exists`; + + projectName = formatted; + projectDir = targetDir; + + return true; + }; + + await input({ + message: 'What do you want to name your project directory?', + default: 'my-catalyst-app', + validate: validateProjectName, + }); + } + + if (!projectName) throw new Error('Something went wrong, projectName is not defined'); + if (!projectDir) throw new Error('Something went wrong, projectDir is not defined'); + + return { projectName, projectDir }; +} + +function checkRequiredTools() { + try { + execSync(getPlatformCheckCommand('git'), { stdio: 'ignore' }); + } catch { + consola.error('git is required to create a Catalyst project'); + process.exit(1); + } +} + +export const create = new Command('create') + .configureHelp({ showGlobalOptions: true }) + .description('Scaffold and connect a Catalyst storefront to your BigCommerce store.') + .addHelpText( + 'after', + ` +Examples: + # Interactive scaffold (default — self-hosted, no hosting prompt) + $ catalyst create + + # Non-interactive: skip the project-name prompt + $ catalyst create --project-name my-store + + # Eagerly set up Commerce Hosting at create time + $ catalyst create --project-name my-store --hosting commerce`, + ) + .option('--project-name ', 'Name of your Catalyst project') + .option('--project-dir ', 'Directory in which to create your project', process.cwd()) + .addOption(storeHashOption()) + .addOption(accessTokenOption()) + .option('--channel-id ', 'BigCommerce channel ID', parseChannelId) + .option('--storefront-token ', 'BigCommerce storefront token') + .option( + '--gh-ref ', + 'Extract a specific ref (tag, branch, or commit) from the source repository', + '@bigcommerce/catalyst-core@latest', + ) + .option('--repository ', 'GitHub repository to extract from', 'bigcommerce/catalyst') + .option( + '--env ', + 'Arbitrary environment variables to set in .env.local. Format: KEY=VALUE (repeatable).', + parseEnvFlag, + ) + .addOption( + new Option( + '--hosting ', + 'Hosting mode: "self-hosted" (default) or "commerce" to set up Commerce Hosting at create time. When omitted, scaffolding is hosting-agnostic; run `catalyst deploy` later to opt in.', + ).choices(['self-hosted', 'commerce'] as const), + ) + .option( + '--use-existing', + 'Only used with --hosting commerce and --project-name. When the named project already exists on the store, reuse it instead of prompting. Has no effect without --hosting commerce.', + ) + .addOption( + new Option('--bigcommerce-hostname ', 'BigCommerce hostname') + .default('bigcommerce.com') + .hideHelp(), + ) + .addOption( + new Option('--login-url ', 'BigCommerce login URL.') + .env('BIGCOMMERCE_LOGIN_URL') + .default(DEFAULT_LOGIN_URL) + .hideHelp(), + ) + .addOption( + new Option('--cli-api-origin ', 'Catalyst CLI API origin') + .default('https://cxm-prd.bigcommerceapp.com') + .hideHelp(), + ) + // eslint-disable-next-line complexity + .action(async (options) => { + const { ghRef, repository } = options; + + if (options.useExisting && options.hosting !== 'commerce') { + consola.warn('--use-existing has no effect without --hosting commerce. Ignoring.'); + } + + checkRequiredTools(); + + const { projectName, projectDir } = await setupProject({ + projectName: options.projectName, + projectDir: options.projectDir, + }); + + let storeHash = options.storeHash; + let accessToken = options.accessToken; + let channelId = options.channelId; + let storefrontToken = options.storefrontToken; + + let envVars: Record = {}; + + // Always require store creds. `--channel-id` + `--storefront-token` aren't + // enough on their own — the storefront also needs BIGCOMMERCE_STORE_HASH at + // runtime, and downstream catalyst commands (deploy, project, ...) need an + // access token. Device login covers the missing pieces; the user picks the + // store during the OAuth flow regardless of any partial flags they passed. + if (!storeHash || !accessToken) { + const apiHost = `api.${options.bigcommerceHostname}`; + + try { + const credentials = await login(options.loginUrl, apiHost); + + storeHash = credentials.storeHash; + accessToken = credentials.accessToken; + } catch (error) { + if (error instanceof LoginAbortedError) { + consola.info( + 'Login aborted. Re-run `catalyst create` when you have your credentials ready.', + ); + process.exit(0); + + return; + } + + throw error; + } + } + + const useCommerceHosting = options.hosting === 'commerce'; + + await getTelemetry().identify(storeHash); + + // Seed env vars from local state when all three were flag-provided. Channel + // resolution below overwrites this wholesale via `envVars = { ...initData.envVars }`, + // which is fine — that path means the user wanted us to fetch fresh values. + if (storeHash && channelId && storefrontToken) { + envVars.BIGCOMMERCE_STORE_HASH = storeHash; + envVars.BIGCOMMERCE_CHANNEL_ID = channelId.toString(); + envVars.BIGCOMMERCE_STOREFRONT_TOKEN = storefrontToken; + } + + // Resolve channel only when we have creds and are missing channel info. + if (storeHash && accessToken && (!channelId || !storefrontToken)) { + const apiHost = `api.${options.bigcommerceHostname}`; + const cliApiOrigin = options.cliApiOrigin; + + if (channelId && !storefrontToken) { + const initData = await getChannelInit(channelId, storeHash, accessToken, cliApiOrigin); + + envVars = { ...initData.envVars }; + storefrontToken = initData.storefrontToken; + } else if (!channelId) { + const eligibility = await checkChannelEligibility(storeHash, accessToken, cliApiOrigin); + + if (!eligibility.eligible) { + consola.warn(eligibility.message); + } + + let shouldCreateChannel; + + if (eligibility.eligible) { + shouldCreateChannel = await select({ + message: 'Would you like to create a new channel?', + choices: [ + { name: 'Yes', value: true }, + { name: 'No', value: false }, + ], + }); + } + + if (shouldCreateChannel) { + const channelData = await runCreateChannelFlow({ + storeHash, + accessToken, + apiHost, + cliApiOrigin, + }); + + channelId = channelData.channelId; + storefrontToken = channelData.storefrontToken; + envVars = { ...channelData.envVars }; + + consola.success('Channel created successfully.'); + consola.warn( + 'A preview storefront has been deployed in your BigCommerce control panel. This preview may look different from your local environment as it may be running different code. Additionally, it may take a few minutes for the channel storefront to be accessible.', + ); + } + + if (!shouldCreateChannel) { + channelId = await handleChannelSelection(storeHash, accessToken, apiHost); + + const initData = await getChannelInit(channelId, storeHash, accessToken, cliApiOrigin); + + envVars = { ...initData.envVars }; + storefrontToken = initData.storefrontToken; + } + } + } + + if (options.env) { + Object.assign(envVars, options.env); + } + + if (options.storeHash) envVars.BIGCOMMERCE_STORE_HASH = options.storeHash; + if (options.channelId) envVars.BIGCOMMERCE_CHANNEL_ID = options.channelId.toString(); + if (options.storefrontToken) envVars.BIGCOMMERCE_STOREFRONT_TOKEN = options.storefrontToken; + + if (useCommerceHosting && accessToken) { + envVars.BIGCOMMERCE_ACCESS_TOKEN = accessToken; + } + + // Pre-populate CATALYST_ACCESS_TOKEN so subsequent catalyst CLI commands + // (`catalyst deploy`, `catalyst project ...`, etc.) work without re-auth. + // CATALYST_STORE_HASH isn't written — the CLI falls back to BIGCOMMERCE_STORE_HASH + // (already in envVars from the channel init response) at startup. + if (accessToken) envVars.CATALYST_ACCESS_TOKEN = accessToken; + + // Resolve the Commerce Hosting project before extraction so credential checks + // and prompts happen up-front. We defer the file mutations + // (`setupCommerceHosting`) until after extraction. + let commerceHostingProject: ProjectListItem | undefined; + + if (useCommerceHosting && storeHash && accessToken) { + const apiHost = `api.${options.bigcommerceHostname}`; + const hasAccess = await hasProjectsAccess(storeHash, accessToken, apiHost); + + if (!hasAccess) { + consola.error( + 'This store does not have access to the Infrastructure Projects API. Contact support@bigcommerce.com to enable it.', + ); + process.exit(1); + } + + commerceHostingProject = await promptForCommerceHostingProject( + { storeHash, accessToken, apiHost }, + projectName, + !!options.projectName, + options.useExisting, + ); + } + + consola.info(`Creating '${projectName}' at '${projectDir}'`); + + const packageManager = detectPackageManager(); + + // Anything that mutates `projectDir` runs inside this block. If a step + // fails, the directory is likely partially populated — surface that to the + // user so they can clean up before retrying. We don't auto-delete because + // they may want to inspect the partial state first. + try { + await extractCatalyst({ repository, ref: ghRef, projectDir }); + // Turn the extracted core/ into an installable standalone project + // (resolve workspace deps, drop `private`, record the catalyst version/ref). + await rewriteCorePackage(projectDir, ghRef); + setupCoreProject(projectDir); + + if (useCommerceHosting && commerceHostingProject && storeHash && accessToken) { + await setupCommerceHosting({ + projectDir, + projectUuid: commerceHostingProject.uuid, + storeHash, + accessToken, + }); + } + + // Write env before install — matters for postinstall scripts that may + // resolve env-driven config. + writeEnv(projectDir, envVars); + + await installDependencies(projectDir, packageManager); + initGitRepo(projectDir); + } catch (error) { + if (pathExistsSync(projectDir)) { + consola.warn( + `Setup failed before completion. '${projectDir}' may be in a partial state — review and delete it before re-running 'catalyst create'.`, + ); + } + + throw error; + } + + consola.success(`Created '${projectName}' at '${projectDir}'`); + + const steps = [`cd ${projectName} && ${packageManager} run dev`]; + + if (useCommerceHosting) { + steps.push( + `Run 'cd ${projectName} && ${packageManager} run deploy' when ready to deploy to Commerce Hosting.`, + ); + } + + consola.log( + `Next steps:\n\n${steps.map((step) => ` ${colorize('yellow', step)}`).join('\n')}`, + ); + }); diff --git a/packages/catalyst/src/cli/commands/debug.spec.ts b/packages/catalyst/src/cli/commands/debug.spec.ts new file mode 100644 index 0000000000..c6c266f331 --- /dev/null +++ b/packages/catalyst/src/cli/commands/debug.spec.ts @@ -0,0 +1,188 @@ +import { Command } from 'commander'; +import { afterEach, beforeAll, beforeEach, expect, test, vi } from 'vitest'; + +import { collectDiagnostics, type Diagnostics } from '../lib/collect-diagnostics'; +import { consola } from '../lib/logger'; +import { program } from '../program'; + +import { debug } from './debug'; + +vi.mock('../lib/collect-diagnostics', () => ({ + collectDiagnostics: vi.fn(), +})); + +const fullDiagnostics: Diagnostics = { + cli: { name: '@bigcommerce/catalyst', version: '9.9.9' }, + runtime: { + node: 'v20.0.0', + platform: 'darwin', + arch: 'arm64', + osRelease: '25.5.0', + packageManager: 'pnpm', + }, + project: { + cwd: '/tmp/project', + storefrontName: '@bigcommerce/catalyst-core', + storefrontVersion: '1.8.0', + projectUuid: 'uuid-123', + isLinked: true, + isTransformed: true, + isFullySetUp: true, + hasMiddleware: true, + hasProxy: false, + hasOpenNextDep: true, + }, + config: { + storeHash: { present: true, source: 'project.json' }, + accessToken: { present: true, source: 'process.env' }, + projectUuid: { present: true, source: 'project.json' }, + apiHost: { present: true, source: 'project.json' }, + projectJsonKeys: ['projectUuid', 'storeHash'], + storedEnvKeys: ['FOO', 'BAR'], + // Both branches of the source formatter (set-with-source and unset). + cliEnvVars: { CATALYST_STORE_HASH: 'process.env', CATALYST_PROJECT_UUID: 'unset' }, + buildEnvVars: { AUTH_SECRET: '.env.local', BIGCOMMERCE_CHANNEL_ID: 'unset' }, + }, + telemetry: { enabled: true, correlationId: 'corr-abc' }, + // Both branches of the presence formatter. + files: { '.env.local': true, '.env': false }, +}; + +const emptyDiagnostics: Diagnostics = { + cli: { name: '@bigcommerce/catalyst', version: '9.9.9' }, + runtime: { + node: 'v20.0.0', + platform: 'linux', + arch: 'x64', + osRelease: '6.0.0', + packageManager: 'npm', + }, + project: { + cwd: '/tmp/empty', + storefrontName: null, + storefrontVersion: null, + projectUuid: null, + isLinked: false, + isTransformed: false, + isFullySetUp: false, + hasMiddleware: false, + hasProxy: false, + hasOpenNextDep: false, + }, + config: { + storeHash: { present: false, source: 'unset' }, + accessToken: { present: false, source: 'unset' }, + projectUuid: { present: false, source: 'unset' }, + apiHost: { present: false, source: 'unset' }, + projectJsonKeys: [], + storedEnvKeys: [], + cliEnvVars: {}, + buildEnvVars: {}, + }, + telemetry: { enabled: false, correlationId: 'corr-xyz' }, + files: {}, +}; + +// The report is the last consola.log call (the program banner also logs, at +// import time, so we take the most recent call rather than relying on order). +const lastLogOutput = (): string => { + const calls = vi.mocked(consola.log).mock.calls; + + return calls.length > 0 ? String(calls[calls.length - 1][0]) : ''; +}; + +beforeAll(() => { + consola.wrapAll(); +}); + +beforeEach(() => { + consola.mockTypes(() => vi.fn()); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +test('properly configured Command instance', () => { + expect(debug).toBeInstanceOf(Command); + expect(debug.name()).toBe('debug'); + expect(debug.description()).toContain('diagnostic report'); + expect(debug.options).toEqual( + expect.arrayContaining([expect.objectContaining({ long: '--json' })]), + ); +}); + +test('prints a human-readable report by default', async () => { + vi.mocked(collectDiagnostics).mockReturnValue(fullDiagnostics); + + await program.parseAsync(['node', 'catalyst', 'debug']); + + const output = lastLogOutput(); + + expect(output).toContain('Catalyst CLI Diagnostics'); + expect(output).toContain('@bigcommerce/catalyst'); + expect(output).toContain('pnpm'); + expect(output).toContain('Storefront: @bigcommerce/catalyst-core@1.8.0'); + expect(output).toContain('Project UUID: uuid-123'); + expect(output).toContain('Linked: yes'); + expect(output).toContain('middleware.ts: present'); + expect(output).toContain('proxy.ts: absent'); + expect(output).toContain('OpenNext dep: installed'); + expect(output).toContain('Store hash: present (source: project.json)'); + expect(output).toContain('Access token: present (source: process.env)'); + expect(output).toContain('API host: present (source: project.json)'); + expect(output).toContain('project.json keys: projectUuid, storeHash'); + expect(output).toContain('Stored env keys: FOO, BAR'); + expect(output).toContain('CLI environment variables (used to run the CLI)'); + expect(output).toContain('CATALYST_STORE_HASH: set (process.env)'); + expect(output).toContain('CATALYST_PROJECT_UUID: not set'); + expect(output).toContain('Build environment variables (used to build the Next.js app)'); + expect(output).toContain('AUTH_SECRET: set (.env.local)'); + expect(output).toContain('BIGCOMMERCE_CHANNEL_ID: not set'); + expect(output).toContain('Enabled: yes'); + expect(output).toContain('Correlation ID: corr-abc'); + expect(output).toContain('.env.local: present'); + expect(output).toContain('.env: absent'); +}); + +test('renders the empty-project branches', async () => { + vi.mocked(collectDiagnostics).mockReturnValue(emptyDiagnostics); + + await program.parseAsync(['node', 'catalyst', 'debug']); + + const output = lastLogOutput(); + + expect(output).toContain('Storefront: (unknown)'); + expect(output).toContain('Project UUID: (not linked)'); + expect(output).toContain('Linked: no'); + expect(output).toContain('OpenNext dep: not installed'); + expect(output).toContain('Store hash: not set'); + expect(output).toContain('API host: default (api.bigcommerce.com)'); + expect(output).toContain('project.json keys: (none)'); + expect(output).toContain('Stored env keys: (none)'); + expect(output).toContain('Enabled: no'); +}); + +test('shows the storefront version alone when the package name is unknown', async () => { + vi.mocked(collectDiagnostics).mockReturnValue({ + ...emptyDiagnostics, + project: { ...emptyDiagnostics.project, storefrontName: null, storefrontVersion: '3.2.1' }, + }); + + await program.parseAsync(['node', 'catalyst', 'debug']); + + expect(lastLogOutput()).toContain('Storefront: 3.2.1'); +}); + +test('--json prints machine-readable output', async () => { + vi.mocked(collectDiagnostics).mockReturnValue(fullDiagnostics); + + await program.parseAsync(['node', 'catalyst', 'debug', '--json']); + + const output = lastLogOutput(); + + expect(() => { + JSON.parse(output); + }).not.toThrow(); + expect(JSON.parse(output)).toEqual(fullDiagnostics); +}); diff --git a/packages/catalyst/src/cli/commands/debug.ts b/packages/catalyst/src/cli/commands/debug.ts new file mode 100644 index 0000000000..012d5547b6 --- /dev/null +++ b/packages/catalyst/src/cli/commands/debug.ts @@ -0,0 +1,112 @@ +import { Command, Option } from 'commander'; + +import { + collectDiagnostics, + type ConfigSource, + type Diagnostics, + type ResolvedValue, +} from '../lib/collect-diagnostics'; +import { consola } from '../lib/logger'; + +const yesNo = (value: boolean): string => (value ? 'yes' : 'no'); + +const presence = (value: boolean): string => (value ? 'present' : 'absent'); + +const formatResolved = (value: ResolvedValue): string => + value.present ? `present (source: ${value.source})` : 'not set'; + +const formatSource = (source: ConfigSource): string => + source === 'unset' ? 'not set' : `set (${source})`; + +const formatStorefront = (name: string | null, version: string | null): string => { + if (!version) { + return '(unknown)'; + } + + return name ? `${name}@${version}` : version; +}; + +const formatList = (values: string[]): string => (values.length > 0 ? values.join(', ') : '(none)'); + +const envVarLines = (vars: Record): string[] => + Object.entries(vars).map(([name, source]) => ` ${name}: ${formatSource(source)}`); + +// Build a human-readable, copy-pasteable report. Kept to plain text (no colors) +// so it survives a paste into a GitHub issue or support ticket unchanged. +const formatReport = (d: Diagnostics): string => { + const lines = [ + 'Catalyst CLI Diagnostics', + '', + 'CLI', + ` Package: ${d.cli.name}`, + ` Version: ${d.cli.version}`, + '', + 'Runtime', + ` Node: ${d.runtime.node}`, + ` Platform: ${d.runtime.platform} (${d.runtime.arch})`, + ` OS release: ${d.runtime.osRelease}`, + ` Package manager: ${d.runtime.packageManager}`, + '', + 'Project', + ` Directory: ${d.project.cwd}`, + ` Storefront: ${formatStorefront(d.project.storefrontName, d.project.storefrontVersion)}`, + ` Project UUID: ${d.project.projectUuid ?? '(not linked)'}`, + ` Linked: ${yesNo(d.project.isLinked)}`, + ` Transformed: ${yesNo(d.project.isTransformed)}`, + ` Fully set up: ${yesNo(d.project.isFullySetUp)}`, + ` middleware.ts: ${presence(d.project.hasMiddleware)}`, + ` proxy.ts: ${presence(d.project.hasProxy)}`, + ` OpenNext dep: ${d.project.hasOpenNextDep ? 'installed' : 'not installed'}`, + '', + 'Config (resolved without secrets)', + ` Store hash: ${formatResolved(d.config.storeHash)}`, + ` Access token: ${formatResolved(d.config.accessToken)}`, + ` Project UUID: ${formatResolved(d.config.projectUuid)}`, + ` API host: ${d.config.apiHost.present ? formatResolved(d.config.apiHost) : 'default (api.bigcommerce.com)'}`, + ` project.json keys: ${formatList(d.config.projectJsonKeys)}`, + ` Stored env keys: ${formatList(d.config.storedEnvKeys)}`, + '', + 'CLI environment variables (used to run the CLI)', + ...envVarLines(d.config.cliEnvVars), + '', + 'Build environment variables (used to build the Next.js app)', + ...envVarLines(d.config.buildEnvVars), + '', + 'Telemetry', + ` Enabled: ${yesNo(d.telemetry.enabled)}`, + ` Correlation ID: ${d.telemetry.correlationId}`, + '', + 'Files', + ...Object.entries(d.files).map(([name, exists]) => ` ${name}: ${presence(exists)}`), + ]; + + return lines.join('\n'); +}; + +export const debug = new Command('debug') + .configureHelp({ showGlobalOptions: true }) + .description( + 'Print a diagnostic report (CLI, runtime, project, and config state) to include when filing a bug report. Never prints secret values — credentials and env vars are reported by presence only.', + ) + .addHelpText( + 'after', + ` +Examples: + # Print a human-readable report + $ catalyst debug + + # Print machine-readable JSON (useful for copy/paste or piping) + $ catalyst debug --json`, + ) + .addOption(new Option('--json', 'Output the report as JSON.')) + .action((options: { json?: boolean }) => { + const diagnostics = collectDiagnostics(); + + if (options.json) { + consola.log(JSON.stringify(diagnostics, null, 2)); + + return; + } + + consola.log(formatReport(diagnostics)); + }); diff --git a/packages/catalyst/src/cli/commands/deploy.spec.ts b/packages/catalyst/src/cli/commands/deploy.spec.ts index 22e7c81ddf..939ffc4145 100644 --- a/packages/catalyst/src/cli/commands/deploy.spec.ts +++ b/packages/catalyst/src/cli/commands/deploy.spec.ts @@ -1,7 +1,8 @@ +import { confirm, input, select } from '@inquirer/prompts'; import AdmZip from 'adm-zip'; import { Command } from 'commander'; import { http, HttpResponse } from 'msw'; -import { mkdir, realpath, stat, writeFile } from 'node:fs/promises'; +import { mkdir, realpath, rm, stat, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { afterAll, @@ -17,10 +18,15 @@ import { import { server } from '../../../tests/mocks/node'; import { textHistory } from '../../../tests/mocks/spinner'; +import { setupCommerceHosting } from '../lib/commerce-hosting'; +import { installDependencies } from '../lib/install-dependencies'; import { consola } from '../lib/logger'; import { mkTempDir } from '../lib/mk-temp-dir'; +import { getProjectConfig } from '../lib/project-config'; +import { getProjectState } from '../lib/project-state'; import { program } from '../program'; +import { buildCatalystProject } from './build'; import { createDeployment, deploy, @@ -31,8 +37,49 @@ import { uploadBundleZip, } from './deploy'; +vi.mock('@inquirer/prompts', () => ({ + confirm: vi.fn(), + input: vi.fn(), + select: vi.fn(), +})); + // eslint-disable-next-line import/dynamic-import-chunkname vi.mock('yocto-spinner', () => import('../../../tests/mocks/spinner')); +vi.mock('./build', async (importOriginal) => { + const actual = await importOriginal(); + + return { ...actual, buildCatalystProject: vi.fn() }; +}); + +// Default to a transformed project so the deploy flow's transformation guard +// is a no-op for tests that don't care about it. Tests that exercise the +// guard override this per-case via `vi.mocked(getProjectState).mockReturnValueOnce(...)`. +vi.mock('../lib/project-state', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + getProjectState: vi.fn(() => ({ + projectUuid: 'mock-uuid', + hasMiddleware: true, + hasProxy: false, + hasOpenNextDep: true, + isLinked: true, + isTransformed: true, + isFullySetUp: true, + })), + }; +}); + +vi.mock('../lib/commerce-hosting', async (importOriginal) => { + const actual = await importOriginal(); + + return { ...actual, setupCommerceHosting: vi.fn() }; +}); + +vi.mock('../lib/install-dependencies', () => ({ + installDependencies: vi.fn(), +})); let exitMock: MockInstance; @@ -71,6 +118,9 @@ beforeAll(async () => { beforeEach(() => { process.chdir(tmpDir); + // Default the transformation-guard confirm to "yes" so tests that don't + // exercise it proceed past the guard. + vi.mocked(confirm).mockResolvedValue(true); }); afterEach(() => { @@ -92,10 +142,11 @@ test('properly configured Command instance', () => { expect.arrayContaining([ expect.objectContaining({ flags: '--store-hash ' }), expect.objectContaining({ flags: '--access-token ' }), - expect.objectContaining({ flags: '--api-host ', defaultValue: 'api.bigcommerce.com' }), + expect.objectContaining({ flags: '--api-host ' }), expect.objectContaining({ flags: '--project-uuid ' }), - expect.objectContaining({ flags: '--secret ' }), + expect.objectContaining({ flags: '--secret ' }), expect.objectContaining({ flags: '--dry-run' }), + expect.objectContaining({ flags: '--prebuilt' }), ]), ); }); @@ -268,7 +319,9 @@ describe('deployment and event streaming', () => { await expect( getDeploymentStatus(deploymentUuid, storeHash, accessToken, apiHost), - ).rejects.toThrow('Deployment failed with error code: 30'); + ).rejects.toThrow( + 'Deployment failed (error code 30): Your bundle could not be extracted. This may mean your build output is too large (max 64 MB compressed / 512 MB uncompressed) or the archive is corrupted. Try reducing your build size or rebuilding your project and deploying again.', + ); expect(consola.info).toHaveBeenCalledWith('Fetching deployment status...'); @@ -276,6 +329,127 @@ describe('deployment and event streaming', () => { }); }); +describe('linked project verification', () => { + test('proceeds when the linked project still exists on the server', async () => { + await program.parseAsync([ + 'node', + 'catalyst', + 'deploy', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--api-host', + apiHost, + '--project-uuid', + projectUuid, + '--dry-run', + ]); + + expect(consola.info).not.toHaveBeenCalledWith('No project is currently linked.'); + expect(consola.warn).not.toHaveBeenCalledWith(expect.stringContaining('no longer exists')); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + test('prompts for a new project when the linked uuid no longer exists', async () => { + const config = getProjectConfig(); + const staleUuid = '00000000-0000-0000-0000-000000000000'; + + config.set('projectUuid', staleUuid); + config.set('storeHash', storeHash); + config.set('accessToken', accessToken); + + // The project picker is a select that returns the chosen project UUID. + vi.mocked(select).mockResolvedValue(projectUuid); + + await program.parseAsync(['node', 'catalyst', 'deploy', '--dry-run']); + + expect(consola.warn).toHaveBeenCalledWith( + expect.stringContaining(`The linked project (${staleUuid}) no longer exists`), + ); + expect(consola.success).toHaveBeenCalledWith('Linked project "Project One".'); + expect(config.get('projectUuid')).toBe(projectUuid); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + test('prompts for a project when none is linked yet', async () => { + const config = getProjectConfig(); + + config.delete('projectUuid'); + config.set('storeHash', storeHash); + config.set('accessToken', accessToken); + + // The project picker is a select that returns the chosen project UUID. + vi.mocked(select).mockResolvedValue(projectUuid); + + await program.parseAsync(['node', 'catalyst', 'deploy', '--dry-run']); + + expect(consola.info).toHaveBeenCalledWith('No project is currently linked.'); + expect(consola.success).toHaveBeenCalledWith('Linked project "Project One".'); + expect(config.get('projectUuid')).toBe(projectUuid); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + test('offers to create when no projects exist on the store', async () => { + const config = getProjectConfig(); + + config.delete('projectUuid'); + config.set('storeHash', storeHash); + config.set('accessToken', accessToken); + + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => + HttpResponse.json({ data: [] }), + ), + ); + + // No projects exist → a confirm ("create one?") then an input (project name). + // The transformation-guard confirm defaults to true via beforeEach. + vi.mocked(input).mockResolvedValue('My New Project'); + + await program.parseAsync(['node', 'catalyst', 'deploy', '--dry-run']); + + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + message: expect.stringContaining( + 'There are not any hosting projects that you can link to yet', + ), + }), + ); + expect(input).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Enter a name for the new project:' }), + ); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + test('exits gracefully with guidance when user declines to create', async () => { + const config = getProjectConfig(); + + config.delete('projectUuid'); + config.set('storeHash', storeHash); + config.set('accessToken', accessToken); + + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => + HttpResponse.json({ data: [] }), + ), + ); + + // Declining the "create one?" confirm throws NoLinkedProjectError. + vi.mocked(confirm).mockResolvedValue(false); + + await expect(program.parseAsync(['node', 'catalyst', 'deploy', '--dry-run'])).rejects.toThrow( + 'No infrastructure project linked', + ); + + expect(consola.info).toHaveBeenCalledWith( + "When you're ready to create a project, run `catalyst project create` or re-run `catalyst deploy`.", + ); + expect(exitMock).toHaveBeenCalledWith(0); + }); +}); + test('--dry-run skips upload and deployment', async () => { await program.parseAsync([ 'node', @@ -304,6 +478,118 @@ test('--dry-run skips upload and deployment', async () => { expect(exitMock).toHaveBeenCalledWith(0); }); +test('passes --wrangler-version through to the build', async () => { + await program.parseAsync([ + 'node', + 'catalyst', + 'deploy', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--api-host', + apiHost, + '--project-uuid', + projectUuid, + '--wrangler-version', + '4.24.3', + '--dry-run', + ]); + + expect(buildCatalystProject).toHaveBeenCalledWith(projectUuid, '4.24.3'); +}); + +test('builds with the default Wrangler version when --wrangler-version is omitted', async () => { + await program.parseAsync([ + 'node', + 'catalyst', + 'deploy', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--api-host', + apiHost, + '--project-uuid', + projectUuid, + '--dry-run', + ]); + + expect(buildCatalystProject).toHaveBeenCalledWith(projectUuid, undefined); +}); + +test('rejects an invalid --wrangler-version value', async () => { + await expect( + program.parseAsync([ + 'node', + 'catalyst', + 'deploy', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--project-uuid', + projectUuid, + '--wrangler-version', + 'foo; rm -rf /', + ]), + ).rejects.toThrow(/not a valid Wrangler version/); + + expect(buildCatalystProject).not.toHaveBeenCalled(); +}); + +test('--dry-run uses storeHash and accessToken from .bigcommerce/project.json when not provided', async () => { + const config = getProjectConfig(); + + config.set('projectUuid', projectUuid); + config.set('storeHash', storeHash); + config.set('accessToken', accessToken); + + await program.parseAsync(['node', 'catalyst', 'deploy', '--dry-run']); + + expect(consola.info).toHaveBeenCalledWith('Generating bundle...'); + expect(consola.success).toHaveBeenCalledWith(`Bundle created at: ${outputZip}`); + expect(exitMock).toHaveBeenCalledWith(0); +}); + +test('errors when store hash is missing and not in .bigcommerce/project.json', async () => { + const config = getProjectConfig(); + + config.set('projectUuid', projectUuid); + config.set('accessToken', accessToken); + config.delete('storeHash'); + + vi.stubEnv('CATALYST_STORE_HASH', undefined); + + await expect(program.parseAsync(['node', 'catalyst', 'deploy'])).rejects.toThrow( + 'Missing credentials', + ); + + expect(consola.error).toHaveBeenCalledWith('Missing credentials.'); + expect(exitMock).toHaveBeenCalledWith(1); + + vi.unstubAllEnvs(); +}); + +test('errors when access token is missing and not in .bigcommerce/project.json', async () => { + const config = getProjectConfig(); + + config.set('projectUuid', projectUuid); + config.set('storeHash', storeHash); + config.delete('accessToken'); + + vi.stubEnv('CATALYST_ACCESS_TOKEN', undefined); + + await expect(program.parseAsync(['node', 'catalyst', 'deploy'])).rejects.toThrow( + 'Missing credentials', + ); + + expect(consola.error).toHaveBeenCalledWith('Missing credentials.'); + expect(exitMock).toHaveBeenCalledWith(1); + + vi.unstubAllEnvs(); +}); + test('reads from env options', () => { const envVariables = parseEnvironmentVariables([ 'BIGCOMMERCE_STORE_HASH=123', @@ -324,6 +610,405 @@ test('reads from env options', () => { ]); expect(() => parseEnvironmentVariables(['foo_bar'])).toThrow( - 'Invalid secret format: foo_bar. Expected format: KEY=VALUE', + 'Invalid env var format: foo_bar. Expected format: KEY=VALUE', ); }); + +test('splits secrets on the first = so values containing = survive', () => { + const envVariables = parseEnvironmentVariables(['TOKEN=abc=def==']); + + expect(envVariables).toEqual([{ type: 'secret', key: 'TOKEN', value: 'abc=def==' }]); +}); + +describe('persisted env vars', () => { + interface DeploymentBody { + environment_variables?: Array<{ type: string; key: string; value: string }>; + } + + test('sends stored env vars and lets inline --secret override the same key', async () => { + const config = getProjectConfig(); + + config.set('projectUuid', projectUuid); + config.set('storeHash', storeHash); + config.set('accessToken', accessToken); + config.set('env', { PERSISTED_ONLY: 'keep', SHARED: 'stored' }); + + let body: DeploymentBody | undefined; + + server.use( + http.post( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/deployments', + async ({ request }) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + body = (await request.json()) as DeploymentBody; + + return HttpResponse.json({ data: { deployment_uuid: deploymentUuid } }); + }, + ), + ); + + await program.parseAsync([ + 'node', + 'catalyst', + 'deploy', + '--api-host', + apiHost, + '--prebuilt', + '--secret', + 'SHARED=override', + '--secret', + 'FLAG_ONLY=flag', + ]); + + expect(body?.environment_variables).toEqual( + expect.arrayContaining([ + { type: 'secret', key: 'PERSISTED_ONLY', value: 'keep' }, + { type: 'secret', key: 'SHARED', value: 'override' }, + { type: 'secret', key: 'FLAG_ONLY', value: 'flag' }, + ]), + ); + // The shared key is sent once, with the inline flag value winning. + expect(body?.environment_variables?.filter((e) => e.key === 'SHARED')).toHaveLength(1); + + config.delete('env'); + }); + + test('omits environment_variables when nothing is stored or passed', async () => { + const config = getProjectConfig(); + + config.set('projectUuid', projectUuid); + config.set('storeHash', storeHash); + config.set('accessToken', accessToken); + config.delete('env'); + + let body: DeploymentBody | undefined; + + server.use( + http.post( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/deployments', + async ({ request }) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + body = (await request.json()) as DeploymentBody; + + return HttpResponse.json({ data: { deployment_uuid: deploymentUuid } }); + }, + ), + ); + + await program.parseAsync(['node', 'catalyst', 'deploy', '--api-host', apiHost, '--prebuilt']); + + expect(body?.environment_variables).toBeUndefined(); + }); +}); + +describe('--prebuilt flag', () => { + test('skips build step when --prebuilt is passed', async () => { + await program.parseAsync([ + 'node', + 'catalyst', + 'deploy', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--api-host', + apiHost, + '--project-uuid', + projectUuid, + '--prebuilt', + '--dry-run', + ]); + + expect(buildCatalystProject).not.toHaveBeenCalled(); + expect(consola.info).toHaveBeenCalledWith('Using existing build output (--prebuilt).'); + expect(consola.info).toHaveBeenCalledWith('Generating bundle...'); + }); + + test('fails when dist directory is missing', async () => { + const [missingDistDir, missingDistCleanup] = await mkTempDir(); + const resolvedDir = await realpath(missingDistDir); + + process.chdir(resolvedDir); + + await expect( + program.parseAsync([ + 'node', + 'catalyst', + 'deploy', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--api-host', + apiHost, + '--project-uuid', + projectUuid, + '--prebuilt', + ]), + ).rejects.toThrow( + 'No build output found at .bigcommerce/dist/. Run `catalyst build` first or remove `--prebuilt` to build automatically.', + ); + + process.chdir(tmpDir); + await missingDistCleanup(); + }); + + test('fails when dist directory is empty', async () => { + const [emptyDistDir, emptyDistCleanup] = await mkTempDir(); + const resolvedDir = await realpath(emptyDistDir); + + await mkdir(join(resolvedDir, '.bigcommerce', 'dist'), { recursive: true }); + + process.chdir(resolvedDir); + + await expect( + program.parseAsync([ + 'node', + 'catalyst', + 'deploy', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--api-host', + apiHost, + '--project-uuid', + projectUuid, + '--prebuilt', + ]), + ).rejects.toThrow( + 'No build output found at .bigcommerce/dist/. Run `catalyst build` first or remove `--prebuilt` to build automatically.', + ); + + process.chdir(tmpDir); + await rm(join(resolvedDir, '.bigcommerce'), { recursive: true }); + await emptyDistCleanup(); + }); +}); + +describe('transformation guard', () => { + const untransformedState = { + projectUuid: undefined, + hasMiddleware: false, + hasProxy: true, + hasOpenNextDep: false, + isLinked: false, + isTransformed: false, + isFullySetUp: false, + }; + + test('runs setupCommerceHosting + installDependencies when project is not transformed', async () => { + vi.mocked(getProjectState).mockReturnValueOnce(untransformedState); + + await program.parseAsync([ + 'node', + 'catalyst', + 'deploy', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--api-host', + apiHost, + '--project-uuid', + projectUuid, + '--prebuilt', + '--dry-run', + ]); + + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + message: expect.stringContaining('not yet set up for Commerce Hosting deployments'), + }), + ); + expect(setupCommerceHosting).toHaveBeenCalledWith({ + projectDir: tmpDir, + projectUuid, + storeHash, + accessToken, + }); + expect(installDependencies).toHaveBeenCalledWith(tmpDir, expect.any(String)); + }); + + test('exits gracefully when user declines to run setup', async () => { + vi.mocked(getProjectState).mockReturnValueOnce(untransformedState); + vi.mocked(confirm).mockResolvedValueOnce(false); + + // In production, process.exit halts. In tests it's mocked, so we can only + // verify the user-visible signals: the guidance log and the exit code. + await program.parseAsync([ + 'node', + 'catalyst', + 'deploy', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--api-host', + apiHost, + '--project-uuid', + projectUuid, + '--prebuilt', + '--dry-run', + ]); + + expect(consola.info).toHaveBeenCalledWith( + "When you're ready to deploy, re-run `catalyst deploy` to complete setup.", + ); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + test('skips setup when project is already transformed', async () => { + await program.parseAsync([ + 'node', + 'catalyst', + 'deploy', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--api-host', + apiHost, + '--project-uuid', + projectUuid, + '--prebuilt', + '--dry-run', + ]); + + expect(setupCommerceHosting).not.toHaveBeenCalled(); + expect(installDependencies).not.toHaveBeenCalled(); + }); +}); + +describe('--update-site-url', () => { + function deployArgs(extra: string[] = []) { + return [ + 'node', + 'catalyst', + 'deploy', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--api-host', + apiHost, + '--project-uuid', + projectUuid, + '--prebuilt', + ...extra, + ]; + } + + test('triggers the interactive flow and PUTs the chosen hostname after deploy', async () => { + let putBody: unknown; + let putChannelId: string | undefined; + + server.use( + http.put( + 'https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', + async ({ request, params }) => { + putBody = await request.json(); + putChannelId = String(params.channelId); + + return HttpResponse.json({ + data: { + id: 1, + url: 'https://project-one.catalyst-sandbox.store', + channel_id: 2, + }, + }); + }, + ), + ); + + // The interactive flow asks two selects in order: the channel (returns the + // numeric channel id) then the hostname (returns the hostname string). + vi.mocked(select) + .mockResolvedValueOnce(2) + .mockResolvedValueOnce('project-one.catalyst-sandbox.store'); + + await program.parseAsync(deployArgs(['--update-site-url'])); + + expect(putChannelId).toBe('2'); + expect(putBody).toEqual({ url: 'https://project-one.catalyst-sandbox.store' }); + expect(consola.success).toHaveBeenCalledWith( + expect.stringContaining('Updated channel "Catalyst Storefront" (2) site URL'), + ); + }); + + test('places the freshly-deployed hostname first in the hostname prompt', async () => { + let hostnameChoices: Array<{ name: string; value: string }> | undefined; + + // Project Two has no hostnames by default; use Project One whose handler + // already returns the two seeded hostnames. Inject the freshly-deployed + // hostname into Project One's list so preferHostname has something to match. + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => + HttpResponse.json({ + data: [ + { + uuid: projectUuid, + name: 'Project One', + deployment_hostnames: [ + 'project-one.catalyst-sandbox.store', + 'example.com', // the just-deployed hostname (per the SSE default) + ], + }, + ], + }), + ), + ); + + vi.mocked(select) + .mockResolvedValueOnce(2) // channel + .mockImplementationOnce((config) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const cfg = config as unknown as { choices: Array<{ name: string; value: string }> }; + + hostnameChoices = cfg.choices; + + return Object.assign(Promise.resolve('example.com'), { cancel: () => undefined }); + }); + + await program.parseAsync(deployArgs(['--update-site-url'])); + + expect(hostnameChoices?.[0]).toMatchObject({ value: 'example.com' }); + }); + + test('does not call the channel site API when the flag is omitted', async () => { + let putCalled = false; + + server.use( + http.put('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () => { + putCalled = true; + + return HttpResponse.json({}, { status: 200 }); + }), + ); + + await program.parseAsync(deployArgs()); + + expect(putCalled).toBe(false); + expect(consola.success).not.toHaveBeenCalledWith(expect.stringContaining('Updated channel')); + }); + + test('soft-fails with a warning when the update API returns an error', async () => { + server.use( + http.put('https://:apiHost/stores/:storeHash/v3/channels/:channelId/site', () => + HttpResponse.json({}, { status: 401 }), + ), + ); + + vi.mocked(select) + .mockResolvedValueOnce(2) + .mockResolvedValueOnce('project-one.catalyst-sandbox.store'); + + await program.parseAsync(deployArgs(['--update-site-url'])); + + expect(consola.warn).toHaveBeenCalledWith( + expect.stringContaining('Failed to update channel site URL'), + ); + expect(consola.info).toHaveBeenCalledWith(expect.stringContaining('catalyst auth login')); + }); +}); diff --git a/packages/catalyst/src/cli/commands/deploy.ts b/packages/catalyst/src/cli/commands/deploy.ts index eee50458f6..cc5f82071a 100644 --- a/packages/catalyst/src/cli/commands/deploy.ts +++ b/packages/catalyst/src/cli/commands/deploy.ts @@ -1,15 +1,46 @@ +import { confirm } from '@inquirer/prompts'; import AdmZip from 'adm-zip'; import { Command, Option } from 'commander'; +import { colorize } from 'consola/utils'; import { access, readdir, readFile } from 'node:fs/promises'; import { join } from 'node:path'; import yoctoSpinner from 'yocto-spinner'; import { z } from 'zod'; +import { assertAuthorized } from '../lib/auth-errors'; +import { loadBuildEnv } from '../lib/build-env'; +import { runChannelSiteUrlFlow } from '../lib/channel-site-flow'; +import { + cleanupCloudflareIncompatibilities, + NoLinkedProjectError, + selectOrCreateInfrastructureProject, + setupCommerceHosting, +} from '../lib/commerce-hosting'; +import { getDeploymentErrorMessage } from '../lib/deployment-errors'; +import { detectProjectPackageManager } from '../lib/detect-package-manager'; +import { + getStoredEnv, + mergeDeploymentSecrets, + parseEnvAssignment, + toDeploymentSecrets, +} from '../lib/env-config'; +import { httpError } from '../lib/http-errors'; +import { installDependencies } from '../lib/install-dependencies'; import { consola } from '../lib/logger'; import { getProjectConfig } from '../lib/project-config'; -import { Telemetry } from '../lib/telemetry'; - -const telemetry = new Telemetry(); +import { getProjectState } from '../lib/project-state'; +import { resolveCredentials } from '../lib/resolve-credentials'; +import { + accessTokenOption, + apiHostOption, + envPathOption, + projectUuidOption, + resolveApiHost, + storeHashOption, +} from '../lib/shared-options'; +import { getTelemetry } from '../lib/telemetry'; + +import { buildCatalystProject, parseWranglerVersion, WRANGLER_VERSION } from './build'; const stepsEnum = z.enum([ 'initializing', @@ -44,6 +75,15 @@ const CreateDeploymentSchema = z.object({ }), }); +const ProjectSchema = z.object({ + data: z.array( + z.object({ + uuid: z.uuid(), + name: z.string(), + }), + ), +}); + const DeploymentStatusSchema = z.object({ deployment_uuid: z.uuid(), deployment_status: z.enum(['queued', 'in_progress', 'failed', 'completed']), @@ -53,6 +93,7 @@ const DeploymentStatusSchema = z.object({ progress: z.number(), }) .nullable(), + deployment_hostnames: z.array(z.string()).optional(), error: z .object({ code: z.number(), @@ -106,13 +147,16 @@ export const generateUploadSignature = async ( 'X-Auth-Token': accessToken, 'Content-Type': 'application/json', Accept: 'application/json', + 'X-Correlation-Id': getTelemetry().correlationId, }, body: JSON.stringify({}), }, ); + assertAuthorized(response); + if (!response.ok) { - throw new Error(`Failed to fetch upload signature: ${response.status} ${response.statusText}`); + throw await httpError(response, 'Failed to generate upload signature'); } const res: unknown = await response.json(); @@ -140,7 +184,7 @@ export const uploadBundleZip = async (uploadUrl: string) => { }); if (!response.ok) { - throw new Error(`Failed to upload bundle: ${response.status} ${response.statusText}`); + throw await httpError(response, 'Failed to upload bundle'); } consola.success('Bundle uploaded successfully.'); @@ -150,16 +194,12 @@ export const uploadBundleZip = async (uploadUrl: string) => { export const parseEnvironmentVariables = (secretOption?: string[]) => { return secretOption?.map((envVar) => { - const [key, value] = envVar.split('='); - - if (!key || !value) { - throw new Error(`Invalid secret format: ${envVar}. Expected format: KEY=VALUE`); - } + const { key, value } = parseEnvAssignment(envVar); return { type: 'secret' as const, - key: key.trim(), - value: value.trim(), + key, + value, }; }); }; @@ -182,6 +222,7 @@ export const createDeployment = async ( 'X-Auth-Token': accessToken, 'Content-Type': 'application/json', Accept: 'application/json', + 'X-Correlation-Id': getTelemetry().correlationId, }, body: JSON.stringify({ project_uuid: projectUuid, @@ -191,8 +232,10 @@ export const createDeployment = async ( }, ); + assertAuthorized(response); + if (!response.ok) { - throw new Error(`Failed to create deployment: ${response.status} ${response.statusText}`); + throw await httpError(response, 'Failed to create deployment'); } const res: unknown = await response.json(); @@ -208,7 +251,7 @@ export const getDeploymentStatus = async ( storeHash: string, accessToken: string, apiHost: string, -) => { +): Promise => { consola.info('Fetching deployment status...'); const spinner = yoctoSpinner().start('Fetching...'); @@ -221,12 +264,15 @@ export const getDeploymentStatus = async ( 'X-Auth-Token': accessToken, Accept: 'text/event-stream', Connection: 'keep-alive', + 'X-Correlation-Id': getTelemetry().correlationId, }, }, ); + assertAuthorized(response); + if (!response.ok) { - throw new Error(`Failed to open event stream: ${response.status} ${response.statusText}`); + throw await httpError(response, 'Failed to open deployment event stream'); } const reader = response.body?.getReader(); @@ -237,6 +283,7 @@ export const getDeploymentStatus = async ( const decoder = new TextDecoder(); let done = false; + let deploymentHostname: string | undefined; while (!done) { // eslint-disable-next-line no-await-in-loop @@ -250,6 +297,7 @@ export const getDeploymentStatus = async ( .map((s) => s.replace('data:', '').trim()) .filter(Boolean); + // eslint-disable-next-line no-loop-func split.forEach((event) => { try { json = JSON.parse(event); @@ -262,12 +310,18 @@ export const getDeploymentStatus = async ( const data = DeploymentStatusSchema.parse(json); if (data.error) { - throw new Error(`Deployment failed with error code: ${data.error.code}`); + throw new Error( + `Deployment failed (error code ${data.error.code}): ${getDeploymentErrorMessage(data.error.code)}`, + ); } if (data.event && STEPS[data.event.step] !== spinner.text) { spinner.text = STEPS[data.event.step]; } + + if (data.deployment_hostnames && data.deployment_hostnames.length > 0) { + deploymentHostname = data.deployment_hostnames[0]; + } }); } @@ -275,98 +329,271 @@ export const getDeploymentStatus = async ( } spinner.success('Deployment completed successfully.'); + + if (deploymentHostname) { + consola.success( + `View your deployment at: ${colorize('blue', `https://${deploymentHostname}`)}`, + ); + } + + return deploymentHostname; +}; + +export const fetchProject = async ( + projectUuid: string, + storeHash: string, + accessToken: string, + apiHost: string, +) => { + const response = await fetch( + `https://${apiHost}/stores/${storeHash}/v3/infrastructure/projects`, + { + method: 'GET', + headers: { + 'X-Auth-Token': accessToken, + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-Correlation-Id': getTelemetry().correlationId, + }, + }, + ); + + assertAuthorized(response); + + if (!response.ok) { + throw await httpError(response, 'Failed to fetch projects'); + } + + const res: unknown = await response.json(); + const { data } = ProjectSchema.parse(res); + + return data.find((project) => project.uuid === projectUuid); }; export const deploy = new Command('deploy') + .configureHelp({ showGlobalOptions: true }) .description('Deploy your application to Cloudflare.') - .addOption( - new Option( - '--store-hash ', - 'BigCommerce store hash. Can be found in the URL of your store Control Panel.', - ) - .env('BIGCOMMERCE_STORE_HASH') - .makeOptionMandatory(), + .addHelpText( + 'after', + ` +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. + +Example: + $ catalyst deploy --secret BIGCOMMERCE_STORE_HASH= --secret BIGCOMMERCE_STOREFRONT_TOKEN=`, ) - .addOption( - new Option( - '--access-token ', - 'BigCommerce access token. Can be found after creating a store-level API account.', - ) - .env('BIGCOMMERCE_ACCESS_TOKEN') - .makeOptionMandatory(), - ) - .addOption( - new Option('--api-host ', 'BigCommerce API host. The default is api.bigcommerce.com.') - .env('BIGCOMMERCE_API_HOST') - .default('api.bigcommerce.com'), + .addOption(storeHashOption()) + .addOption(accessTokenOption()) + .addOption(apiHostOption()) + .addOption(projectUuidOption()) + .option( + '--update-site-url', + "After a successful deploy, prompt to update a channel's site URL to the new hostname.", ) .addOption( new Option( - '--project-uuid ', - 'BigCommerce intrastructure project UUID. Can be found via the BigCommerce API (GET /v3/infrastructure/projects).', - ).env('BIGCOMMERCE_PROJECT_UUID'), + '--secret ', + 'Secret to set for this deployment (repeatable). Overrides a stored value for the same key. Format: --secret KEY=VALUE', + ).argParser((value: string, previous: string[] = []) => { + return previous.concat([value]); + }), + ) + .option('--dry-run', 'Run the command to generate the bundle without uploading or deploying.') + .option( + '--prebuilt', + 'Skip the build step. Requires .bigcommerce/dist/ to already contain build output.', ) .addOption( new Option( - '--secret ', - 'Secrets to set for the deployment. Format: SECRET_1=FOO SECRET_2=BAR', - ), + '--wrangler-version ', + `Wrangler version or dist-tag to build with. Ignored with --prebuilt. Defaults to ${WRANGLER_VERSION}.`, + ).argParser(parseWranglerVersion), ) - .option('--dry-run', 'Run the command to generate the bundle without uploading or deploying.') - + .addOption(envPathOption()) .action(async (options) => { - try { - const config = getProjectConfig(); + const config = getProjectConfig(); + const apiHost = resolveApiHost(options, config); + const { storeHash, accessToken } = resolveCredentials(options, config); + const telemetry = getTelemetry(); + + await telemetry.identify(storeHash); + + // Resolve a *valid* projectUuid before doing any expensive build/upload + // work. If the linked UUID no longer exists on the server (e.g. project + // deleted out from under us), prompt the user to pick a new one rather + // than failing mid-deploy. + const linkedProjectUuid = options.projectUuid ?? config.get('projectUuid'); + let projectUuid: string; + + const promptForProject = async (): Promise<{ uuid: string; name: string }> => { + try { + return await selectOrCreateInfrastructureProject({ + storeHash, + accessToken, + apiHost, + }); + } catch (error) { + if (error instanceof NoLinkedProjectError) { + consola.info( + "When you're ready to create a project, run `catalyst project create` or re-run `catalyst deploy`.", + ); + process.exit(0); + } - await telemetry.identify(options.storeHash); + throw error; + } + }; - const projectUuid = options.projectUuid ?? config.get('projectUuid'); + if (linkedProjectUuid) { + const existing = await fetchProject(linkedProjectUuid, storeHash, accessToken, apiHost); - if (!projectUuid) { - throw new Error( - 'Project UUID is required. Please run either `catalyst project link` or `catalyst project create` or this command again with --project-uuid .', + if (existing) { + projectUuid = linkedProjectUuid; + } else { + consola.warn( + `The linked project (${linkedProjectUuid}) no longer exists on this store. It may have been deleted.`, ); + + const selected = await promptForProject(); + + projectUuid = selected.uuid; + config.set('projectUuid', projectUuid); + consola.success(`Linked project "${selected.name}".`); } + } else { + consola.info('No project is currently linked.'); - await generateBundleZip(); + const selected = await promptForProject(); - if (options.dryRun) { - consola.info('Dry run enabled — skipping upload and deployment steps.'); - consola.info('Next steps (skipped):'); - consola.info('- Generate upload signature'); - consola.info('- Upload bundle.zip'); - consola.info('- Create deployment'); + projectUuid = selected.uuid; + config.set('projectUuid', projectUuid); + consola.success(`Linked project "${selected.name}".`); + } + // The OpenNext build pipeline requires the project to be transformed + // (proxy.ts → middleware.ts, @opennextjs/cloudflare installed). Run setup + // here so first-run `catalyst deploy` works on a fresh self-hosted scaffold + // without forcing the user to re-run after a separate setup step. + if (!getProjectState().isTransformed) { + const shouldSetup = await confirm({ + message: + 'Your project is not yet set up for Commerce Hosting deployments. Would you like to run the Commerce Hosting setup now?', + default: true, + }); + + if (!shouldSetup) { + consola.info("When you're ready to deploy, re-run `catalyst deploy` to complete setup."); process.exit(0); } - const uploadSignature = await generateUploadSignature( - options.storeHash, - options.accessToken, - options.apiHost, - ); + const projectDir = process.cwd(); + + await setupCommerceHosting({ projectDir, projectUuid, storeHash, accessToken }); + consola.success('Commerce Hosting setup complete.'); + + // Match the manager the project was scaffolded with (from its lockfile), + // rather than forcing pnpm on npm/yarn/bun projects. + const packageManager = await detectProjectPackageManager(projectDir); + + await installDependencies(projectDir, packageManager); + } else { + // Existing Commerce Hosting users may carry artifacts incompatible with + // the Cloudflare worker bundle from earlier Catalyst versions + // (`core/instrumentation.ts`, `@vercel/otel`). Sweep them on every deploy + // so the fix lands without forcing a re-link. + await cleanupCloudflareIncompatibilities(process.cwd()); + } + + if (options.prebuilt) { + const distDir = join(process.cwd(), '.bigcommerce', 'dist'); - await uploadBundleZip(uploadSignature.upload_url); + try { + await access(distDir); + } catch { + throw new Error( + 'No build output found at .bigcommerce/dist/. Run `catalyst build` first or remove `--prebuilt` to build automatically.', + ); + } + + const contents = await readdir(distDir); + + if (contents.length === 0) { + throw new Error( + 'No build output found at .bigcommerce/dist/. Run `catalyst build` first or remove `--prebuilt` to build automatically.', + ); + } + + consola.info('Using existing build output (--prebuilt).'); + } else { + // The build reads storefront env vars (BIGCOMMERCE_*). Load them from the + // env file(s) before building so both the build and any pre-build checks + // see them. Skipped for --prebuilt above, which doesn't run the build. + loadBuildEnv({ envPath: options.envPath }); + + await buildCatalystProject(projectUuid, options.wranglerVersion); + } - const environmentVariables = parseEnvironmentVariables(options.secret); + await generateBundleZip(); - const { deployment_uuid: deploymentUuid } = await createDeployment( + if (options.dryRun) { + consola.info('Dry run enabled — skipping upload and deployment steps.'); + consola.info('Next steps (skipped):'); + consola.info('- Generate upload signature'); + consola.info('- Upload bundle.zip'); + consola.info('- Create deployment'); + + process.exit(0); + } + + const uploadSignature = await generateUploadSignature(storeHash, accessToken, apiHost); + + await uploadBundleZip(uploadSignature.upload_url); + + // Merge persisted env vars (`catalyst env add`) with any inline `--secret` + // flags. Inline flags win on conflict, letting users override a stored + // value for a single run. Send `undefined` when there's nothing to set so + // we preserve the prior payload shape. + const flagSecrets = parseEnvironmentVariables(options.secret) ?? []; + const persistedSecrets = toDeploymentSecrets(getStoredEnv(config)); + const mergedSecrets = mergeDeploymentSecrets(persistedSecrets, flagSecrets); + const environmentVariables = mergedSecrets.length > 0 ? mergedSecrets : undefined; + + const { deployment_uuid: deploymentUuid } = await createDeployment( + projectUuid, + uploadSignature.upload_uuid, + storeHash, + accessToken, + apiHost, + environmentVariables, + ); + + const deploymentHostname = await getDeploymentStatus( + deploymentUuid, + storeHash, + accessToken, + apiHost, + ); + + if (!options.updateSiteUrl) { + return; + } + + try { + await runChannelSiteUrlFlow({ + storeHash, + accessToken, + apiHost, projectUuid, - uploadSignature.upload_uuid, - options.storeHash, - options.accessToken, - options.apiHost, - environmentVariables, + preferHostname: deploymentHostname, + }); + } catch (error) { + // Soft-fail: the deploy already succeeded and the bundle is live. A + // non-zero exit here would be misleading. + consola.warn( + `Failed to update channel site URL: ${error instanceof Error ? error.message : String(error)}`, ); - - await getDeploymentStatus( - deploymentUuid, - options.storeHash, - options.accessToken, - options.apiHost, + consola.info( + 'Update it manually in the control panel, or re-run `catalyst auth login` if the token is missing the store_channel_settings scope.', ); - } catch (error) { - consola.error(error); - process.exit(1); } }); diff --git a/packages/catalyst/src/cli/commands/dev.spec.ts b/packages/catalyst/src/cli/commands/dev.spec.ts deleted file mode 100644 index 17ce28951d..0000000000 --- a/packages/catalyst/src/cli/commands/dev.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Command } from 'commander'; -import { execa } from 'execa'; -import { join } from 'node:path'; -import { expect, test, vi } from 'vitest'; - -import { program } from '../program'; - -import { dev } from './dev'; - -vi.mock('node:fs', () => ({ - existsSync: vi.fn(() => true), -})); - -vi.mock('execa', () => ({ - execa: vi.fn(() => Promise.resolve({ stdout: '' })), - __esModule: true, -})); - -test('properly configured Command instance', () => { - expect(dev).toBeInstanceOf(Command); - expect(dev.name()).toBe('dev'); - expect(dev.description()).toBe('Start the Catalyst development server.'); -}); - -test('calls execa with Next.js development server', async () => { - await program.parseAsync(['node', 'catalyst', 'dev', '-p', '3001']); - - expect(execa).toHaveBeenCalledWith( - join('node_modules', '.bin', 'next'), - ['dev', '-p', '3001'], - expect.objectContaining({ - stdio: 'inherit', - cwd: process.cwd(), - }), - ); -}); diff --git a/packages/catalyst/src/cli/commands/dev.ts b/packages/catalyst/src/cli/commands/dev.ts deleted file mode 100644 index 2d9119a24c..0000000000 --- a/packages/catalyst/src/cli/commands/dev.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Command } from 'commander'; -import { execa } from 'execa'; -import { existsSync } from 'node:fs'; -import { join } from 'node:path'; - -import { consola } from '../lib/logger'; - -export const dev = new Command('dev') - .description('Start the Catalyst development server.') - // Proxy `--help` to the underlying `next dev` command - .helpOption(false) - .allowUnknownOption(true) - // The unknown options end up in program.args, not in program.opts(). Commander does not take a guess at how to interpret the unknown options. - .argument( - '[options...]', - 'Next.js `dev` options (see: https://nextjs.org/docs/app/api-reference/cli/next#next-dev-options)', - ) - .action(async (options) => { - try { - const nextBin = join('node_modules', '.bin', 'next'); - - if (!existsSync(nextBin)) { - throw new Error( - `Next.js is not installed in ${process.cwd()}. Are you in a valid Next.js project?`, - ); - } - - await execa(nextBin, ['dev', ...options], { - stdio: 'inherit', - cwd: process.cwd(), - }); - } catch (error) { - consola.error(error instanceof Error ? error.message : error); - process.exit(1); - } - }); diff --git a/packages/catalyst/src/cli/commands/domains.spec.ts b/packages/catalyst/src/cli/commands/domains.spec.ts new file mode 100644 index 0000000000..38a60de7bb --- /dev/null +++ b/packages/catalyst/src/cli/commands/domains.spec.ts @@ -0,0 +1,1159 @@ +import { confirm, select } from '@inquirer/prompts'; +import { Command } from 'commander'; +import Conf from 'conf'; +import { http, HttpResponse } from 'msw'; +import { afterAll, afterEach, beforeAll, describe, expect, MockInstance, test, vi } from 'vitest'; + +import { server } from '../../../tests/mocks/node'; +import { + claimDomain, + createDomain, + deleteDomain, + findDomain, + getDomain, + listDomains, + transferDomain, +} from '../lib/domains'; +import { UserActionableError } from '../lib/errors'; +import { consola } from '../lib/logger'; +import { mkTempDir } from '../lib/mk-temp-dir'; +import { getProjectConfig, ProjectConfigSchema } from '../lib/project-config'; + +import { domains, formatDomain, formatDomainStatus, waitForDomainVerification } from './domains'; + +vi.mock('@inquirer/prompts', () => ({ + confirm: vi.fn(), + select: vi.fn(), +})); + +const confirmMock = vi.mocked(confirm); +const selectMock = vi.mocked(select); + +let exitMock: MockInstance; +let tmpDir: string; +let cleanup: () => Promise; +let config: Conf; + +const projectUuid = '6b202364-10f3-11f1-8bc7-fe9b9d8b14ab'; +const storeHash = 'test-store'; +const accessToken = 'test-token'; +const apiHost = 'api.bigcommerce.com'; +const domain = 'www.example.com'; + +function writeCredentials() { + config.set('storeHash', storeHash); + config.set('accessToken', accessToken); + config.set('projectUuid', projectUuid); +} + +beforeAll(async () => { + process.env.CATALYST_TELEMETRY_DISABLED = '1'; + + consola.mockTypes(() => vi.fn()); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + exitMock = vi.spyOn(process, 'exit').mockImplementation(() => null as never); + + [tmpDir, cleanup] = await mkTempDir(); + + vi.spyOn(process, 'cwd').mockReturnValue(tmpDir); + + config = getProjectConfig(); +}); + +afterEach(() => { + vi.clearAllMocks(); + config.delete('storeHash'); + config.delete('accessToken'); + config.delete('projectUuid'); +}); + +afterAll(async () => { + delete process.env.CATALYST_TELEMETRY_DISABLED; + + vi.restoreAllMocks(); + exitMock.mockRestore(); + + await cleanup(); +}); + +describe('command configuration', () => { + test('domains has add, list, status, claim, transfer, and remove subcommands', () => { + expect(domains).toBeInstanceOf(Command); + expect(domains.name()).toBe('domains'); + expect(domains.description()).toBe( + 'Manage custom domains for the current Native Hosting project.', + ); + + const add = domains.commands.find((command) => command.name() === 'add'); + const list = domains.commands.find((command) => command.name() === 'list'); + const status = domains.commands.find((command) => command.name() === 'status'); + const claim = domains.commands.find((command) => command.name() === 'claim'); + const transfer = domains.commands.find((command) => command.name() === 'transfer'); + const remove = domains.commands.find((command) => command.name() === 'remove'); + + expect(add).toBeDefined(); + expect(add?.description()).toBe('Add a custom domain to the current Native Hosting project.'); + expect(add?.options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ flags: '--store-hash ' }), + expect.objectContaining({ flags: '--access-token ' }), + expect.objectContaining({ flags: '--api-host ' }), + expect.objectContaining({ flags: '--project-uuid ' }), + expect.objectContaining({ flags: '--wait' }), + ]), + ); + + expect(list).toBeDefined(); + expect(list?.description()).toBe('List custom domains for the current Native Hosting project.'); + expect(list?.options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ flags: '--store-hash ' }), + expect.objectContaining({ flags: '--access-token ' }), + expect.objectContaining({ flags: '--api-host ' }), + expect.objectContaining({ flags: '--project-uuid ' }), + expect.objectContaining({ flags: '--domain ' }), + expect.objectContaining({ flags: '--status ' }), + ]), + ); + + expect(status).toBeDefined(); + expect(status?.description()).toBe( + 'Show the status of a custom domain on the current Native Hosting project.', + ); + expect(status?.options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ flags: '--store-hash ' }), + expect.objectContaining({ flags: '--access-token ' }), + expect.objectContaining({ flags: '--api-host ' }), + expect.objectContaining({ flags: '--project-uuid ' }), + expect.objectContaining({ flags: '--wait' }), + ]), + ); + + expect(claim).toBeDefined(); + expect(claim?.description()).toBe( + 'Claim a custom domain that is currently in use on another store.', + ); + expect(claim?.options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ flags: '--store-hash ' }), + expect.objectContaining({ flags: '--access-token ' }), + expect.objectContaining({ flags: '--api-host ' }), + expect.objectContaining({ flags: '--project-uuid ' }), + expect.objectContaining({ flags: '--wait' }), + ]), + ); + + expect(transfer).toBeDefined(); + expect(transfer?.description()).toBe( + 'Transfer a custom domain to another project in the same store.', + ); + expect(transfer?.options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ flags: '--store-hash ' }), + expect.objectContaining({ flags: '--access-token ' }), + expect.objectContaining({ flags: '--api-host ' }), + expect.objectContaining({ flags: '--project-uuid ' }), + expect.objectContaining({ flags: '--to-project-uuid ' }), + expect.objectContaining({ flags: '--wait' }), + ]), + ); + + expect(remove).toBeDefined(); + expect(remove?.description()).toBe( + 'Remove a custom domain from the current Native Hosting project.', + ); + expect(remove?.options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ flags: '--store-hash ' }), + expect.objectContaining({ flags: '--access-token ' }), + expect.objectContaining({ flags: '--api-host ' }), + expect.objectContaining({ flags: '--project-uuid ' }), + expect.objectContaining({ flags: '--force' }), + ]), + ); + }); +}); + +describe('formatDomainStatus', () => { + test('formats known domain statuses', () => { + expect(formatDomainStatus('pending')).toContain('pending'); + expect(formatDomainStatus('verified')).toContain('active'); + expect(formatDomainStatus('failed')).toContain('failed'); + expect(formatDomainStatus('unknown')).toContain('unknown'); + }); +}); + +describe('formatDomain', () => { + test('formats a domain line with the display status', () => { + expect( + formatDomain({ + domain, + project_uuid: projectUuid, + verification_status: 'verified', + }), + ).toContain(`${domain} `); + expect( + formatDomain({ + domain, + project_uuid: projectUuid, + verification_status: 'verified', + }), + ).toContain('active'); + }); +}); + +describe('domain API client', () => { + test('creates a domain with the expected request body', async () => { + let capturedBody: unknown; + + server.use( + http.post( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains', + async ({ request }) => { + capturedBody = await request.json(); + + return HttpResponse.json( + { + data: { + domain, + project_uuid: projectUuid, + verification_status: 'pending', + }, + }, + { status: 201 }, + ); + }, + ), + ); + + await expect( + createDomain(domain, projectUuid, storeHash, accessToken, apiHost), + ).resolves.toEqual({ + domain, + project_uuid: projectUuid, + verification_status: 'pending', + }); + expect(capturedBody).toEqual({ domain }); + }); + + test('surfaces disabled API responses', async () => { + server.use( + http.post( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains', + () => new HttpResponse(null, { status: 403 }), + ), + ); + + await expect( + createDomain(domain, projectUuid, storeHash, accessToken, apiHost), + ).rejects.toThrow('Infrastructure Domains API not enabled'); + }); + + test('surfaces V3 validation errors', async () => { + server.use( + http.post( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains', + () => + HttpResponse.json( + { + title: 'Domain could not be added.', + errors: { domain: 'Enter a valid domain.' }, + }, + { status: 422 }, + ), + ), + ); + + await expect( + createDomain(domain, projectUuid, storeHash, accessToken, apiHost), + ).rejects.toThrow('Domain could not be added. (domain: Enter a valid domain.)'); + }); + + test('falls back to status text when a client error response is not a V3 body', async () => { + server.use( + http.get( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains/:domain', + () => new HttpResponse(null, { status: 400, statusText: 'Bad Request' }), + ), + ); + + await expect(getDomain(domain, projectUuid, storeHash, accessToken, apiHost)).rejects.toThrow( + 'Failed to fetch domain: Bad Request', + ); + }); + + test('adds status and correlation details for server errors', async () => { + server.use( + http.post( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains', + () => + HttpResponse.json( + { + status: 502, + title: 'Bad Gateway', + errors: {}, + }, + { status: 502, statusText: 'Bad Gateway' }, + ), + ), + ); + + const error = await createDomain(domain, projectUuid, storeHash, accessToken, apiHost).catch( + (err: unknown) => err, + ); + + expect(error).toBeInstanceOf(Error); + // A 5xx is a server-side failure worth escalating, so it stays a plain Error + // and keeps the top-level Correlation ID + support framing. + expect(error).not.toBeInstanceOf(UserActionableError); + expect(error).toHaveProperty( + 'message', + 'Failed to add domain: 502 Bad Gateway. This is a server-side response from the Domains API.', + ); + }); + + test('treats a 4xx conflict as user-actionable (no support/correlation framing)', async () => { + server.use( + http.post( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains/:domain/claim', + () => + HttpResponse.json( + { + status: 409, + title: + 'The domain is already bound to another project in this store. Use the transfer endpoint to move it.', + errors: { + domain: `'${domain}' is already bound to another project in this store.`, + }, + }, + { status: 409 }, + ), + ), + ); + + const error = await claimDomain(domain, projectUuid, storeHash, accessToken, apiHost).catch( + (err: unknown) => err, + ); + + expect(error).toBeInstanceOf(UserActionableError); + expect(error).toHaveProperty( + 'message', + expect.stringContaining('already bound to another project in this store'), + ); + }); + + test('lists domains for a project', async () => { + await expect(listDomains(projectUuid, storeHash, accessToken, apiHost)).resolves.toEqual([ + { + domain: 'www.example.com', + project_uuid: projectUuid, + verification_status: 'pending', + }, + { + domain: 'shop.example.com', + project_uuid: projectUuid, + verification_status: 'verified', + }, + ]); + }); + + test('passes list filters to the API', async () => { + let capturedSearchParams: URLSearchParams | undefined; + + server.use( + http.get( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains', + ({ request }) => { + capturedSearchParams = new URL(request.url).searchParams; + + return HttpResponse.json({ + data: [ + { + domain, + project_uuid: projectUuid, + verification_status: 'pending', + }, + ], + }); + }, + ), + ); + + await expect( + listDomains(projectUuid, storeHash, accessToken, apiHost, { + domains: [domain], + verificationStatus: 'pending', + }), + ).resolves.toEqual([ + { + domain, + project_uuid: projectUuid, + verification_status: 'pending', + }, + ]); + expect(capturedSearchParams?.get('domain:in')).toBe(domain); + expect(capturedSearchParams?.get('verification_status')).toBe('pending'); + }); + + test('deletes a domain', async () => { + let deletedDomain: string | readonly string[] | undefined; + + server.use( + http.delete( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains/:domain', + ({ params }) => { + deletedDomain = params.domain; + + return new HttpResponse(null, { status: 204 }); + }, + ), + ); + + await expect( + deleteDomain(domain, projectUuid, storeHash, accessToken, apiHost), + ).resolves.toBeUndefined(); + expect(deletedDomain).toBe(domain); + }); + + test('claims a domain', async () => { + let claimedDomain: string | readonly string[] | undefined; + + server.use( + http.post( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains/:domain/claim', + ({ params }) => { + claimedDomain = params.domain; + + return new HttpResponse(null, { status: 204 }); + }, + ), + ); + + await expect( + claimDomain(domain, projectUuid, storeHash, accessToken, apiHost), + ).resolves.toBeUndefined(); + expect(claimedDomain).toBe(domain); + }); + + test('transfers a domain with the destination project in the body', async () => { + const newProjectUuid = 'a23f5785-fd99-4a94-9fb3-945551623923'; + let capturedBody: unknown; + let capturedSourceProject: string | readonly string[] | undefined; + + server.use( + http.post( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains/:domain/transfer', + async ({ request, params }) => { + capturedBody = await request.json(); + capturedSourceProject = params.projectUuid; + + return new HttpResponse(null, { status: 204 }); + }, + ), + ); + + await expect( + transferDomain(domain, projectUuid, newProjectUuid, storeHash, accessToken, apiHost), + ).resolves.toBeUndefined(); + expect(capturedSourceProject).toBe(projectUuid); + expect(capturedBody).toEqual({ new_project_uuid: newProjectUuid }); + }); + + test('findDomain returns the domain when it exists on the project', async () => { + await expect(findDomain(domain, projectUuid, storeHash, accessToken, apiHost)).resolves.toEqual( + { + domain, + project_uuid: projectUuid, + verification_status: 'verified', + }, + ); + }); + + test('findDomain returns null when the domain is not on the project', async () => { + server.use( + http.get( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains/:domain', + () => new HttpResponse(null, { status: 404 }), + ), + ); + + await expect( + findDomain(domain, projectUuid, storeHash, accessToken, apiHost), + ).resolves.toBeNull(); + }); + + test('findDomain still throws on non-404 errors', async () => { + server.use( + http.get( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains/:domain', + () => new HttpResponse(null, { status: 403 }), + ), + ); + + await expect(findDomain(domain, projectUuid, storeHash, accessToken, apiHost)).rejects.toThrow( + 'Infrastructure Domains API not enabled', + ); + }); + + test('surfaces the ownership-verification TXT record when a claim is not yet verified', async () => { + server.use( + http.post( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains/:domain/claim', + () => + HttpResponse.json( + { + title: 'Domain ownership could not be verified.', + errors: { + domain: `No ownership verification TXT record was found at '_bigcommerce-verification.${domain}'. Add the record, then try again.`, + }, + meta: { + ownership_verification: { + type: 'TXT', + name: `_bigcommerce-verification.${domain}`, + value: 'bc-verify=019500e2933d70578e81090dd7240795', + }, + }, + }, + { status: 422 }, + ), + ), + ); + + await expect( + claimDomain(domain, projectUuid, storeHash, accessToken, apiHost), + ).rejects.toMatchObject({ + name: 'DomainOwnershipVerificationError', + ownershipVerification: { + type: 'TXT', + name: `_bigcommerce-verification.${domain}`, + value: 'bc-verify=019500e2933d70578e81090dd7240795', + }, + }); + }); +}); + +describe('waitForDomainVerification', () => { + test('returns immediately when the domain is already verified', async () => { + await expect( + waitForDomainVerification({ + domain, + projectUuid, + storeHash, + accessToken, + apiHost, + intervalMs: 0, + }), + ).resolves.toEqual({ + domain, + project_uuid: projectUuid, + verification_status: 'verified', + }); + }); + + test('polls pending domains until they leave pending status', async () => { + let requests = 0; + + server.use( + http.get( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains/:domain', + () => { + requests += 1; + + return HttpResponse.json({ + data: { + domain, + project_uuid: projectUuid, + verification_status: requests === 1 ? 'pending' : 'failed', + }, + }); + }, + ), + ); + + await expect( + waitForDomainVerification({ + domain, + projectUuid, + storeHash, + accessToken, + apiHost, + intervalMs: 0, + }), + ).resolves.toEqual({ + domain, + project_uuid: projectUuid, + verification_status: 'failed', + }); + expect(requests).toBe(2); + }); + + test('returns the latest pending status after the timeout expires', async () => { + server.use( + http.get( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains/:domain', + () => + HttpResponse.json({ + data: { + domain, + project_uuid: projectUuid, + verification_status: 'pending', + }, + }), + ), + ); + + await expect( + waitForDomainVerification({ + domain, + projectUuid, + storeHash, + accessToken, + apiHost, + timeoutMs: 0, + }), + ).resolves.toEqual({ + domain, + project_uuid: projectUuid, + verification_status: 'pending', + }); + }); +}); + +describe('add command', () => { + test('adds a domain to the linked project', async () => { + writeCredentials(); + + await domains.parseAsync(['add', domain], { from: 'user' }); + + expect(consola.start).toHaveBeenCalledWith(`Adding domain ${domain}...`); + expect(consola.success).toHaveBeenCalledWith(`Domain ${domain} added.`); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining(domain)); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('pending')); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + test('can wait for domain verification after adding', async () => { + writeCredentials(); + + await domains.parseAsync(['add', domain, '--wait'], { from: 'user' }); + + expect(consola.start).toHaveBeenCalledWith(`Waiting for ${domain} to verify...`); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('active')); + }); + + test('surfaces the ownership TXT record and claim guidance on a cross-store collision', async () => { + server.use( + http.post( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains', + () => + HttpResponse.json( + { + title: + 'The domain is already bound to a different store. Verify ownership using the claim endpoint, then try again.', + errors: { + domain: `'${domain}' is already bound to a different store; verify ownership, then try again.`, + }, + meta: { + ownership_verification: { + type: 'TXT', + name: `_bigcommerce-verification.${domain}`, + value: 'bc-verify=019500e2933d70578e81090dd7240795', + }, + }, + }, + { status: 409 }, + ), + ), + ); + + writeCredentials(); + + await domains.parseAsync(['add', domain], { from: 'user' }); + + expect(consola.warn).toHaveBeenCalledWith( + expect.stringContaining('already in use on another store'), + ); + expect(consola.log).toHaveBeenCalledWith( + expect.stringContaining('bc-verify=019500e2933d70578e81090dd7240795'), + ); + expect(consola.info).toHaveBeenCalledWith('Once the record is live, claim it with:'); + expect(consola.log).toHaveBeenCalledWith(` catalyst domains claim ${domain}`); + // The raw V3 title/field text isn't echoed — the concise message replaces it. + expect(consola.warn).not.toHaveBeenCalledWith( + expect.stringContaining('Verify ownership using the claim endpoint'), + ); + expect(exitMock).toHaveBeenCalledWith(1); + }); + + test('suggests the transfer command when the domain is bound to another project in the store', async () => { + const boundProjectUuid = 'b23f5785-fd99-4a94-9fb3-945551623924'; + + server.use( + http.post( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains', + () => + HttpResponse.json( + { + title: + 'The domain is already bound to another project in this store. Use the transfer endpoint to move it.', + errors: { + domain: `'${domain}' is already bound to another project in this store.`, + }, + meta: { project_uuid: boundProjectUuid }, + }, + { status: 409 }, + ), + ), + ); + + writeCredentials(); + + await domains.parseAsync(['add', domain], { from: 'user' }); + + expect(consola.warn).toHaveBeenCalledWith( + expect.stringContaining('already bound to another project in this store'), + ); + expect(consola.info).toHaveBeenCalledWith('To move it to this project, run:'); + expect(consola.log).toHaveBeenCalledWith( + ` catalyst domains transfer ${domain} --project-uuid ${boundProjectUuid} --to-project-uuid ${projectUuid}`, + ); + // The raw V3 title/field text isn't echoed — the concise message + suggestion replace it. + expect(consola.warn).not.toHaveBeenCalledWith( + expect.stringContaining('Use the transfer endpoint to move it'), + ); + expect(exitMock).toHaveBeenCalledWith(1); + }); +}); + +describe('list command', () => { + test('lists domains for the linked project', async () => { + writeCredentials(); + + await domains.parseAsync(['list'], { from: 'user' }); + + expect(consola.start).toHaveBeenCalledWith('Fetching domains...'); + expect(consola.success).toHaveBeenCalledWith('Domains fetched.'); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('www.example.com')); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('pending')); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('shop.example.com')); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('active')); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + test('passes filters from options', async () => { + let capturedSearchParams: URLSearchParams | undefined; + + server.use( + http.get( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains', + ({ request }) => { + capturedSearchParams = new URL(request.url).searchParams; + + return HttpResponse.json({ + data: [ + { + domain, + project_uuid: projectUuid, + verification_status: 'pending', + }, + ], + }); + }, + ), + ); + + writeCredentials(); + + await domains.parseAsync(['list', '--domain', domain, '--status', 'pending'], { from: 'user' }); + + expect(capturedSearchParams?.get('domain:in')).toBe(domain); + expect(capturedSearchParams?.get('verification_status')).toBe('pending'); + }); + + test('reports when no domains are configured', async () => { + server.use( + http.get( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains', + () => HttpResponse.json({ data: [] }), + ), + ); + + writeCredentials(); + + await domains.parseAsync(['list'], { from: 'user' }); + + expect(consola.info).toHaveBeenCalledWith('No custom domains found.'); + expect(exitMock).toHaveBeenCalledWith(0); + }); +}); + +describe('status command', () => { + test('shows a domain status for the linked project', async () => { + writeCredentials(); + + await domains.parseAsync(['status', domain], { from: 'user' }); + + expect(consola.start).toHaveBeenCalledWith(`Fetching status for ${domain}...`); + expect(consola.success).toHaveBeenCalledWith('Domain status fetched.'); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining(domain)); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('active')); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + test('can wait for a pending domain to leave pending status', async () => { + let requests = 0; + + server.use( + http.get( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains/:domain', + () => { + requests += 1; + + return HttpResponse.json({ + data: { + domain, + project_uuid: projectUuid, + verification_status: requests === 1 ? 'pending' : 'verified', + }, + }); + }, + ), + ); + + writeCredentials(); + + await domains.parseAsync(['status', domain, '--wait'], { from: 'user' }); + + expect(consola.start).toHaveBeenCalledWith(`Waiting for ${domain} to verify...`); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('active')); + expect(requests).toBe(2); + }); +}); + +describe('claim command', () => { + test('claims a domain for the linked project', async () => { + writeCredentials(); + + await domains.parseAsync(['claim', domain], { from: 'user' }); + + expect(consola.start).toHaveBeenCalledWith(`Claiming domain ${domain}...`); + expect(consola.success).toHaveBeenCalledWith(`Domain ${domain} claimed.`); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining(domain)); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('active')); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + test('prints the ownership TXT record when verification has not completed', async () => { + server.use( + http.post( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains/:domain/claim', + () => + HttpResponse.json( + { + title: 'Domain ownership could not be verified.', + errors: { + domain: `No ownership verification TXT record was found at '_bigcommerce-verification.${domain}'. Add the record, then try again.`, + }, + meta: { + ownership_verification: { + type: 'TXT', + name: `_bigcommerce-verification.${domain}`, + value: 'bc-verify=019500e2933d70578e81090dd7240795', + }, + }, + }, + { status: 422 }, + ), + ), + ); + + writeCredentials(); + + await domains.parseAsync(['claim', domain], { from: 'user' }); + + expect(consola.warn).toHaveBeenCalledWith(expect.stringContaining('could not be verified')); + expect(consola.log).toHaveBeenCalledWith( + expect.stringContaining('bc-verify=019500e2933d70578e81090dd7240795'), + ); + expect(consola.success).not.toHaveBeenCalled(); + expect(exitMock).toHaveBeenCalledWith(1); + }); + + test('can wait for a claimed domain to leave pending status', async () => { + let getRequests = 0; + + server.use( + http.post( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains/:domain/claim', + () => new HttpResponse(null, { status: 204 }), + ), + http.get( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains/:domain', + () => { + getRequests += 1; + + return HttpResponse.json({ + data: { + domain, + project_uuid: projectUuid, + verification_status: getRequests === 1 ? 'pending' : 'verified', + }, + }); + }, + ), + ); + + writeCredentials(); + + await domains.parseAsync(['claim', domain, '--wait'], { from: 'user' }); + + expect(consola.success).toHaveBeenCalledWith(`Domain ${domain} claimed.`); + expect(consola.start).toHaveBeenCalledWith(`Waiting for ${domain} to verify...`); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('active')); + expect(getRequests).toBe(2); + }); +}); + +describe('transfer command', () => { + const destinationProjectUuid = 'a23f5785-fd99-4a94-9fb3-945551623923'; + + test('transfers to the project passed via --to-project-uuid without prompting', async () => { + let capturedBody: unknown; + let capturedSourceProject: string | readonly string[] | undefined; + + server.use( + http.post( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains/:domain/transfer', + async ({ request, params }) => { + capturedBody = await request.json(); + capturedSourceProject = params.projectUuid; + + return new HttpResponse(null, { status: 204 }); + }, + ), + ); + + writeCredentials(); + + await domains.parseAsync(['transfer', domain, '--to-project-uuid', destinationProjectUuid], { + from: 'user', + }); + + expect(selectMock).not.toHaveBeenCalled(); + expect(capturedSourceProject).toBe(projectUuid); + expect(capturedBody).toEqual({ new_project_uuid: destinationProjectUuid }); + expect(consola.success).toHaveBeenCalledWith(`Domain ${domain} transferred.`); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('active')); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + test('prompts to pick a destination project, excluding the source', async () => { + // Default fetchProjects handler returns Project One + Project Two; the + // linked (source) project UUID matches neither, so both are offered. + selectMock.mockResolvedValue(destinationProjectUuid); + + let capturedBody: unknown; + + server.use( + http.post( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains/:domain/transfer', + async ({ request }) => { + capturedBody = await request.json(); + + return new HttpResponse(null, { status: 204 }); + }, + ), + ); + + writeCredentials(); + + await domains.parseAsync(['transfer', domain], { from: 'user' }); + + expect(selectMock).toHaveBeenCalledTimes(1); + + const choiceValues = selectMock.mock.calls[0][0].choices.map((choice) => + typeof choice === 'object' && 'value' in choice ? choice.value : undefined, + ); + + expect(choiceValues).not.toContain(projectUuid); + expect(capturedBody).toEqual({ new_project_uuid: destinationProjectUuid }); + expect(consola.success).toHaveBeenCalledWith(`Domain ${domain} transferred.`); + }); + + test('points at the owning project when the domain lives on another one', async () => { + // Default fetchProjects returns Project One (a23f…) + Project Two (b23f…). + // The domain is on Project One; the linked project and Project Two 404. + const ownerUuid = destinationProjectUuid; + let transferRequests = 0; + + server.use( + http.get( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains/:domain', + ({ params }) => + params.projectUuid === ownerUuid + ? HttpResponse.json({ + data: { domain, project_uuid: ownerUuid, verification_status: 'verified' }, + }) + : new HttpResponse(null, { status: 404 }), + ), + http.post( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains/:domain/transfer', + () => { + transferRequests += 1; + + return new HttpResponse(null, { status: 204 }); + }, + ), + ); + + writeCredentials(); + + await domains.parseAsync(['transfer', domain], { from: 'user' }); + + expect(consola.warn).toHaveBeenCalledWith( + expect.stringContaining(`${domain} is on project "Project One" (${ownerUuid})`), + ); + expect(consola.log).toHaveBeenCalledWith( + ` catalyst domains transfer ${domain} --project-uuid ${ownerUuid}`, + ); + // The transfer is never attempted, and the user is never prompted. + expect(transferRequests).toBe(0); + expect(selectMock).not.toHaveBeenCalled(); + expect(exitMock).toHaveBeenCalledWith(1); + }); + + test('errors when the domain is on no project in the store', async () => { + let transferRequests = 0; + + server.use( + http.get( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains/:domain', + () => new HttpResponse(null, { status: 404 }), + ), + http.post( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains/:domain/transfer', + () => { + transferRequests += 1; + + return new HttpResponse(null, { status: 204 }); + }, + ), + ); + + writeCredentials(); + + await expect( + domains.parseAsync(['transfer', domain, '--to-project-uuid', destinationProjectUuid], { + from: 'user', + }), + ).rejects.toThrow(`${domain} isn't on any project in this store`); + + // The transfer is never attempted, and the user is never prompted. + expect(transferRequests).toBe(0); + expect(selectMock).not.toHaveBeenCalled(); + }); + + test('rejects a destination that matches the source project', async () => { + writeCredentials(); + + await expect( + domains.parseAsync(['transfer', domain, '--to-project-uuid', projectUuid], { from: 'user' }), + ).rejects.toThrow('destination project must differ from the source project'); + }); + + test('errors when there are no other projects to transfer to', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => + HttpResponse.json({ + data: [{ uuid: projectUuid, name: 'Only Project', deployment_hostnames: [] }], + }), + ), + ); + + writeCredentials(); + + await expect(domains.parseAsync(['transfer', domain], { from: 'user' })).rejects.toThrow( + 'No other projects to transfer to', + ); + expect(selectMock).not.toHaveBeenCalled(); + }); +}); + +describe('remove command', () => { + test('confirms before removing an active domain', async () => { + confirmMock.mockResolvedValue(true); + + writeCredentials(); + + await domains.parseAsync(['remove', domain], { from: 'user' }); + + expect(consola.start).toHaveBeenCalledWith(`Fetching status for ${domain}...`); + expect(confirmMock).toHaveBeenCalledWith({ + message: `Remove active domain ${domain}? Traffic may stop routing to this project.`, + default: false, + }); + expect(consola.start).toHaveBeenCalledWith(`Removing domain ${domain}...`); + expect(consola.success).toHaveBeenCalledWith(`Domain ${domain} removed.`); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + test('does not remove an active domain when confirmation is declined', async () => { + let deleteRequests = 0; + + server.use( + http.delete( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains/:domain', + () => { + deleteRequests += 1; + + return new HttpResponse(null, { status: 204 }); + }, + ), + ); + + confirmMock.mockResolvedValue(false); + + writeCredentials(); + + await domains.parseAsync(['remove', domain], { from: 'user' }); + + expect(consola.info).toHaveBeenCalledWith('Aborted. No domain was removed.'); + expect(deleteRequests).toBe(0); + expect(exitMock).toHaveBeenCalledWith(0); + }); + + test('skips confirmation with --force', async () => { + confirmMock.mockResolvedValue(false); + + writeCredentials(); + + await domains.parseAsync(['remove', domain, '--force'], { from: 'user' }); + + expect(confirmMock).not.toHaveBeenCalled(); + expect(consola.success).toHaveBeenCalledWith(`Domain ${domain} removed.`); + }); + + test('does not prompt before removing a pending domain', async () => { + server.use( + http.get( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains/:domain', + () => + HttpResponse.json({ + data: { + domain, + project_uuid: projectUuid, + verification_status: 'pending', + }, + }), + ), + ); + + writeCredentials(); + + await domains.parseAsync(['remove', domain], { from: 'user' }); + + expect(confirmMock).not.toHaveBeenCalled(); + expect(consola.success).toHaveBeenCalledWith(`Domain ${domain} removed.`); + }); +}); diff --git a/packages/catalyst/src/cli/commands/domains.ts b/packages/catalyst/src/cli/commands/domains.ts new file mode 100644 index 0000000000..024497dffb --- /dev/null +++ b/packages/catalyst/src/cli/commands/domains.ts @@ -0,0 +1,606 @@ +import { confirm, select } from '@inquirer/prompts'; +import { Command, Option } from 'commander'; +import { colorize } from 'consola/utils'; + +import { + claimDomain, + createDomain, + deleteDomain, + Domain, + DomainBoundToProjectError, + DomainOwnershipVerificationError, + DomainStatus, + DomainStatusFilter, + findDomain, + getDomain, + listDomains, + OwnershipVerification, + transferDomain, +} from '../lib/domains'; +import { UserActionableError } from '../lib/errors'; +import { consola } from '../lib/logger'; +import { fetchProjects } from '../lib/project'; +import { getProjectConfig } from '../lib/project-config'; +import { resolveCredentials } from '../lib/resolve-credentials'; +import { + accessTokenOption, + apiHostOption, + projectUuidOption, + resolveApiHost, + resolveProjectUuid, + storeHashOption, +} from '../lib/shared-options'; +import { getTelemetry } from '../lib/telemetry'; + +const WAIT_INTERVAL_MS = 5000; +const WAIT_TIMEOUT_MS = 5 * 60 * 1000; +const DOMAIN_STATUS_FILTERS: DomainStatusFilter[] = ['pending', 'verified', 'failed']; + +const STATUS_COLORS: Record[0]> = { + pending: 'yellow', + verified: 'green', + failed: 'red', + unknown: 'gray', +}; + +const STATUS_LABELS: Record = { + pending: 'pending', + verified: 'active', + failed: 'failed', + unknown: 'unknown', +}; + +interface DomainCommandContext { + projectUuid: string; + storeHash: string; + accessToken: string; + apiHost: string; +} + +interface DomainCommandOptions { + storeHash?: string; + accessToken?: string; + apiHost?: string; + projectUuid?: string; +} + +interface WaitForDomainVerificationOptions extends DomainCommandContext { + domain: string; + intervalMs?: number; + timeoutMs?: number; +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +function resolveDomainCommandContext(options: DomainCommandOptions): DomainCommandContext { + const config = getProjectConfig(); + const apiHost = resolveApiHost(options, config); + const { storeHash, accessToken } = resolveCredentials(options, config); + const projectUuid = resolveProjectUuid(options); + + return { + projectUuid, + storeHash, + accessToken, + apiHost, + }; +} + +// Resolves the destination project for a transfer. Uses `--to-project-uuid` +// when given; otherwise fetches the store's projects and prompts the user to +// pick one, excluding the source project (a domain can't be transferred to the +// project it already belongs to). +async function resolveDestinationProject( + domain: string, + context: DomainCommandContext, + toProjectUuid?: string, +): Promise { + if (toProjectUuid) { + if (toProjectUuid === context.projectUuid) { + throw new UserActionableError('The destination project must differ from the source project.'); + } + + return toProjectUuid; + } + + consola.start('Fetching projects...'); + + const projects = await fetchProjects(context.storeHash, context.accessToken, context.apiHost); + + consola.success('Projects fetched.'); + + const destinations = projects.filter((project) => project.uuid !== context.projectUuid); + + if (destinations.length === 0) { + throw new UserActionableError( + 'No other projects to transfer to. Create another project with `catalyst project create` first.', + ); + } + + return select({ + message: `Select the project to transfer ${domain} to (Press to select).`, + choices: destinations.map((project) => ({ + name: project.name, + value: project.uuid, + description: project.uuid, + })), + }); +} + +export function formatDomainStatus(status: DomainStatus): string { + return colorize(STATUS_COLORS[status], STATUS_LABELS[status]); +} + +export function formatDomain(domain: Domain): string { + return `${domain.domain} ${formatDomainStatus(domain.verification_status)}`; +} + +export function formatOwnershipVerification(record: OwnershipVerification): string { + const lines = [ + 'Add this DNS record to verify ownership:', + ` Type: ${record.type}`, + ` Name: ${record.name}`, + ]; + + if (record.value) { + lines.push(` Value: ${record.value}`); + } + + return lines.join('\n'); +} + +export async function waitForDomainVerification({ + domain, + projectUuid, + storeHash, + accessToken, + apiHost, + intervalMs = WAIT_INTERVAL_MS, + timeoutMs = WAIT_TIMEOUT_MS, +}: WaitForDomainVerificationOptions): Promise { + const startedAt = Date.now(); + let current = await getDomain(domain, projectUuid, storeHash, accessToken, apiHost); + + while (current.verification_status === 'pending' && Date.now() - startedAt < timeoutMs) { + // eslint-disable-next-line no-await-in-loop + await sleep(intervalMs); + // eslint-disable-next-line no-await-in-loop + current = await getDomain(domain, projectUuid, storeHash, accessToken, apiHost); + } + + return current; +} + +const add = new Command('add') + .configureHelp({ showGlobalOptions: true }) + .description('Add a custom domain to the current Native Hosting project.') + .argument('', 'Custom domain to add.') + .addHelpText( + 'after', + ` +Examples: + $ catalyst domains add www.example.com + + # Wait until the domain leaves pending verification + $ catalyst domains add www.example.com --wait`, + ) + .addOption(storeHashOption()) + .addOption(accessTokenOption()) + .addOption(apiHostOption()) + .addOption(projectUuidOption()) + .option('--wait', 'Poll until domain verification completes or times out.') + .action(async (domain, options) => { + const context = resolveDomainCommandContext(options); + + await getTelemetry().identify(context.storeHash); + + consola.start(`Adding domain ${domain}...`); + + let result: Domain; + + try { + result = await createDomain( + domain, + context.projectUuid, + context.storeHash, + context.accessToken, + context.apiHost, + ); + } catch (error) { + if (error instanceof DomainOwnershipVerificationError) { + consola.warn(`${domain} is already in use on another store.`); + consola.log(formatOwnershipVerification(error.ownershipVerification)); + consola.info('Once the record is live, claim it with:'); + consola.log(` catalyst domains claim ${domain}`); + process.exit(1); + + return; + } + + if (error instanceof DomainBoundToProjectError) { + consola.warn(`${domain} is already bound to another project in this store.`); + consola.info('To move it to this project, run:'); + consola.log( + ` catalyst domains transfer ${domain} --project-uuid ${error.projectUuid} --to-project-uuid ${context.projectUuid}`, + ); + process.exit(1); + + return; + } + + throw error; + } + + consola.success(`Domain ${result.domain} added.`); + + if (options.wait && result.verification_status === 'pending') { + consola.start(`Waiting for ${result.domain} to verify...`); + result = await waitForDomainVerification({ domain: result.domain, ...context }); + } + + consola.log(formatDomain(result)); + process.exit(0); + }); + +const list = new Command('list') + .configureHelp({ showGlobalOptions: true }) + .description('List custom domains for the current Native Hosting project.') + .addHelpText( + 'after', + ` +Examples: + $ catalyst domains list + + # Show pending domains only + $ catalyst domains list --status pending`, + ) + .addOption(storeHashOption()) + .addOption(accessTokenOption()) + .addOption(apiHostOption()) + .addOption(projectUuidOption()) + .option('--domain ', 'Only show a specific domain.') + .addOption( + new Option('--status ', 'Filter by verification status.').choices( + DOMAIN_STATUS_FILTERS, + ), + ) + .action(async (options) => { + const context = resolveDomainCommandContext(options); + + await getTelemetry().identify(context.storeHash); + + consola.start('Fetching domains...'); + + const result = await listDomains( + context.projectUuid, + context.storeHash, + context.accessToken, + context.apiHost, + { + domains: options.domain ? [options.domain] : undefined, + verificationStatus: options.status, + }, + ); + + consola.success('Domains fetched.'); + + if (result.length === 0) { + consola.info('No custom domains found.'); + process.exit(0); + + return; + } + + result.forEach((item) => consola.log(formatDomain(item))); + process.exit(0); + }); + +const showStatus = new Command('status') + .configureHelp({ showGlobalOptions: true }) + .description('Show the status of a custom domain on the current Native Hosting project.') + .argument('', 'Custom domain to check.') + .addHelpText( + 'after', + ` +Examples: + $ catalyst domains status www.example.com + + # Wait until the domain leaves pending verification + $ catalyst domains status www.example.com --wait`, + ) + .addOption(storeHashOption()) + .addOption(accessTokenOption()) + .addOption(apiHostOption()) + .addOption(projectUuidOption()) + .option('--wait', 'Poll until domain verification completes or times out.') + .action(async (domain, options) => { + const context = resolveDomainCommandContext(options); + + await getTelemetry().identify(context.storeHash); + + consola.start(`Fetching status for ${domain}...`); + + let result = await getDomain( + domain, + context.projectUuid, + context.storeHash, + context.accessToken, + context.apiHost, + ); + + consola.success('Domain status fetched.'); + + if (options.wait && result.verification_status === 'pending') { + consola.start(`Waiting for ${result.domain} to verify...`); + result = await waitForDomainVerification({ domain: result.domain, ...context }); + } + + consola.log(formatDomain(result)); + process.exit(0); + }); + +const claim = new Command('claim') + .configureHelp({ showGlobalOptions: true }) + .description('Claim a custom domain that is currently in use on another store.') + .argument('', 'Custom domain to claim.') + .addHelpText( + 'after', + ` +Claiming requires publishing the ownership-verification TXT record returned when +you try to add a domain that is already in use on another store. Publish the +record with your DNS provider, then run this command to complete the claim. + +Examples: + $ catalyst domains claim www.example.com + + # Wait until the claimed domain leaves pending verification + $ catalyst domains claim www.example.com --wait`, + ) + .addOption(storeHashOption()) + .addOption(accessTokenOption()) + .addOption(apiHostOption()) + .addOption(projectUuidOption()) + .option('--wait', 'Poll until domain verification completes or times out.') + .action(async (domain, options) => { + const context = resolveDomainCommandContext(options); + + await getTelemetry().identify(context.storeHash); + + consola.start(`Claiming domain ${domain}...`); + + try { + await claimDomain( + domain, + context.projectUuid, + context.storeHash, + context.accessToken, + context.apiHost, + ); + } catch (error) { + if (error instanceof DomainOwnershipVerificationError) { + consola.warn(`Ownership of ${domain} could not be verified yet.`); + consola.log(formatOwnershipVerification(error.ownershipVerification)); + consola.info('Once the record is live, run the claim again:'); + consola.log(` catalyst domains claim ${domain}`); + process.exit(1); + + return; + } + + throw error; + } + + consola.success(`Domain ${domain} claimed.`); + + let result = await getDomain( + domain, + context.projectUuid, + context.storeHash, + context.accessToken, + context.apiHost, + ); + + if (options.wait && result.verification_status === 'pending') { + consola.start(`Waiting for ${result.domain} to verify...`); + result = await waitForDomainVerification({ domain: result.domain, ...context }); + } + + consola.log(formatDomain(result)); + process.exit(0); + }); + +const transfer = new Command('transfer') + .configureHelp({ showGlobalOptions: true }) + .description('Transfer a custom domain to another project in the same store.') + .argument('', 'Custom domain to transfer.') + .addHelpText( + 'after', + ` +The domain is transferred from the current project to the destination project. +Omit --to-project-uuid to pick the destination from a list of your projects. + +Examples: + # Pick the destination project interactively + $ catalyst domains transfer www.example.com + + # Transfer to a specific project + $ catalyst domains transfer www.example.com --to-project-uuid `, + ) + .addOption(storeHashOption()) + .addOption(accessTokenOption()) + .addOption(apiHostOption()) + .addOption(projectUuidOption()) + .option('--to-project-uuid ', 'Destination project UUID. Prompts to choose one if omitted.') + .option('--wait', 'Poll until domain verification completes or times out.') + .action(async (domain, options) => { + const context = resolveDomainCommandContext(options); + + await getTelemetry().identify(context.storeHash); + + // Confirm the domain is on the source project before doing anything else. + // `transfer` moves a domain *from* the current project, so a domain that + // lives elsewhere can't be transferred from here — catch it now instead of + // letting the API reject the transfer with an opaque ownership error after + // the user has already picked a destination. + consola.start(`Checking ${domain} on the current project...`); + + const owned = await findDomain( + domain, + context.projectUuid, + context.storeHash, + context.accessToken, + context.apiHost, + ); + + if (!owned) { + // The domain isn't on the linked project. Scan the store's other projects + // so we can point the user at the exact owner — `domains list` only shows + // the *current* project's domains, so it can't help them find where the + // domain actually lives. + consola.start(`Locating the project that owns ${domain}...`); + + const projects = await fetchProjects(context.storeHash, context.accessToken, context.apiHost); + const candidates = projects.filter((project) => project.uuid !== context.projectUuid); + const matches = await Promise.all( + candidates.map(async (project) => ({ + project, + found: await findDomain( + domain, + project.uuid, + context.storeHash, + context.accessToken, + context.apiHost, + ), + })), + ); + const owner = matches.find((match) => match.found)?.project; + + if (!owner) { + throw new UserActionableError( + `${domain} isn't on any project in this store. If it's in use on another store, ` + + `claim it with \`catalyst domains claim ${domain}\`; otherwise double-check the domain name.`, + ); + } + + const toSuffix = options.toProjectUuid ? ` --to-project-uuid ${options.toProjectUuid}` : ''; + + consola.warn( + `${domain} is on project "${owner.name}" (${owner.uuid}), not the current project.`, + ); + consola.info('Re-run the transfer from that project:'); + consola.log(` catalyst domains transfer ${domain} --project-uuid ${owner.uuid}${toSuffix}`); + process.exit(1); + + return; + } + + consola.success(`${domain} found on the current project.`); + + const newProjectUuid = await resolveDestinationProject(domain, context, options.toProjectUuid); + + consola.start(`Transferring domain ${domain}...`); + + await transferDomain( + domain, + context.projectUuid, + newProjectUuid, + context.storeHash, + context.accessToken, + context.apiHost, + ); + + consola.success(`Domain ${domain} transferred.`); + + let result = await getDomain( + domain, + newProjectUuid, + context.storeHash, + context.accessToken, + context.apiHost, + ); + + if (options.wait && result.verification_status === 'pending') { + consola.start(`Waiting for ${result.domain} to verify...`); + result = await waitForDomainVerification({ + domain: result.domain, + projectUuid: newProjectUuid, + storeHash: context.storeHash, + accessToken: context.accessToken, + apiHost: context.apiHost, + }); + } + + consola.log(formatDomain(result)); + process.exit(0); + }); + +const remove = new Command('remove') + .configureHelp({ showGlobalOptions: true }) + .description('Remove a custom domain from the current Native Hosting project.') + .argument('', 'Custom domain to remove.') + .addHelpText( + 'after', + ` +Examples: + $ catalyst domains remove www.example.com + + # Skip confirmation for an active domain + $ catalyst domains remove www.example.com --force`, + ) + .addOption(storeHashOption()) + .addOption(accessTokenOption()) + .addOption(apiHostOption()) + .addOption(projectUuidOption()) + .option('--force', 'Skip the confirmation prompt before removing an active domain.') + .action(async (domain, options) => { + const context = resolveDomainCommandContext(options); + + await getTelemetry().identify(context.storeHash); + + consola.start(`Fetching status for ${domain}...`); + + const current = await getDomain( + domain, + context.projectUuid, + context.storeHash, + context.accessToken, + context.apiHost, + ); + + if (current.verification_status === 'verified' && !options.force) { + const confirmed = await confirm({ + message: `Remove active domain ${current.domain}? Traffic may stop routing to this project.`, + default: false, + }); + + if (!confirmed) { + consola.info('Aborted. No domain was removed.'); + process.exit(0); + + return; + } + } + + consola.start(`Removing domain ${current.domain}...`); + + await deleteDomain( + current.domain, + context.projectUuid, + context.storeHash, + context.accessToken, + context.apiHost, + ); + + consola.success(`Domain ${current.domain} removed.`); + process.exit(0); + }); + +export const domains = new Command('domains') + .configureHelp({ showGlobalOptions: true }) + .description('Manage custom domains for the current Native Hosting project.') + .addCommand(add) + .addCommand(list) + .addCommand(showStatus) + .addCommand(claim) + .addCommand(transfer) + .addCommand(remove); diff --git a/packages/catalyst/src/cli/commands/env.spec.ts b/packages/catalyst/src/cli/commands/env.spec.ts new file mode 100644 index 0000000000..75a1c9433f --- /dev/null +++ b/packages/catalyst/src/cli/commands/env.spec.ts @@ -0,0 +1,115 @@ +import { Command } from 'commander'; +import { afterAll, afterEach, beforeAll, beforeEach, expect, MockInstance, test, vi } from 'vitest'; + +import { consola } from '../lib/logger'; +import { mkTempDir } from '../lib/mk-temp-dir'; +import { getProjectConfig } from '../lib/project-config'; +import { program } from '../program'; + +import { env } from './env'; + +let exitMock: MockInstance; +let tmpDir: string; +let cleanup: () => Promise; + +beforeAll(async () => { + consola.mockTypes(() => vi.fn()); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + exitMock = vi.spyOn(process, 'exit').mockImplementation(() => null as never); + + [tmpDir, cleanup] = await mkTempDir(); +}); + +beforeEach(() => { + process.chdir(tmpDir); +}); + +afterEach(() => { + getProjectConfig().delete('env'); + vi.clearAllMocks(); +}); + +afterAll(async () => { + await cleanup(); +}); + +test('properly configured Command instance', () => { + expect(env).toBeInstanceOf(Command); + expect(env.name()).toBe('env'); + expect(env.commands.map((c) => c.name()).sort()).toEqual(['add', 'list', 'remove']); +}); + +test('add stores variables in .bigcommerce/project.json', async () => { + await program.parseAsync(['node', 'catalyst', 'env', 'add', 'FOO=bar', 'BAZ=qux']); + + expect(getProjectConfig().get('env')).toEqual({ FOO: 'bar', BAZ: 'qux' }); + expect(exitMock).toHaveBeenCalledWith(0); +}); + +test('add merges with and overwrites existing variables', async () => { + const config = getProjectConfig(); + + config.set('env', { FOO: 'old', KEEP: 'me' }); + + await program.parseAsync(['node', 'catalyst', 'env', 'add', 'FOO=new']); + + expect(config.get('env')).toEqual({ FOO: 'new', KEEP: 'me' }); +}); + +test('add never prints the raw value', async () => { + await program.parseAsync(['node', 'catalyst', 'env', 'add', 'SECRET=supersecret']); + + const logged = vi.mocked(consola.log).mock.calls.flat().join('\n'); + + expect(logged).toContain('SECRET='); + expect(logged).not.toContain('supersecret'); +}); + +test('add rejects an invalid assignment without partially writing', async () => { + const config = getProjectConfig(); + + config.set('env', { EXISTING: 'value' }); + + await expect( + program.parseAsync(['node', 'catalyst', 'env', 'add', 'GOOD=ok', 'bad-entry']), + ).rejects.toThrow('Invalid env var format: bad-entry'); + + // GOOD must not have been persisted since one entry was invalid. + expect(config.get('env')).toEqual({ EXISTING: 'value' }); +}); + +test('remove deletes stored variables', async () => { + const config = getProjectConfig(); + + config.set('env', { FOO: 'bar', BAZ: 'qux' }); + + await program.parseAsync(['node', 'catalyst', 'env', 'remove', 'FOO']); + + expect(config.get('env')).toEqual({ BAZ: 'qux' }); + expect(exitMock).toHaveBeenCalledWith(0); +}); + +test('remove warns for keys that are not stored', async () => { + getProjectConfig().set('env', { FOO: 'bar' }); + + await program.parseAsync(['node', 'catalyst', 'env', 'remove', 'MISSING']); + + expect(consola.warn).toHaveBeenCalledWith(expect.stringContaining('MISSING')); +}); + +test('list shows stored keys with masked values', async () => { + getProjectConfig().set('env', { FOO: 'bar' }); + + await program.parseAsync(['node', 'catalyst', 'env', 'list']); + + const logged = vi.mocked(consola.log).mock.calls.flat().join('\n'); + + expect(logged).toContain('FOO='); + expect(logged).not.toContain('bar'); +}); + +test('list reports when nothing is stored', async () => { + await program.parseAsync(['node', 'catalyst', 'env', 'list']); + + expect(consola.info).toHaveBeenCalledWith(expect.stringContaining('No environment variables')); +}); diff --git a/packages/catalyst/src/cli/commands/env.ts b/packages/catalyst/src/cli/commands/env.ts new file mode 100644 index 0000000000..e17c19eea2 --- /dev/null +++ b/packages/catalyst/src/cli/commands/env.ts @@ -0,0 +1,118 @@ +import { Command } from 'commander'; + +import { getStoredEnv, parseEnvAssignment } from '../lib/env-config'; +import { consola } from '../lib/logger'; +import { getProjectConfig } from '../lib/project-config'; + +// Values are secrets, so we never print them back. A fixed-width mask avoids +// leaking the length of the stored value. +const MASK = '••••••'; + +const add = new Command('add') + .configureHelp({ showGlobalOptions: true }) + .description( + 'Add or update one or more deployment environment variables. Stored in .bigcommerce/project.json and sent as secrets on every `catalyst deploy`.', + ) + .argument('', 'One or more environment variables in KEY=VALUE format.') + .addHelpText( + 'after', + ` +Example: + $ catalyst env add BIGCOMMERCE_STORE_HASH=abc123 BIGCOMMERCE_STOREFRONT_TOKEN=ey...`, + ) + .action((vars: string[]) => { + const config = getProjectConfig(); + const stored = { ...getStoredEnv(config) }; + + // Parse everything before writing so a single invalid entry doesn't leave a + // partial update behind. + const parsed = vars.map((entry) => parseEnvAssignment(entry)); + + parsed.forEach(({ key, value }) => { + stored[key] = value; + }); + + config.set('env', stored); + + const keys = parsed.map(({ key }) => key); + + consola.success( + `Saved ${keys.length} environment variable${keys.length === 1 ? '' : 's'} to .bigcommerce/project.json:`, + ); + keys.forEach((key) => consola.log(` ${key}=${MASK}`)); + + process.exit(0); + }); + +const remove = new Command('remove') + .configureHelp({ showGlobalOptions: true }) + .description('Remove one or more stored deployment environment variables.') + .argument('', 'One or more environment variable names to remove.') + .addHelpText( + 'after', + ` +Example: + $ catalyst env remove BIGCOMMERCE_STORE_HASH BIGCOMMERCE_STOREFRONT_TOKEN`, + ) + .action((keys: string[]) => { + const config = getProjectConfig(); + const stored = getStoredEnv(config); + const toRemove = new Set(); + + keys.forEach((key) => { + if (key in stored) { + toRemove.add(key); + } else { + consola.warn(`No stored environment variable named "${key}". Skipping.`); + } + }); + + const next = Object.fromEntries(Object.entries(stored).filter(([key]) => !toRemove.has(key))); + + config.set('env', next); + + const removed = Array.from(toRemove); + + if (removed.length > 0) { + consola.success( + `Removed ${removed.length} environment variable${removed.length === 1 ? '' : 's'}: ${removed.join(', ')}.`, + ); + } + + process.exit(0); + }); + +const list = new Command('list') + .configureHelp({ showGlobalOptions: true }) + .description('List stored deployment environment variables (values are masked).') + .addHelpText( + 'after', + ` +Example: + $ catalyst env list`, + ) + .action(() => { + const config = getProjectConfig(); + const stored = getStoredEnv(config); + const keys = Object.keys(stored).sort(); + + if (keys.length === 0) { + consola.info('No environment variables stored. Add one with `catalyst env add KEY=VALUE`.'); + process.exit(0); + + return; + } + + keys.forEach((key) => consola.log(`${key}=${MASK}`)); + + process.exit(0); + }); + +export const env = new Command('env') + .configureHelp({ showGlobalOptions: true }) + .description( + 'Manage persistent deployment environment variables. These are sent as secrets on every `catalyst deploy`, so you no longer need to pass `--secret` each time.', + ) + .addCommand(add) + .addCommand(remove) + .addCommand(list); diff --git a/packages/catalyst/src/cli/commands/logs.spec.ts b/packages/catalyst/src/cli/commands/logs.spec.ts new file mode 100644 index 0000000000..b5c70914ba --- /dev/null +++ b/packages/catalyst/src/cli/commands/logs.spec.ts @@ -0,0 +1,900 @@ +import { Command } from 'commander'; +import Conf from 'conf'; +import { http, HttpResponse } from 'msw'; +import { afterAll, afterEach, beforeAll, describe, expect, MockInstance, test, vi } from 'vitest'; + +import { server } from '../../../tests/mocks/node'; +import { consola } from '../lib/logger'; +import { mkTempDir } from '../lib/mk-temp-dir'; +import { getProjectConfig, ProjectConfigSchema } from '../lib/project-config'; +import { program } from '../program'; + +import { logs, parseSSEEvent, tailLogs } from './logs'; + +let exitMock: MockInstance; +let stdoutWriteMock: MockInstance; + +let tmpDir: string; +let cleanup: () => Promise; +let config: Conf; + +const projectUuid = '6b202364-10f3-11f1-8bc7-fe9b9d8b14ab'; +const storeHash = 'test-store'; +const accessToken = 'test-token'; +const apiHost = 'api.bigcommerce.com'; + +const encoder = new TextEncoder(); + +const validLogEvent = { + uuid: '0f258256-0a83-4704-a456-03e99b4445c2', + project_uuid: projectUuid, + request: { method: 'GET', url: 'https://example.com/test', status_code: 200 }, + logs: [{ timestamp: '2026-03-11T22:05:28.870Z', level: 'info', messages: ['hello world'] }], + exceptions: [], + timestamp: '2026-03-11T22:05:28.870Z', +}; + +const createSSEStream = (events: string[], closeDelay = 10) => + new ReadableStream({ + start(controller) { + events.forEach((event) => { + controller.enqueue(encoder.encode(event)); + }); + setTimeout(() => controller.close(), closeDelay); + }, + }); + +// Creates a handler that serves SSE events on the first request, +// then returns 404 on subsequent requests to break the reconnect loop. +const createOneShotLogHandler = (events: string[], closeDelay = 10) => { + let called = false; + + return http.get( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/logs/:projectUuid/tail', + () => { + if (called) { + return new HttpResponse(null, { status: 404, statusText: 'Not Found' }); + } + + called = true; + + return new HttpResponse(createSSEStream(events, closeDelay), { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }); + }, + ); +}; + +const callTailLogs = async (format: Parameters[4], events?: string[]) => { + const sseEvents = events ?? [`data: ${JSON.stringify(validLogEvent)}\n\n`]; + + server.use(createOneShotLogHandler(sseEvents)); + + await tailLogs(projectUuid, storeHash, accessToken, apiHost, format).catch(() => { + // Expected: tailLogs throws when the one-shot handler returns 404 on reconnect + }); +}; + +beforeAll(async () => { + consola.mockTypes(() => vi.fn()); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + exitMock = vi.spyOn(process, 'exit').mockImplementation(() => null as never); + stdoutWriteMock = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + + [tmpDir, cleanup] = await mkTempDir(); + + vi.spyOn(process, 'cwd').mockReturnValue(tmpDir); + + config = getProjectConfig(); +}); + +afterEach(() => { + vi.clearAllMocks(); + config.delete('storeHash'); + config.delete('accessToken'); + config.delete('projectUuid'); +}); + +afterAll(async () => { + await cleanup(); +}); + +describe('command configuration', () => { + test('logs is a properly configured Command with tail and query subcommands', () => { + expect(logs).toBeInstanceOf(Command); + expect(logs.name()).toBe('logs'); + expect(logs.description()).toBe('View logs from your deployed application.'); + + const subcommands = logs.commands.map((c) => c.name()); + + expect(subcommands).toContain('tail'); + expect(subcommands).toContain('query'); + }); + + test('tail subcommand has correct options', () => { + const tail = logs.commands.find((c) => c.name() === 'tail'); + + expect(tail).toBeDefined(); + expect(tail?.options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ flags: '--store-hash ' }), + expect.objectContaining({ flags: '--access-token ' }), + expect.objectContaining({ flags: '--api-host ' }), + expect.objectContaining({ flags: '--project-uuid ' }), + expect.objectContaining({ flags: '--format ', defaultValue: 'default' }), + ]), + ); + }); + + test('query subcommand has correct options', () => { + const query = logs.commands.find((c) => c.name() === 'query'); + + expect(query).toBeDefined(); + expect(query?.options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ flags: '--store-hash ' }), + expect.objectContaining({ flags: '--access-token ' }), + expect.objectContaining({ flags: '--api-host ' }), + expect.objectContaining({ flags: '--project-uuid ' }), + expect.objectContaining({ flags: '--format ', defaultValue: 'default' }), + ]), + ); + }); +}); + +describe('parseSSEEvent', () => { + test('extracts data from a single data line', () => { + expect(parseSSEEvent('data: {"foo":"bar"}')).toBe('{"foo":"bar"}'); + }); + + test('joins multiple data lines with newlines', () => { + expect(parseSSEEvent('data: line1\ndata: line2')).toBe('line1\nline2'); + }); + + test('ignores non-data SSE fields', () => { + expect(parseSSEEvent('event: message\ndata: {"foo":"bar"}\nid: 123')).toBe('{"foo":"bar"}'); + }); + + test('ignores SSE comments', () => { + expect(parseSSEEvent(': this is a comment\ndata: {"foo":"bar"}')).toBe('{"foo":"bar"}'); + }); + + test('returns null for events with no data lines', () => { + expect(parseSSEEvent('event: ping')).toBeNull(); + expect(parseSSEEvent(': comment only')).toBeNull(); + expect(parseSSEEvent('')).toBeNull(); + }); + + test('returns null for heartbeat events with empty data', () => { + expect(parseSSEEvent('data: ')).toBeNull(); + expect(parseSSEEvent('data:')).toBeNull(); + }); +}); + +describe('format: default', () => { + test('logs timestamp, level, and message', async () => { + await callTailLogs('default'); + + expect(consola.info).toHaveBeenCalledWith('Tailing logs...'); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('[2026-03-11T22:05:28.870Z]')); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('hello world')); + }); +}); + +describe('format: json', () => { + test('writes raw JSON to stdout', async () => { + await callTailLogs('json'); + + expect(stdoutWriteMock).toHaveBeenCalledWith( + expect.stringContaining('"uuid":"0f258256-0a83-4704-a456-03e99b4445c2"'), + ); + }); +}); + +describe('format: pretty', () => { + test('logs pretty-printed JSON', async () => { + await callTailLogs('pretty'); + + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('"uuid"')); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('"hello world"')); + }); + + test('preserves unknown fields via loose schema', async () => { + const eventWithExtra = { ...validLogEvent, extra_field: 'should be preserved' }; + + await callTailLogs('pretty', [`data: ${JSON.stringify(eventWithExtra)}\n\n`]); + + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('should be preserved')); + }); +}); + +describe('format: short', () => { + test('logs only the message', async () => { + await callTailLogs('short'); + + expect(consola.log).toHaveBeenCalledWith('hello world'); + }); +}); + +describe('format: request', () => { + test('logs timestamp, level, request info, and message', async () => { + await callTailLogs('request'); + + expect(consola.log).toHaveBeenCalledWith( + expect.stringContaining('GET https://example.com/test'), + ); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('(200)')); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('hello world')); + }); +}); + +describe('log event processing', () => { + test.each([ + ['info', 'INFO'], + ['warn', 'WARN'], + ['error', 'ERROR'], + ['debug', 'DEBUG'], + ])('formats %s level as %s', async (level, expected) => { + const event = { ...validLogEvent, logs: [{ ...validLogEvent.logs[0], level }] }; + + await callTailLogs('default', [`data: ${JSON.stringify(event)}\n\n`]); + + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining(expected)); + }); + + test('logs exceptions from the event', async () => { + const event = { + ...validLogEvent, + exceptions: [{ message: 'something broke', stack: 'Error: something broke' }], + }; + + await callTailLogs('default', [`data: ${JSON.stringify(event)}\n\n`]); + + expect(consola.error).toHaveBeenCalledWith( + expect.stringContaining('EXCEPTION'), + expect.objectContaining({ message: 'something broke' }), + ); + }); + + test('serializes non-string messages as JSON', async () => { + const event = { + ...validLogEvent, + logs: [{ ...validLogEvent.logs[0], messages: [{ nested: 'object' }, 42] }], + }; + + await callTailLogs('default', [`data: ${JSON.stringify(event)}\n\n`]); + + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('{"nested":"object"}')); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('42')); + }); + + test('multiple events in a single chunk are all processed', async () => { + const event1 = { + ...validLogEvent, + logs: [{ ...validLogEvent.logs[0], messages: ['first'] }], + }; + const event2 = { + ...validLogEvent, + logs: [{ ...validLogEvent.logs[0], messages: ['second'] }], + }; + + await callTailLogs('default', [ + `data: ${JSON.stringify(event1)}\n\ndata: ${JSON.stringify(event2)}\n\n`, + ]); + + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('first')); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('second')); + }); +}); + +describe('error handling', () => { + test('silently ignores heartbeat events', async () => { + await callTailLogs('default', [`data: \n\ndata: ${JSON.stringify(validLogEvent)}\n\n`]); + + expect(consola.warn).not.toHaveBeenCalled(); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('hello world')); + }); + + test('warns on invalid JSON in stream', async () => { + await callTailLogs('default', [`data: {not valid json}\n\n`]); + + expect(consola.warn).toHaveBeenCalledWith(expect.stringContaining('Failed to parse log event')); + }); + + test('warns on valid JSON that does not match schema', async () => { + await callTailLogs('default', [`data: {"valid":"json","but":"wrong schema"}\n\n`]); + + expect(consola.warn).toHaveBeenCalledWith(expect.stringContaining('Failed to parse log event')); + }); + + test('throws on fatal 4xx status codes', async () => { + server.use( + http.get( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/logs/:projectUuid/tail', + () => new HttpResponse(null, { status: 404, statusText: 'Not Found' }), + ), + ); + + await expect(tailLogs(projectUuid, storeHash, accessToken, apiHost, 'default')).rejects.toThrow( + 'Failed to open log stream: The request was rejected. Check your input — your access token may be missing a required scope, or the resource may not exist.', + ); + }); + + test('throws a re-auth error on fatal 401 unauthorized', async () => { + server.use( + http.get( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/logs/:projectUuid/tail', + () => new HttpResponse(null, { status: 401, statusText: 'Unauthorized' }), + ), + ); + + await expect(tailLogs(projectUuid, storeHash, accessToken, apiHost, 'default')).rejects.toThrow( + 'catalyst auth login', + ); + }); +}); + +describe('retry and reconnect', () => { + test('retries on 5xx errors and throws after max retries', async () => { + server.use( + http.get( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/logs/:projectUuid/tail', + () => new HttpResponse(null, { status: 502, statusText: 'Bad Gateway' }), + ), + ); + + await expect(tailLogs(projectUuid, storeHash, accessToken, apiHost, 'default')).rejects.toThrow( + 'Failed to connect to log stream after 5 retries.', + ); + + // 4 warnings logged for attempts 1-4, then throw on attempt 5 + expect(consola.warn).toHaveBeenCalledTimes(4); + }); + + test('logs retry attempt number in warning messages', async () => { + server.use( + http.get( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/logs/:projectUuid/tail', + () => new HttpResponse(null, { status: 503, statusText: 'Service Unavailable' }), + ), + ); + + await expect( + tailLogs(projectUuid, storeHash, accessToken, apiHost, 'default'), + ).rejects.toThrow(); + + expect(consola.warn).toHaveBeenCalledWith( + expect.stringContaining('attempt 1/5'), + expect.anything(), + ); + expect(consola.warn).toHaveBeenCalledWith( + expect.stringContaining('attempt 4/5'), + expect.anything(), + ); + }); + + test('resets retry counter after a successful connection', async () => { + let requestCount = 0; + + server.use( + http.get( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/logs/:projectUuid/tail', + () => { + requestCount += 1; + + // First 3 requests: 502 errors (builds up retries to 3) + if (requestCount <= 3) { + return new HttpResponse(null, { status: 502, statusText: 'Bad Gateway' }); + } + + // 4th request: successful stream (resets retries to 0) + if (requestCount === 4) { + return new HttpResponse( + createSSEStream([`data: ${JSON.stringify(validLogEvent)}\n\n`]), + { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }, + ); + } + + // 5th+ requests: 502 again until retries are exhausted a second time + return new HttpResponse(null, { status: 502, statusText: 'Bad Gateway' }); + }, + ), + ); + + await expect(tailLogs(projectUuid, storeHash, accessToken, apiHost, 'default')).rejects.toThrow( + 'Failed to connect to log stream after 5 retries.', + ); + + // 3 warnings before success + 4 warnings after success (throw on 5th retry) + expect(consola.warn).toHaveBeenCalledTimes(7); + }); + + test('server disconnect does not increment retry counter', async () => { + let requestCount = 0; + + server.use( + http.get( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/logs/:projectUuid/tail', + () => { + requestCount += 1; + + // Return a stream that immediately errors to simulate server disconnect + if (requestCount <= 3) { + const stream = new ReadableStream({ + start(controller) { + controller.error(new TypeError('terminated')); + }, + }); + + return new HttpResponse(stream, { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }); + } + + // After 3 server disconnects, return 404 to break the loop + return new HttpResponse(null, { status: 404, statusText: 'Not Found' }); + }, + ), + ); + + await expect(tailLogs(projectUuid, storeHash, accessToken, apiHost, 'default')).rejects.toThrow( + 'Failed to open log stream: The request was rejected. Check your input — your access token may be missing a required scope, or the resource may not exist.', + ); + + // Server disconnect warnings should NOT contain "attempt X/5" + expect(consola.warn).toHaveBeenCalledTimes(3); + expect(consola.warn).toHaveBeenCalledWith('Log stream closed by server, reconnecting...'); + }); + + test( + 'reconnects when the stream stalls without emitting any data', + { timeout: 3000 }, + async () => { + let requestCount = 0; + const ttlMs = 200; + + // Stream that stays open but never enqueues — simulates an API proxy + // half-closing the socket: bytes stop arriving but no FIN or error is + // surfaced, so reader.read() would otherwise block forever. + const createStalledStream = () => + new ReadableStream({ + start() { + // intentionally empty + }, + }); + + server.use( + http.get( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/logs/:projectUuid/tail', + () => { + requestCount += 1; + + if (requestCount <= 2) { + return new HttpResponse(createStalledStream(), { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }); + } + + return new HttpResponse(null, { status: 404, statusText: 'Not Found' }); + }, + ), + ); + + await expect( + tailLogs(projectUuid, storeHash, accessToken, apiHost, 'default', ttlMs), + ).rejects.toThrow( + 'Failed to open log stream: The request was rejected. Check your input — your access token may be missing a required scope, or the resource may not exist.', + ); + + expect(requestCount).toBe(3); + expect(consola.warn).toHaveBeenCalledWith('Log stream idle, reconnecting...'); + }, + ); + + test('reconnects when connection TTL is reached', { timeout: 3000 }, async () => { + let requestCount = 0; + const ttlMs = 200; + + // Creates a stream that sends data every 30ms via setInterval and never closes. + // The cancel callback cleans up the interval so reader.cancel() resolves. + const createOpenEndedStream = () => { + let intervalId: ReturnType; + + return new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(validLogEvent)}\n\n`)); + intervalId = setInterval(() => { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(validLogEvent)}\n\n`)); + }, 30); + }, + cancel() { + clearInterval(intervalId); + }, + }); + }; + + server.use( + http.get( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/logs/:projectUuid/tail', + () => { + requestCount += 1; + + if (requestCount <= 2) { + return new HttpResponse(createOpenEndedStream(), { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }); + } + + // 3rd request: return 404 to break the reconnect loop + return new HttpResponse(null, { status: 404, statusText: 'Not Found' }); + }, + ), + ); + + await expect( + tailLogs(projectUuid, storeHash, accessToken, apiHost, 'default', ttlMs), + ).rejects.toThrow( + 'Failed to open log stream: The request was rejected. Check your input — your access token may be missing a required scope, or the resource may not exist.', + ); + + // Should have connected 3 times: 2 TTL-triggered reconnects + final 404 + expect(requestCount).toBe(3); + + // Events from both successful connections should have been processed + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('hello world')); + }); +}); + +describe('credential resolution', () => { + test('falls back to project.json for storeHash and accessToken', async () => { + config.set('storeHash', storeHash); + config.set('accessToken', accessToken); + + server.use(createOneShotLogHandler([`data: ${JSON.stringify(validLogEvent)}\n\n`])); + + await program.parseAsync(['node', 'catalyst', 'logs', 'tail', '--project-uuid', projectUuid]); + + expect(consola.info).toHaveBeenCalledWith('Tailing logs...'); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('hello world')); + }); + + test('exits with error when no credentials are provided', async () => { + const savedStoreHash = process.env.CATALYST_STORE_HASH; + const savedAccessToken = process.env.CATALYST_ACCESS_TOKEN; + + delete process.env.CATALYST_STORE_HASH; + delete process.env.CATALYST_ACCESS_TOKEN; + + await program.parseAsync(['node', 'catalyst', 'logs', 'tail', '--project-uuid', projectUuid]); + + if (savedStoreHash !== undefined) process.env.CATALYST_STORE_HASH = savedStoreHash; + if (savedAccessToken !== undefined) process.env.CATALYST_ACCESS_TOKEN = savedAccessToken; + + expect(consola.error).toHaveBeenCalledWith('Missing credentials.'); + expect(consola.info).toHaveBeenCalledWith( + 'Run `catalyst auth login`, or provide --store-hash and --access-token flags (or set CATALYST_STORE_HASH and CATALYST_ACCESS_TOKEN environment variables).', + ); + expect(exitMock).toHaveBeenCalledWith(1); + }); +}); + +describe('query subcommand', () => { + const start = '2026-06-01T00:00:00Z'; + const end = '2026-06-02T00:00:00Z'; + + const queryArgs = (extra: string[] = []) => [ + 'node', + 'catalyst', + 'logs', + 'query', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--project-uuid', + projectUuid, + '--start', + start, + '--end', + end, + ...extra, + ]; + + test('prints formatted log lines and a count footer', async () => { + await program.parseAsync(queryArgs()); + + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('/cart')); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('[TypeError]')); + expect(consola.info).toHaveBeenCalledWith('1 entry shown (oldest first, times in UTC).'); + // TODO(TRAC-934): no next-page hint is printed while pagination is removed. + expect(consola.info).not.toHaveBeenCalledWith(expect.stringContaining('More available')); + }); + + test('warns when the result fills --limit exactly', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/logs/:projectUuid', () => + HttpResponse.json({ + data: [ + { id: '2', timestamp: '2026-06-01T13:00:00Z', level: 'info', messages: ['newer'] }, + { id: '1', timestamp: '2026-06-01T12:00:00Z', level: 'info', messages: ['older'] }, + ], + }), + ), + ); + + await program.parseAsync(queryArgs(['--limit', '2'])); + + expect(consola.info).toHaveBeenCalledWith(expect.stringContaining('Limit of 2 reached')); + }); + + test('does not warn when the result is below --limit', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/logs/:projectUuid', () => + HttpResponse.json({ + data: [{ id: '1', timestamp: '2026-06-01T12:00:00Z', level: 'info', messages: ['only'] }], + }), + ), + ); + + await program.parseAsync(queryArgs(['--limit', '5'])); + + expect(consola.info).not.toHaveBeenCalledWith(expect.stringContaining('reached')); + }); + + test('prints entries oldest-first', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/logs/:projectUuid', () => + HttpResponse.json({ + data: [ + { id: '2', timestamp: '2026-06-01T13:00:00Z', level: 'info', messages: ['newer'] }, + { id: '1', timestamp: '2026-06-01T12:00:00Z', level: 'info', messages: ['older'] }, + ], + meta: { + cursor_pagination: { count: 2, per_page: 50, start_cursor: null, end_cursor: null }, + }, + }), + ), + ); + + await program.parseAsync(queryArgs()); + + const logged = vi.mocked(consola.log).mock.calls.map(([line]) => String(line)); + const olderIndex = logged.findIndex((line) => line.includes('older')); + const newerIndex = logged.findIndex((line) => line.includes('newer')); + + expect(olderIndex).toBeGreaterThanOrEqual(0); + expect(olderIndex).toBeLessThan(newerIndex); + }); + + test('--since queries a relative window ending now', async () => { + let captured: URLSearchParams | undefined; + + server.use( + http.get( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/logs/:projectUuid', + ({ request }) => { + captured = new URL(request.url).searchParams; + + return HttpResponse.json({ + data: [], + meta: { + cursor_pagination: { count: 0, per_page: 50, start_cursor: null, end_cursor: null }, + }, + }); + }, + ), + ); + + await program.parseAsync([ + 'node', + 'catalyst', + 'logs', + 'query', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--project-uuid', + projectUuid, + '--since', + '1h', + ]); + + const sentStart = Date.parse(captured?.get('start') ?? ''); + const sentEnd = Date.parse(captured?.get('end') ?? ''); + + expect(sentEnd - sentStart).toBe(60 * 60 * 1000); + expect(Math.abs(Date.now() - sentEnd)).toBeLessThan(60 * 1000); + }); + + test('reads project UUID from project.json when --project-uuid is omitted', async () => { + config.set('projectUuid', projectUuid); + + await program.parseAsync([ + 'node', + 'catalyst', + 'logs', + 'query', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--start', + start, + '--end', + end, + ]); + + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('/cart')); + }); + + test('outputs one raw JSON entry per line with --format json', async () => { + await program.parseAsync(queryArgs(['--format', 'json'])); + + // NDJSON to stdout (like `tail --format json`), no footer chrome. + expect(stdoutWriteMock).toHaveBeenCalledWith(expect.stringContaining('"is_exception":true')); + expect(consola.info).not.toHaveBeenCalledWith(expect.stringContaining('entry shown')); + }); + + test('includes request details with --format request', async () => { + await program.parseAsync(queryArgs(['--format', 'request'])); + + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('GET /cart (500)')); + }); + + test('reports when no entries are found', async () => { + server.use( + http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/logs/:projectUuid', () => + HttpResponse.json({ + data: [], + meta: { + cursor_pagination: { count: 0, per_page: 50, start_cursor: null, end_cursor: null }, + }, + }), + ), + ); + + await program.parseAsync(queryArgs()); + + expect(consola.info).toHaveBeenCalledWith( + 'No log entries found for the given window and filters.', + ); + }); + + test('forwards filters as query params', async () => { + let captured: URLSearchParams | undefined; + + server.use( + http.get( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/logs/:projectUuid', + ({ request }) => { + captured = new URL(request.url).searchParams; + + return HttpResponse.json({ + data: [], + meta: { + cursor_pagination: { count: 0, per_page: 50, start_cursor: null, end_cursor: null }, + }, + }); + }, + ), + ); + + await program.parseAsync( + queryArgs([ + '--method', + 'GET', + '--status-code', + '500', + '--url-like', + '/cart', + '--level-min', + 'warn', + '--limit', + '10', + ]), + ); + + expect(captured?.get('method')).toBe('GET'); + expect(captured?.get('status_code')).toBe('500'); + expect(captured?.get('url:like')).toBe('/cart'); + expect(captured?.get('level:min')).toBe('warn'); + expect(captured?.get('limit')).toBe('10'); + expect(captured?.get('after')).toBeNull(); + }); + + test('exits with error when no project UUID can be resolved', async () => { + await program.parseAsync([ + 'node', + 'catalyst', + 'logs', + 'query', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--start', + start, + '--end', + end, + ]); + + expect(consola.error).toHaveBeenCalledWith(expect.stringContaining('Project UUID is required')); + expect(exitMock).toHaveBeenCalledWith(1); + }); + + test('exits with an error on an invalid time window', async () => { + await program.parseAsync(queryArgs(['--end', '2026-05-01T00:00:00Z'])); + + expect(consola.error).toHaveBeenCalledWith( + expect.stringContaining('--start must be before or equal to --end'), + ); + expect(exitMock).toHaveBeenCalledWith(1); + }); + + test('exits with missing credentials error when none are provided', async () => { + const savedStoreHash = process.env.CATALYST_STORE_HASH; + const savedAccessToken = process.env.CATALYST_ACCESS_TOKEN; + + delete process.env.CATALYST_STORE_HASH; + delete process.env.CATALYST_ACCESS_TOKEN; + + await program.parseAsync(['node', 'catalyst', 'logs', 'query', '--start', start, '--end', end]); + + if (savedStoreHash !== undefined) process.env.CATALYST_STORE_HASH = savedStoreHash; + if (savedAccessToken !== undefined) process.env.CATALYST_ACCESS_TOKEN = savedAccessToken; + + expect(consola.error).toHaveBeenCalledWith('Missing credentials.'); + expect(exitMock).toHaveBeenCalledWith(1); + }); +}); + +describe('program integration', () => { + test('logs tail is the default subcommand', async () => { + server.use(createOneShotLogHandler([`data: ${JSON.stringify(validLogEvent)}\n\n`])); + + await program.parseAsync([ + 'node', + 'catalyst', + 'logs', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--project-uuid', + projectUuid, + ]); + + expect(consola.info).toHaveBeenCalledWith('Tailing logs...'); + expect(exitMock).toHaveBeenCalledWith(1); + }); + + test('logs tail with --format json', async () => { + server.use(createOneShotLogHandler([`data: ${JSON.stringify(validLogEvent)}\n\n`])); + + await program.parseAsync([ + 'node', + 'catalyst', + 'logs', + 'tail', + '--store-hash', + storeHash, + '--access-token', + accessToken, + '--project-uuid', + projectUuid, + '--format', + 'json', + ]); + + expect(consola.info).toHaveBeenCalledWith('Tailing logs...'); + expect(stdoutWriteMock).toHaveBeenCalled(); + }); +}); diff --git a/packages/catalyst/src/cli/commands/logs.ts b/packages/catalyst/src/cli/commands/logs.ts new file mode 100644 index 0000000000..1bf0a4a60a --- /dev/null +++ b/packages/catalyst/src/cli/commands/logs.ts @@ -0,0 +1,496 @@ +import { Command, InvalidArgumentError, Option } from 'commander'; +import { colorize } from 'consola/utils'; +import { z } from 'zod'; + +import { UnauthorizedError } from '../lib/auth-errors'; +import { httpError } from '../lib/http-errors'; +import { consola } from '../lib/logger'; +import { formatLogEntry, LOG_LEVELS, queryLogs, resolveTimeWindow } from '../lib/observability'; +import { getProjectConfig } from '../lib/project-config'; +import { resolveCredentials } from '../lib/resolve-credentials'; +import { + accessTokenOption, + apiHostOption, + projectUuidOption, + resolveApiHost, + resolveProjectUuid, + storeHashOption, +} from '../lib/shared-options'; +import { Telemetry } from '../lib/telemetry'; + +type LogFormat = 'json' | 'pretty' | 'default' | 'short' | 'request'; + +const telemetry = new Telemetry(); + +const DEFAULT_CONNECTION_TTL_MS = 1 * 60 * 1000; // 1 minute +const MAX_RETRIES = 5; + +const isFatalStatusCode = (status: number) => status >= 400 && status < 500; + +const LEVEL_COLORS: Record[0]> = { + INFO: 'green', + WARN: 'yellow', + ERROR: 'red', + DEBUG: 'gray', +}; + +const LogEventSchema = z + .object({ + uuid: z.string(), + project_uuid: z.string(), + request: z.object({ + method: z.string(), + url: z.string(), + status_code: z.number(), + }), + logs: z.array( + z.object({ + timestamp: z.string(), + level: z.string(), + messages: z.array(z.unknown()), + }), + ), + exceptions: z.array(z.unknown()), + timestamp: z.string(), + }) + .loose(); + +class StreamError extends Error { + fatal: boolean; + + constructor(message: string, fatal: boolean) { + super(message); + this.fatal = fatal; + } +} + +const formatMessages = (messages: unknown[]) => + messages.map((m) => (typeof m === 'string' ? m : JSON.stringify(m))).join(' '); + +const formatLogEvent = ( + event: z.infer, + format: 'default' | 'short' | 'request', +) => { + const { request, logs: logEntries, exceptions } = event; + + logEntries.forEach((entry) => { + const msg = formatMessages(entry.messages); + const level = entry.level.toUpperCase(); + const coloredLevel = colorize(LEVEL_COLORS[level] ?? 'white', level); + + switch (format) { + case 'short': + consola.log(msg); + break; + + case 'request': + consola.log( + `[${entry.timestamp}] [${coloredLevel}] ${request.method} ${request.url}` + + ` (${request.status_code}) ${msg}`, + ); + break; + + default: + consola.log(`[${entry.timestamp}] [${coloredLevel}] ${msg}`); + break; + } + }); + + exceptions.forEach((exception) => { + consola.error(`[${event.timestamp}] EXCEPTION`, exception); + }); +}; + +export const parseSSEEvent = (raw: string): string | null => { + const joined = raw + .split('\n') + .flatMap((line) => (line.startsWith('data:') ? [line.slice(5).trim()] : [])) + .join('\n'); + + return joined.length > 0 ? joined : null; +}; + +const processLogEvent = (event: string, format: LogFormat) => { + if (format === 'json') { + process.stdout.write(`${event}\n`); + + return; + } + + try { + const parsed: unknown = JSON.parse(event); + const logEvent = LogEventSchema.parse(parsed); + + if (format === 'pretty') { + consola.log(JSON.stringify(logEvent, null, 2)); + } else { + formatLogEvent(logEvent, format); + } + } catch { + consola.warn(`Failed to parse log event: ${event}`); + } +}; + +type TimeoutRaceResult = { kind: 'value'; value: T } | { kind: 'timeout' }; + +// Races a promise against a timer so a hung `reader.read()` doesn't block the +// read pump. Without this, the connection TTL check never runs when the API +// proxy half-closes the socket — bytes stop arriving but no FIN/error +// surfaces, so the read promise stays pending forever. +const raceWithTimeout = async ( + promise: Promise, + timeoutMs: number, +): Promise> => { + let timeoutHandle: ReturnType | undefined; + + const timeoutPromise = new Promise>((resolve) => { + timeoutHandle = setTimeout(() => resolve({ kind: 'timeout' }), timeoutMs); + }); + + try { + return await Promise.race([ + promise.then((value): TimeoutRaceResult => ({ kind: 'value', value })), + timeoutPromise, + ]); + } finally { + if (timeoutHandle) clearTimeout(timeoutHandle); + } +}; + +const openLogStream = async ( + projectUuid: string, + storeHash: string, + accessToken: string, + apiHost: string, +) => { + const response = await fetch( + `https://${apiHost}/stores/${storeHash}/v3/infrastructure/logs/${projectUuid}/tail`, + { + method: 'GET', + headers: { + 'X-Auth-Token': accessToken, + Accept: 'text/event-stream', + Connection: 'keep-alive', + }, + }, + ); + + // An invalid/expired token won't recover by reconnecting — surface the + // re-auth guidance and stop the loop (fatal) rather than burning retries. + if (response.status === 401) { + throw new StreamError(new UnauthorizedError().message, true); + } + + if (!response.ok) { + const error = await httpError(response, 'Failed to open log stream'); + + throw new StreamError(error.message, isFatalStatusCode(response.status)); + } + + const reader = response.body?.getReader(); + + if (!reader) { + throw new StreamError('Failed to read log stream.', true); + } + + return reader; +}; + +// Reasons the read pump stops on its own (vs. throwing). The outer reconnect +// loop maps each to a user-facing message — or silence — and opens a fresh +// stream. +type Rotation = 'ttl' | 'idle-timeout' | 'stream-done'; + +const pumpUntilRotation = async ( + reader: ReadableStreamDefaultReader, + format: LogFormat, + connectionTtlMs: number, +): Promise => { + const decoder = new TextDecoder(); + const connectTime = Date.now(); + let buffer = ''; + let receivedData = false; + + // eslint-disable-next-line no-constant-condition, @typescript-eslint/no-unnecessary-condition + while (true) { + const remainingTtlMs = connectionTtlMs - (Date.now() - connectTime); + + if (remainingTtlMs <= 0) { + void reader.cancel(); + + return 'ttl'; + } + + // eslint-disable-next-line no-await-in-loop + const readResult = await raceWithTimeout(reader.read(), remainingTtlMs); + + if (readResult.kind === 'timeout') { + void reader.cancel(); + + // No data for the whole window: proxy likely half-closed the socket. + // If data flowed earlier, treat it as a normal TTL boundary instead. + return receivedData ? 'ttl' : 'idle-timeout'; + } + + const { value, done: streamDone } = readResult.value; + + if (value) { + receivedData = true; + buffer += decoder.decode(value, { stream: true }); + + const parts = buffer.split('\n\n'); + + // Last element is either empty (complete event) or a partial chunk to carry over + buffer = parts.pop() ?? ''; + + parts + .map((raw) => parseSSEEvent(raw)) + .filter((event): event is string => event !== null) + .forEach((event) => processLogEvent(event, format)); + } + + if (streamDone) { + void reader.cancel(); + + return 'stream-done'; + } + } +}; + +export const tailLogs = async ( + projectUuid: string, + storeHash: string, + accessToken: string, + apiHost: string, + format: LogFormat, + connectionTtlMs = DEFAULT_CONNECTION_TTL_MS, +) => { + consola.info('Tailing logs...'); + + let retries = 0; + + // eslint-disable-next-line no-constant-condition, @typescript-eslint/no-unnecessary-condition + while (true) { + try { + // eslint-disable-next-line no-await-in-loop + const reader = await openLogStream(projectUuid, storeHash, accessToken, apiHost); + + retries = 0; + + // eslint-disable-next-line no-await-in-loop + const rotation = await pumpUntilRotation(reader, format, connectionTtlMs); + + if (rotation === 'idle-timeout') { + consola.warn('Log stream idle, reconnecting...'); + } + // 'ttl' and 'stream-done' are healthy rotations — reconnect silently. + } catch (error) { + if (error instanceof StreamError && error.fatal) { + throw error; + } + + const isServerDisconnect = error instanceof TypeError && error.message === 'terminated'; + + if (isServerDisconnect) { + consola.warn('Log stream closed by server, reconnecting...'); + } else { + retries += 1; + + if (retries >= MAX_RETRIES) { + throw new Error(`Failed to connect to log stream after ${MAX_RETRIES} retries.`); + } + + consola.warn( + `Log stream disconnected, reconnecting (attempt ${retries}/${MAX_RETRIES})...`, + error, + ); + } + } + } +}; + +const tail = new Command('tail') + .configureHelp({ showGlobalOptions: true }) + .description('Tail live logs from your deployed application.') + .addHelpText( + 'after', + ` +Examples: + $ catalyst logs tail + + # Tail logs with request format + $ catalyst logs tail --format request + + # Tail logs as raw JSON (useful for piping to other tools) + $ catalyst logs tail --format json`, + ) + .addOption(storeHashOption()) + .addOption(accessTokenOption()) + .addOption(apiHostOption()) + .addOption(projectUuidOption()) + .addOption( + new Option('--format ', 'Output format for log events.') + .choices(['json', 'pretty', 'default', 'short', 'request']) + .default('default'), + ) + .action(async (options) => { + try { + const config = getProjectConfig(); + const apiHost = resolveApiHost(options, config); + const { storeHash, accessToken } = resolveCredentials(options, config); + + await telemetry.identify(storeHash); + + const projectUuid = resolveProjectUuid(options); + + await tailLogs(projectUuid, storeHash, accessToken, apiHost, options.format); + } catch (error) { + consola.error(error); + process.exit(1); + } + }); + +// Validates a numeric flag client-side so typos fail instantly with a clear +// message instead of sending NaN (or an out-of-range value) to the API. +const parseIntInRange = (flag: string, min: number, max: number) => (value: string) => { + const parsed = Number(value); + + if (!Number.isInteger(parsed) || parsed < min || parsed > max) { + throw new InvalidArgumentError(`${flag} must be an integer between ${min} and ${max}.`); + } + + return parsed; +}; + +const query = new Command('query') + .configureHelp({ showGlobalOptions: true }) + .description('Query historical logs from your deployed application.') + .addHelpText( + 'after', + ` +Specify a time window with \`--since\` (relative to now) or \`--start\`/\`--end\` +(ISO-8601 timestamps or Unix epoch seconds, UTC). The window may not exceed +7 days. Entries print oldest-first; timestamps are UTC. + +Examples: + # Last hour of logs + $ catalyst logs query --since 1h + + # Everything from today (UTC) + $ catalyst logs query --start 2026-06-11T00:00:00Z + + # Errors only for a specific path + $ catalyst logs query --since 24h --level-min error --url-like /cart + + # Errors with request details (method, URL, status) + $ catalyst logs query --since 1h --level-min error --format request + + # Raw JSON (NDJSON) for piping to other tools + $ catalyst logs query --since 2d --format json`, + ) + .addOption(storeHashOption()) + .addOption(accessTokenOption()) + .addOption(apiHostOption()) + .addOption(projectUuidOption()) + .addOption( + new Option( + '--since ', + 'Relative window ending at --end (default: now), e.g. 30m, 6h, 2d (units: s, m, h, d).', + ).conflicts('start'), + ) + .option('--start