diff --git a/docs/product/command-principles.md b/docs/product/command-principles.md index a12318d..6476167 100644 --- a/docs/product/command-principles.md +++ b/docs/product/command-principles.md @@ -51,6 +51,7 @@ The CLI should keep the meaning of these nouns stable: - `database` - `app` - `deployment` +- `domain` If a noun means one thing in docs and a different thing in commands or output, the model is already drifting. @@ -98,6 +99,14 @@ Build and release an app into a target branch. Resolve a deployment and show or stream its logs. +### `wait` + +Block until a remote resource reaches a terminal state. + +Example: + +- `app domain wait` + ### `promote` Take a validated source and release it into a more trusted branch. diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index 5974dcc..57ccaa7 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -76,7 +76,7 @@ When `PRISMA_SERVICE_TOKEN` is set and non-empty, the token is fully sufficient Commands resolve project context in this order: 1. explicit `--project ` when present -2. `PRISMA_PROJECT_ID` when set for headless deploys +2. `PRISMA_PROJECT_ID` when set for headless deploy/domain commands 3. `.prisma/local.json` project pin when present, revalidated against platform data 4. durable platform mapping when available 5. remembered local project context, revalidated against platform data @@ -87,15 +87,15 @@ Commands resolve project context in this order: `--project` is an escape hatch for ambiguous or unavailable automatic resolution, not a setup step. Only `app deploy` may create a missing project, and only when the inferred name is unambiguous. -When `PRISMA_PROJECT_ID` is set, `app deploy` skips `.prisma/local.json` reads -and does not write a new pin. +When `PRISMA_PROJECT_ID` is set, `app deploy` and `app domain` commands skip +`.prisma/local.json` reads and do not write a new pin. ### App Selection Preview app commands that need an app resolve it in this order: 1. `--app ` -2. `PRISMA_APP_ID` when set for headless deploys +2. `PRISMA_APP_ID` when set for headless deploy/domain commands 3. locally selected app for non-deploy commands when it still exists in the resolved branch 4. inferred app name from `package.json#name` 5. current directory name @@ -103,13 +103,16 @@ Preview app commands that need an app resolve it in this order: 7. interactive picker only when multiple matching apps make the target ambiguous 8. `APP_AMBIGUOUS` in non-interactive or `--json` mode when unresolved -When `PRISMA_APP_ID` is set, `app deploy` skips `.prisma/local.json` reads and -does not write a new pin. +When `PRISMA_APP_ID` is set, `app deploy` and `app domain` commands skip +`.prisma/local.json` reads and do not write a new pin. `.prisma/local.json` pins the directory to a Workspace and Project only. It does not pin an App ID. App services are branch-scoped; a service ID from `main` must not be reused automatically when the user deploys from `feat/billing`. +`app domain` commands do not create apps. They resolve an existing app on the +resolved production Branch and fail when none exists. + ### Branch Commands that use branch context resolve it in this order: @@ -121,6 +124,10 @@ Commands that use branch context resolve it in this order: `local` is local CLI context only. It is never a branch or deploy target. Production is a protected durable branch and must require explicit user intent. +`app domain` commands default to the production Branch. During Public Beta, +custom domains are supported only on production Branches. Passing a +non-production `--branch` fails with `BRANCH_NOT_DEPLOYABLE`. + ## Command Result Envelopes Successful `--json` output uses: @@ -720,6 +727,148 @@ prisma-cli app open prisma-cli app open --app hello-world ``` +## `prisma-cli app domain` + +Purpose: + +- manage custom domains for an app's production Branch runtime + +Behavior: + +- requires auth and project context +- resolves the selected app on the production Branch +- supports only production Branch custom domains during Public Beta +- does not expose workspace-wide domain listing until the Management API has a + workspace-scoped list endpoint + +Commands: + +- `add ` registers a custom domain +- `show ` shows status, certificate detail, and fix hints +- `remove ` detaches a custom domain +- `retry ` re-triggers DNS verification and TLS issuance +- `wait ` blocks until `active`, terminal `failed`, or timeout + +Examples: + +```bash +prisma-cli app domain add shop.acme.com +prisma-cli app domain wait shop.acme.com --timeout 15m +prisma-cli app domain retry shop.acme.com +``` + +## `prisma-cli app domain add ` + +Purpose: + +- register a custom domain on the selected app's production Branch + +Behavior: + +- requires auth and project context +- resolves the selected app +- registers the hostname against the selected app's compute service +- is idempotent for a hostname already attached to the same app +- does not re-trigger DNS verification for an existing row +- prints DNS record instructions only when returned by the API +- does not synthesize DNS records client-side when the API omits them +- returns `DOMAIN_DNS_NOT_CONFIGURED` with a CNAME target only when the API error includes the required target +- returns `DOMAIN_ALREADY_REGISTERED` when the hostname is attached outside the selected app +- rejects non-production `--branch` with `BRANCH_NOT_DEPLOYABLE` + +Examples: + +```bash +prisma-cli app domain add shop.acme.com +prisma-cli app domain add shop.acme.com --app shop --branch production +``` + +## `prisma-cli app domain show ` + +Purpose: + +- show status and recovery guidance for one custom domain + +Behavior: + +- requires auth and project context +- resolves the selected app +- finds the domain by hostname within the selected app +- includes failure category, failure reason, certificate expiry, and DNS record + instructions when returned by the API + +Examples: + +```bash +prisma-cli app domain show checkout.acme.com +``` + +## `prisma-cli app domain remove ` + +Purpose: + +- detach a custom domain from the selected app + +Behavior: + +- requires auth and project context +- resolves the selected app +- requires confirmation unless `-y` or `--yes` is passed +- deletes the domain binding by id after resolving the hostname + +Examples: + +```bash +prisma-cli app domain remove old.acme.com +prisma-cli app domain remove old.acme.com --yes +``` + +## `prisma-cli app domain retry ` + +Purpose: + +- re-trigger DNS verification and TLS issuance for a failed or stuck domain + +Behavior: + +- requires auth and project context +- resolves the selected app +- finds the domain by hostname within the selected app +- calls the domain retry endpoint +- prints DNS record instructions and failure guidance when returned by the API +- returns `DOMAIN_RETRY_NOT_ELIGIBLE` when the API reports the domain is not in + a retryable state + +Examples: + +```bash +prisma-cli app domain retry checkout.acme.com +``` + +## `prisma-cli app domain wait ` + +Purpose: + +- block until a custom domain reaches `active`, terminal `failed`, or timeout + +Behavior: + +- requires auth and project context +- resolves the selected app +- finds the domain by hostname within the selected app +- polls domain detail until status is `active`, `failed`, or the timeout expires +- defaults `--timeout` to `15m` +- treats `--timeout 0` as poll-once snapshot mode +- exits 0 on `active`, and 1 on terminal `failed` or timeout +- in `--json` mode, streams newline-delimited status events + +Examples: + +```bash +prisma-cli app domain wait shop.acme.com +prisma-cli app domain wait shop.acme.com --timeout 0 --json +``` + ## `prisma-cli app logs --app --deployment ` Purpose: diff --git a/docs/product/error-conventions.md b/docs/product/error-conventions.md index 63d956a..4e32ad3 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -172,6 +172,14 @@ These codes are the minimum stable set for the MVP: - `PROMOTE_SOURCE_INVALID` - `ROLLBACK_UNAVAILABLE` - `CONFIRMATION_REQUIRED` +- `DOMAIN_HOSTNAME_INVALID` +- `DOMAIN_DNS_NOT_CONFIGURED` +- `DOMAIN_ALREADY_REGISTERED` +- `DOMAIN_QUOTA_EXCEEDED` +- `DOMAIN_NOT_FOUND` +- `DOMAIN_RETRY_NOT_ELIGIBLE` +- `DOMAIN_VERIFICATION_FAILED` +- `DOMAIN_VERIFICATION_TIMEOUT` - `REMOVE_FAILED` - `FEATURE_UNAVAILABLE` - `REPO_PROVIDER_UNSUPPORTED` @@ -202,6 +210,14 @@ Recommended meanings: - `PROMOTE_SOURCE_INVALID`: source for promote is missing, invalid, or not promotable - `ROLLBACK_UNAVAILABLE`: no previous healthy production deployment exists - `CONFIRMATION_REQUIRED`: command cannot continue without confirmation in the current mode +- `DOMAIN_HOSTNAME_INVALID`: custom-domain hostname is malformed or rejected by the platform +- `DOMAIN_DNS_NOT_CONFIGURED`: custom-domain hostname does not yet point to the required Prisma DNS target +- `DOMAIN_ALREADY_REGISTERED`: custom-domain hostname is already attached outside the selected app +- `DOMAIN_QUOTA_EXCEEDED`: selected app has reached its custom-domain quota +- `DOMAIN_NOT_FOUND`: requested custom domain is not attached to the selected app +- `DOMAIN_RETRY_NOT_ELIGIBLE`: requested custom domain is not in a state where verification can be retried +- `DOMAIN_VERIFICATION_FAILED`: custom-domain verification reached a terminal failed state +- `DOMAIN_VERIFICATION_TIMEOUT`: custom-domain verification did not reach a terminal state before the requested timeout - `REMOVE_FAILED`: app removal could not complete remotely - `FEATURE_UNAVAILABLE`: the command exists in the CLI model, but the current preview cannot support it yet - `REPO_PROVIDER_UNSUPPORTED`: repository connection received a non-GitHub repository URL diff --git a/packages/cli/package.json b/packages/cli/package.json index 5cfb3a1..66e1d16 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -44,7 +44,7 @@ "@prisma/compute-sdk": "^0.19.0", "c12": "4.0.0-beta.4", "@prisma/credentials-store": "^7.7.0", - "@prisma/management-api-sdk": "^1.32.1", + "@prisma/management-api-sdk": "^1.33.0", "colorette": "^2.0.20", "commander": "^12.1.0", "magicast": "^0.3.5", diff --git a/packages/cli/src/commands/app/index.ts b/packages/cli/src/commands/app/index.ts index b53bbba..809b0a0 100644 --- a/packages/cli/src/commands/app/index.ts +++ b/packages/cli/src/commands/app/index.ts @@ -3,6 +3,11 @@ import { Command, Option } from "commander"; import { runAppBuild, runAppDeploy, + runAppDomainAdd, + runAppDomainRemove, + runAppDomainRetry, + runAppDomainShow, + runAppDomainWait, runAppListEnv, runAppListDeploys, runAppLogs, @@ -18,6 +23,10 @@ import { import { renderAppBuild, renderAppDeploy, + renderAppDomainAdd, + renderAppDomainRemove, + renderAppDomainRetry, + renderAppDomainShow, renderAppListEnv, renderAppListDeploys, renderAppOpen, @@ -30,6 +39,10 @@ import { renderAppUpdateEnv, serializeAppBuild, serializeAppDeploy, + serializeAppDomainAdd, + serializeAppDomainRemove, + serializeAppDomainRetry, + serializeAppDomainShow, serializeAppListEnv, serializeAppListDeploys, serializeAppOpen, @@ -49,6 +62,10 @@ import { PREVIEW_BUILD_TYPES } from "../../lib/app/preview-build"; import type { AppBuildResult, AppDeployResult, + AppDomainAddResult, + AppDomainRemoveResult, + AppDomainRetryResult, + AppDomainShowResult, AppListEnvResult, AppListDeploysResult, AppOpenResult, @@ -73,6 +90,7 @@ export function createAppCommand(runtime: CliRuntime): Command { app.addCommand(createListEnvCommand(runtime)); app.addCommand(createShowCommand(runtime)); app.addCommand(createOpenCommand(runtime)); + app.addCommand(createDomainCommand(runtime)); app.addCommand(createLogsCommand(runtime)); app.addCommand(createListDeploysCommand(runtime)); app.addCommand(createShowDeployCommand(runtime)); @@ -339,6 +357,178 @@ function createOpenCommand(runtime: CliRuntime): Command { return command; } +function createDomainCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor( + configureRuntimeCommand(new Command("domain"), runtime), + "app.domain", + ); + + addCompactGlobalFlags(command); + + command.addCommand(createDomainAddCommand(runtime)); + command.addCommand(createDomainShowCommand(runtime)); + command.addCommand(createDomainRemoveCommand(runtime)); + command.addCommand(createDomainRetryCommand(runtime)); + command.addCommand(createDomainWaitCommand(runtime)); + + return command; +} + +function addDomainTargetOptions(command: Command): Command { + return command + .addOption(new Option("--app ", "App name")) + .addOption(new Option("--project ", "Project id or name")) + .addOption(new Option("--branch ", "Branch name")); +} + +function createDomainAddCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor( + configureRuntimeCommand(new Command("add"), runtime), + "app.domain.add", + ); + + command.argument("", "Custom domain hostname"); + addDomainTargetOptions(command); + addGlobalFlags(command); + + command.action(async (hostname: string, options) => { + const appName = (options as { app?: string }).app; + const projectRef = (options as { project?: string }).project; + const branchName = (options as { branch?: string }).branch; + + await runCommand( + runtime, + "app.domain.add", + options as Record, + (context) => runAppDomainAdd(context, hostname, { appName, projectRef, branchName }), + { + renderHuman: (context, descriptor, result) => renderAppDomainAdd(context, descriptor, result), + renderJson: (result) => serializeAppDomainAdd(result), + }, + ); + }); + + return command; +} + +function createDomainShowCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor( + configureRuntimeCommand(new Command("show"), runtime), + "app.domain.show", + ); + + command.argument("", "Custom domain hostname"); + addDomainTargetOptions(command); + addGlobalFlags(command); + + command.action(async (hostname: string, options) => { + const appName = (options as { app?: string }).app; + const projectRef = (options as { project?: string }).project; + const branchName = (options as { branch?: string }).branch; + + await runCommand( + runtime, + "app.domain.show", + options as Record, + (context) => runAppDomainShow(context, hostname, { appName, projectRef, branchName }), + { + renderHuman: (context, descriptor, result) => renderAppDomainShow(context, descriptor, result), + renderJson: (result) => serializeAppDomainShow(result), + }, + ); + }); + + return command; +} + +function createDomainRemoveCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor( + configureRuntimeCommand(new Command("remove"), runtime), + "app.domain.remove", + ); + + command.argument("", "Custom domain hostname"); + addDomainTargetOptions(command); + addGlobalFlags(command); + + command.action(async (hostname: string, options) => { + const appName = (options as { app?: string }).app; + const projectRef = (options as { project?: string }).project; + const branchName = (options as { branch?: string }).branch; + + await runCommand( + runtime, + "app.domain.remove", + options as Record, + (context) => runAppDomainRemove(context, hostname, { appName, projectRef, branchName }), + { + renderHuman: (context, descriptor, result) => renderAppDomainRemove(context, descriptor, result), + renderJson: (result) => serializeAppDomainRemove(result), + }, + ); + }); + + return command; +} + +function createDomainRetryCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor( + configureRuntimeCommand(new Command("retry"), runtime), + "app.domain.retry", + ); + + command.argument("", "Custom domain hostname"); + addDomainTargetOptions(command); + addGlobalFlags(command); + + command.action(async (hostname: string, options) => { + const appName = (options as { app?: string }).app; + const projectRef = (options as { project?: string }).project; + const branchName = (options as { branch?: string }).branch; + + await runCommand( + runtime, + "app.domain.retry", + options as Record, + (context) => runAppDomainRetry(context, hostname, { appName, projectRef, branchName }), + { + renderHuman: (context, descriptor, result) => renderAppDomainRetry(context, descriptor, result), + renderJson: (result) => serializeAppDomainRetry(result), + }, + ); + }); + + return command; +} + +function createDomainWaitCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor( + configureRuntimeCommand(new Command("wait"), runtime), + "app.domain.wait", + ); + + command.argument("", "Custom domain hostname"); + addDomainTargetOptions(command); + command.addOption(new Option("--timeout ", "Maximum time to wait").default("15m")); + addGlobalFlags(command); + + command.action(async (hostname: string, options) => { + const appName = (options as { app?: string }).app; + const projectRef = (options as { project?: string }).project; + const branchName = (options as { branch?: string }).branch; + const timeout = (options as { timeout?: string }).timeout; + + await runStreamingCommand( + runtime, + "app.domain.wait", + options as Record, + (context) => runAppDomainWait(context, hostname, { appName, projectRef, branchName, timeout }), + ); + }); + + return command; +} + function createLogsCommand(runtime: CliRuntime): Command { const command = attachCommandDescriptor( configureRuntimeCommand(new Command("logs"), runtime), diff --git a/packages/cli/src/controllers/app.ts b/packages/cli/src/controllers/app.ts index 03b3e3f..bc2bea1 100644 --- a/packages/cli/src/controllers/app.ts +++ b/packages/cli/src/controllers/app.ts @@ -15,6 +15,14 @@ import type { AppBuildResult, AppDeployResult, AppDeploymentSummary, + AppDomainAddResult, + AppDomainDnsRecord, + AppDomainRemoveResult, + AppDomainRetryResult, + AppDomainShowResult, + AppDomainStatus, + AppDomainSummary, + AppDomainTarget, AppListEnvResult, AppListDeploysResult, AppOpenResult, @@ -70,11 +78,18 @@ import { createPreviewUpdateEnvProgress, type PreviewDeployProgressState, } from "../lib/app/preview-progress"; -import { createPreviewAppProvider, type PreviewAppRecord } from "../lib/app/preview-provider"; +import { + createPreviewAppProvider, + PreviewDomainApiError, + type PreviewAppRecord, + type PreviewDomainRecord, +} from "../lib/app/preview-provider"; +import { formatDomainFailureFix } from "../lib/app/domain-guidance"; import { requireAuthenticatedAuthState } from "./auth"; import { listRealWorkspaceProjects } from "./project"; import { createSelectPromptPort } from "./select-prompt-port"; +type AppDomainCommand = "add" | "show" | "remove" | "retry" | "wait"; type DeployFramework = "nextjs" | "hono" | "tanstack-start"; const DEPLOY_FRAMEWORKS = ["nextjs", "hono", "tanstack-start"] as const satisfies readonly DeployFramework[]; @@ -768,6 +783,209 @@ export async function runAppOpen( }; } +export async function runAppDomainAdd( + context: CommandContext, + hostname: string, + options?: { + appName?: string; + projectRef?: string; + branchName?: string; + }, +): Promise> { + const normalizedHostname = normalizeDomainHostname(hostname); + const target = await resolveAppDomainTarget(context, options); + + const added = await target.provider.addDomain({ + appId: target.app.id, + hostname: normalizedHostname, + }).catch((error) => { + throw domainCommandError("add", error, normalizedHostname); + }); + + return { + command: "app.domain.add", + result: { + ...target.resultTarget, + domain: toAppDomainSummary(added.domain), + existing: added.existing, + }, + warnings: [], + nextSteps: [ + `prisma-cli app domain wait ${normalizedHostname}`, + `prisma-cli app domain show ${normalizedHostname}`, + ], + }; +} + +export async function runAppDomainShow( + context: CommandContext, + hostname: string, + options?: { + appName?: string; + projectRef?: string; + branchName?: string; + }, +): Promise> { + const normalizedHostname = normalizeDomainHostname(hostname); + const target = await resolveAppDomainTarget(context, options); + const domain = await resolveDomainByHostname(target.provider, target.app.id, normalizedHostname, "show"); + const detail = await target.provider.showDomain(domain.id).catch((error) => { + throw domainCommandError("show", error, normalizedHostname); + }); + + return { + command: "app.domain.show", + result: { + ...target.resultTarget, + domain: toAppDomainSummary(detail), + }, + warnings: [], + nextSteps: buildDomainShowNextSteps(detail), + }; +} + +export async function runAppDomainRemove( + context: CommandContext, + hostname: string, + options?: { + appName?: string; + projectRef?: string; + branchName?: string; + }, +): Promise> { + const normalizedHostname = normalizeDomainHostname(hostname); + const target = await resolveAppDomainTarget(context, options); + const domain = await resolveDomainByHostname(target.provider, target.app.id, normalizedHostname, "remove"); + + await confirmDomainRemoval(context, target.resultTarget, normalizedHostname); + + await target.provider.removeDomain(domain.id).catch((error) => { + throw domainCommandError("remove", error, normalizedHostname); + }); + + return { + command: "app.domain.remove", + result: { + ...target.resultTarget, + hostname: normalizedHostname, + removed: true, + }, + warnings: [], + nextSteps: [], + }; +} + +export async function runAppDomainRetry( + context: CommandContext, + hostname: string, + options?: { + appName?: string; + projectRef?: string; + branchName?: string; + }, +): Promise> { + const normalizedHostname = normalizeDomainHostname(hostname); + const target = await resolveAppDomainTarget(context, options); + const domain = await resolveDomainByHostname(target.provider, target.app.id, normalizedHostname, "retry"); + const retried = await target.provider.retryDomain(domain.id).catch((error) => { + throw domainCommandError("retry", error, normalizedHostname); + }); + + return { + command: "app.domain.retry", + result: { + ...target.resultTarget, + domain: toAppDomainSummary(retried), + }, + warnings: [], + nextSteps: [`prisma-cli app domain wait ${normalizedHostname}`], + }; +} + +export async function runAppDomainWait( + context: CommandContext, + hostname: string, + options?: { + appName?: string; + projectRef?: string; + branchName?: string; + timeout?: string; + }, +): Promise { + const normalizedHostname = normalizeDomainHostname(hostname); + const timeoutMs = parseDomainWaitTimeout(options?.timeout); + const target = await resolveAppDomainTarget(context, options); + const domain = await resolveDomainByHostname(target.provider, target.app.id, normalizedHostname, "wait"); + + if (!context.flags.json && !context.flags.quiet) { + context.output.stderr.write( + [ + `app domain wait -> Waiting for ${normalizedHostname} to become active.`, + "", + `Workspace: ${target.resultTarget.workspace.name} Project: ${target.resultTarget.project.name} Branch: ${target.resultTarget.branch.name} App: ${target.resultTarget.app.name}`, + "", + ].join("\n"), + ); + } + + const start = Date.now(); + const deadline = start + timeoutMs; + const pollIntervalMs = readDomainWaitPollIntervalMs(context); + let lastStatus: AppDomainStatus | null = null; + let current = domain; + + // eslint-disable-next-line no-constant-condition + while (true) { + emitDomainWaitStatus(context, { + hostname: normalizedHostname, + domainId: current.id, + previousStatus: lastStatus, + status: current.status, + elapsedMs: Date.now() - start, + }); + lastStatus = current.status; + + if (current.status === "active") { + if (!context.flags.json && !context.flags.quiet) { + context.output.stderr.write(`\n${normalizedHostname} is live at https://${normalizedHostname}\n`); + } + return; + } + + if (current.status === "failed") { + throw new CliError({ + code: "DOMAIN_VERIFICATION_FAILED", + domain: "app", + summary: `Custom domain "${normalizedHostname}" failed verification`, + why: formatDomainFailureWhy(current), + fix: formatDomainFailureFix(current) ?? `Run prisma-cli app domain retry ${normalizedHostname}.`, + exitCode: 1, + nextSteps: [ + `prisma-cli app domain show ${normalizedHostname}`, + `prisma-cli app domain retry ${normalizedHostname}`, + ], + }); + } + + if (timeoutMs === 0 || Date.now() >= deadline) { + throw new CliError({ + code: "DOMAIN_VERIFICATION_TIMEOUT", + domain: "app", + summary: `Timed out waiting for "${normalizedHostname}" to become active`, + why: `The domain is still "${current.status}".`, + fix: `Run prisma-cli app domain show ${normalizedHostname} to inspect the current status, or retry wait with a longer --timeout.`, + exitCode: 1, + nextSteps: [`prisma-cli app domain show ${normalizedHostname}`], + }); + } + + await sleep(Math.min(pollIntervalMs, Math.max(deadline - Date.now(), 0))); + current = await target.provider.showDomain(current.id).catch((error) => { + throw domainCommandError("wait", error, normalizedHostname); + }); + } +} + export async function runAppLogs( context: CommandContext, appName: string | undefined, @@ -1132,6 +1350,516 @@ export async function runAppRemove( }; } +interface ResolvedAppDomainTarget { + provider: ReturnType; + app: PreviewAppRecord; + resultTarget: AppDomainTarget; +} + +async function resolveAppDomainTarget( + context: CommandContext, + options?: { + appName?: string; + projectRef?: string; + branchName?: string; + }, +): Promise { + ensurePreviewAppMode(context); + + const branch = resolveDomainBranch(options?.branchName); + if (toBranchKind(branch.name) !== "production") { + throw new CliError({ + code: "BRANCH_NOT_DEPLOYABLE", + domain: "branch", + summary: "Custom domains require the production branch", + why: `Custom domains on preview branch "${branch.name}" are not supported in Public Beta.`, + fix: "Use --branch production, or attach the domain after promoting/deploying to the production branch.", + exitCode: 2, + nextSteps: ["prisma-cli app domain add --branch production"], + }); + } + + const envProjectId = readDeployEnvOverride(context, PRISMA_PROJECT_ID_ENV_VAR); + const envAppId = readDeployEnvOverride(context, PRISMA_APP_ID_ENV_VAR); + const skipLocalPin = Boolean(envProjectId || envAppId); + const localPin = skipLocalPin + ? ({ kind: "missing" } satisfies LocalResolutionPinReadResult) + : await readLocalResolutionPin(context.runtime.cwd); + if (!skipLocalPin && localPin.kind === "invalid") { + throw localResolutionPinStaleError(); + } + + const { provider, target, projectId } = await requireProviderAndDeployProjectContext(context, options?.projectRef, { + allowCreate: false, + branch, + envProjectId, + localPin, + }); + const apps = await listApps(context, provider, projectId, target.branch.name); + const selectedApp = await resolveDomainAppSelection(context, projectId, apps, { + explicitAppName: options?.appName, + explicitAppId: envAppId, + }); + + await context.stateStore.setSelectedApp(projectId, { + id: selectedApp.id, + name: selectedApp.name, + }); + + return { + provider, + app: selectedApp, + resultTarget: { + workspace: target.workspace, + project: target.project, + branch: target.branch, + app: { + id: selectedApp.id, + name: selectedApp.name, + }, + }, + }; +} + +function resolveDomainBranch(explicitBranchName: string | undefined): ResolvedDeployBranch { + return { + name: explicitBranchName?.trim() || "production", + annotation: explicitBranchName ? "set by --branch" : "production default", + }; +} + +async function resolveDomainAppSelection( + context: CommandContext, + projectId: string, + apps: PreviewAppRecord[], + options: { + explicitAppName: string | undefined; + explicitAppId: string | undefined; + }, +): Promise { + if (options.explicitAppId) { + const matched = apps.find((app) => app.id === options.explicitAppId); + if (!matched) { + throw usageError( + "Selected app does not exist in the resolved production branch", + `The app "${options.explicitAppId}" from ${PRISMA_APP_ID_ENV_VAR} could not be found in resolved project "${projectId}".`, + `Unset ${PRISMA_APP_ID_ENV_VAR}, pass --app , or deploy the app on the production branch.`, + ["prisma-cli app deploy --branch production"], + "app", + ); + } + return matched; + } + + const selectedApp = await resolveExistingAppSelection(context, projectId, apps, options.explicitAppName); + if (selectedApp) { + return selectedApp; + } + + throw usageError( + "Custom domain requires an existing app on the production branch", + "The resolved production branch does not have an app that can receive a custom domain.", + "Deploy or promote an app to production first, then rerun the domain command.", + ["prisma-cli app deploy --branch production", "prisma-cli app show"], + "app", + ); +} + +async function resolveDomainByHostname( + provider: ReturnType, + appId: string, + hostname: string, + command: AppDomainCommand, +): Promise { + const domains = await provider.listDomains(appId).catch((error) => { + throw domainCommandError(command, error, hostname); + }); + const matched = domains.find((domain) => sameDomainHostname(domain.hostname, hostname)); + if (matched) { + return matched; + } + + throw domainNotFoundError(hostname); +} + +function normalizeDomainHostname(hostname: string): string { + const normalized = hostname.trim().replace(/\.$/, "").toLowerCase(); + if (!isValidDomainHostname(normalized)) { + throw new CliError({ + code: "DOMAIN_HOSTNAME_INVALID", + domain: "app", + summary: `Invalid custom domain "${hostname}"`, + why: "Custom domains must be valid hostnames without protocol, path, wildcard, or port.", + fix: "Pass a hostname like shop.acme.com.", + exitCode: 2, + nextSteps: ["prisma-cli app domain add shop.acme.com"], + }); + } + + return normalized; +} + +function isValidDomainHostname(hostname: string): boolean { + if (hostname.length < 1 || hostname.length > 253) { + return false; + } + if (hostname.includes("://") || hostname.includes("/") || hostname.includes(":") || hostname.startsWith("*.")) { + return false; + } + + const labels = hostname.split("."); + if (labels.length < 2) { + return false; + } + + return labels.every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)); +} + +function sameDomainHostname(left: string, right: string): boolean { + return left.trim().replace(/\.$/, "").toLowerCase() === right.trim().replace(/\.$/, "").toLowerCase(); +} + +function toAppDomainSummary(domain: PreviewDomainRecord): AppDomainSummary { + return { + id: domain.id, + type: domain.type, + url: domain.url, + hostname: domain.hostname, + computeServiceId: domain.computeServiceId, + status: domain.status, + foundryStatus: domain.foundryStatus, + failureReason: domain.failureReason, + failureCategory: domain.failureCategory, + certExpiresAt: domain.certExpiresAt, + createdAt: domain.createdAt, + updatedAt: domain.updatedAt, + dnsRecords: toAppDomainDnsRecords(domain), + }; +} + +function toAppDomainDnsRecords(domain: Pick): AppDomainDnsRecord[] { + return domain.dnsRecords.map((record) => ({ + type: record.type, + name: record.name, + value: record.value, + ttl: record.ttl, + })); +} + +function buildDomainShowNextSteps(domain: PreviewDomainRecord): string[] { + if (domain.status === "active") { + return []; + } + if (domain.status === "failed") { + return [`prisma-cli app domain retry ${domain.hostname}`]; + } + return [`prisma-cli app domain wait ${domain.hostname}`]; +} + +async function confirmDomainRemoval( + context: CommandContext, + target: AppDomainTarget, + hostname: string, +): Promise { + if (context.flags.yes) { + return; + } + + if (!canPrompt(context)) { + throw new CliError({ + code: "CONFIRMATION_REQUIRED", + domain: "app", + summary: "Custom domain removal requires confirmation in the current mode", + why: "This command detaches a domain and cannot prompt for confirmation in the current mode.", + fix: `Pass --yes to confirm removal of "${hostname}", or rerun prisma-cli app domain remove in an interactive TTY.`, + exitCode: 1, + nextSteps: [`prisma-cli app domain remove ${hostname} --app ${target.app.name} --yes`], + }); + } + + const confirmed = await confirmPrompt({ + input: context.runtime.stdin, + output: context.output.stderr, + message: `Detach ${hostname} from App "${target.app.name}"?`, + initialValue: false, + }); + + if (!confirmed) { + throw usageError( + "Custom domain removal canceled", + "The command was canceled before the domain was detached.", + "Rerun the command and confirm removal, or pass --yes.", + [`prisma-cli app domain remove ${hostname} --app ${target.app.name} --yes`], + "app", + ); + } +} + +function domainCommandError( + command: AppDomainCommand, + error: unknown, + hostname: string, +): CliError { + if (error instanceof PreviewDomainApiError) { + if (command === "add" && (error.status === 400 || error.status === 422) && isDomainDnsError(error)) { + return domainDnsNotConfiguredError(hostname, error); + } + + if (command === "add" && error.status === 400) { + return new CliError({ + code: "DOMAIN_HOSTNAME_INVALID", + domain: "app", + summary: `Invalid custom domain "${hostname}"`, + why: error.message, + fix: "Pass a valid hostname like shop.acme.com and make sure DNS can be verified.", + debug: formatDebugDetails(error), + exitCode: 2, + nextSteps: ["prisma-cli app domain add shop.acme.com"], + }); + } + + if (command === "add" && (error.status === 429 || isDomainQuotaError(error))) { + return new CliError({ + code: "DOMAIN_QUOTA_EXCEEDED", + domain: "app", + summary: "Custom domain quota exceeded", + why: error.message, + fix: "Remove an existing custom domain before adding another one.", + debug: formatDebugDetails(error), + exitCode: 1, + nextSteps: ["prisma-cli app domain remove "], + }); + } + + if (command === "add" && error.status === 409) { + return domainAlreadyRegisteredError(hostname, error); + } + + if (command === "add" && error.status === 422) { + return new CliError({ + code: "NO_DEPLOYMENTS", + domain: "app", + summary: "Custom domain requires a live production deployment", + why: "The selected production app does not have a promoted version that can receive a custom domain.", + fix: "Deploy the app to the production branch, then rerun the domain command.", + debug: formatDebugDetails(error), + exitCode: 1, + nextSteps: [ + "prisma-cli app deploy --branch production", + `prisma-cli app domain add ${hostname}`, + ], + }); + } + + if ((command === "show" || command === "remove" || command === "retry" || command === "wait") && error.status === 404) { + return domainNotFoundError(hostname); + } + + if (command === "retry" && error.status === 409) { + return new CliError({ + code: "DOMAIN_RETRY_NOT_ELIGIBLE", + domain: "app", + summary: `Custom domain "${hostname}" is not eligible for retry`, + why: error.message, + fix: "Wait for the current verification or TLS step to finish, then rerun retry if the domain fails.", + debug: formatDebugDetails(error), + exitCode: 1, + nextSteps: [`prisma-cli app domain show ${hostname}`], + }); + } + } + + return new CliError({ + code: "DEPLOY_FAILED", + domain: "app", + summary: `Custom domain ${command} failed`, + why: error instanceof Error ? error.message : String(error), + fix: "Retry the command, or rerun with --trace for more detailed diagnostics.", + debug: formatDebugDetails(error), + exitCode: 1, + nextSteps: [`prisma-cli app domain show ${hostname}`], + }); +} + +function isDomainQuotaError(error: PreviewDomainApiError): boolean { + if (error.status !== 409) { + return false; + } + + const text = `${error.message} ${error.hint ?? ""}`.toLowerCase(); + return text.includes("quota") || text.includes("maximum") || text.includes("limit"); +} + +function domainAlreadyRegisteredError(hostname: string, error: PreviewDomainApiError): CliError { + return new CliError({ + code: "DOMAIN_ALREADY_REGISTERED", + domain: "app", + summary: `Custom domain "${hostname}" is already registered`, + why: error.hint ?? error.message, + fix: "Select the app that owns this hostname and remove it there, or contact support if you cannot access it.", + debug: formatDebugDetails(error), + exitCode: 1, + nextSteps: [ + `Select the owning app and remove ${hostname} there.`, + "Contact Prisma support if you cannot access the owning app.", + ], + }); +} + +function isDomainDnsError(error: PreviewDomainApiError): boolean { + const text = `${error.message} ${error.hint ?? ""}`.toLowerCase(); + return ( + text.includes("dns is not configured") || + text.includes("dns verification failed") || + text.includes("no cname") || + text.includes("cname record") || + text.includes("no a/aaaa") || + /\bcname(?:s)?\s+to\b/.test(text) + ); +} + +function domainDnsNotConfiguredError(hostname: string, error: PreviewDomainApiError): CliError { + const target = extractDomainDnsTarget(error); + const record = target ? `CNAME ${hostname} -> ${target}` : null; + + return new CliError({ + code: "DOMAIN_DNS_NOT_CONFIGURED", + domain: "app", + summary: `DNS is not configured for "${hostname}"`, + why: error.hint ?? error.message, + fix: record + ? `Add ${record} at your DNS provider, then rerun the domain command.` + : "The platform did not return the required DNS target. Re-run with --trace for the underlying API response details.", + debug: formatDebugDetails(error), + exitCode: 1, + nextSteps: record + ? [ + `add ${record}`, + `prisma-cli app domain add ${hostname}`, + ] + : [`prisma-cli app domain add ${hostname} --trace`], + }); +} + +function extractDomainDnsTarget(error: PreviewDomainApiError): string | null { + const text = `${error.hint ?? ""} ${error.message}`; + const match = /\b((?:[a-z0-9-]+\.)+prisma\.build)\b/i.exec(text); + return match?.[1]?.toLowerCase() ?? null; +} + +function domainNotFoundError(hostname: string): CliError { + return new CliError({ + code: "DOMAIN_NOT_FOUND", + domain: "app", + summary: `Custom domain "${hostname}" not found`, + why: "The hostname is not attached to the selected app.", + fix: "Check the hostname and selected app, or add the domain first.", + exitCode: 1, + nextSteps: [`prisma-cli app domain add ${hostname}`], + }); +} + +function formatDomainFailureWhy(domain: PreviewDomainRecord): string { + if (domain.failureReason) { + return domain.failureCategory + ? `${domain.failureCategory}: ${domain.failureReason}` + : domain.failureReason; + } + + return "The platform reported a terminal failed state for this custom domain."; +} + +function parseDomainWaitTimeout(value: string | undefined): number { + if (!value) { + return 15 * 60 * 1000; + } + + const trimmed = value.trim().toLowerCase(); + if (trimmed === "0") { + return 0; + } + + const match = /^(\d+)(ms|s|m|h)$/.exec(trimmed); + if (!match) { + throw usageError( + `Invalid timeout "${value}"`, + "Timeout must be a duration such as 0, 30s, 15m, or 1h.", + "Pass --timeout 15m, or --timeout 0 to poll once.", + ["prisma-cli app domain wait shop.acme.com --timeout 15m"], + "app", + ); + } + + const amount = Number.parseInt(match[1], 10); + const unit = match[2]; + const multiplier = unit === "h" ? 60 * 60 * 1000 : unit === "m" ? 60 * 1000 : unit === "s" ? 1000 : 1; + return amount * multiplier; +} + +function readDomainWaitPollIntervalMs(context: CommandContext): number { + const raw = context.runtime.env.PRISMA_CLI_DOMAIN_WAIT_POLL_MS; + if (!raw) { + return 5_000; + } + + const parsed = Number.parseInt(raw, 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : 5_000; +} + +function emitDomainWaitStatus( + context: CommandContext, + event: { + hostname: string; + domainId: string; + previousStatus: AppDomainStatus | null; + status: AppDomainStatus; + elapsedMs: number; + }, +): void { + if (context.flags.json) { + writeJsonEvent(context.output, { + type: "status", + command: "app.domain.wait", + timestamp: new Date().toISOString(), + data: { + hostname: event.hostname, + domainId: event.domainId, + previousStatus: event.previousStatus, + status: event.status, + elapsedMs: event.elapsedMs, + }, + }); + return; + } + + if (context.flags.quiet) { + return; + } + + if (event.previousStatus === event.status) { + return; + } + + const transition = event.previousStatus + ? `${event.previousStatus} -> ${event.status}` + : event.status; + context.output.stderr.write(` ${transition} (${formatElapsed(event.elapsedMs)})\n`); +} + +function formatElapsed(milliseconds: number): string { + const seconds = Math.max(Math.floor(milliseconds / 1000), 0); + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes}:${String(remainingSeconds).padStart(2, "0")}`; +} + +async function sleep(milliseconds: number): Promise { + if (milliseconds <= 0) { + return; + } + await new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + async function resolveDeployAppSelection( context: CommandContext, projectId: string, diff --git a/packages/cli/src/lib/app/domain-guidance.ts b/packages/cli/src/lib/app/domain-guidance.ts new file mode 100644 index 0000000..762dcf7 --- /dev/null +++ b/packages/cli/src/lib/app/domain-guidance.ts @@ -0,0 +1,40 @@ +type DomainFailureCategory = "dns" | "acme" | "storage" | "unknown" | null; + +interface DomainDnsRecord { + type: string; + name: string; + value: string; +} + +interface DomainFailureGuidanceInput { + hostname: string; + status: string; + failureCategory: DomainFailureCategory; + dnsRecords: DomainDnsRecord[]; +} + +export function formatDomainFailureFix(domain: DomainFailureGuidanceInput): string | null { + if (domain.status !== "failed") { + return null; + } + + const dnsRecord = domain.dnsRecords[0]; + + if (domain.failureCategory === "dns") { + if (dnsRecord) { + return `Add ${dnsRecord.type} ${dnsRecord.name} -> ${dnsRecord.value}, then run prisma-cli app domain retry ${domain.hostname}.`; + } + + return `DNS verification failed, but the platform did not return a DNS record. Run prisma-cli app domain show ${domain.hostname} later, then retry when the DNS target is available.`; + } + + if (domain.failureCategory === "acme") { + return `Retry TLS issuance with prisma-cli app domain retry ${domain.hostname}. Contact support if it fails again.`; + } + + if (domain.failureCategory === "storage") { + return `Retry provisioning with prisma-cli app domain retry ${domain.hostname}. Contact support if it fails again.`; + } + + return `Run prisma-cli app domain retry ${domain.hostname}. Contact support if it fails again.`; +} diff --git a/packages/cli/src/lib/app/preview-provider.ts b/packages/cli/src/lib/app/preview-provider.ts index 8b66629..3129049 100644 --- a/packages/cli/src/lib/app/preview-provider.ts +++ b/packages/cli/src/lib/app/preview-provider.ts @@ -57,10 +57,61 @@ export interface PreviewRemovedAppRecord { name: string; } +export type PreviewDomainStatus = + | "pending_dns" + | "verifying" + | "verified_routing_blocked" + | "provisioning_tls" + | "active" + | "failed" + | "removing"; + +export interface PreviewDomainDnsRecord { + type: string; + name: string; + value: string; + ttl: number | null; +} + +export interface PreviewDomainRecord { + id: string; + type: "custom-domain"; + url: string; + hostname: string; + computeServiceId: string; + status: PreviewDomainStatus; + foundryStatus: string; + failureReason: string | null; + failureCategory: "dns" | "acme" | "storage" | "unknown" | null; + certExpiresAt: string | null; + createdAt: string; + updatedAt: string; + dnsRecords: PreviewDomainDnsRecord[]; +} + +export class PreviewDomainApiError extends Error { + readonly status: number; + readonly code: string | null; + readonly hint: string | null; + + constructor(options: { summary: string; status: number; message: string; code?: string | null; hint?: string | null }) { + super(`${options.summary}: ${options.message}${options.hint ? ` ${options.hint}` : ""}`); + this.name = "PreviewDomainApiError"; + this.status = options.status; + this.code = options.code ?? null; + this.hint = options.hint ?? null; + } +} + export interface PreviewAppProvider { createProject(options: { name: string }): Promise; listApps(projectId: string, options?: { branchName?: string }): Promise; removeApp(appId: string): Promise; + listDomains(appId: string): Promise; + addDomain(options: { appId: string; hostname: string }): Promise<{ domain: PreviewDomainRecord; existing: boolean }>; + showDomain(domainId: string): Promise; + removeDomain(domainId: string): Promise; + retryDomain(domainId: string): Promise; promoteDeployment(options: { appId: string; deploymentId: string; @@ -154,6 +205,81 @@ export function createPreviewAppProvider( }; }, + async listDomains(appId) { + return listComputeServiceDomains(client, appId); + }, + + async addDomain(options) { + const result = await client.POST("/v1/compute-services/{computeServiceId}/domains", { + params: { + path: { computeServiceId: options.appId }, + }, + body: { + hostname: options.hostname, + }, + }); + + if (result.error || !result.data) { + if (result.response.status === 409) { + const existing = (await listComputeServiceDomains(client, options.appId)) + .find((domain) => sameHostname(domain.hostname, options.hostname)); + if (existing) { + return { + domain: existing, + existing: true, + }; + } + } + + throw domainApiCallError("Failed to add custom domain", result.response, result.error); + } + + return { + domain: normalizeDomainRecord(result.data.data), + existing: false, + }; + }, + + async showDomain(domainId) { + const result = await client.GET("/v1/domains/{domainId}", { + params: { + path: { domainId }, + }, + }); + + if (result.error || !result.data) { + throw domainApiCallError("Failed to show custom domain", result.response, result.error); + } + + return normalizeDomainRecord(result.data.data); + }, + + async removeDomain(domainId) { + const result = await client.DELETE("/v1/domains/{domainId}", { + params: { + path: { domainId }, + }, + }); + + if (result.error) { + throw domainApiCallError("Failed to remove custom domain", result.response, result.error); + } + }, + + async retryDomain(domainId) { + const result = await client.POST("/v1/domains/{domainId}/retry", { + params: { + path: { domainId }, + }, + }); + + if (result.error || !result.data) { + throw domainApiCallError("Failed to retry custom domain", result.response, result.error); + } + + return normalizeDomainRecord(result.data.data); + }, + async promoteDeployment(options) { const promoteResult = await sdk.promote({ serviceId: options.appId, @@ -436,6 +562,29 @@ interface RawApiErrorBody { }; } +interface RawDomainDnsRecord { + type?: unknown; + name?: unknown; + value?: unknown; + ttl?: unknown; +} + +interface RawDomainRecord { + id: string; + type: "custom-domain"; + url: string; + hostname: string; + computeServiceId: string; + status: PreviewDomainStatus; + foundryStatus: string; + failureReason: string | null; + failureCategory: "dns" | "acme" | "storage" | "unknown" | null; + certExpiresAt: string | null; + createdAt: string; + updatedAt: string; + dnsRecords?: RawDomainDnsRecord[] | null; +} + async function listBranches( client: ManagementApiClient, options: { @@ -534,6 +683,70 @@ async function listComputeServices( })); } +async function listComputeServiceDomains( + client: ManagementApiClient, + computeServiceId: string, +): Promise { + const result = await client.GET("/v1/compute-services/{computeServiceId}/domains", { + params: { + path: { computeServiceId }, + }, + }); + + if (result.error || !result.data) { + throw domainApiCallError("Failed to list custom domains", result.response, result.error); + } + + return result.data.data.map((domain) => normalizeDomainRecord(domain)); +} + +function normalizeDomainRecord(domain: RawDomainRecord): PreviewDomainRecord { + return { + id: domain.id, + type: domain.type, + url: domain.url, + hostname: domain.hostname, + computeServiceId: domain.computeServiceId, + status: domain.status, + foundryStatus: domain.foundryStatus, + failureReason: domain.failureReason, + failureCategory: domain.failureCategory, + certExpiresAt: domain.certExpiresAt, + createdAt: domain.createdAt, + updatedAt: domain.updatedAt, + dnsRecords: normalizeDomainDnsRecords(domain.dnsRecords), + }; +} + +function normalizeDomainDnsRecords(records: RawDomainDnsRecord[] | null | undefined): PreviewDomainDnsRecord[] { + if (!Array.isArray(records)) { + return []; + } + + return records + .map((record) => { + if (typeof record.type !== "string" || typeof record.name !== "string" || typeof record.value !== "string") { + return null; + } + + return { + type: record.type, + name: record.name, + value: record.value, + ttl: typeof record.ttl === "number" ? record.ttl : null, + }; + }) + .filter((record): record is PreviewDomainDnsRecord => Boolean(record)); +} + +function sameHostname(left: string, right: string): boolean { + return normalizeHostnameForComparison(left) === normalizeHostnameForComparison(right); +} + +function normalizeHostnameForComparison(hostname: string): string { + return hostname.trim().replace(/\.$/, "").toLowerCase(); +} + async function createBranchApp( client: ManagementApiClient, options: { @@ -596,6 +809,20 @@ function apiCallError( return new Error(`${summary}: ${message}${hint}`); } +function domainApiCallError( + summary: string, + response: Response, + error: RawApiErrorBody, +): PreviewDomainApiError { + return new PreviewDomainApiError({ + summary, + status: response.status, + code: error.error?.code ?? null, + message: error.error?.message ?? `Management API returned HTTP ${response.status}.`, + hint: error.error?.hint ?? null, + }); +} + async function findAppForDeployment( sdk: ComputeClient, deploymentId: string, diff --git a/packages/cli/src/presenters/app.ts b/packages/cli/src/presenters/app.ts index 01da98a..89c3c74 100644 --- a/packages/cli/src/presenters/app.ts +++ b/packages/cli/src/presenters/app.ts @@ -3,6 +3,11 @@ import type { CommandContext } from "../shell/runtime"; import type { AppBuildResult, AppDeployResult, + AppDomainAddResult, + AppDomainRemoveResult, + AppDomainRetryResult, + AppDomainShowResult, + AppDomainStatus, AppListEnvResult, AppListDeploysResult, AppOpenResult, @@ -16,6 +21,7 @@ import type { } from "../types/app"; import { renderList, renderShow, serializeList } from "../output/patterns"; import { renderDeployOutputRows } from "../lib/app/deploy-output"; +import { formatDomainFailureFix } from "../lib/app/domain-guidance"; export function renderAppBuild( context: CommandContext, @@ -272,6 +278,107 @@ export function serializeAppOpen(result: AppOpenResult) { return result; } +export function renderAppDomainAdd( + context: CommandContext, + descriptor: CommandDescriptor, + result: AppDomainAddResult, +): string[] { + return renderShow( + { + title: result.existing + ? "Showing the existing custom domain for the selected app." + : "Adding a custom domain to the selected app.", + descriptor, + fields: [ + ...domainTargetFields(result), + { key: "hostname", value: result.domain.hostname }, + { key: "status", value: result.domain.status, tone: toneForDomainStatus(result.domain.status) }, + ...domainDnsFields(result.domain), + ], + }, + context.ui, + ); +} + +export function serializeAppDomainAdd(result: AppDomainAddResult) { + return result; +} + +export function renderAppDomainShow( + context: CommandContext, + descriptor: CommandDescriptor, + result: AppDomainShowResult, +): string[] { + return renderShow( + { + title: "Showing custom domain status.", + descriptor, + fields: [ + ...domainTargetFields(result), + { key: "hostname", value: result.domain.hostname }, + { key: "status", value: result.domain.status, tone: toneForDomainStatus(result.domain.status) }, + ...domainFailureFields(result.domain), + { key: "cert expires", value: formatOptionalUtcDate(result.domain.certExpiresAt), tone: result.domain.certExpiresAt ? "default" : "dim" }, + { key: "created", value: formatUtcDate(result.domain.createdAt), tone: "dim" }, + ...domainDnsFields(result.domain), + ], + }, + context.ui, + ); +} + +export function serializeAppDomainShow(result: AppDomainShowResult) { + return result; +} + +export function renderAppDomainRemove( + context: CommandContext, + descriptor: CommandDescriptor, + result: AppDomainRemoveResult, +): string[] { + return renderShow( + { + title: "Removing a custom domain from the selected app.", + descriptor, + fields: [ + ...domainTargetFields(result), + { key: "hostname", value: result.hostname }, + { key: "removed", value: result.removed ? "yes" : "no", tone: result.removed ? "success" : "dim" }, + ], + }, + context.ui, + ); +} + +export function serializeAppDomainRemove(result: AppDomainRemoveResult) { + return result; +} + +export function renderAppDomainRetry( + context: CommandContext, + descriptor: CommandDescriptor, + result: AppDomainRetryResult, +): string[] { + return renderShow( + { + title: "Retrying custom domain verification.", + descriptor, + fields: [ + ...domainTargetFields(result), + { key: "hostname", value: result.domain.hostname }, + { key: "status", value: result.domain.status, tone: toneForDomainStatus(result.domain.status) }, + ...domainFailureFields(result.domain), + ...domainDnsFields(result.domain), + ], + }, + context.ui, + ); +} + +export function serializeAppDomainRetry(result: AppDomainRetryResult) { + return result; +} + export function renderAppPromote( context: CommandContext, descriptor: CommandDescriptor, @@ -380,6 +487,95 @@ function toneForStatus(status: string): "success" | "warning" | "error" | "defau return "default"; } +function toneForDomainStatus(status: AppDomainStatus): "success" | "warning" | "error" | "default" { + if (status === "active") { + return "success"; + } + + if (status === "failed") { + return "error"; + } + + if (status === "pending_dns" || status === "verifying" || status === "provisioning_tls" || status === "verified_routing_blocked") { + return "warning"; + } + + return "default"; +} + +function domainTargetFields(result: Pick) { + return [ + { key: "workspace", value: result.workspace.name }, + { key: "project", value: result.project.name }, + { key: "branch", value: result.branch.name }, + { key: "app", value: result.app.name }, + ]; +} + +function domainDnsFields(domain: Pick) { + const records = domain.dnsRecords; + if (records.length === 0) { + return [{ + key: "dns record", + value: "not provided by platform", + tone: "dim" as const, + }]; + } + + return [{ + key: "dns record", + value: records.map((record) => { + const ttl = record.ttl ? ` ttl ${record.ttl}` : ""; + return `${record.type} ${record.name} -> ${record.value}${ttl}`; + }).join(", "), + }]; +} + +function formatDomainFailure(domain: AppDomainShowResult["domain"]): string { + if (!domain.failureReason) { + return domain.failureCategory ?? "none"; + } + + return domain.failureCategory ? `${domain.failureCategory} - ${domain.failureReason}` : domain.failureReason; +} + +function domainFailureFields(domain: AppDomainShowResult["domain"]) { + const tone = hasDomainFailure(domain) ? "error" : "dim"; + + return [ + { key: "failure", value: formatDomainFailure(domain), tone }, + ...domainFixFields(domain), + ]; +} + +function hasDomainFailure(domain: AppDomainShowResult["domain"]): boolean { + return Boolean(domain.failureCategory || domain.failureReason); +} + +function domainFixFields(domain: AppDomainShowResult["domain"]) { + const fix = formatDomainFailureFix(domain); + + return fix ? [{ key: "fix", value: fix }] : []; +} + +function formatOptionalUtcDate(value: string | null): string { + return value ? formatUtcDate(value) : "-"; +} + +function formatUtcDate(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, "0"); + const day = String(date.getUTCDate()).padStart(2, "0"); + const hours = String(date.getUTCHours()).padStart(2, "0"); + const minutes = String(date.getUTCMinutes()).padStart(2, "0"); + return `${year}-${month}-${day} ${hours}:${minutes} UTC`; +} + function formatRecentDeployments(deployments: AppShowResult["recentDeployments"]): string { if (deployments.length === 0) { return "none"; diff --git a/packages/cli/src/shell/command-meta.ts b/packages/cli/src/shell/command-meta.ts index 9574930..a48eec5 100644 --- a/packages/cli/src/shell/command-meta.ts +++ b/packages/cli/src/shell/command-meta.ts @@ -170,6 +170,46 @@ const DESCRIPTORS: CommandDescriptor[] = [ description: "Open the app's live URL", examples: ["prisma-cli app open", "prisma-cli app open --app hello-world"], }, + { + id: "app.domain", + path: ["prisma", "app", "domain"], + description: "Manage custom domains for an app", + examples: [ + "prisma-cli app domain add shop.acme.com", + "prisma-cli app domain wait shop.acme.com --timeout 15m", + "prisma-cli app domain retry shop.acme.com", + ], + }, + { + id: "app.domain.add", + path: ["prisma", "app", "domain", "add"], + description: "Register a custom domain on the app's production branch", + examples: ["prisma-cli app domain add shop.acme.com"], + }, + { + id: "app.domain.show", + path: ["prisma", "app", "domain", "show"], + description: "Show custom domain status and certificate details", + examples: ["prisma-cli app domain show shop.acme.com"], + }, + { + id: "app.domain.remove", + path: ["prisma", "app", "domain", "remove"], + description: "Detach a custom domain from the app", + examples: ["prisma-cli app domain remove shop.acme.com --yes"], + }, + { + id: "app.domain.retry", + path: ["prisma", "app", "domain", "retry"], + description: "Retry custom domain DNS verification and TLS provisioning", + examples: ["prisma-cli app domain retry shop.acme.com"], + }, + { + id: "app.domain.wait", + path: ["prisma", "app", "domain", "wait"], + description: "Wait until a custom domain is active or failed", + examples: ["prisma-cli app domain wait shop.acme.com", "prisma-cli app domain wait shop.acme.com --timeout 0 --json"], + }, { id: "app.logs", path: ["prisma", "app", "logs"], diff --git a/packages/cli/src/types/app.ts b/packages/cli/src/types/app.ts index d3596ac..729db75 100644 --- a/packages/cli/src/types/app.ts +++ b/packages/cli/src/types/app.ts @@ -107,3 +107,65 @@ export interface AppRemoveResult { app: AppSummary; removed: true; } + +export type AppDomainStatus = + | "pending_dns" + | "verifying" + | "verified_routing_blocked" + | "provisioning_tls" + | "active" + | "failed" + | "removing"; + +export type AppDomainFailureCategory = "dns" | "acme" | "storage" | "unknown" | null; + +export interface AppDomainDnsRecord { + type: string; + name: string; + value: string; + ttl: number | null; +} + +export interface AppDomainSummary { + id: string; + type: "custom-domain"; + url: string; + hostname: string; + computeServiceId: string; + status: AppDomainStatus; + foundryStatus: string; + failureReason: string | null; + failureCategory: AppDomainFailureCategory; + certExpiresAt: string | null; + createdAt: string; + updatedAt: string; + dnsRecords: AppDomainDnsRecord[]; +} + +export interface AppDomainTarget { + workspace: AuthWorkspace; + project: ProjectSummary; + branch: { + name: string; + kind: BranchKind; + }; + app: AppSummary; +} + +export interface AppDomainAddResult extends AppDomainTarget { + domain: AppDomainSummary; + existing: boolean; +} + +export interface AppDomainShowResult extends AppDomainTarget { + domain: AppDomainSummary; +} + +export interface AppDomainRemoveResult extends AppDomainTarget { + hostname: string; + removed: true; +} + +export interface AppDomainRetryResult extends AppDomainTarget { + domain: AppDomainSummary; +} diff --git a/packages/cli/tests/app-controller.test.ts b/packages/cli/tests/app-controller.test.ts index 018bb97..3da2345 100644 --- a/packages/cli/tests/app-controller.test.ts +++ b/packages/cli/tests/app-controller.test.ts @@ -65,6 +65,40 @@ function createProjectClient(projectId = "proj_123") { }; } +function createDomain(overrides: Partial<{ + id: string; + hostname: string; + computeServiceId: string; + status: "pending_dns" | "verifying" | "verified_routing_blocked" | "provisioning_tls" | "active" | "failed" | "removing"; + failureReason: string | null; + failureCategory: "dns" | "acme" | "storage" | "unknown" | null; + dnsRecords: Array<{ type: string; name: string; value: string; ttl: number | null }>; +}> = {}) { + const hostname = overrides.hostname ?? "shop.acme.com"; + return { + id: overrides.id ?? "dom_123", + type: "custom-domain" as const, + url: `https://api.prisma.io/v1/domains/${overrides.id ?? "dom_123"}`, + hostname, + computeServiceId: overrides.computeServiceId ?? "app_1", + status: overrides.status ?? "pending_dns", + foundryStatus: overrides.status ?? "pending_dns", + failureReason: overrides.failureReason ?? null, + failureCategory: overrides.failureCategory ?? null, + certExpiresAt: null, + createdAt: "2026-05-22T09:14:00.000Z", + updatedAt: "2026-05-22T09:14:00.000Z", + dnsRecords: overrides.dnsRecords ?? [ + { + type: "CNAME", + name: hostname, + value: "switchboard.fra.prisma.build", + ttl: 300, + }, + ], + }; +} + async function writePackageJson( cwd: string, packageJson: { @@ -259,6 +293,549 @@ describe("app controller", () => { ); }); + it("add_on_active_domain_does_not_retrigger_verification", async () => { + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const activeDomain = createDomain({ status: "active" }); + const listApps = vi.fn().mockResolvedValue([ + { id: "app_1", name: "shop", region: "eu-central-1", liveDeploymentId: "dep_live", liveUrl: "https://shop.prisma.app" }, + ]); + const addDomain = vi.fn().mockResolvedValue({ + domain: activeDomain, + existing: true, + }); + const retryDomain = vi.fn(); + + vi.doMock("../src/lib/auth/guard", () => ({ + requireComputeAuth, + })); + vi.doMock("../src/lib/app/preview-provider", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createPreviewAppProvider: vi.fn(() => ({ + listApps, + listDomains: vi.fn(), + addDomain, + retryDomain, + })), + }; + }); + + const { createTempCwd, createTestCommandContext } = await import("./helpers"); + const { runAppDomainAdd } = await import("../src/controllers/app"); + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + const { context } = await createTestCommandContext({ + cwd, + stateDir, + env: { + ...process.env, + PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, + }, + }); + + const result = await runAppDomainAdd(context, "Shop.Acme.com.", { + projectRef: "proj_123", + appName: "shop", + }); + + expect(addDomain).toHaveBeenCalledWith({ + appId: "app_1", + hostname: "shop.acme.com", + }); + expect(retryDomain).not.toHaveBeenCalled(); + expect(result.result).toMatchObject({ + workspace: { + id: "ws_123", + name: "Acme Inc", + }, + project: { + id: "proj_123", + name: "Acme Dashboard", + }, + branch: { + name: "production", + kind: "production", + }, + app: { + id: "app_1", + name: "shop", + }, + domain: { + hostname: "shop.acme.com", + status: "active", + dnsRecords: [{ + type: "CNAME", + name: "shop.acme.com", + value: "switchboard.fra.prisma.build", + ttl: 300, + }], + }, + existing: true, + }); + }); + + it("domain add does not synthesize DNS records when the API omits them", async () => { + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const listApps = vi.fn().mockResolvedValue([ + { + id: "app_1", + name: "shop", + region: "eu-central-1", + liveDeploymentId: "dep_live", + liveUrl: "https://shop.fra.prisma.build", + }, + ]); + const addDomain = vi.fn().mockResolvedValue({ + domain: createDomain({ status: "pending_dns", dnsRecords: [] }), + existing: false, + }); + + vi.doMock("../src/lib/auth/guard", () => ({ + requireComputeAuth, + })); + vi.doMock("../src/lib/app/preview-provider", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createPreviewAppProvider: vi.fn(() => ({ + listApps, + addDomain, + })), + }; + }); + + const { createTempCwd, createTestCommandContext } = await import("./helpers"); + const { runAppDomainAdd } = await import("../src/controllers/app"); + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + const { context } = await createTestCommandContext({ + cwd, + stateDir, + env: { + ...process.env, + PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, + }, + }); + + const result = await runAppDomainAdd(context, "shop.acme.com", { + projectRef: "proj_123", + appName: "shop", + }); + + expect(result.result.domain.dnsRecords).toEqual([]); + }); + + it("domain add maps quota conflicts to DOMAIN_QUOTA_EXCEEDED", async () => { + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const listApps = vi.fn().mockResolvedValue([ + { + id: "app_1", + name: "shop", + region: "eu-central-1", + liveDeploymentId: "dep_live", + liveUrl: "https://shop.fra.prisma.build", + }, + ]); + + vi.doMock("../src/lib/auth/guard", () => ({ + requireComputeAuth, + })); + vi.doMock("../src/lib/app/preview-provider", async (importOriginal) => { + const actual = await importOriginal(); + const addDomain = vi.fn().mockRejectedValue(new actual.PreviewDomainApiError({ + summary: "Failed to add custom domain", + status: 409, + message: "Domain quota exceeded.", + hint: "This compute service has reached the maximum of 3 custom domains.", + })); + return { + ...actual, + createPreviewAppProvider: vi.fn(() => ({ + listApps, + addDomain, + })), + }; + }); + + const { createTempCwd, createTestCommandContext } = await import("./helpers"); + const { runAppDomainAdd } = await import("../src/controllers/app"); + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + const { context } = await createTestCommandContext({ + cwd, + stateDir, + env: { + ...process.env, + PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, + }, + }); + + await expect(runAppDomainAdd(context, "shop.acme.com", { + projectRef: "proj_123", + appName: "shop", + })).rejects.toMatchObject({ + code: "DOMAIN_QUOTA_EXCEEDED", + domain: "app", + }); + }); + + it("domain add maps already-registered conflicts to DOMAIN_ALREADY_REGISTERED", async () => { + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const listApps = vi.fn().mockResolvedValue([ + { + id: "app_1", + name: "shop", + region: "eu-central-1", + liveDeploymentId: "dep_live", + liveUrl: "https://shop.fra.prisma.build", + }, + ]); + + vi.doMock("../src/lib/auth/guard", () => ({ + requireComputeAuth, + })); + vi.doMock("../src/lib/app/preview-provider", async (importOriginal) => { + const actual = await importOriginal(); + const addDomain = vi.fn().mockRejectedValue(new actual.PreviewDomainApiError({ + summary: "Failed to add custom domain", + status: 409, + message: "Hostname already registered.", + hint: "This hostname is already registered to another compute service.", + })); + return { + ...actual, + createPreviewAppProvider: vi.fn(() => ({ + listApps, + addDomain, + })), + }; + }); + + const { createTempCwd, createTestCommandContext } = await import("./helpers"); + const { runAppDomainAdd } = await import("../src/controllers/app"); + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + const { context } = await createTestCommandContext({ + cwd, + stateDir, + env: { + ...process.env, + PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, + }, + }); + + await expect(runAppDomainAdd(context, "shop.acme.com", { + projectRef: "proj_123", + appName: "shop", + })).rejects.toMatchObject({ + code: "DOMAIN_ALREADY_REGISTERED", + domain: "app", + summary: "Custom domain \"shop.acme.com\" is already registered", + fix: "Select the app that owns this hostname and remove it there, or contact support if you cannot access it.", + nextSteps: [ + "Select the owning app and remove shop.acme.com there.", + "Contact Prisma support if you cannot access the owning app.", + ], + }); + }); + + it("domain add maps DNS preflight failures to DOMAIN_DNS_NOT_CONFIGURED", async () => { + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const listApps = vi.fn().mockResolvedValue([ + { + id: "app_1", + name: "shop", + region: "eu-central-1", + liveDeploymentId: "dep_live", + liveUrl: "https://shop.fra.prisma.build", + }, + ]); + + vi.doMock("../src/lib/auth/guard", () => ({ + requireComputeAuth, + })); + vi.doMock("../src/lib/app/preview-provider", async (importOriginal) => { + const actual = await importOriginal(); + const addDomain = vi.fn().mockRejectedValue(new actual.PreviewDomainApiError({ + summary: "Failed to add custom domain", + status: 400, + message: "No CNAME or A/AAAA records found for hostname.", + hint: "DNS verification failed: ensure the hostname CNAMEs to switchboard.fra.prisma.build.", + })); + return { + ...actual, + createPreviewAppProvider: vi.fn(() => ({ + listApps, + addDomain, + })), + }; + }); + + const { createTempCwd, createTestCommandContext } = await import("./helpers"); + const { runAppDomainAdd } = await import("../src/controllers/app"); + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + const { context } = await createTestCommandContext({ + cwd, + stateDir, + env: { + ...process.env, + PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, + }, + }); + + await expect(runAppDomainAdd(context, "compute-test.amanv.dev", { + projectRef: "proj_123", + appName: "shop", + })).rejects.toMatchObject({ + code: "DOMAIN_DNS_NOT_CONFIGURED", + domain: "app", + fix: "Add CNAME compute-test.amanv.dev -> switchboard.fra.prisma.build at your DNS provider, then rerun the domain command.", + nextSteps: [ + "add CNAME compute-test.amanv.dev -> switchboard.fra.prisma.build", + "prisma-cli app domain add compute-test.amanv.dev", + ], + }); + }); + + it("domain add does not invent a DNS target when the API omits one", async () => { + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const listApps = vi.fn().mockResolvedValue([ + { + id: "app_1", + name: "shop", + region: "eu-central-1", + liveDeploymentId: "dep_live", + liveUrl: "https://shop.fra.prisma.build", + }, + ]); + + vi.doMock("../src/lib/auth/guard", () => ({ + requireComputeAuth, + })); + vi.doMock("../src/lib/app/preview-provider", async (importOriginal) => { + const actual = await importOriginal(); + const addDomain = vi.fn().mockRejectedValue(new actual.PreviewDomainApiError({ + summary: "Failed to add custom domain", + status: 400, + message: "DNS is not configured for hostname compute-test.amanv.dev.", + hint: "DNS verification failed.", + })); + return { + ...actual, + createPreviewAppProvider: vi.fn(() => ({ + listApps, + addDomain, + })), + }; + }); + + const { createTempCwd, createTestCommandContext } = await import("./helpers"); + const { runAppDomainAdd } = await import("../src/controllers/app"); + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + const { context } = await createTestCommandContext({ + cwd, + stateDir, + env: { + ...process.env, + PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, + }, + }); + + await expect(runAppDomainAdd(context, "compute-test.amanv.dev", { + projectRef: "proj_123", + appName: "shop", + })).rejects.toMatchObject({ + code: "DOMAIN_DNS_NOT_CONFIGURED", + domain: "app", + fix: "The platform did not return the required DNS target. Re-run with --trace for the underlying API response details.", + nextSteps: ["prisma-cli app domain add compute-test.amanv.dev --trace"], + }); + }); + + it("domain remove reports list-domain failures with the remove command label", async () => { + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const listApps = vi.fn().mockResolvedValue([ + { + id: "app_1", + name: "shop", + region: "eu-central-1", + liveDeploymentId: "dep_live", + liveUrl: "https://shop.fra.prisma.build", + }, + ]); + const listDomains = vi.fn().mockRejectedValue(new Error("list failed")); + + vi.doMock("../src/lib/auth/guard", () => ({ + requireComputeAuth, + })); + vi.doMock("../src/lib/app/preview-provider", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createPreviewAppProvider: vi.fn(() => ({ + listApps, + listDomains, + })), + }; + }); + + const { createTempCwd, createTestCommandContext } = await import("./helpers"); + const { runAppDomainRemove } = await import("../src/controllers/app"); + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + const { context } = await createTestCommandContext({ + cwd, + stateDir, + flags: { yes: true }, + env: { + ...process.env, + PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, + }, + }); + + await expect(runAppDomainRemove(context, "shop.acme.com", { + projectRef: "proj_123", + appName: "shop", + })).rejects.toMatchObject({ + code: "DEPLOY_FAILED", + summary: "Custom domain remove failed", + }); + }); + + it("domain add rejects preview branches", async () => { + const { createTempCwd, createTestCommandContext } = await import("./helpers"); + const { runAppDomainAdd } = await import("../src/controllers/app"); + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + const { context } = await createTestCommandContext({ + cwd, + stateDir, + env: { + ...process.env, + PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, + }, + }); + + await expect(runAppDomainAdd(context, "shop.acme.com", { + projectRef: "proj_123", + appName: "shop", + branchName: "feat/login", + })).rejects.toMatchObject({ + code: "BRANCH_NOT_DEPLOYABLE", + domain: "branch", + exitCode: 2, + }); + }); + + it("domain retry maps API 409 to DOMAIN_RETRY_NOT_ELIGIBLE", async () => { + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const listApps = vi.fn().mockResolvedValue([ + { id: "app_1", name: "shop", region: "eu-central-1", liveDeploymentId: "dep_live", liveUrl: "https://shop.prisma.app" }, + ]); + const listDomains = vi.fn().mockResolvedValue([ + createDomain({ status: "provisioning_tls" }), + ]); + + vi.doMock("../src/lib/auth/guard", () => ({ + requireComputeAuth, + })); + vi.doMock("../src/lib/app/preview-provider", async (importOriginal) => { + const actual = await importOriginal(); + const retryDomain = vi.fn().mockRejectedValue(new actual.PreviewDomainApiError({ + summary: "Failed to retry custom domain", + status: 409, + message: "Domain is not eligible for retry.", + })); + return { + ...actual, + createPreviewAppProvider: vi.fn(() => ({ + listApps, + listDomains, + retryDomain, + })), + }; + }); + + const { createTempCwd, createTestCommandContext } = await import("./helpers"); + const { runAppDomainRetry } = await import("../src/controllers/app"); + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + const { context } = await createTestCommandContext({ + cwd, + stateDir, + env: { + ...process.env, + PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, + }, + }); + + await expect(runAppDomainRetry(context, "shop.acme.com", { + projectRef: "proj_123", + appName: "shop", + })).rejects.toMatchObject({ + code: "DOMAIN_RETRY_NOT_ELIGIBLE", + domain: "app", + }); + }); + + it("domain wait supports poll-once timeout mode", async () => { + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const listApps = vi.fn().mockResolvedValue([ + { id: "app_1", name: "shop", region: "eu-central-1", liveDeploymentId: "dep_live", liveUrl: "https://shop.prisma.app" }, + ]); + const listDomains = vi.fn().mockResolvedValue([ + createDomain({ status: "verifying" }), + ]); + const showDomain = vi.fn(); + + vi.doMock("../src/lib/auth/guard", () => ({ + requireComputeAuth, + })); + vi.doMock("../src/lib/app/preview-provider", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createPreviewAppProvider: vi.fn(() => ({ + listApps, + listDomains, + showDomain, + })), + }; + }); + + const { createTempCwd, createTestCommandContext } = await import("./helpers"); + const { runAppDomainWait } = await import("../src/controllers/app"); + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + const { context, stdout } = await createTestCommandContext({ + cwd, + stateDir, + flags: { + json: true, + }, + env: { + ...process.env, + PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, + }, + }); + + await expect(runAppDomainWait(context, "shop.acme.com", { + projectRef: "proj_123", + appName: "shop", + timeout: "0", + })).rejects.toMatchObject({ + code: "DOMAIN_VERIFICATION_TIMEOUT", + domain: "app", + exitCode: 1, + }); + expect(showDomain).not.toHaveBeenCalled(); + expect(stdout.buffer).toContain("\"command\":\"app.domain.wait\""); + expect(stdout.buffer).toContain("\"status\":\"verifying\""); + }); + it("infers project, branch, app, framework, and runtime for a first deploy", async () => { const client = { token: "token", diff --git a/packages/cli/tests/app-presenter.test.ts b/packages/cli/tests/app-presenter.test.ts new file mode 100644 index 0000000..d0def53 --- /dev/null +++ b/packages/cli/tests/app-presenter.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; + +import { getCommandDescriptor } from "../src/shell/command-meta"; +import { renderAppDomainAdd, renderAppDomainRetry, renderAppDomainShow } from "../src/presenters/app"; +import type { AppDomainAddResult, AppDomainRetryResult, AppDomainShowResult, AppDomainSummary } from "../src/types/app"; +import { createTestCommandContext } from "./helpers"; + +function createDomain(overrides: Partial = {}): AppDomainSummary { + return { + id: "dom_123", + type: "custom-domain", + url: "https://api.prisma.io/v1/domains/dom_123", + hostname: "shop.acme.com", + computeServiceId: "app_1", + status: "pending_dns", + foundryStatus: "pending", + failureReason: null, + failureCategory: null, + certExpiresAt: null, + createdAt: "2026-05-22T09:14:00.000Z", + updatedAt: "2026-05-22T09:14:00.000Z", + dnsRecords: [ + { + type: "CNAME", + name: "shop.acme.com", + value: "switchboard.fra.prisma.build", + ttl: 300, + }, + ], + ...overrides, + }; +} + +function createTarget() { + return { + workspace: { id: "ws_123", name: "Acme Inc" }, + project: { id: "proj_123", name: "Acme Dashboard" }, + branch: { name: "production", kind: "production" as const }, + app: { id: "app_1", name: "shop" }, + }; +} + +describe("app domain presenters", () => { + it("shows when DNS records were not provided by the platform", async () => { + const { context } = await createTestCommandContext({}); + const result: AppDomainAddResult = { + ...createTarget(), + domain: createDomain({ dnsRecords: [] }), + existing: false, + }; + + const lines = renderAppDomainAdd( + context, + getCommandDescriptor("app.domain.add"), + result, + ).join("\n"); + + expect(lines).toContain("dns record"); + expect(lines).toContain("not provided by platform"); + expect(lines).not.toContain("switchboard.fra.prisma.build"); + }); + + it("does not print CNAME fixes for certificate failures", async () => { + const { context } = await createTestCommandContext({}); + const result: AppDomainShowResult = { + ...createTarget(), + domain: createDomain({ + status: "failed", + failureCategory: "acme", + failureReason: "Certificate issuance failed", + }), + }; + + const lines = renderAppDomainShow( + context, + getCommandDescriptor("app.domain.show"), + result, + ).join("\n"); + + expect(lines).toContain("Retry TLS issuance"); + expect(lines).not.toContain("Add CNAME"); + }); + + it("renders failure categories without reasons as errors", async () => { + const { context } = await createTestCommandContext({}); + context.ui.error = (text) => `error(${text})`; + context.ui.dim = (text) => `dim(${text})`; + const result: AppDomainShowResult = { + ...createTarget(), + domain: createDomain({ + status: "failed", + failureCategory: "acme", + failureReason: null, + }), + }; + + const lines = renderAppDomainShow( + context, + getCommandDescriptor("app.domain.show"), + result, + ).join("\n"); + + expect(lines).toContain("error(acme)"); + expect(lines).not.toContain("dim(acme)"); + }); + + it("includes DNS and recovery guidance in retry output", async () => { + const { context } = await createTestCommandContext({}); + const result: AppDomainRetryResult = { + ...createTarget(), + domain: createDomain({ + status: "failed", + failureCategory: "dns", + failureReason: "CNAME record not found", + }), + }; + + const lines = renderAppDomainRetry( + context, + getCommandDescriptor("app.domain.retry"), + result, + ).join("\n"); + + expect(lines).toContain("dns record"); + expect(lines).toContain("CNAME shop.acme.com -> switchboard.fra.prisma.build ttl 300"); + expect(lines).toContain("Add CNAME shop.acme.com -> switchboard.fra.prisma.build, then run prisma-cli app domain retry shop.acme.com."); + }); +}); diff --git a/packages/cli/tests/app-provider.test.ts b/packages/cli/tests/app-provider.test.ts index af697bd..b9a7fbf 100644 --- a/packages/cli/tests/app-provider.test.ts +++ b/packages/cli/tests/app-provider.test.ts @@ -307,4 +307,154 @@ describe("preview app provider", () => { }), ); }); + + it("treats re-adding an existing custom domain as idempotent", async () => { + const client = { + GET: vi.fn().mockImplementation((pathName: string) => { + if (pathName === "/v1/compute-services/{computeServiceId}/domains") { + return { + data: { + data: [{ + id: "dom_123", + type: "custom-domain", + url: "https://api.prisma.io/v1/domains/dom_123", + hostname: "shop.acme.com", + computeServiceId: "app_1", + status: "active", + foundryStatus: "active", + failureReason: null, + failureCategory: null, + certExpiresAt: null, + createdAt: "2026-05-22T09:14:00.000Z", + updatedAt: "2026-05-22T09:14:00.000Z", + }], + pagination: { hasMore: false, nextCursor: null }, + }, + response: { status: 200 }, + }; + } + + throw new Error(`Unexpected path ${pathName}`); + }), + POST: vi.fn().mockImplementation((pathName: string) => { + if (pathName === "/v1/compute-services/{computeServiceId}/domains") { + return { + error: { + error: { + code: "CONFLICT", + message: "Hostname already registered.", + }, + }, + response: { status: 409 }, + }; + } + + throw new Error(`Unexpected path ${pathName}`); + }), + }; + + vi.doMock("@prisma/compute-sdk", () => ({ + ApiError: { is: () => false }, + ComputeClient: class {}, + })); + + const { createPreviewAppProvider } = await import("../src/lib/app/preview-provider"); + + const provider = createPreviewAppProvider(client as never); + const result = await provider.addDomain({ + appId: "app_1", + hostname: "Shop.Acme.com", + }); + + expect(result).toMatchObject({ + existing: true, + domain: { + id: "dom_123", + hostname: "shop.acme.com", + status: "active", + }, + }); + expect(client.POST).toHaveBeenCalledWith( + "/v1/compute-services/{computeServiceId}/domains", + expect.objectContaining({ + params: { + path: { computeServiceId: "app_1" }, + }, + body: { + hostname: "Shop.Acme.com", + }, + }), + ); + expect(client.GET).toHaveBeenCalledWith( + "/v1/compute-services/{computeServiceId}/domains", + expect.objectContaining({ + params: { + path: { computeServiceId: "app_1" }, + }, + }), + ); + }); + + it("surfaces domain conflicts when the hostname is not on the selected app", async () => { + const client = { + GET: vi.fn().mockImplementation((pathName: string) => { + if (pathName === "/v1/compute-services/{computeServiceId}/domains") { + return { + data: { + data: [{ + id: "dom_123", + type: "custom-domain", + url: "https://api.prisma.io/v1/domains/dom_123", + hostname: "other.acme.com", + computeServiceId: "app_1", + status: "active", + foundryStatus: "active", + failureReason: null, + failureCategory: null, + certExpiresAt: null, + createdAt: "2026-05-22T09:14:00.000Z", + updatedAt: "2026-05-22T09:14:00.000Z", + }], + pagination: { hasMore: false, nextCursor: null }, + }, + response: { status: 200 }, + }; + } + + throw new Error(`Unexpected path ${pathName}`); + }), + POST: vi.fn().mockImplementation((pathName: string) => { + if (pathName === "/v1/compute-services/{computeServiceId}/domains") { + return { + error: { + error: { + code: "CONFLICT", + message: "Hostname already registered.", + }, + }, + response: { status: 409 }, + }; + } + + throw new Error(`Unexpected path ${pathName}`); + }), + }; + + vi.doMock("@prisma/compute-sdk", () => ({ + ApiError: { is: () => false }, + ComputeClient: class {}, + })); + + const { createPreviewAppProvider } = await import("../src/lib/app/preview-provider"); + + const provider = createPreviewAppProvider(client as never); + + await expect(provider.addDomain({ + appId: "app_1", + hostname: "shop.acme.com", + })).rejects.toMatchObject({ + status: 409, + code: "CONFLICT", + }); + }); }); diff --git a/packages/cli/tests/app.test.ts b/packages/cli/tests/app.test.ts index 4941ece..579e2bf 100644 --- a/packages/cli/tests/app.test.ts +++ b/packages/cli/tests/app.test.ts @@ -65,6 +65,42 @@ describe("app commands", () => { stateDir, fixturePath, }); + const domainHelp = await executeCli({ + argv: ["app", "domain", "--help"], + cwd, + stateDir, + fixturePath, + }); + const domainAddHelp = await executeCli({ + argv: ["app", "domain", "add", "--help"], + cwd, + stateDir, + fixturePath, + }); + const domainShowHelp = await executeCli({ + argv: ["app", "domain", "show", "--help"], + cwd, + stateDir, + fixturePath, + }); + const domainRemoveHelp = await executeCli({ + argv: ["app", "domain", "remove", "--help"], + cwd, + stateDir, + fixturePath, + }); + const domainRetryHelp = await executeCli({ + argv: ["app", "domain", "retry", "--help"], + cwd, + stateDir, + fixturePath, + }); + const domainWaitHelp = await executeCli({ + argv: ["app", "domain", "wait", "--help"], + cwd, + stateDir, + fixturePath, + }); const logsHelp = await executeCli({ argv: ["app", "logs", "--help"], cwd, @@ -144,6 +180,27 @@ describe("app commands", () => { expect(openHelp.stderr).toContain("Open the app's live URL"); expect(openHelp.stderr).toContain("$ prisma-cli app open"); + expect(domainHelp.exitCode).toBe(0); + expect(domainHelp.stderr).toContain("Manage custom domains for an app"); + expect(domainHelp.stderr).toContain("$ prisma-cli app domain add shop.acme.com"); + + expect(domainAddHelp.exitCode).toBe(0); + expect(domainAddHelp.stderr).toContain("Register a custom domain on the app's production branch"); + expect(domainAddHelp.stderr).toContain("--branch "); + + expect(domainShowHelp.exitCode).toBe(0); + expect(domainShowHelp.stderr).toContain("Show custom domain status and certificate details"); + + expect(domainRemoveHelp.exitCode).toBe(0); + expect(domainRemoveHelp.stderr).toContain("Detach a custom domain from the app"); + + expect(domainRetryHelp.exitCode).toBe(0); + expect(domainRetryHelp.stderr).toContain("Retry custom domain DNS verification and TLS provisioning"); + + expect(domainWaitHelp.exitCode).toBe(0); + expect(domainWaitHelp.stderr).toContain("Wait until a custom domain is active or failed"); + expect(domainWaitHelp.stderr).toContain("--timeout "); + expect(logsHelp.exitCode).toBe(0); expect(logsHelp.stderr).toContain("Stream logs for the app's current deployment"); expect(logsHelp.stderr).toContain("$ prisma-cli app logs --deployment dep_123"); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 16a62b1..465cde4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,13 +19,13 @@ importers: version: 1.2.0 '@prisma/compute-sdk': specifier: ^0.19.0 - version: 0.19.0(@prisma/management-api-sdk@1.32.1) + version: 0.19.0(@prisma/management-api-sdk@1.33.0) '@prisma/credentials-store': specifier: ^7.7.0 version: 7.7.0 '@prisma/management-api-sdk': - specifier: ^1.32.1 - version: 1.32.1 + specifier: ^1.33.0 + version: 1.33.0 c12: specifier: 4.0.0-beta.4 version: 4.0.0-beta.4(jiti@2.6.1)(magicast@0.3.5) @@ -292,8 +292,8 @@ packages: '@prisma/credentials-store@7.7.0': resolution: {integrity: sha512-SVaMCL1Q8rFPKQB5W9B7HDuRdD/KyBfKjCgZfnlN+sqrAXIrUGU/m/whcRgkuvygB5GFPAeeZ/4QgvvH0vPSWg==} - '@prisma/management-api-sdk@1.32.1': - resolution: {integrity: sha512-AroVoLGpLQ1cCC9P/5nDcoOA2kst0sPMwVkeT6PmVeSnXXh7RC1u97WAOCF6RPDdzotyRsHuVKVAHHYoBnhUuQ==} + '@prisma/management-api-sdk@1.33.0': + resolution: {integrity: sha512-o6AEjRti1hjh1FXM7/PsndUS149FnzIYO9wLMr7QBt9ZdgPQDoxKDrUb9IV4N/rl/rTu5NsqMVwrb/UtuPkcnA==} '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} @@ -1312,9 +1312,9 @@ snapshots: '@oxc-project/types@0.124.0': {} - '@prisma/compute-sdk@0.19.0(@prisma/management-api-sdk@1.32.1)': + '@prisma/compute-sdk@0.19.0(@prisma/management-api-sdk@1.33.0)': dependencies: - '@prisma/management-api-sdk': 1.32.1 + '@prisma/management-api-sdk': 1.33.0 better-result: 2.8.2 tar-stream: 3.1.8 tiny-invariant: 1.3.3 @@ -1330,7 +1330,7 @@ snapshots: dependencies: xdg-app-paths: 8.3.0 - '@prisma/management-api-sdk@1.32.1': + '@prisma/management-api-sdk@1.33.0': dependencies: openapi-fetch: 0.14.0