From 78337cfe4bdbcb8f5a3769c3b05d7cc1302ec2b6 Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Wed, 8 Jul 2026 14:09:07 -0500 Subject: [PATCH 1/4] feat(cli) - Add domains transfer command for same-store project moves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `catalyst domains transfer ` to move a custom domain from its current project to another project in the same store — the same-store counterpart to `domains claim` (cross-store). The source is the linked project; the destination is `--to-project-uuid`, or, when that's omitted, chosen interactively from the store's other projects (the source is excluded). Adds a `transferDomain` client (`POST .../domains/{domain}/transfer` with `{ new_project_uuid }`), the subcommand with `--wait`, a default MSW handler, and tests. Refs LTRAC-1117 Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/domains-transfer-command.md | 5 + .../catalyst/src/cli/commands/domains.spec.ts | 185 +++++++++++++++++- packages/catalyst/src/cli/commands/domains.ts | 124 +++++++++++- packages/catalyst/src/cli/lib/domains.ts | 73 ++++++- packages/catalyst/tests/mocks/handlers.ts | 6 + 5 files changed, 385 insertions(+), 8 deletions(-) create mode 100644 .changeset/domains-transfer-command.md diff --git a/.changeset/domains-transfer-command.md b/.changeset/domains-transfer-command.md new file mode 100644 index 000000000..28949667b --- /dev/null +++ b/.changeset/domains-transfer-command.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst": minor +--- + +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. diff --git a/packages/catalyst/src/cli/commands/domains.spec.ts b/packages/catalyst/src/cli/commands/domains.spec.ts index 205cad3b8..9d14221fd 100644 --- a/packages/catalyst/src/cli/commands/domains.spec.ts +++ b/packages/catalyst/src/cli/commands/domains.spec.ts @@ -1,11 +1,18 @@ -import { confirm } from '@inquirer/prompts'; +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, getDomain, listDomains } from '../lib/domains'; +import { + claimDomain, + createDomain, + deleteDomain, + getDomain, + listDomains, + transferDomain, +} from '../lib/domains'; import { UserActionableError } from '../lib/errors'; import { consola } from '../lib/logger'; import { mkTempDir } from '../lib/mk-temp-dir'; @@ -15,9 +22,11 @@ import { domains, formatDomain, formatDomainStatus, waitForDomainVerification } 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; @@ -67,7 +76,7 @@ afterAll(async () => { }); describe('command configuration', () => { - test('domains has add, list, status, claim, and remove subcommands', () => { + test('domains has add, list, status, claim, transfer, and remove subcommands', () => { expect(domains).toBeInstanceOf(Command); expect(domains.name()).toBe('domains'); expect(domains.description()).toBe( @@ -78,6 +87,7 @@ describe('command configuration', () => { 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(); @@ -133,6 +143,21 @@ describe('command configuration', () => { ]), ); + 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.', @@ -410,6 +435,30 @@ describe('domain API client', () => { 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('surfaces the ownership-verification TXT record when a claim is not yet verified', async () => { server.use( http.post( @@ -600,6 +649,44 @@ describe('add command', () => { ); 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: 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', () => { @@ -791,6 +878,98 @@ describe('claim command', () => { }); }); +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('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); diff --git a/packages/catalyst/src/cli/commands/domains.ts b/packages/catalyst/src/cli/commands/domains.ts index f01070008..dd22dff08 100644 --- a/packages/catalyst/src/cli/commands/domains.ts +++ b/packages/catalyst/src/cli/commands/domains.ts @@ -1,4 +1,4 @@ -import { confirm } from '@inquirer/prompts'; +import { confirm, select } from '@inquirer/prompts'; import { Command, Option } from 'commander'; import { colorize } from 'consola/utils'; @@ -7,14 +7,18 @@ import { createDomain, deleteDomain, Domain, + DomainBoundToProjectError, DomainOwnershipVerificationError, DomainStatus, DomainStatusFilter, 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 { @@ -79,6 +83,47 @@ function resolveDomainCommandContext(options: DomainCommandOptions): DomainComma }; } +// 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]); } @@ -168,6 +213,16 @@ Examples: 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: catalyst domains transfer ${domain} --project-uuid ${error.projectUuid} --to-project-uuid ${context.projectUuid}`, + ); + process.exit(1); + + return; + } + throw error; } @@ -350,6 +405,72 @@ Examples: 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); + + 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.') @@ -418,4 +539,5 @@ export const domains = new Command('domains') .addCommand(list) .addCommand(showStatus) .addCommand(claim) + .addCommand(transfer) .addCommand(remove); diff --git a/packages/catalyst/src/cli/lib/domains.ts b/packages/catalyst/src/cli/lib/domains.ts index 797235673..3020b078e 100644 --- a/packages/catalyst/src/cli/lib/domains.ts +++ b/packages/catalyst/src/cli/lib/domains.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-classes-per-file -- the two domain-collision error types are co-located with the response parsing that throws them */ import { z } from 'zod'; import { assertAuthorized } from './auth-errors'; @@ -49,6 +50,31 @@ function parseOwnershipVerification(body: unknown): OwnershipVerification | unde return parsed.success ? parsed.data.meta.ownership_verification : undefined; } +const boundProjectMetaSchema = z.object({ + meta: z.object({ + project_uuid: z.string(), + }), +}); + +// Raised when the Domains API rejects an add/claim because the domain is +// already bound to a different project in the *same* store. Carries the UUID of +// that project so the caller can point the user at `domains transfer`. +export class DomainBoundToProjectError extends UserActionableError { + readonly projectUuid: string; + + constructor(message: string, projectUuid: string) { + super(message); + this.name = 'DomainBoundToProjectError'; + this.projectUuid = projectUuid; + } +} + +function parseBoundProjectUuid(body: unknown): string | undefined { + const parsed = boundProjectMetaSchema.safeParse(body); + + return parsed.success ? parsed.data.meta.project_uuid : undefined; +} + const domainResponseSchema = z.object({ data: domainSchema, }); @@ -80,6 +106,15 @@ function domainClaimUrl(storeHash: string, projectUuid: string, domain: string, return `${domainUrl(storeHash, projectUuid, domain, apiHost)}/claim`; } +function domainTransferUrl( + storeHash: string, + projectUuid: string, + domain: string, + apiHost: string, +) { + return `${domainUrl(storeHash, projectUuid, domain, apiHost)}/transfer`; +} + function authHeaders(accessToken: string) { return { Accept: 'application/json', @@ -104,10 +139,11 @@ function buildErrorMessage(response: Response, body: unknown, action: string): s return message ?? `${action}: ${response.statusText}`; } -// Throws for any non-OK Domains API response. When the body carries an -// `meta.ownership_verification` TXT record (a domain bound to another store), -// surfaces a DomainOwnershipVerificationError so callers can guide the user -// through the claim flow; otherwise throws a plain formatted error. +// Throws for any non-OK Domains API response. Surfaces typed errors for the two +// claimable/movable conflicts so callers can guide the user: a +// `meta.ownership_verification` TXT record (domain on another store → claim) or +// a `meta.project_uuid` (domain on another project in this store → transfer). +// Otherwise throws a plain formatted error. async function assertDomainResponse(response: Response, action: string): Promise { assertAuthorized(response); @@ -127,6 +163,12 @@ async function assertDomainResponse(response: Response, action: string): Promise throw new DomainOwnershipVerificationError(message, ownershipVerification); } + const boundProjectUuid = parseBoundProjectUuid(body); + + if (boundProjectUuid) { + throw new DomainBoundToProjectError(message, boundProjectUuid); + } + // 5xx responses are server-side failures worth escalating, so keep the // Correlation ID + support framing. A 4xx (validation, not-found, conflict) // is a clear, user-actionable response — surface just the message. @@ -239,3 +281,26 @@ export async function claimDomain( await assertDomainResponse(response, 'Failed to claim domain'); } + +// Moves a domain from its current project (`projectUuid`, the source) to +// `newProjectUuid` (the destination) within the same store. The API rejects a +// transfer where the destination equals the source. +export async function transferDomain( + domain: string, + projectUuid: string, + newProjectUuid: string, + storeHash: string, + accessToken: string, + apiHost: string, +): Promise { + const response = await fetch(domainTransferUrl(storeHash, projectUuid, domain, apiHost), { + method: 'POST', + headers: { + ...authHeaders(accessToken), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ new_project_uuid: newProjectUuid }), + }); + + await assertDomainResponse(response, 'Failed to transfer domain'); +} diff --git a/packages/catalyst/tests/mocks/handlers.ts b/packages/catalyst/tests/mocks/handlers.ts index 5b4d4bb2e..c0d0386dd 100644 --- a/packages/catalyst/tests/mocks/handlers.ts +++ b/packages/catalyst/tests/mocks/handlers.ts @@ -90,6 +90,12 @@ export const handlers = [ () => new HttpResponse(null, { status: 204 }), ), + // Handler for transferDomain + http.post( + 'https://:apiHost/stores/:storeHash/v3/infrastructure/projects/:projectUuid/domains/:domain/transfer', + () => new HttpResponse(null, { status: 204 }), + ), + // Handler for fetchProjects http.get('https://:apiHost/stores/:storeHash/v3/infrastructure/projects', () => HttpResponse.json({ From c31dba6532b8b60aa8ac7eb2f8c75e235cfd1362 Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Thu, 9 Jul 2026 10:38:37 -0500 Subject: [PATCH 2/4] feat(cli) - Verify domain is on source project before transfer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `domains transfer` moves a domain from the linked (source) project to a destination. When the linked project doesn't own the domain, the Domains API rejected the transfer with an opaque "domain is not owned by the specified project" error — after the user had already picked a destination. Add a `findDomain` client (a `getDomain` that returns null on a 404 instead of throwing) and a pre-flight in the transfer command: before prompting for a destination, confirm the domain is on the source project, otherwise throw an actionable error pointing at `--project-uuid` and `catalyst domains list`. Refs LTRAC-1117 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../catalyst/src/cli/commands/domains.spec.ts | 68 +++++++++++++++++++ packages/catalyst/src/cli/commands/domains.ts | 26 +++++++ packages/catalyst/src/cli/lib/domains.ts | 27 ++++++++ 3 files changed, 121 insertions(+) diff --git a/packages/catalyst/src/cli/commands/domains.spec.ts b/packages/catalyst/src/cli/commands/domains.spec.ts index 9d14221fd..2230c5664 100644 --- a/packages/catalyst/src/cli/commands/domains.spec.ts +++ b/packages/catalyst/src/cli/commands/domains.spec.ts @@ -9,6 +9,7 @@ import { claimDomain, createDomain, deleteDomain, + findDomain, getDomain, listDomains, transferDomain, @@ -459,6 +460,42 @@ describe('domain API client', () => { 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( @@ -944,6 +981,37 @@ describe('transfer command', () => { expect(consola.success).toHaveBeenCalledWith(`Domain ${domain} transferred.`); }); + test('errors with guidance when the domain is not on the source project', 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 the current project`); + + // 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(); diff --git a/packages/catalyst/src/cli/commands/domains.ts b/packages/catalyst/src/cli/commands/domains.ts index dd22dff08..b003a2d24 100644 --- a/packages/catalyst/src/cli/commands/domains.ts +++ b/packages/catalyst/src/cli/commands/domains.ts @@ -11,6 +11,7 @@ import { DomainOwnershipVerificationError, DomainStatus, DomainStatusFilter, + findDomain, getDomain, listDomains, OwnershipVerification, @@ -433,6 +434,31 @@ Examples: 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) { + throw new UserActionableError( + `${domain} isn't on the current project (${context.projectUuid}), so there's nothing to transfer from it. ` + + `Run this from the project that currently owns ${domain}, or pass its UUID with --project-uuid . ` + + `Use \`catalyst domains list\` to find which project has it.`, + ); + } + + consola.success(`${domain} found on the current project.`); + const newProjectUuid = await resolveDestinationProject(domain, context, options.toProjectUuid); consola.start(`Transferring domain ${domain}...`); diff --git a/packages/catalyst/src/cli/lib/domains.ts b/packages/catalyst/src/cli/lib/domains.ts index 3020b078e..3dcf1b899 100644 --- a/packages/catalyst/src/cli/lib/domains.ts +++ b/packages/catalyst/src/cli/lib/domains.ts @@ -221,6 +221,33 @@ export async function getDomain( return domainResponseSchema.parse(result).data; } +// Like `getDomain`, but returns null when the domain isn't present on the +// given project (404) instead of throwing. Used by `transfer` to confirm the +// source project actually owns the domain before attempting the move, so the +// user gets clear guidance instead of the API's opaque ownership rejection. +export async function findDomain( + domain: string, + projectUuid: string, + storeHash: string, + accessToken: string, + apiHost: string, +): Promise { + const response = await fetch(domainUrl(storeHash, projectUuid, domain, apiHost), { + method: 'GET', + headers: authHeaders(accessToken), + }); + + if (response.status === 404) { + return null; + } + + await assertDomainResponse(response, 'Failed to fetch domain'); + + const result: unknown = await response.json(); + + return domainResponseSchema.parse(result).data; +} + export async function listDomains( projectUuid: string, storeHash: string, From 4225bc32894e26228791318c018ce82836206765 Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Thu, 9 Jul 2026 10:39:36 -0500 Subject: [PATCH 3/4] fix(cli) - Drop per-line log timestamps and isolate command hints consola's fancy reporter printed a per-line wall-clock timestamp. It adds noise for a short-lived CLI (no comparable dev CLI does it), and on long lines consola can't right-align it, so it falls back to an inline `[time]` prefix that breaks copy-pasteable command suggestions. Disable it with `formatOptions: { date: false }`. The `logs` command is unaffected: its timestamps come from the log payload, not consola's clock. Also move the runnable command in the add/claim "already in use" error branches onto its own unprefixed `consola.log` line so it stays clean to copy at any terminal width. Refs LTRAC-1117 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/catalyst/src/cli/commands/domains.spec.ts | 10 +++++----- packages/catalyst/src/cli/commands/domains.ts | 13 +++++++------ packages/catalyst/src/cli/lib/logger.ts | 4 ++++ 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/packages/catalyst/src/cli/commands/domains.spec.ts b/packages/catalyst/src/cli/commands/domains.spec.ts index 2230c5664..2f15fce54 100644 --- a/packages/catalyst/src/cli/commands/domains.spec.ts +++ b/packages/catalyst/src/cli/commands/domains.spec.ts @@ -677,9 +677,8 @@ describe('add command', () => { expect(consola.log).toHaveBeenCalledWith( expect.stringContaining('bc-verify=019500e2933d70578e81090dd7240795'), ); - expect(consola.info).toHaveBeenCalledWith( - `Once the record is live, run: catalyst domains claim ${domain}`, - ); + 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'), @@ -715,8 +714,9 @@ describe('add command', () => { 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: catalyst domains transfer ${domain} --project-uuid ${boundProjectUuid} --to-project-uuid ${projectUuid}`, + 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( diff --git a/packages/catalyst/src/cli/commands/domains.ts b/packages/catalyst/src/cli/commands/domains.ts index b003a2d24..a7ee87f26 100644 --- a/packages/catalyst/src/cli/commands/domains.ts +++ b/packages/catalyst/src/cli/commands/domains.ts @@ -208,7 +208,8 @@ Examples: 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, run: catalyst domains claim ${domain}`); + consola.info('Once the record is live, claim it with:'); + consola.log(` catalyst domains claim ${domain}`); process.exit(1); return; @@ -216,8 +217,9 @@ Examples: 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: catalyst domains transfer ${domain} --project-uuid ${error.projectUuid} --to-project-uuid ${context.projectUuid}`, + 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); @@ -376,9 +378,8 @@ Examples: 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: catalyst domains claim ${domain}`, - ); + consola.info('Once the record is live, run the claim again:'); + consola.log(` catalyst domains claim ${domain}`); process.exit(1); return; diff --git a/packages/catalyst/src/cli/lib/logger.ts b/packages/catalyst/src/cli/lib/logger.ts index 065d7b55c..51643fdcd 100644 --- a/packages/catalyst/src/cli/lib/logger.ts +++ b/packages/catalyst/src/cli/lib/logger.ts @@ -2,4 +2,8 @@ import { createConsola } from 'consola'; export const consola = createConsola({ level: process.env.CONSOLA_LEVEL ? parseInt(process.env.CONSOLA_LEVEL, 10) : 3, + // Drop the per-line timestamp. It adds noise for a short-lived CLI and, on + // long lines, consola can't right-align it so it falls back to an inline + // `[time]` prefix that breaks copy-pasteable command suggestions. + formatOptions: { date: false }, }); From f82d65bf8f063ec3cd80913117fcaa46abcb600a Mon Sep 17 00:00:00 2001 From: Jorge Moya Date: Thu, 9 Jul 2026 10:52:49 -0500 Subject: [PATCH 4/4] fix(cli) - Name the domain's owning project on transfer pre-flight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transfer pre-flight told the user to run `catalyst domains list` to find which project owns the domain, but `list` only shows the *current* project's domains — so it could never surface a domain that lives on another project. When the linked project doesn't own the domain, scan the store's other projects and name the one that does, with the exact `--project-uuid` command to re-run. If no project in the store owns it, say so and point at `catalyst domains claim` for the cross-store case. Refs LTRAC-1117 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../catalyst/src/cli/commands/domains.spec.ts | 46 ++++++++++++++++++- packages/catalyst/src/cli/commands/domains.ts | 42 +++++++++++++++-- 2 files changed, 82 insertions(+), 6 deletions(-) diff --git a/packages/catalyst/src/cli/commands/domains.spec.ts b/packages/catalyst/src/cli/commands/domains.spec.ts index 2f15fce54..38a60de7b 100644 --- a/packages/catalyst/src/cli/commands/domains.spec.ts +++ b/packages/catalyst/src/cli/commands/domains.spec.ts @@ -981,7 +981,49 @@ describe('transfer command', () => { expect(consola.success).toHaveBeenCalledWith(`Domain ${domain} transferred.`); }); - test('errors with guidance when the domain is not on the source project', async () => { + 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( @@ -1005,7 +1047,7 @@ describe('transfer command', () => { domains.parseAsync(['transfer', domain, '--to-project-uuid', destinationProjectUuid], { from: 'user', }), - ).rejects.toThrow(`${domain} isn't on the current project`); + ).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); diff --git a/packages/catalyst/src/cli/commands/domains.ts b/packages/catalyst/src/cli/commands/domains.ts index a7ee87f26..8a961e651 100644 --- a/packages/catalyst/src/cli/commands/domains.ts +++ b/packages/catalyst/src/cli/commands/domains.ts @@ -451,11 +451,45 @@ Examples: ); if (!owned) { - throw new UserActionableError( - `${domain} isn't on the current project (${context.projectUuid}), so there's nothing to transfer from it. ` + - `Run this from the project that currently owns ${domain}, or pass its UUID with --project-uuid . ` + - `Use \`catalyst domains list\` to find which project has it.`, + // 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.`);