Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/domains-pointing-records.md
Original file line number Diff line number Diff line change
@@ -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.
130 changes: 129 additions & 1 deletion packages/catalyst/src/cli/commands/domains.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand All @@ -219,6 +255,7 @@ describe('domain API client', () => {
domain,
project_uuid: projectUuid,
verification_status: 'pending',
pointing_records: pointingRecords,
},
},
{ status: 201 },
Expand All @@ -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(
Expand Down Expand Up @@ -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();

Expand Down
59 changes: 56 additions & 3 deletions packages/catalyst/src/cli/commands/domains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { colorize } from 'consola/utils';

import {
claimDomain,
CreatedDomain,
createDomain,
deleteDomain,
Domain,
Expand All @@ -15,6 +16,7 @@ import {
getDomain,
listDomains,
OwnershipVerification,
PointingRecords,
transferDomain,
} from '../lib/domains';
import { UserActionableError } from '../lib/errors';
Expand All @@ -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<DomainStatus, Parameters<typeof colorize>[0]> = {
pending: 'yellow',
Expand Down Expand Up @@ -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:',
Expand Down Expand Up @@ -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

Expand All @@ -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,
Expand Down Expand Up @@ -231,14 +263,35 @@ 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...`);
result = await waitForDomainVerification({ domain: result.domain, ...context });
}

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);
});

Expand Down
27 changes: 25 additions & 2 deletions packages/catalyst/src/cli/lib/domains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,30 @@ export const domainStatusSchema = z.enum(['pending', 'verified', 'failed', 'unkn

export type DomainStatus = z.infer<typeof domainStatusSchema>;

// 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<typeof pointingRecordsSchema>;

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(),
Expand Down Expand Up @@ -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<typeof domainSchema>;
export type CreatedDomain = z.infer<typeof createdDomainSchema>;
export type DomainStatusFilter = Exclude<DomainStatus, 'unknown'>;

interface ListDomainsFilters {
Expand Down Expand Up @@ -185,7 +208,7 @@ export async function createDomain(
storeHash: string,
accessToken: string,
apiHost: string,
): Promise<Domain> {
): Promise<CreatedDomain> {
const response = await fetch(domainsUrl(storeHash, projectUuid, apiHost), {
method: 'POST',
headers: {
Expand All @@ -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(
Expand Down
5 changes: 5 additions & 0 deletions packages/catalyst/tests/mocks/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
Loading