diff --git a/docs/product/command-principles.md b/docs/product/command-principles.md index f59a8821..937fdee9 100644 --- a/docs/product/command-principles.md +++ b/docs/product/command-principles.md @@ -146,6 +146,23 @@ 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. + +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/product/error-conventions.md b/docs/product/error-conventions.md index 8b4f270c..7301e137 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -201,4 +201,12 @@ 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. + 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 fd43568e..7a5e2ec0 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. @@ -466,13 +462,30 @@ 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. ### 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 @@ -520,7 +533,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..53788dd6 100644 --- a/packages/cli/src/commands/service/domain-add.ts +++ b/packages/cli/src/commands/service/domain-add.ts @@ -12,15 +12,21 @@ 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"], + "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", + ], }, 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, @@ -35,7 +41,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..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, @@ -32,7 +35,7 @@ export const serviceDomainDeleteCommand = defineCommand({ }); const domain = await resolveDomainByHostname( target.provider, - target.service.id, + target.resultTarget, hostname, "delete", ctx.signal, @@ -53,7 +56,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..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, @@ -30,7 +33,7 @@ export const serviceDomainRetryCommand = defineCommand({ }); const domain = await resolveDomainByHostname( target.provider, - target.service.id, + target.resultTarget, hostname, "retry", ctx.signal, @@ -38,7 +41,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..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, @@ -30,7 +33,7 @@ export const serviceDomainShowCommand = defineCommand({ }); const domain = await resolveDomainByHostname( target.provider, - target.service.id, + target.resultTarget, hostname, "show", ctx.signal, @@ -38,7 +41,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..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, @@ -107,7 +113,7 @@ export const serviceDomainWaitCommand = defineCommand({ }); const domain = await resolveDomainByHostname( target.provider, - target.service.id, + target.resultTarget, hostname, "wait", ctx.signal, @@ -147,11 +153,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 +176,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 9be0f355..7525544f 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}` }; } @@ -289,50 +299,30 @@ 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, + 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)}`}`, ), ], }, ); } -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`, @@ -344,7 +334,7 @@ export function domainNotFoundError(hostname: string): CliStructuredError { ), runCommandAction( "Add the domain", - `service domain add ${hostname} --service `, + domainRecoveryCommand("add", hostname, target), ), ], }, @@ -364,6 +354,7 @@ function formatDomainFailureWhy(domain: DomainRecord): string { export function domainVerificationFailedError( hostname: string, domain: DomainRecord, + target: ServiceDomainTarget, ): CliStructuredError { const why = formatDomainFailureWhy(domain); const guidance = formatDomainFailureFix(domain); @@ -376,11 +367,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), ), ], }, @@ -390,6 +381,7 @@ export function domainVerificationFailedError( export function domainVerificationTimeoutError( hostname: string, lastStatus: string, + target: ServiceDomainTarget, ): CliStructuredError { return new CliStructuredError( "SERVICE.DOMAIN_VERIFICATION_TIMEOUT", @@ -399,7 +391,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."), ], @@ -407,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}"`, @@ -416,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)}`}`, ), ], }, @@ -434,9 +429,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; } @@ -451,7 +447,7 @@ export function domainCommandError( nextActions: [ runCommandAction( "Show the domain", - `service domain show ${hostname} --service `, + domainRecoveryCommand("show", hostname, target), ), ], cause: error, @@ -463,15 +459,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; } @@ -479,24 +476,67 @@ function domainApiFailure( function domainAddFailure( error: DomainApiError, 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) ) { - 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; } @@ -504,6 +544,7 @@ function domainAddFailure( function domainHostnameRejectedError( hostname: string, error: DomainApiError, + target: ServiceDomainTarget, ): CliStructuredError { return new CliStructuredError( "SERVICE.DOMAIN_HOSTNAME_INVALID", @@ -517,29 +558,34 @@ 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", { why: error.message, meta: debugMeta(error), - nextActions: [ - adviceAction( - "Delete an existing custom domain before adding another one.", - ), - runCommandAction( - "Delete a domain", - "service domain delete --service ", - ), - ], + nextActions: error.hint + ? [adviceAction(error.hint)] + : [ + adviceAction( + "Delete an existing custom domain before adding another one.", + ), + runCommandAction( + "Delete a domain", + domainRecoveryCommand("delete", "", target), + ), + ], }, ); } @@ -566,12 +612,13 @@ function domainAlreadyRegisteredError( function domainRequiresDeploymentError( hostname: string, error: DomainApiError, + target: ServiceDomainTarget, ): 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,11 +626,11 @@ 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", - `service domain add ${hostname} --service `, + domainRecoveryCommand("add", hostname, target), ), ], }, @@ -593,6 +640,7 @@ function domainRequiresDeploymentError( function domainRetryNotEligibleError( hostname: string, error: DomainApiError, + target: ServiceDomainTarget, ): CliStructuredError { return new CliStructuredError( "SERVICE.DOMAIN_RETRY_NOT_ELIGIBLE", @@ -606,7 +654,7 @@ function domainRetryNotEligibleError( ), runCommandAction( "Show the domain", - `service domain show ${hostname} --service `, + domainRecoveryCommand("show", hostname, target), ), ], }, @@ -638,9 +686,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}"`, @@ -654,7 +703,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 ac16c9c4..c00c5952 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, @@ -392,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; } @@ -428,15 +430,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), @@ -444,7 +446,7 @@ export async function resolveDomainByHostname( if (matched) { return matched; } - throw domainNotFoundError(hostname); + throw domainNotFoundError(hostname, target); } interface ServiceProjectState { @@ -520,9 +522,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..b64d2316 100644 --- a/packages/cli/tests/service-domain.test.ts +++ b/packages/cli/tests/service-domain.test.ts @@ -27,7 +27,220 @@ 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("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({ + "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({ @@ -153,7 +366,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,12 +394,13 @@ 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", 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", }, ]); }); @@ -205,11 +419,40 @@ 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("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 +461,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 +1078,141 @@ 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" }, + }); +}); + +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'", + }); +});