Skip to content

Commit aadaf27

Browse files
jorgemoyaclaude
andauthored
feat(cli): add domains claim command for cross-store domain ownership (#3084)
* feat(cli) - Add domains claim command for cross-store ownership Add `catalyst domains claim <domain>` so users can claim ownership of a custom domain that is already in use on another store. Publishing the ownership-verification TXT record and then claiming releases the domain from the other store and binds it to the current project. The Domains API returns the TXT record in the `meta.ownership_verification` field of a cross-store collision. Surface it as a typed DomainOwnershipVerificationError so both `domains add` (on a 409 collision) and `domains claim` (on a 422 unverified claim) print the record and the next step instead of an opaque error. Refs LTRAC-1106 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(cli) - Tidy the already-claimed domain message When `domains add` hits a domain already in use on another store, the WARN line echoed the V3 error's title and field message, which repeat each other. Print a concise, purpose-written line instead and let the DNS record plus next-step guidance carry the detail. Same tidy-up for `domains claim`'s not-yet-verified message. Follows the existing convention of mapping a detected error condition to CLI-authored guidance (see UnauthorizedError, the "API not enabled" 403s). Refs LTRAC-1106 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent cb7d025 commit aadaf27

5 files changed

Lines changed: 394 additions & 13 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@bigcommerce/catalyst": minor
3+
---
4+
5+
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 <domain>` to release the domain from the other store and bind it to your project.

packages/catalyst/src/cli/commands/domains.spec.ts

Lines changed: 202 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { http, HttpResponse } from 'msw';
55
import { afterAll, afterEach, beforeAll, describe, expect, MockInstance, test, vi } from 'vitest';
66

77
import { server } from '../../../tests/mocks/node';
8-
import { createDomain, deleteDomain, getDomain, listDomains } from '../lib/domains';
8+
import { claimDomain, createDomain, deleteDomain, getDomain, listDomains } from '../lib/domains';
99
import { consola } from '../lib/logger';
1010
import { mkTempDir } from '../lib/mk-temp-dir';
1111
import { getProjectConfig, ProjectConfigSchema } from '../lib/project-config';
@@ -66,7 +66,7 @@ afterAll(async () => {
6666
});
6767

6868
describe('command configuration', () => {
69-
test('domains has add, list, status, and remove subcommands', () => {
69+
test('domains has add, list, status, claim, and remove subcommands', () => {
7070
expect(domains).toBeInstanceOf(Command);
7171
expect(domains.name()).toBe('domains');
7272
expect(domains.description()).toBe(
@@ -76,6 +76,7 @@ describe('command configuration', () => {
7676
const add = domains.commands.find((command) => command.name() === 'add');
7777
const list = domains.commands.find((command) => command.name() === 'list');
7878
const status = domains.commands.find((command) => command.name() === 'status');
79+
const claim = domains.commands.find((command) => command.name() === 'claim');
7980
const remove = domains.commands.find((command) => command.name() === 'remove');
8081

8182
expect(add).toBeDefined();
@@ -117,6 +118,20 @@ describe('command configuration', () => {
117118
]),
118119
);
119120

121+
expect(claim).toBeDefined();
122+
expect(claim?.description()).toBe(
123+
'Claim a custom domain that is currently in use on another store.',
124+
);
125+
expect(claim?.options).toEqual(
126+
expect.arrayContaining([
127+
expect.objectContaining({ flags: '--store-hash <hash>' }),
128+
expect.objectContaining({ flags: '--access-token <token>' }),
129+
expect.objectContaining({ flags: '--api-host <host>' }),
130+
expect.objectContaining({ flags: '--project-uuid <uuid>' }),
131+
expect.objectContaining({ flags: '--wait' }),
132+
]),
133+
);
134+
120135
expect(remove).toBeDefined();
121136
expect(remove?.description()).toBe(
122137
'Remove a custom domain from the current Native Hosting project.',
@@ -336,6 +351,62 @@ describe('domain API client', () => {
336351
).resolves.toBeUndefined();
337352
expect(deletedDomain).toBe(domain);
338353
});
354+
355+
test('claims a domain', async () => {
356+
let claimedDomain: string | readonly string[] | undefined;
357+
358+
server.use(
359+
http.post(
360+
'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains/:domain/claim',
361+
({ params }) => {
362+
claimedDomain = params.domain;
363+
364+
return new HttpResponse(null, { status: 204 });
365+
},
366+
),
367+
);
368+
369+
await expect(
370+
claimDomain(domain, projectUuid, storeHash, accessToken, apiHost),
371+
).resolves.toBeUndefined();
372+
expect(claimedDomain).toBe(domain);
373+
});
374+
375+
test('surfaces the ownership-verification TXT record when a claim is not yet verified', async () => {
376+
server.use(
377+
http.post(
378+
'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains/:domain/claim',
379+
() =>
380+
HttpResponse.json(
381+
{
382+
title: 'Domain ownership could not be verified.',
383+
errors: {
384+
domain: `No ownership verification TXT record was found at '_bigcommerce-verification.${domain}'. Add the record, then try again.`,
385+
},
386+
meta: {
387+
ownership_verification: {
388+
type: 'TXT',
389+
name: `_bigcommerce-verification.${domain}`,
390+
value: 'bc-verify=019500e2933d70578e81090dd7240795',
391+
},
392+
},
393+
},
394+
{ status: 422 },
395+
),
396+
),
397+
);
398+
399+
await expect(
400+
claimDomain(domain, projectUuid, storeHash, accessToken, apiHost),
401+
).rejects.toMatchObject({
402+
name: 'DomainOwnershipVerificationError',
403+
ownershipVerification: {
404+
type: 'TXT',
405+
name: `_bigcommerce-verification.${domain}`,
406+
value: 'bc-verify=019500e2933d70578e81090dd7240795',
407+
},
408+
});
409+
});
339410
});
340411

341412
describe('waitForDomainVerification', () => {
@@ -446,6 +517,51 @@ describe('add command', () => {
446517
expect(consola.start).toHaveBeenCalledWith(`Waiting for ${domain} to verify...`);
447518
expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('active'));
448519
});
520+
521+
test('surfaces the ownership TXT record and claim guidance on a cross-store collision', async () => {
522+
server.use(
523+
http.post(
524+
'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains',
525+
() =>
526+
HttpResponse.json(
527+
{
528+
title:
529+
'The domain is already bound to a different store. Verify ownership using the claim endpoint, then try again.',
530+
errors: {
531+
domain: `'${domain}' is already bound to a different store; verify ownership, then try again.`,
532+
},
533+
meta: {
534+
ownership_verification: {
535+
type: 'TXT',
536+
name: `_bigcommerce-verification.${domain}`,
537+
value: 'bc-verify=019500e2933d70578e81090dd7240795',
538+
},
539+
},
540+
},
541+
{ status: 409 },
542+
),
543+
),
544+
);
545+
546+
writeCredentials();
547+
548+
await domains.parseAsync(['add', domain], { from: 'user' });
549+
550+
expect(consola.warn).toHaveBeenCalledWith(
551+
expect.stringContaining('already in use on another store'),
552+
);
553+
expect(consola.log).toHaveBeenCalledWith(
554+
expect.stringContaining('bc-verify=019500e2933d70578e81090dd7240795'),
555+
);
556+
expect(consola.info).toHaveBeenCalledWith(
557+
`Once the record is live, run: catalyst domains claim ${domain}`,
558+
);
559+
// The raw V3 title/field text isn't echoed — the concise message replaces it.
560+
expect(consola.warn).not.toHaveBeenCalledWith(
561+
expect.stringContaining('Verify ownership using the claim endpoint'),
562+
);
563+
expect(exitMock).toHaveBeenCalledWith(1);
564+
});
449565
});
450566

451567
describe('list command', () => {
@@ -553,6 +669,90 @@ describe('status command', () => {
553669
});
554670
});
555671

672+
describe('claim command', () => {
673+
test('claims a domain for the linked project', async () => {
674+
writeCredentials();
675+
676+
await domains.parseAsync(['claim', domain], { from: 'user' });
677+
678+
expect(consola.start).toHaveBeenCalledWith(`Claiming domain ${domain}...`);
679+
expect(consola.success).toHaveBeenCalledWith(`Domain ${domain} claimed.`);
680+
expect(consola.log).toHaveBeenCalledWith(expect.stringContaining(domain));
681+
expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('active'));
682+
expect(exitMock).toHaveBeenCalledWith(0);
683+
});
684+
685+
test('prints the ownership TXT record when verification has not completed', async () => {
686+
server.use(
687+
http.post(
688+
'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains/:domain/claim',
689+
() =>
690+
HttpResponse.json(
691+
{
692+
title: 'Domain ownership could not be verified.',
693+
errors: {
694+
domain: `No ownership verification TXT record was found at '_bigcommerce-verification.${domain}'. Add the record, then try again.`,
695+
},
696+
meta: {
697+
ownership_verification: {
698+
type: 'TXT',
699+
name: `_bigcommerce-verification.${domain}`,
700+
value: 'bc-verify=019500e2933d70578e81090dd7240795',
701+
},
702+
},
703+
},
704+
{ status: 422 },
705+
),
706+
),
707+
);
708+
709+
writeCredentials();
710+
711+
await domains.parseAsync(['claim', domain], { from: 'user' });
712+
713+
expect(consola.warn).toHaveBeenCalledWith(expect.stringContaining('could not be verified'));
714+
expect(consola.log).toHaveBeenCalledWith(
715+
expect.stringContaining('bc-verify=019500e2933d70578e81090dd7240795'),
716+
);
717+
expect(consola.success).not.toHaveBeenCalled();
718+
expect(exitMock).toHaveBeenCalledWith(1);
719+
});
720+
721+
test('can wait for a claimed domain to leave pending status', async () => {
722+
let getRequests = 0;
723+
724+
server.use(
725+
http.post(
726+
'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains/:domain/claim',
727+
() => new HttpResponse(null, { status: 204 }),
728+
),
729+
http.get(
730+
'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains/:domain',
731+
() => {
732+
getRequests += 1;
733+
734+
return HttpResponse.json({
735+
data: {
736+
domain,
737+
project_uuid: projectUuid,
738+
verification_status: getRequests === 1 ? 'pending' : 'verified',
739+
},
740+
});
741+
},
742+
),
743+
);
744+
745+
writeCredentials();
746+
747+
await domains.parseAsync(['claim', domain, '--wait'], { from: 'user' });
748+
749+
expect(consola.success).toHaveBeenCalledWith(`Domain ${domain} claimed.`);
750+
expect(consola.start).toHaveBeenCalledWith(`Waiting for ${domain} to verify...`);
751+
expect(consola.log).toHaveBeenCalledWith(expect.stringContaining('active'));
752+
expect(getRequests).toBe(2);
753+
});
754+
});
755+
556756
describe('remove command', () => {
557757
test('confirms before removing an active domain', async () => {
558758
confirmMock.mockResolvedValue(true);

0 commit comments

Comments
 (0)