From 39ddf6cea332749ff2f36c7d76186a69f488cd60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bramer=20Schmidt?= Date: Fri, 16 Oct 2026 21:12:21 +0700 Subject: [PATCH 1/5] feat(service): support custom domains on preview branches --- docs/product/command-principles.md | 7 + docs/reference/error-reference.md | 6 +- packages/cli/src/cli.ts | 2 +- .../cli/src/commands/service/domain-add.ts | 7 +- packages/cli/src/commands/service/errors.ts | 27 +--- packages/cli/src/commands/service/target.ts | 4 - packages/cli/tests/service-domain.test.ts | 145 ++++++++++++++++-- 7 files changed, 147 insertions(+), 51 deletions(-) diff --git a/docs/product/command-principles.md b/docs/product/command-principles.md index f59a8821..7ef6835c 100644 --- a/docs/product/command-principles.md +++ b/docs/product/command-principles.md @@ -146,6 +146,13 @@ When a group acts on a subordinate resource, the action may be a compound verb p That is still one action in the command grammar. It is preferable to inventing a temporary top-level `deployment` group for the MVP. +## Custom domains + +`service domain` commands manage domains for the service on the selected branch. +They default to `production`; `--branch ` selects a preview or staging +branch. The service must have a promoted, running version before a domain can +be added. Domain lookup, retry, waiting, and deletion stay within that service. + ## Shared Flag Conventions Shared CLI flag rules: diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index fd43568e..ff0769d8 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -442,10 +442,6 @@ A `project` or `branch` group command was invoked with unusable arguments — fo A `--branch` flag was passed with an empty or whitespace-only value to a service command; the check runs before any resolution because a blank value must never fall through to the default-branch behavior of omitting the flag. The fix is to pass a non-empty branch name, or omit `--branch` to target the default branch. Meta: none. -### SERVICE.BRANCH_NOT_DEPLOYABLE - -A `service domain` command was pointed at a non-production branch, which the domain-target resolver refuses because custom domains on preview branches are not supported in Public Beta. The fix is to use `--branch production`, or attach the domain after promoting to the production branch. Meta: none. - ### SERVICE.DELETE_FAILED `service delete` called the platform's app-teardown API and it rejected; the underlying error's message becomes `why` and the original error is carried in `cause`. Next actions point at `service show` and `service version list` for the service. Meta: none. @@ -520,7 +516,7 @@ A `service logs` response body ended without the terminal record that closes a p ### SERVICE.NO_VERSIONS -The resolved service has no usable version for the command — raised by `service open` when the service has no versions, by `service logs` when it has no live version, and by `service domain add` when the API answers 422 because the production service has no promoted version that can receive a custom domain. The fix on the domain path is to promote a version on the production branch first, then add the domain again. Meta: `status`, `apiCode`, `hint` (domain-add path only; otherwise none). +The resolved service has no usable version for the command — raised by `service open` when the service has no versions, by `service logs` when it has no live version, and by `service domain add` when the API answers 422 because the selected service has no promoted version that can receive a custom domain. The fix on the domain path is to promote a version on the selected branch first, then add the domain again. Meta: `status`, `apiCode`, `hint` (domain-add path only; otherwise none). ### SERVICE.SELECTION_INVALID diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 7afbb9cc..9a28713e 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -294,7 +294,7 @@ export const cliGroups: Readonly< brief: "Manage custom domains for a service. Register hostnames, drive DNS and TLS verification, inspect status", description: - "Custom domains point hostnames you own at a service's production branch. After add, create the DNS record the platform reports; wait and retry drive DNS verification and TLS provisioning to done.", + "Custom domains point hostnames you own at a service on any branch. Commands default to production; use --branch to select another branch. After add, create the DNS record the platform reports; wait and retry drive DNS verification and TLS provisioning to done.", workflow: [ { run: "service domain add shop.acme.com --service my-api", diff --git a/packages/cli/src/commands/service/domain-add.ts b/packages/cli/src/commands/service/domain-add.ts index 854de396..d65750a2 100644 --- a/packages/cli/src/commands/service/domain-add.ts +++ b/packages/cli/src/commands/service/domain-add.ts @@ -12,10 +12,13 @@ import { export const serviceDomainAddCommand = defineCommand({ help: { - summary: "Register a custom domain on the service's production branch", + summary: "Register a custom domain on the selected service branch", description: "Points a hostname you own at the service instead of its generated URL. After registering, create the DNS record the platform asks for, then 'service domain wait' blocks until verification and TLS provisioning finish.", - examples: ["service domain add shop.acme.com --service my-service"], + examples: [ + "service domain add shop.acme.com --service my-service", + "service domain add staging.acme.com --service my-service --branch staging", + ], }, args: domainTargetArgs(), needs: { credentials: true }, diff --git a/packages/cli/src/commands/service/errors.ts b/packages/cli/src/commands/service/errors.ts index 9be0f355..ab5231e4 100644 --- a/packages/cli/src/commands/service/errors.ts +++ b/packages/cli/src/commands/service/errors.ts @@ -289,27 +289,6 @@ export function liveUrlUnavailableError( ); } -export function branchNotDeployableError( - branchName: string, -): CliStructuredError { - return new CliStructuredError( - "SERVICE.BRANCH_NOT_DEPLOYABLE", - "Custom domains require the production branch", - { - why: `Custom domains on preview branch "${branchName}" are not supported in Public Beta.`, - nextActions: [ - adviceAction( - "Use --branch production, or attach the domain after promoting/deploying to the production branch.", - ), - runCommandAction( - "Add on production", - "service domain add --service --branch production", - ), - ], - }, - ); -} - export function domainHostnameInvalidError( hostname: string, why?: string, @@ -569,9 +548,9 @@ function domainRequiresDeploymentError( ): CliStructuredError { return new CliStructuredError( "SERVICE.NO_VERSIONS", - "Custom domain requires a live production version", + "Custom domain requires a live version", { - why: "The selected production service does not have a promoted version that can receive a custom domain.", + why: "The selected service does not have a promoted version that can receive a custom domain.", meta: debugMeta(error), nextActions: [ // Legacy paired "rerun the domain command" with the fix line @@ -579,7 +558,7 @@ function domainRequiresDeploymentError( // with the dropped command; without the advice the only thing // left told the user to rerun what had just failed. adviceAction( - "Promote a version on the service's production branch, then add the domain again.", + "Promote a version on the selected branch, then add the domain again.", ), runCommandAction( "Add the domain", diff --git a/packages/cli/src/commands/service/target.ts b/packages/cli/src/commands/service/target.ts index ac16c9c4..df7a3aa3 100644 --- a/packages/cli/src/commands/service/target.ts +++ b/packages/cli/src/commands/service/target.ts @@ -19,7 +19,6 @@ import type { AuthWorkspace } from "../../types/auth"; import type { BranchKind } from "../../types/branch"; import type { ProjectResolution, ProjectSummary } from "../../types/project"; import { - branchNotDeployableError, branchValueEmptyError, deployFailedError, domainCommandError, @@ -520,9 +519,6 @@ export async function resolveServiceDomainTarget( ): Promise { requireBranchFlagValue(options.branchName); const branchName = options.branchName?.trim() ?? "production"; - if (toBranchKind(branchName) !== "production") { - throw branchNotDeployableError(branchName); - } const requested = requireServiceArgument( options.serviceName, diff --git a/packages/cli/tests/service-domain.test.ts b/packages/cli/tests/service-domain.test.ts index 815d7034..84336510 100644 --- a/packages/cli/tests/service-domain.test.ts +++ b/packages/cli/tests/service-domain.test.ts @@ -153,7 +153,7 @@ describe("prisma service domain add", () => { }); }); - it("maps a 422 without a live production deployment to SERVICE.NO_VERSIONS", async () => { + it("maps a 422 without a live deployment to SERVICE.NO_VERSIONS", async () => { const harness = await makeServiceCli({ routes: domainRoutes({ "POST /v1/apps/{appId}/domains": () => ({ @@ -181,7 +181,7 @@ describe("prisma service domain add", () => { { kind: "user-choice", label: - "Promote a version on the service's production branch, then add the domain again.", + "Promote a version on the selected branch, then add the domain again.", }, { kind: "run-command", @@ -207,9 +207,33 @@ describe("prisma service domain add", () => { expect(frame.envelope.error.code).toBe("SERVICE.DOMAIN_HOSTNAME_INVALID"); }); - it("rejects preview branches as SERVICE.BRANCH_NOT_DEPLOYABLE", async () => { - const harness = await makeServiceCli({ routes: domainRoutes() }); - + it.each([ + "feature/foo", + "staging", + ])("registers a domain on branch %s", async (branchName) => { + const harness = await makeServiceCli({ + routes: domainRoutes({ + "GET /v1/apps": (init) => { + expect(init.params?.query?.branchGitName).toBe(branchName); + return { + data: page([ + { + id: "svc_preview", + name: "hello-world", + region: { id: "eu-central-1" }, + branchId: "br_preview", + latestDeploymentId: "dep_preview", + appEndpointDomain: "preview.prisma.build", + }, + ]), + }; + }, + "POST /v1/apps/{appId}/domains": (init) => { + expect(init.params?.path?.appId).toBe("svc_preview"); + return { data: { data: domainRecord({ appId: "svc_preview" }) } }; + }, + }), + }); const result = await harness.cli.run( [ "service", @@ -218,18 +242,17 @@ describe("prisma service domain add", () => { "shop.acme.com", ...TARGET_ARGS, "--branch", - "feature/foo", - "--json", + branchName, ], - { cwd: harness.cwd, env: harness.env }, + { cwd: harness.cwd, env: harness.env, isTty: { stdout: true } }, ); - - expect(result.exitCode).toBe(2); - const frame = result.json[result.json.length - 1]; - if (frame?.kind !== "result" || frame.envelope.ok) { - throw new Error("expected an errored envelope"); - } - expect(frame.envelope.error.code).toBe("SERVICE.BRANCH_NOT_DEPLOYABLE"); + expect(result.exitCode).toBe(0); + expect(result.presented?.data).toMatchObject({ + branch: { name: branchName, kind: "preview" }, + service: { id: "svc_preview", name: "hello-world" }, + domain: { serviceId: "svc_preview" }, + }); + expect(presentedSummary(result.presented)?.status).toBe("ok"); }); it("accepts a service id in --service", async () => { @@ -836,3 +859,95 @@ describe("prisma service domain delete", () => { expect(frame.envelope.error.code).toBe("CLI.CONSENT_REQUIRED"); }); }); + +it.each([ + "show", + "retry", + "wait", + "delete", +])("manages a staging domain with %s", async (command) => { + const harness = await makeServiceCli({ + routes: domainRoutes({ + "GET /v1/apps": (init) => { + expect(init.params?.query?.branchGitName).toBe("staging"); + return { + data: page([ + { + id: "svc_staging", + name: "hello-world", + region: { id: "eu-central-1" }, + branchId: "br_staging", + latestDeploymentId: "dep_staging", + appEndpointDomain: "staging.prisma.build", + }, + ]), + }; + }, + "GET /v1/apps/{appId}/domains": (init) => { + expect(init.params?.path?.appId).toBe("svc_staging"); + return { + data: { + data: [ + domainRecord({ + id: "dom_staging", + appId: "svc_staging", + status: "active", + }), + ], + }, + }; + }, + "GET /v1/domains/{domainId}": (init) => { + expect(init.params?.path?.domainId).toBe("dom_staging"); + return { + data: { + data: domainRecord({ + id: "dom_staging", + appId: "svc_staging", + status: "active", + }), + }, + }; + }, + "POST /v1/domains/{domainId}/retry": (init) => { + expect(init.params?.path?.domainId).toBe("dom_staging"); + return { + data: { + data: domainRecord({ + id: "dom_staging", + appId: "svc_staging", + status: "verifying", + }), + }, + }; + }, + "DELETE /v1/domains/{domainId}": (init) => { + expect(init.params?.path?.domainId).toBe("dom_staging"); + return { data: { data: null } }; + }, + }), + }); + const result = await harness.cli.run( + [ + "service", + "domain", + command, + "shop.acme.com", + ...TARGET_ARGS, + "--branch", + "staging", + "--json", + ...(command === "delete" ? ["--confirm", "shop.acme.com"] : []), + ], + { cwd: harness.cwd, env: harness.env }, + ); + expect(result.exitCode).toBe(0); + const frame = result.json[result.json.length - 1]; + if (frame?.kind !== "result" || !frame.envelope.ok) { + throw new Error("expected a completed envelope"); + } + expect(frame.envelope.result).toMatchObject({ + branch: { name: "staging", kind: "preview" }, + service: { id: "svc_staging", name: "hello-world" }, + }); +}); From fe057fa0ed3fbb9ded07540cde610ef2a8ebc236 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bramer=20Schmidt?= Date: Sat, 17 Oct 2026 09:05:53 +0700 Subject: [PATCH 2/5] fix(service): preserve targets in domain recovery commands --- .../cli/src/commands/service/domain-add.ts | 2 +- .../cli/src/commands/service/domain-delete.ts | 9 ++- .../cli/src/commands/service/domain-retry.ts | 4 +- .../cli/src/commands/service/domain-show.ts | 4 +- .../cli/src/commands/service/domain-wait.ts | 21 ++++-- packages/cli/src/commands/service/errors.ts | 69 +++++++++++++------ packages/cli/src/commands/service/target.ts | 8 +-- packages/cli/tests/service-domain.test.ts | 49 ++++++++++++- 8 files changed, 128 insertions(+), 38 deletions(-) diff --git a/packages/cli/src/commands/service/domain-add.ts b/packages/cli/src/commands/service/domain-add.ts index d65750a2..c8cadb46 100644 --- a/packages/cli/src/commands/service/domain-add.ts +++ b/packages/cli/src/commands/service/domain-add.ts @@ -38,7 +38,7 @@ export const serviceDomainAddCommand = defineCommand({ signal: ctx.signal, }) .catch((error) => { - throw domainCommandError("add", error, hostname); + throw domainCommandError("add", error, hostname, target.resultTarget); }); const result: ServiceDomainAddResult = { diff --git a/packages/cli/src/commands/service/domain-delete.ts b/packages/cli/src/commands/service/domain-delete.ts index 7ba8ae0e..2c4496ba 100644 --- a/packages/cli/src/commands/service/domain-delete.ts +++ b/packages/cli/src/commands/service/domain-delete.ts @@ -32,7 +32,7 @@ export const serviceDomainDeleteCommand = defineCommand({ }); const domain = await resolveDomainByHostname( target.provider, - target.service.id, + target.resultTarget, hostname, "delete", ctx.signal, @@ -53,7 +53,12 @@ export const serviceDomainDeleteCommand = defineCommand({ await target.provider .removeDomain(domain.id, { signal: ctx.signal }) .catch((error) => { - throw domainCommandError("delete", error, hostname); + throw domainCommandError( + "delete", + error, + hostname, + target.resultTarget, + ); }); const result: ServiceDomainDeleteResult = { diff --git a/packages/cli/src/commands/service/domain-retry.ts b/packages/cli/src/commands/service/domain-retry.ts index df6bf6db..a7f874a9 100644 --- a/packages/cli/src/commands/service/domain-retry.ts +++ b/packages/cli/src/commands/service/domain-retry.ts @@ -30,7 +30,7 @@ export const serviceDomainRetryCommand = defineCommand({ }); const domain = await resolveDomainByHostname( target.provider, - target.service.id, + target.resultTarget, hostname, "retry", ctx.signal, @@ -38,7 +38,7 @@ export const serviceDomainRetryCommand = defineCommand({ const retried = await target.provider .retryDomain(domain.id, { signal: ctx.signal }) .catch((error) => { - throw domainCommandError("retry", error, hostname); + throw domainCommandError("retry", error, hostname, target.resultTarget); }); const result: ServiceDomainRetryResult = { diff --git a/packages/cli/src/commands/service/domain-show.ts b/packages/cli/src/commands/service/domain-show.ts index 1c264ce1..6ef6a852 100644 --- a/packages/cli/src/commands/service/domain-show.ts +++ b/packages/cli/src/commands/service/domain-show.ts @@ -30,7 +30,7 @@ export const serviceDomainShowCommand = defineCommand({ }); const domain = await resolveDomainByHostname( target.provider, - target.service.id, + target.resultTarget, hostname, "show", ctx.signal, @@ -38,7 +38,7 @@ export const serviceDomainShowCommand = defineCommand({ const detail = await target.provider .showDomain(domain.id, { signal: ctx.signal }) .catch((error) => { - throw domainCommandError("show", error, hostname); + throw domainCommandError("show", error, hostname, target.resultTarget); }); const result: ServiceDomainShowResult = { diff --git a/packages/cli/src/commands/service/domain-wait.ts b/packages/cli/src/commands/service/domain-wait.ts index 8e0f489b..5a0ea10e 100644 --- a/packages/cli/src/commands/service/domain-wait.ts +++ b/packages/cli/src/commands/service/domain-wait.ts @@ -107,7 +107,7 @@ export const serviceDomainWaitCommand = defineCommand({ }); const domain = await resolveDomainByHostname( target.provider, - target.service.id, + target.resultTarget, hostname, "wait", ctx.signal, @@ -147,11 +147,19 @@ export const serviceDomainWaitCommand = defineCommand({ } if (current.status === "failed") { - throw domainVerificationFailedError(hostname, current); + throw domainVerificationFailedError( + hostname, + current, + target.resultTarget, + ); } if (timeoutMs === 0 || Date.now() >= deadline) { - throw domainVerificationTimeoutError(hostname, current.status); + throw domainVerificationTimeoutError( + hostname, + current.status, + target.resultTarget, + ); } // biome-ignore lint/performance/noAwaitInLoops: this polls one domain until it settles, reporting each status change in the order it happened; the sleep between reads keeps the management API request rate down. @@ -162,7 +170,12 @@ export const serviceDomainWaitCommand = defineCommand({ current = await target.provider .showDomain(current.id, { signal: ctx.signal }) .catch((error) => { - throw domainCommandError("wait", error, hostname); + throw domainCommandError( + "wait", + error, + hostname, + target.resultTarget, + ); }); } }, diff --git a/packages/cli/src/commands/service/errors.ts b/packages/cli/src/commands/service/errors.ts index ab5231e4..6d4055a3 100644 --- a/packages/cli/src/commands/service/errors.ts +++ b/packages/cli/src/commands/service/errors.ts @@ -1,11 +1,21 @@ import type { NextAction } from "@prisma/cli-engine/protocol"; import { CliStructuredError } from "@prisma/cli-engine/protocol"; import { CLI_NAME } from "../../cli-name"; +import { formatCommandArgument } from "../../command-arguments"; import { DomainApiError, type DomainRecord } from "../../lib/app/app-provider"; import { formatDomainFailureFix } from "../../lib/app/domain-guidance"; +import type { ServiceDomainTarget } from "./results"; type DomainCommand = "add" | "show" | "delete" | "retry" | "wait"; +function domainRecoveryCommand( + command: DomainCommand, + hostname: string, + target: ServiceDomainTarget, +): string { + return `service domain ${command} ${formatCommandArgument(hostname)} --service ${formatCommandArgument(target.service.id)} --project ${formatCommandArgument(target.project.id)} --branch ${formatCommandArgument(target.branch.name)}`; +} + export function runCommandAction(label: string, command: string): NextAction { return { kind: "run-command", label, command: `${CLI_NAME} ${command}` }; } @@ -311,7 +321,10 @@ export function domainHostnameInvalidError( ); } -export function domainNotFoundError(hostname: string): CliStructuredError { +export function domainNotFoundError( + hostname: string, + target: ServiceDomainTarget, +): CliStructuredError { return new CliStructuredError( "SERVICE.DOMAIN_NOT_FOUND", `Custom domain "${hostname}" not found`, @@ -323,7 +336,7 @@ export function domainNotFoundError(hostname: string): CliStructuredError { ), runCommandAction( "Add the domain", - `service domain add ${hostname} --service `, + domainRecoveryCommand("add", hostname, target), ), ], }, @@ -343,6 +356,7 @@ function formatDomainFailureWhy(domain: DomainRecord): string { export function domainVerificationFailedError( hostname: string, domain: DomainRecord, + target: ServiceDomainTarget, ): CliStructuredError { const why = formatDomainFailureWhy(domain); const guidance = formatDomainFailureFix(domain); @@ -355,11 +369,11 @@ export function domainVerificationFailedError( ...(guidance ? [adviceAction(guidance)] : []), runCommandAction( "Show the domain", - `service domain show ${hostname} --service `, + domainRecoveryCommand("show", hostname, target), ), runCommandAction( "Retry verification", - `service domain retry ${hostname} --service `, + domainRecoveryCommand("retry", hostname, target), ), ], }, @@ -369,6 +383,7 @@ export function domainVerificationFailedError( export function domainVerificationTimeoutError( hostname: string, lastStatus: string, + target: ServiceDomainTarget, ): CliStructuredError { return new CliStructuredError( "SERVICE.DOMAIN_VERIFICATION_TIMEOUT", @@ -378,7 +393,7 @@ export function domainVerificationTimeoutError( nextActions: [ runCommandAction( "Show the domain", - `service domain show ${hostname} --service `, + domainRecoveryCommand("show", hostname, target), ), adviceAction("Retry wait with a longer --timeout."), ], @@ -413,9 +428,10 @@ export function domainCommandError( command: DomainCommand, error: unknown, hostname: string, + target: ServiceDomainTarget, ): CliStructuredError { if (error instanceof DomainApiError) { - const known = domainApiFailure(command, error, hostname); + const known = domainApiFailure(command, error, hostname, target); if (known) { return known; } @@ -430,7 +446,7 @@ export function domainCommandError( nextActions: [ runCommandAction( "Show the domain", - `service domain show ${hostname} --service `, + domainRecoveryCommand("show", hostname, target), ), ], cause: error, @@ -442,15 +458,16 @@ function domainApiFailure( command: DomainCommand, error: DomainApiError, hostname: string, + target: ServiceDomainTarget, ): CliStructuredError | null { if (command === "add") { - return domainAddFailure(error, hostname); + return domainAddFailure(error, hostname, target); } if (error.status === 404) { - return domainNotFoundError(hostname); + return domainNotFoundError(hostname, target); } if (command === "retry" && error.status === 409) { - return domainRetryNotEligibleError(hostname, error); + return domainRetryNotEligibleError(hostname, error, target); } return null; } @@ -458,24 +475,25 @@ function domainApiFailure( function domainAddFailure( error: DomainApiError, hostname: string, + target: ServiceDomainTarget, ): CliStructuredError | null { if ( (error.status === 400 || error.status === 422) && isDomainDnsError(error) ) { - return domainDnsNotConfiguredError(hostname, error); + return domainDnsNotConfiguredError(hostname, error, target); } if (error.status === 400) { - return domainHostnameRejectedError(hostname, error); + return domainHostnameRejectedError(hostname, error, target); } if (error.status === 429 || isDomainQuotaError(error)) { - return domainQuotaExceededError(error); + return domainQuotaExceededError(error, target); } if (error.status === 409) { return domainAlreadyRegisteredError(hostname, error); } if (error.status === 422) { - return domainRequiresDeploymentError(hostname, error); + return domainRequiresDeploymentError(hostname, error, target); } return null; } @@ -483,6 +501,7 @@ function domainAddFailure( function domainHostnameRejectedError( hostname: string, error: DomainApiError, + target: ServiceDomainTarget, ): CliStructuredError { return new CliStructuredError( "SERVICE.DOMAIN_HOSTNAME_INVALID", @@ -496,14 +515,17 @@ function domainHostnameRejectedError( ), runCommandAction( "Add a domain", - "service domain add shop.acme.com --service ", + domainRecoveryCommand("add", hostname, target), ), ], }, ); } -function domainQuotaExceededError(error: DomainApiError): CliStructuredError { +function domainQuotaExceededError( + error: DomainApiError, + target: ServiceDomainTarget, +): CliStructuredError { return new CliStructuredError( "SERVICE.DOMAIN_QUOTA_EXCEEDED", "Custom domain quota exceeded", @@ -516,7 +538,7 @@ function domainQuotaExceededError(error: DomainApiError): CliStructuredError { ), runCommandAction( "Delete a domain", - "service domain delete --service ", + domainRecoveryCommand("delete", "", target), ), ], }, @@ -545,6 +567,7 @@ function domainAlreadyRegisteredError( function domainRequiresDeploymentError( hostname: string, error: DomainApiError, + target: ServiceDomainTarget, ): CliStructuredError { return new CliStructuredError( "SERVICE.NO_VERSIONS", @@ -562,7 +585,7 @@ function domainRequiresDeploymentError( ), runCommandAction( "Add the domain", - `service domain add ${hostname} --service `, + domainRecoveryCommand("add", hostname, target), ), ], }, @@ -572,6 +595,7 @@ function domainRequiresDeploymentError( function domainRetryNotEligibleError( hostname: string, error: DomainApiError, + target: ServiceDomainTarget, ): CliStructuredError { return new CliStructuredError( "SERVICE.DOMAIN_RETRY_NOT_ELIGIBLE", @@ -585,7 +609,7 @@ function domainRetryNotEligibleError( ), runCommandAction( "Show the domain", - `service domain show ${hostname} --service `, + domainRecoveryCommand("show", hostname, target), ), ], }, @@ -617,9 +641,10 @@ function isDomainDnsError(error: DomainApiError): boolean { function domainDnsNotConfiguredError( hostname: string, error: DomainApiError, + target: ServiceDomainTarget, ): CliStructuredError { - const target = extractDomainDnsTarget(error); - const record = target ? `CNAME ${hostname} -> ${target}` : null; + const dnsTarget = extractDomainDnsTarget(error); + const record = dnsTarget ? `CNAME ${hostname} -> ${dnsTarget}` : null; return new CliStructuredError( "SERVICE.DOMAIN_DNS_NOT_CONFIGURED", `DNS is not configured for "${hostname}"`, @@ -633,7 +658,7 @@ function domainDnsNotConfiguredError( ), runCommandAction( "Add the domain", - `service domain add ${hostname} --service `, + domainRecoveryCommand("add", hostname, target), ), ] : [ diff --git a/packages/cli/src/commands/service/target.ts b/packages/cli/src/commands/service/target.ts index df7a3aa3..25474cd3 100644 --- a/packages/cli/src/commands/service/target.ts +++ b/packages/cli/src/commands/service/target.ts @@ -427,15 +427,15 @@ export function sameDomainHostname(left: string, right: string): boolean { export async function resolveDomainByHostname( provider: AppProvider, - serviceId: string, + target: ServiceDomainTarget, hostname: string, command: "add" | "show" | "delete" | "retry" | "wait", signal: AbortSignal, ): Promise { const domains = await provider - .listDomains(serviceId, { signal }) + .listDomains(target.service.id, { signal }) .catch((error) => { - throw domainCommandError(command, error, hostname); + throw domainCommandError(command, error, hostname, target); }); const matched = domains.find((domain) => sameDomainHostname(domain.hostname, hostname), @@ -443,7 +443,7 @@ export async function resolveDomainByHostname( if (matched) { return matched; } - throw domainNotFoundError(hostname); + throw domainNotFoundError(hostname, target); } interface ServiceProjectState { diff --git a/packages/cli/tests/service-domain.test.ts b/packages/cli/tests/service-domain.test.ts index 84336510..3e0952bf 100644 --- a/packages/cli/tests/service-domain.test.ts +++ b/packages/cli/tests/service-domain.test.ts @@ -186,7 +186,8 @@ describe("prisma service domain add", () => { { kind: "run-command", label: "Add the domain", - command: "prisma service domain add shop.acme.com --service ", + command: + "prisma service domain add shop.acme.com --service svc_1 --project proj_1 --branch production", }, ]); }); @@ -951,3 +952,49 @@ it.each([ service: { id: "svc_staging", name: "hello-world" }, }); }); + +it.each([ + { message: "no promoted deployment", code: "SERVICE.NO_VERSIONS" }, + { + message: "DNS is not configured", + hint: "Create a CNAME to switchboard.ewr.prisma.build first.", + code: "SERVICE.DOMAIN_DNS_NOT_CONFIGURED", + }, +])("keeps the selected target in $code recovery commands", async ({ + message, + hint, + code, +}) => { + const harness = await makeServiceCli({ + routes: domainRoutes({ + "POST /v1/apps/{appId}/domains": () => ({ + error: { error: { message, hint } }, + status: 422, + }), + }), + }); + const result = await harness.cli.run( + [ + "service", + "domain", + "add", + "shop.acme.com", + ...TARGET_ARGS, + "--branch", + "feature/customer's-test", + "--json", + ], + { cwd: harness.cwd, env: harness.env }, + ); + expect(result.exitCode).toBe(2); + const frame = result.json[result.json.length - 1]; + if (frame?.kind !== "result" || frame.envelope.ok) + throw new Error("expected an errored envelope"); + expect(frame.envelope.error.code).toBe(code); + expect(frame.envelope.nextActions).toContainEqual({ + kind: "run-command", + label: "Add the domain", + command: + "prisma service domain add shop.acme.com --service svc_1 --project proj_1 --branch 'feature/customer'\\''s-test'", + }); +}); From bd269c7dd57b44c292b4f211446027844819f794 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bramer=20Schmidt?= Date: Fri, 23 Oct 2026 15:44:32 +0700 Subject: [PATCH 3/5] feat(cli): show domain creation rate-limit guidance Use the API recovery hint for custom-domain quota errors so an hourly creation limit tells users to wait instead of deleting a domain. Verified root format, types, lint, error reference, the full CLI package suite, and local end-to-end tests. Live API tests skip without a token. Root pnpm test stops at an unchanged cli-engine prompt timeout that also reproduces in isolation; pnpm --filter @prisma/cli test passes. --- docs/product/error-conventions.md | 4 ++ docs/reference/error-reference.md | 2 +- packages/cli/src/commands/service/errors.ts | 20 +++++----- packages/cli/tests/service-domain.test.ts | 43 +++++++++++++++++++++ 4 files changed, 59 insertions(+), 10 deletions(-) diff --git a/docs/product/error-conventions.md b/docs/product/error-conventions.md index 8b4f270c..1b729e1b 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -201,4 +201,8 @@ If a production action is blocked or fails, the output should make clear whether Every error should help the user recover without opening another document first. +Custom-domain quota errors use the API's recovery hint when supplied. An hourly +creation limit requires waiting; deleting a domain does not restore that budget. +Without an API hint, the CLI keeps its advice to free an existing domain slot. + If a condition is expected and actionable, document it as a structured failure. If it is a bug, do not hide it behind a fake user-facing success envelope. diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index ff0769d8..78dc52e0 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -468,7 +468,7 @@ A `service domain` command targeted a hostname that is not attached to the resol ### SERVICE.DOMAIN_QUOTA_EXCEEDED -`service domain add` was refused because the custom-domain quota is reached — HTTP 429, or a 409 whose text mentions a quota, maximum, or limit. The fix is to delete an existing custom domain before adding another. Meta: `status`, `apiCode`, `hint`. +`service domain add` was refused because the custom-domain quota is reached — HTTP 429, or a 409 whose text mentions a quota, maximum, or limit. The CLI uses the API's recovery hint when supplied, including waiting for an hourly creation budget. Without a hint, it advises deleting an existing custom domain before adding another. Meta: `status`, `apiCode`, `hint`. ### SERVICE.DOMAIN_RETRY_NOT_ELIGIBLE diff --git a/packages/cli/src/commands/service/errors.ts b/packages/cli/src/commands/service/errors.ts index 6d4055a3..3552b968 100644 --- a/packages/cli/src/commands/service/errors.ts +++ b/packages/cli/src/commands/service/errors.ts @@ -532,15 +532,17 @@ function domainQuotaExceededError( { why: error.message, meta: debugMeta(error), - nextActions: [ - adviceAction( - "Delete an existing custom domain before adding another one.", - ), - runCommandAction( - "Delete a domain", - domainRecoveryCommand("delete", "", target), - ), - ], + nextActions: error.hint + ? [adviceAction(error.hint)] + : [ + adviceAction( + "Delete an existing custom domain before adding another one.", + ), + runCommandAction( + "Delete a domain", + domainRecoveryCommand("delete", "", target), + ), + ], }, ); } diff --git a/packages/cli/tests/service-domain.test.ts b/packages/cli/tests/service-domain.test.ts index 3e0952bf..5d012e56 100644 --- a/packages/cli/tests/service-domain.test.ts +++ b/packages/cli/tests/service-domain.test.ts @@ -28,6 +28,49 @@ function domainRoutes(overrides: Routes = {}): Routes { const TARGET_ARGS = ["--project", "acme-app", "--service", "hello-world"]; describe("prisma service domain add", () => { + it("uses the API's wait guidance when the workspace creation budget is exhausted", async () => { + const harness = await makeServiceCli({ + routes: domainRoutes({ + "POST /v1/apps/{appId}/domains": () => ({ + status: 429, + error: { + error: { + code: "rate-limit-exceeded", + message: + "This workspace can create 10 custom domains per hour. Try again in 60 minutes.", + hint: "Wait before adding another custom domain to this workspace.", + }, + }, + }), + }), + }); + + const result = await harness.cli.run( + [ + "service", + "domain", + "add", + "staging.example.com", + ...TARGET_ARGS, + "--json", + ], + { cwd: harness.cwd, env: harness.env }, + ); + + const frame = result.json[result.json.length - 1]; + if (frame?.kind !== "result" || frame.envelope.ok) + throw new Error("expected an errored envelope"); + expect(result.exitCode).not.toBe(0); + expect(frame.envelope.error.code).toBe("SERVICE.DOMAIN_QUOTA_EXCEEDED"); + expect(frame.envelope.error.why).toContain("10 custom domains per hour"); + expect(frame.envelope.nextActions).toEqual([ + { + kind: "user-choice", + label: "Wait before adding another custom domain to this workspace.", + }, + ]); + }); + it("registers the domain and presents the target with dns records", async () => { const harness = await makeServiceCli({ routes: domainRoutes({ From 54b4ea48389004d000508070c0ca1c05a1f57d43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bramer=20Schmidt?= Date: Fri, 23 Oct 2026 15:56:26 +0700 Subject: [PATCH 4/5] fix(service): preserve branch in input error recovery Address the CodeRabbit finding by carrying the supplied branch through hostname and timeout validation. Quote it in recovery commands before target selection. Cover all five domain commands and keep omitted-branch guidance unchanged. --- docs/product/error-conventions.md | 4 + .../cli/src/commands/service/domain-add.ts | 5 +- .../cli/src/commands/service/domain-delete.ts | 5 +- .../cli/src/commands/service/domain-retry.ts | 5 +- .../cli/src/commands/service/domain-show.ts | 5 +- .../cli/src/commands/service/domain-wait.ts | 14 +++- packages/cli/src/commands/service/errors.ts | 15 ++-- packages/cli/src/commands/service/target.ts | 7 +- packages/cli/tests/service-domain.test.ts | 83 +++++++++++++++++++ 9 files changed, 126 insertions(+), 17 deletions(-) diff --git a/docs/product/error-conventions.md b/docs/product/error-conventions.md index 1b729e1b..7301e137 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -201,6 +201,10 @@ If a production action is blocked or fails, the output should make clear whether Every error should help the user recover without opening another document first. +Custom-domain input errors preserve an explicitly supplied `--branch` in recovery +commands, even when validation fails before the service is selected. Recovery must +not silently switch a preview or staging request to production. + Custom-domain quota errors use the API's recovery hint when supplied. An hourly creation limit requires waiting; deleting a domain does not restore that budget. Without an API hint, the CLI keeps its advice to free an existing domain slot. diff --git a/packages/cli/src/commands/service/domain-add.ts b/packages/cli/src/commands/service/domain-add.ts index c8cadb46..68d22726 100644 --- a/packages/cli/src/commands/service/domain-add.ts +++ b/packages/cli/src/commands/service/domain-add.ts @@ -23,7 +23,10 @@ export const serviceDomainAddCommand = defineCommand({ args: domainTargetArgs(), needs: { credentials: true }, handler: async (args, ctx) => { - const hostname = normalizeDomainHostname(args.positionals.hostname); + const hostname = normalizeDomainHostname( + args.positionals.hostname, + args.flags.branch, + ); const target = await resolveServiceDomainTarget(ctx, { serviceName: args.flags.service, projectRef: args.flags.project, diff --git a/packages/cli/src/commands/service/domain-delete.ts b/packages/cli/src/commands/service/domain-delete.ts index 2c4496ba..2668a49b 100644 --- a/packages/cli/src/commands/service/domain-delete.ts +++ b/packages/cli/src/commands/service/domain-delete.ts @@ -23,7 +23,10 @@ export const serviceDomainDeleteCommand = defineCommand({ args: domainTargetArgs(), needs: { credentials: true }, handler: async (args, ctx) => { - const hostname = normalizeDomainHostname(args.positionals.hostname); + const hostname = normalizeDomainHostname( + args.positionals.hostname, + args.flags.branch, + ); const target = await resolveServiceDomainTarget(ctx, { serviceName: args.flags.service, projectRef: args.flags.project, diff --git a/packages/cli/src/commands/service/domain-retry.ts b/packages/cli/src/commands/service/domain-retry.ts index a7f874a9..5af0fcee 100644 --- a/packages/cli/src/commands/service/domain-retry.ts +++ b/packages/cli/src/commands/service/domain-retry.ts @@ -21,7 +21,10 @@ export const serviceDomainRetryCommand = defineCommand({ args: domainTargetArgs(), needs: { credentials: true }, handler: async (args, ctx) => { - const hostname = normalizeDomainHostname(args.positionals.hostname); + const hostname = normalizeDomainHostname( + args.positionals.hostname, + args.flags.branch, + ); const target = await resolveServiceDomainTarget(ctx, { serviceName: args.flags.service, projectRef: args.flags.project, diff --git a/packages/cli/src/commands/service/domain-show.ts b/packages/cli/src/commands/service/domain-show.ts index 6ef6a852..0cf90280 100644 --- a/packages/cli/src/commands/service/domain-show.ts +++ b/packages/cli/src/commands/service/domain-show.ts @@ -21,7 +21,10 @@ export const serviceDomainShowCommand = defineCommand({ args: domainTargetArgs(), needs: { credentials: true }, handler: async (args, ctx) => { - const hostname = normalizeDomainHostname(args.positionals.hostname); + const hostname = normalizeDomainHostname( + args.positionals.hostname, + args.flags.branch, + ); const target = await resolveServiceDomainTarget(ctx, { serviceName: args.flags.service, projectRef: args.flags.project, diff --git a/packages/cli/src/commands/service/domain-wait.ts b/packages/cli/src/commands/service/domain-wait.ts index 5a0ea10e..2eef2eef 100644 --- a/packages/cli/src/commands/service/domain-wait.ts +++ b/packages/cli/src/commands/service/domain-wait.ts @@ -26,7 +26,10 @@ const UNIT_MULTIPLIER_MS: Record = { h: 3_600_000, }; -function parseWaitTimeout(value: string | undefined): number { +function parseWaitTimeout( + value: string | undefined, + branchName?: string, +): number { if (!value) { return DEFAULT_TIMEOUT_MS; } @@ -36,7 +39,7 @@ function parseWaitTimeout(value: string | undefined): number { } const match = DURATION.exec(trimmed); if (!match) { - throw timeoutInvalidError(value); + throw timeoutInvalidError(value, branchName); } const amount = Number.parseInt(match[1] as string, 10); const multiplier = UNIT_MULTIPLIER_MS[match[2] as string] ?? 1; @@ -97,8 +100,11 @@ export const serviceDomainWaitCommand = defineCommand({ }, needs: { credentials: true }, handler: async (args, ctx) => { - const hostname = normalizeDomainHostname(args.positionals.hostname); - const timeoutMs = parseWaitTimeout(args.flags.timeout); + const hostname = normalizeDomainHostname( + args.positionals.hostname, + args.flags.branch, + ); + const timeoutMs = parseWaitTimeout(args.flags.timeout, args.flags.branch); const target = await resolveServiceDomainTarget(ctx, { serviceName: args.flags.service, projectRef: args.flags.project, diff --git a/packages/cli/src/commands/service/errors.ts b/packages/cli/src/commands/service/errors.ts index 3552b968..4e8ff353 100644 --- a/packages/cli/src/commands/service/errors.ts +++ b/packages/cli/src/commands/service/errors.ts @@ -301,20 +301,18 @@ export function liveUrlUnavailableError( export function domainHostnameInvalidError( hostname: string, - why?: string, + branchName?: string, ): CliStructuredError { return new CliStructuredError( "SERVICE.DOMAIN_HOSTNAME_INVALID", `Invalid custom domain "${hostname}"`, { - why: - why ?? - "Custom domains must be valid hostnames without protocol, path, wildcard, or port.", + why: "Custom domains must be valid hostnames without protocol, path, wildcard, or port.", nextActions: [ adviceAction("Pass a hostname like shop.acme.com."), runCommandAction( "Add a domain", - "service domain add shop.acme.com --service ", + `service domain add shop.acme.com --service ${branchName === undefined ? "" : ` --branch ${formatCommandArgument(branchName)}`}`, ), ], }, @@ -401,7 +399,10 @@ export function domainVerificationTimeoutError( ); } -export function timeoutInvalidError(value: string): CliStructuredError { +export function timeoutInvalidError( + value: string, + branchName?: string, +): CliStructuredError { return new CliStructuredError( "SERVICE.TIMEOUT_INVALID", `Invalid timeout "${value}"`, @@ -410,7 +411,7 @@ export function timeoutInvalidError(value: string): CliStructuredError { nextActions: [ runCommandAction( "Wait with a valid timeout", - "service domain wait shop.acme.com --service --timeout 15m", + `service domain wait shop.acme.com --service --timeout 15m${branchName === undefined ? "" : ` --branch ${formatCommandArgument(branchName)}`}`, ), ], }, diff --git a/packages/cli/src/commands/service/target.ts b/packages/cli/src/commands/service/target.ts index 25474cd3..c00c5952 100644 --- a/packages/cli/src/commands/service/target.ts +++ b/packages/cli/src/commands/service/target.ts @@ -391,10 +391,13 @@ export function toServiceDomainSummary( }; } -export function normalizeDomainHostname(hostname: string): string { +export function normalizeDomainHostname( + hostname: string, + branchName?: string, +): string { const normalized = hostname.trim().replace(TRAILING_DOT, "").toLowerCase(); if (!isValidDomainHostname(normalized)) { - throw domainHostnameInvalidError(hostname); + throw domainHostnameInvalidError(hostname, branchName); } return normalized; } diff --git a/packages/cli/tests/service-domain.test.ts b/packages/cli/tests/service-domain.test.ts index 5d012e56..f0092e1c 100644 --- a/packages/cli/tests/service-domain.test.ts +++ b/packages/cli/tests/service-domain.test.ts @@ -27,6 +27,84 @@ function domainRoutes(overrides: Routes = {}): Routes { const TARGET_ARGS = ["--project", "acme-app", "--service", "hello-world"]; +it.each([ + "add", + "show", + "retry", + "wait", + "delete", +])("keeps the supplied branch in %s invalid-hostname recovery", async (command) => { + const harness = await makeServiceCli({ routes: domainRoutes() }); + const result = await harness.cli.run( + [ + "service", + "domain", + command, + "not a hostname", + ...TARGET_ARGS, + "--branch", + "feature/domain fix", + "--json", + ], + { cwd: harness.cwd, env: harness.env }, + ); + + const frame = result.json[result.json.length - 1]; + if (frame?.kind !== "result" || frame.envelope.ok) + throw new Error("expected an errored envelope"); + expect(frame.envelope.error.code).toBe("SERVICE.DOMAIN_HOSTNAME_INVALID"); + expect(frame.envelope.nextActions).toContainEqual({ + kind: "run-command", + label: "Add a domain", + command: + "prisma service domain add shop.acme.com --service --branch 'feature/domain fix'", + }); +}); + +it.each([ + { + branch: undefined, + expected: + "prisma service domain wait shop.acme.com --service --timeout 15m", + }, + { + branch: "feature/domain fix", + expected: + "prisma service domain wait shop.acme.com --service --timeout 15m --branch 'feature/domain fix'", + }, +])("keeps branch $branch in invalid-timeout recovery", async ({ + branch, + expected, +}) => { + const harness = await makeServiceCli({ routes: domainRoutes() }); + const result = await harness.cli.run( + [ + "service", + "domain", + "wait", + "shop.acme.com", + ...TARGET_ARGS, + "--timeout", + "invalid", + ...(branch === undefined ? [] : ["--branch", branch]), + "--json", + ], + { cwd: harness.cwd, env: harness.env }, + ); + + const frame = result.json[result.json.length - 1]; + if (frame?.kind !== "result" || frame.envelope.ok) + throw new Error("expected an errored envelope"); + expect(frame.envelope.error.code).toBe("SERVICE.TIMEOUT_INVALID"); + expect(frame.envelope.nextActions).toEqual([ + { + kind: "run-command", + label: "Wait with a valid timeout", + command: expected, + }, + ]); +}); + describe("prisma service domain add", () => { it("uses the API's wait guidance when the workspace creation budget is exhausted", async () => { const harness = await makeServiceCli({ @@ -249,6 +327,11 @@ describe("prisma service domain add", () => { throw new Error("expected an errored envelope"); } expect(frame.envelope.error.code).toBe("SERVICE.DOMAIN_HOSTNAME_INVALID"); + expect(frame.envelope.nextActions).toContainEqual({ + kind: "run-command", + label: "Add a domain", + command: "prisma service domain add shop.acme.com --service ", + }); }); it.each([ From 84a9a5229def23b9dcb9687d483e90b9424bb686 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bramer=20Schmidt?= Date: Sat, 24 Oct 2026 21:55:39 +0700 Subject: [PATCH 5/5] feat(service): guide domain ownership and Cloudflare setup (#370) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Purpose of change Show TXT ownership instructions, Cloudflare proxy recovery, and a safe retry command when a custom domain cannot connect. ## What's changed and why - Recognize `domain-ownership-required` as `SERVICE.DOMAIN_OWNERSHIP_REQUIRED`, with the exact DNS instructions from the API. - Keep the selected project, service, and shell-quoted branch in **Verify and connect** recovery. Do not suggest deploying the service to fix missing ownership proof. - Recognize `domain-cloudflare-proxy` as `SERVICE.DOMAIN_CLOUDFLARE_PROXY`. Explain DNS only during setup, then Proxied after Active with Full (strict) TLS and an accessible ACME challenge path. Preserve the same project, service, and branch when retrying. - Update command help and product/error docs. The CLI stays compatible with the current API and needs no SDK change. Stacked on #354 for branch-domain support. Companions: [platform #5785](https://github.com/prisma/pdp-control-plane/pull/5785) and [docs #8581](https://github.com/prisma/web/pull/8581). Merge and release that PR first. Release this change before the platform TXT enforcement for clear recovery guidance; publish the companion public docs only after the platform deploy and CLI release. ## Terminal examples Captured from this PR's head (`231634ad916894ce495d57afaa24afc7ea11382f`) using the in-process CLI harness and synthetic API responses. ANSI colors are removed. These are actual CLI-rendered errors, not live DNS or certificate tests. `workspace-proof` is a fixture value: use the exact TXT value provided for your workspace. ### Missing workspace TXT proof The CLI shows the API's exact TXT instructions. The recovery command keeps the selected project, service, and shell-quoted branch; it does not suggest deploying the app to fix missing proof. ```console $ prisma service domain add staging.example.com --project acme-app --service hello-world --branch 'feature/domain fix' ✘ [SERVICE.DOMAIN_OWNERSHIP_REQUIRED] Verify ownership of staging.example.com why: DNS does not contain the TXT proof for this workspace. → Add a TXT record named _prisma-verification.staging.example.com with value prisma-verification=workspace-proof. Keep this record to reconnect domains in this workspace, then retry the same command. → Verify and connect: prisma service domain add staging.example.com --service svc_staging --project proj_1 --branch 'feature/domain fix' ``` Exit status: `2`. JSON mode reports `SERVICE.DOMAIN_OWNERSHIP_REQUIRED` and the same **Verify and connect** next action. ### Cloudflare proxy recovery Connect with **DNS only**, wait for **Active**, then restore **Proxied** with **Full (strict)** TLS. The instructions also cover the ACME challenge path for certificate renewal. ```console $ prisma service domain add staging.example.com --project acme-app --service hello-world --branch 'feature/domain fix' ✘ [SERVICE.DOMAIN_CLOUDFLARE_PROXY] Turn off Cloudflare's proxy to connect staging.example.com why: Failed to add custom domain: This hostname appears to use Cloudflare's proxy. Set this record to DNS only (gray cloud). Once the domain is Active, turn Proxied back on with Full (strict) SSL/TLS and keep /.well-known/acme-challenge/* accessible. → Set this record to DNS only (gray cloud). Once the domain is Active, turn Proxied back on with Full (strict) SSL/TLS and keep /.well-known/acme-challenge/* accessible. → Verify and connect: prisma service domain add staging.example.com --service svc_staging --project proj_1 --branch 'feature/domain fix' ``` Exit status: `2`. JSON mode reports `SERVICE.DOMAIN_CLOUDFLARE_PROXY`. Both captured recovery commands keep `--project proj_1`, `--service svc_staging`, and `--branch 'feature/domain fix'`. ## Testing notes / Before-after Before: the ownership error would be mistaken for a missing deployment. After: the CLI shows the workspace TXT instructions or Cloudflare-specific recovery and retries the same branch app. Passed: root format, type checks (nine tasks), lint, error-reference completeness (132 codes), full CLI package suite (1,050 passed, one existing skip), and local end-to-end tests (seven passed). The 48 live API tests skip without a service token. No live DNS or TLS changes were made. --- docs/product/command-principles.md | 10 ++ docs/reference/error-reference.md | 17 ++++ .../cli/src/commands/service/domain-add.ts | 2 +- packages/cli/src/commands/service/errors.ts | 42 +++++++++ packages/cli/tests/service-domain.test.ts | 92 +++++++++++++++++++ 5 files changed, 162 insertions(+), 1 deletion(-) diff --git a/docs/product/command-principles.md b/docs/product/command-principles.md index 7ef6835c..937fdee9 100644 --- a/docs/product/command-principles.md +++ b/docs/product/command-principles.md @@ -153,6 +153,16 @@ They default to `production`; `--branch ` selects a preview or staging branch. The service must have a promoted, running version before a domain can be added. Domain lookup, retry, waiting, and deletion stay within that service. +New domain attachments require a persistent workspace TXT ownership record as +well as the routing CNAME. Missing proof returns the exact DNS instructions and +a command to retry on the same branch. Existing connected domains keep working; +recreating a branch in the same workspace reuses its TXT record. + +Cloudflare proxy detection returns `SERVICE.DOMAIN_CLOUDFLARE_PROXY` with DNS-only +instructions and a retry command for the same target. The customer can turn the +proxy back on after the domain is Active, with Full (strict) SSL/TLS and the +ACME challenge path accessible for certificate renewal. + ## Shared Flag Conventions Shared CLI flag rules: diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 78dc52e0..7a5e2ec0 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -462,6 +462,23 @@ The general "Management API call failed" wrapper for the `service` command famil The hostname given to a `service domain` command is not a usable custom domain — raised either by local validation before any API call (protocol, path, port, wildcard, single label, or bad DNS labels) or when `service domain add` gets a plain HTTP 400 rejection from the domain API. The fix is to pass a bare hostname such as `shop.acme.com`. Meta: `status`, `apiCode`, `hint` (API-rejection path only; none for local validation). +### SERVICE.DOMAIN_OWNERSHIP_REQUIRED + +The API could not find the TXT record proving that this workspace may attach the +hostname. Add the exact name and value in the API hint, keep the TXT record for +future attachments in this workspace, and rerun the suggested command. Recovery +preserves the selected service, project, and branch. This does not mean that the +service needs another deployment. Meta: `status`, `apiCode`, `hint`. + +### SERVICE.DOMAIN_CLOUDFLARE_PROXY + +The API found Cloudflare proxy addresses while the hostname's routing check +failed. Set that DNS record to DNS only (gray cloud) and rerun the suggested +command. After the domain is Active, Proxied can be enabled again with Full +(strict) SSL/TLS. Keep `/.well-known/acme-challenge/*` accessible without caching +or security challenges so Prisma can renew the certificate. Meta: `status`, +`apiCode`, `hint`. + ### SERVICE.DOMAIN_NOT_FOUND A `service domain` command targeted a hostname that is not attached to the resolved service — raised when the service's domain listing has no matching hostname, or when a show/delete/retry/wait call gets HTTP 404. The fix is to check the hostname and service, or add the domain first. Meta: none. diff --git a/packages/cli/src/commands/service/domain-add.ts b/packages/cli/src/commands/service/domain-add.ts index 68d22726..53788dd6 100644 --- a/packages/cli/src/commands/service/domain-add.ts +++ b/packages/cli/src/commands/service/domain-add.ts @@ -14,7 +14,7 @@ export const serviceDomainAddCommand = defineCommand({ help: { summary: "Register a custom domain on the selected service branch", description: - "Points a hostname you own at the service instead of its generated URL. After registering, create the DNS record the platform asks for, then 'service domain wait' blocks until verification and TLS provisioning finish.", + "Points a hostname you own at the service instead of its generated URL. Add the routing CNAME and workspace TXT record shown by Console or the verification error, then rerun this command. Keep the TXT record for future attachments in this workspace. Use 'service domain wait' to wait for TLS provisioning.", examples: [ "service domain add shop.acme.com --service my-service", "service domain add staging.acme.com --service my-service --branch staging", diff --git a/packages/cli/src/commands/service/errors.ts b/packages/cli/src/commands/service/errors.ts index 4e8ff353..7525544f 100644 --- a/packages/cli/src/commands/service/errors.ts +++ b/packages/cli/src/commands/service/errors.ts @@ -478,6 +478,48 @@ function domainAddFailure( hostname: string, target: ServiceDomainTarget, ): CliStructuredError | null { + if (error.code === "domain-cloudflare-proxy") { + return new CliStructuredError( + "SERVICE.DOMAIN_CLOUDFLARE_PROXY", + `Turn off Cloudflare's proxy to connect ${hostname}`, + { + why: error.message, + meta: debugMeta(error), + nextActions: [ + adviceAction( + error.hint ?? + "Set this DNS record to DNS only (gray cloud), then retry. After the domain is Active, you can turn Proxied back on with Full (strict) SSL/TLS. Keep /.well-known/acme-challenge/* accessible for certificate renewal.", + ), + runCommandAction( + "Verify and connect", + domainRecoveryCommand("add", hostname, target), + ), + ], + cause: error, + }, + ); + } + if (error.code === "domain-ownership-required") { + return new CliStructuredError( + "SERVICE.DOMAIN_OWNERSHIP_REQUIRED", + `Verify ownership of ${hostname}`, + { + why: "DNS does not contain the TXT proof for this workspace.", + meta: debugMeta(error), + nextActions: [ + adviceAction( + error.hint ?? + "Add the workspace TXT record shown in Console's domain setup.", + ), + runCommandAction( + "Verify and connect", + domainRecoveryCommand("add", hostname, target), + ), + ], + cause: error, + }, + ); + } if ( (error.status === 400 || error.status === 422) && isDomainDnsError(error) diff --git a/packages/cli/tests/service-domain.test.ts b/packages/cli/tests/service-domain.test.ts index f0092e1c..b64d2316 100644 --- a/packages/cli/tests/service-domain.test.ts +++ b/packages/cli/tests/service-domain.test.ts @@ -106,6 +106,98 @@ it.each([ }); describe("prisma service domain add", () => { + it("explains when to restore the Cloudflare proxy and retries the same branch", async () => { + const hint = + "Set this record to DNS only (gray cloud). Once the domain is Active, turn Proxied back on with Full (strict) SSL/TLS and keep /.well-known/acme-challenge/* accessible."; + const harness = await makeServiceCli({ + routes: domainRoutes({ + "POST /v1/apps/{appId}/domains": () => ({ + status: 400, + error: { + error: { + code: "domain-cloudflare-proxy", + message: "This hostname appears to use Cloudflare's proxy.", + hint, + }, + }, + }), + }), + }); + const result = await harness.cli.run( + [ + "service", + "domain", + "add", + "staging.example.com", + ...TARGET_ARGS, + "--branch", + "feature/domain fix", + "--json", + ], + { cwd: harness.cwd, env: harness.env }, + ); + const frame = result.json[result.json.length - 1]; + if (frame?.kind !== "result" || frame.envelope.ok) { + throw new Error("expected an errored envelope"); + } + expect(frame.envelope.error.code).toBe("SERVICE.DOMAIN_CLOUDFLARE_PROXY"); + expect(frame.envelope.nextActions).toEqual([ + { kind: "user-choice", label: hint }, + { + kind: "run-command", + label: "Verify and connect", + command: + "prisma service domain add staging.example.com --service svc_1 --project proj_1 --branch 'feature/domain fix'", + }, + ]); + }); + + it("shows TXT instructions and keeps the target when workspace ownership is missing", async () => { + const hint = + "Add a TXT record named _prisma-verification.staging.example.com with value prisma-verification=workspace-proof. Keep this record to reconnect domains in this workspace, then retry the same command."; + const harness = await makeServiceCli({ + routes: domainRoutes({ + "POST /v1/apps/{appId}/domains": () => ({ + status: 422, + error: { + error: { + code: "domain-ownership-required", + message: "Verify ownership before connecting this domain.", + hint, + }, + }, + }), + }), + }); + const result = await harness.cli.run( + [ + "service", + "domain", + "add", + "staging.example.com", + ...TARGET_ARGS, + "--branch", + "feature/domain fix", + "--json", + ], + { cwd: harness.cwd, env: harness.env }, + ); + const frame = result.json[result.json.length - 1]; + if (frame?.kind !== "result" || frame.envelope.ok) + throw new Error("expected an errored envelope"); + expect(frame.envelope.error.code).toBe("SERVICE.DOMAIN_OWNERSHIP_REQUIRED"); + expect(frame.envelope.error.meta).toMatchObject({ hint }); + expect(frame.envelope.nextActions).toEqual([ + { kind: "user-choice", label: hint }, + { + kind: "run-command", + label: "Verify and connect", + command: + "prisma service domain add staging.example.com --service svc_1 --project proj_1 --branch 'feature/domain fix'", + }, + ]); + }); + it("uses the API's wait guidance when the workspace creation budget is exhausted", async () => { const harness = await makeServiceCli({ routes: domainRoutes({