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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/product/command-principles.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` 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:
Expand Down
8 changes: 8 additions & 0 deletions docs/product/error-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
25 changes: 19 additions & 6 deletions docs/reference/error-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 11 additions & 5 deletions packages/cli/src/commands/service/domain-add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 = {
Expand Down
14 changes: 11 additions & 3 deletions packages/cli/src/commands/service/domain-delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -32,7 +35,7 @@ export const serviceDomainDeleteCommand = defineCommand({
});
const domain = await resolveDomainByHostname(
target.provider,
target.service.id,
target.resultTarget,
hostname,
"delete",
ctx.signal,
Expand All @@ -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 = {
Expand Down
9 changes: 6 additions & 3 deletions packages/cli/src/commands/service/domain-retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -30,15 +33,15 @@ export const serviceDomainRetryCommand = defineCommand({
});
const domain = await resolveDomainByHostname(
target.provider,
target.service.id,
target.resultTarget,
hostname,
"retry",
ctx.signal,
);
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 = {
Expand Down
9 changes: 6 additions & 3 deletions packages/cli/src/commands/service/domain-show.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -30,15 +33,15 @@ export const serviceDomainShowCommand = defineCommand({
});
const domain = await resolveDomainByHostname(
target.provider,
target.service.id,
target.resultTarget,
hostname,
"show",
ctx.signal,
);
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 = {
Expand Down
35 changes: 27 additions & 8 deletions packages/cli/src/commands/service/domain-wait.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ const UNIT_MULTIPLIER_MS: Record<string, number> = {
h: 3_600_000,
};

function parseWaitTimeout(value: string | undefined): number {
function parseWaitTimeout(
value: string | undefined,
branchName?: string,
): number {
if (!value) {
return DEFAULT_TIMEOUT_MS;
}
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -107,7 +113,7 @@ export const serviceDomainWaitCommand = defineCommand({
});
const domain = await resolveDomainByHostname(
target.provider,
target.service.id,
target.resultTarget,
hostname,
"wait",
ctx.signal,
Expand Down Expand Up @@ -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.
Expand All @@ -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,
);
});
}
},
Expand Down
Loading
Loading