From edc4cc84b4ca82967d55167290858cd2b6743125 Mon Sep 17 00:00:00 2001 From: jordanarldt Date: Fri, 31 Jul 2026 11:03:16 -0500 Subject: [PATCH] TRAC-965: Return DNS pointing records on domain add --- .changeset/domains-pointing-records.md | 5 + .../catalyst/src/cli/commands/domains.spec.ts | 130 +++++++++++++++++- packages/catalyst/src/cli/commands/domains.ts | 59 +++++++- packages/catalyst/src/cli/lib/domains.ts | 27 +++- packages/catalyst/tests/mocks/handlers.ts | 5 + 5 files changed, 220 insertions(+), 6 deletions(-) create mode 100644 .changeset/domains-pointing-records.md diff --git a/.changeset/domains-pointing-records.md b/.changeset/domains-pointing-records.md new file mode 100644 index 0000000000..8adc609d64 --- /dev/null +++ b/.changeset/domains-pointing-records.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst": minor +--- + +Print the DNS records to publish when `catalyst domains add` succeeds. The A and CNAME values that point the domain at the project are shown with the success message, along with which to publish and a note that they are only returned when the domain is added. The records survive `--wait`, and are omitted when the API has none to share yet. diff --git a/packages/catalyst/src/cli/commands/domains.spec.ts b/packages/catalyst/src/cli/commands/domains.spec.ts index 38a60de7bb..0b8293b275 100644 --- a/packages/catalyst/src/cli/commands/domains.spec.ts +++ b/packages/catalyst/src/cli/commands/domains.spec.ts @@ -19,7 +19,13 @@ 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'; +import { + domains, + formatDomain, + formatDomainStatus, + formatPointingRecords, + waitForDomainVerification, +} from './domains'; vi.mock('@inquirer/prompts', () => ({ confirm: vi.fn(), @@ -39,6 +45,11 @@ const storeHash = 'test-store'; const accessToken = 'test-token'; const apiHost = 'api.bigcommerce.com'; const domain = 'www.example.com'; +// Mirrors the records the default handlers return for `domain`. +const pointingRecords = { + a_record_value: '198.51.100.10', + cname_record_value: 'shared.hosting.bigcommerce.com', +}; function writeCredentials() { config.set('storeHash', storeHash); @@ -203,6 +214,31 @@ describe('formatDomain', () => { }); }); +describe('formatPointingRecords', () => { + test('renders both records the merchant can publish', () => { + const output = formatPointingRecords(pointingRecords); + + expect(output).toContain('A 198.51.100.10'); + expect(output).toContain('CNAME shared.hosting.bigcommerce.com'); + // Record types are padded so the values line up. + expect(output?.split('\n')).toHaveLength(2); + }); + + test('omits a record the API left null', () => { + const output = formatPointingRecords({ a_record_value: '198.51.100.10' }); + + expect(output).toContain('198.51.100.10'); + expect(output).not.toContain('CNAME'); + }); + + test('returns null when there is nothing to publish yet', () => { + expect(formatPointingRecords(null)).toBeNull(); + expect(formatPointingRecords(undefined)).toBeNull(); + expect(formatPointingRecords({})).toBeNull(); + expect(formatPointingRecords({ a_record_value: '', cname_record_value: null })).toBeNull(); + }); +}); + describe('domain API client', () => { test('creates a domain with the expected request body', async () => { let capturedBody: unknown; @@ -219,6 +255,7 @@ describe('domain API client', () => { domain, project_uuid: projectUuid, verification_status: 'pending', + pointing_records: pointingRecords, }, }, { status: 201 }, @@ -227,16 +264,43 @@ describe('domain API client', () => { ), ); + // `createDomain` is the only client that keeps `pointing_records` — the + // endpoint is the only one that returns them. await expect( createDomain(domain, projectUuid, storeHash, accessToken, apiHost), ).resolves.toEqual({ domain, project_uuid: projectUuid, verification_status: 'pending', + pointing_records: pointingRecords, }); expect(capturedBody).toEqual({ domain }); }); + test('tolerates a create response without the records', async () => { + server.use( + http.post( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains', + () => + HttpResponse.json( + { + data: { + domain, + project_uuid: projectUuid, + verification_status: 'pending', + pointing_records: null, + }, + }, + { status: 201 }, + ), + ), + ); + + await expect( + createDomain(domain, projectUuid, storeHash, accessToken, apiHost), + ).resolves.toMatchObject({ domain, pointing_records: null }); + }); + test('surfaces disabled API responses', async () => { server.use( http.post( @@ -633,6 +697,70 @@ describe('add command', () => { expect(exitMock).toHaveBeenCalledWith(0); }); + test('prints the DNS records to publish along with the success message', async () => { + writeCredentials(); + + await domains.parseAsync(['add', domain], { from: 'user' }); + + expect(consola.success).toHaveBeenCalledWith(`Domain ${domain} added.`); + expect(consola.info).toHaveBeenCalledWith( + `Point ${domain} at this project with one of these DNS records:`, + ); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('A 198.51.100.10')); + expect(consola.log).toHaveBeenCalledWith( + expect.stringContaining('CNAME shared.hosting.bigcommerce.com'), + ); + expect(consola.info).toHaveBeenCalledWith( + expect.stringContaining('only `domains add` returns them'), + ); + expect(consola.info).toHaveBeenCalledWith( + `Run \`catalyst domains status ${domain}\` to check verification progress.`, + ); + }); + + test('still prints the records after waiting for verification', async () => { + writeCredentials(); + + await domains.parseAsync(['add', domain, '--wait'], { from: 'user' }); + + // Polling re-fetches the domain resource, which no longer carries the + // records — they have to survive from the create response. + expect(consola.start).toHaveBeenCalledWith(`Waiting for ${domain} to verify...`); + expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('active')); + expect(consola.log).toHaveBeenCalledWith( + expect.stringContaining('CNAME shared.hosting.bigcommerce.com'), + ); + }); + + test('skips the DNS guidance when the API has no records yet', async () => { + server.use( + http.post( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains', + () => + HttpResponse.json( + { + data: { + domain, + project_uuid: projectUuid, + verification_status: 'pending', + pointing_records: null, + }, + }, + { status: 201 }, + ), + ), + ); + + writeCredentials(); + + await domains.parseAsync(['add', domain], { from: 'user' }); + + expect(consola.success).toHaveBeenCalledWith(`Domain ${domain} added.`); + expect(consola.info).not.toHaveBeenCalledWith(expect.stringContaining('DNS records:')); + expect(consola.log).not.toHaveBeenCalledWith(expect.stringContaining('198.51.100.10')); + expect(exitMock).toHaveBeenCalledWith(0); + }); + test('can wait for domain verification after adding', async () => { writeCredentials(); diff --git a/packages/catalyst/src/cli/commands/domains.ts b/packages/catalyst/src/cli/commands/domains.ts index 024497dffb..85e5b898a1 100644 --- a/packages/catalyst/src/cli/commands/domains.ts +++ b/packages/catalyst/src/cli/commands/domains.ts @@ -4,6 +4,7 @@ import { colorize } from 'consola/utils'; import { claimDomain, + CreatedDomain, createDomain, deleteDomain, Domain, @@ -15,6 +16,7 @@ import { getDomain, listDomains, OwnershipVerification, + PointingRecords, transferDomain, } from '../lib/domains'; import { UserActionableError } from '../lib/errors'; @@ -35,6 +37,7 @@ 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 RECORD_TYPE_WIDTH = 'CNAME'.length; const STATUS_COLORS: Record[0]> = { pending: 'yellow', @@ -135,6 +138,31 @@ export function formatDomain(domain: Domain): string { return `${domain.domain} ${formatDomainStatus(domain.verification_status)}`; } +// Renders the records to publish as an indented block: +// +// A 198.51.100.10 +// CNAME shop.hosting.bigcommerce.com +// +// Returns null when there's nothing publishable: the API withholds the records +// until the domain is bound, and nulls individual values it doesn't have. +export function formatPointingRecords(records?: PointingRecords | null): string | null { + const rows: string[] = []; + + if (records?.a_record_value) { + rows.push(`${'A'.padEnd(RECORD_TYPE_WIDTH)} ${records.a_record_value}`); + } + + if (records?.cname_record_value) { + rows.push(`${'CNAME'.padEnd(RECORD_TYPE_WIDTH)} ${records.cname_record_value}`); + } + + if (rows.length === 0) { + return null; + } + + return rows.map((row) => ` ${row}`).join('\n'); +} + export function formatOwnershipVerification(record: OwnershipVerification): string { const lines = [ 'Add this DNS record to verify ownership:', @@ -178,6 +206,10 @@ const add = new Command('add') .addHelpText( 'after', ` +The DNS records that point the domain at this project are printed once the domain +is added. They are returned only by this command, so save them: publish the CNAME +for a subdomain, or the A record for an apex domain. + Examples: $ catalyst domains add www.example.com @@ -196,10 +228,10 @@ Examples: consola.start(`Adding domain ${domain}...`); - let result: Domain; + let created: CreatedDomain; try { - result = await createDomain( + created = await createDomain( domain, context.projectUuid, context.storeHash, @@ -231,7 +263,13 @@ Examples: throw error; } - consola.success(`Domain ${result.domain} added.`); + consola.success(`Domain ${created.domain} added.`); + + // Only the create response carries the records, so they're read from + // `created` rather than from `result` — polling re-fetches the domain + // resource, which doesn't include them. + const records = formatPointingRecords(created.pointing_records); + let result: Domain = created; if (options.wait && result.verification_status === 'pending') { consola.start(`Waiting for ${result.domain} to verify...`); @@ -239,6 +277,21 @@ Examples: } consola.log(formatDomain(result)); + + if (records) { + consola.info(`Point ${created.domain} at this project with one of these DNS records:`); + consola.log(records); + consola.info( + 'Use the CNAME for a subdomain, or the A record for an apex domain. Save them now — only `domains add` returns them.', + ); + } + + if (result.verification_status === 'pending') { + consola.info( + `Run \`catalyst domains status ${result.domain}\` to check verification progress.`, + ); + } + process.exit(0); }); diff --git a/packages/catalyst/src/cli/lib/domains.ts b/packages/catalyst/src/cli/lib/domains.ts index 3dcf1b899f..699a0daefd 100644 --- a/packages/catalyst/src/cli/lib/domains.ts +++ b/packages/catalyst/src/cli/lib/domains.ts @@ -10,12 +10,30 @@ export const domainStatusSchema = z.enum(['pending', 'verified', 'failed', 'unkn export type DomainStatus = z.infer; +// The DNS records the merchant publishes to point a domain at their project. +// The API sends `pointing_records: null` until the domain is bound and the +// records are known, and nulls individual values it doesn't have, so every +// field is treated as optional. +const pointingRecordsSchema = z.object({ + a_record_value: z.string().nullish(), + cname_record_value: z.string().nullish(), +}); + +export type PointingRecords = z.infer; + const domainSchema = z.object({ domain: z.string(), project_uuid: z.string(), verification_status: domainStatusSchema, }); +// Only the create endpoint returns the records — they're layered onto the domain +// resource in that one response, so the domain shape itself stays without them. +// A caller that doesn't keep them can't fetch them again. +const createdDomainSchema = domainSchema.extend({ + pointing_records: pointingRecordsSchema.nullish(), +}); + const ownershipVerificationSchema = z.object({ type: z.string(), name: z.string(), @@ -79,11 +97,16 @@ const domainResponseSchema = z.object({ data: domainSchema, }); +const createdDomainResponseSchema = z.object({ + data: createdDomainSchema, +}); + const domainListResponseSchema = z.object({ data: z.array(domainSchema), }); export type Domain = z.infer; +export type CreatedDomain = z.infer; export type DomainStatusFilter = Exclude; interface ListDomainsFilters { @@ -185,7 +208,7 @@ export async function createDomain( storeHash: string, accessToken: string, apiHost: string, -): Promise { +): Promise { const response = await fetch(domainsUrl(storeHash, projectUuid, apiHost), { method: 'POST', headers: { @@ -199,7 +222,7 @@ export async function createDomain( const result: unknown = await response.json(); - return domainResponseSchema.parse(result).data; + return createdDomainResponseSchema.parse(result).data; } export async function getDomain( diff --git a/packages/catalyst/tests/mocks/handlers.ts b/packages/catalyst/tests/mocks/handlers.ts index 0091511192..3a7b91a497 100644 --- a/packages/catalyst/tests/mocks/handlers.ts +++ b/packages/catalyst/tests/mocks/handlers.ts @@ -38,6 +38,11 @@ export const handlers = [ domain: body.domain, project_uuid: '6b202364-10f3-11f1-8bc7-fe9b9d8b14ab', verification_status: 'pending', + // Only the create endpoint returns the records to publish. + pointing_records: { + a_record_value: '198.51.100.10', + cname_record_value: 'shared.hosting.bigcommerce.com', + }, }, }, { status: 201 },