Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
8 changes: 6 additions & 2 deletions docs/product/command-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -1012,7 +1012,7 @@ prisma-cli app run --build-type bun --entry server.ts --port 3000
prisma-cli app run api
```

## `prisma-cli app deploy [app] --project <id-or-name> --create-project <name> --app <name> --branch <name> --framework <nextjs|nuxt|astro|hono|nestjs|tanstack-start|custom|bun> --entry <path> --http-port <port> --region <region> --env <name=value|file> --db --no-db --prod`
## `prisma-cli app deploy [app] --project <id-or-name> --create-project <name> --app <name> --branch <name> --framework <nextjs|nuxt|astro|hono|nestjs|tanstack-start|custom|bun> --entry <path> --http-port <port> --region <region> --env <name=value|file> --db --no-db --prod --no-promote`

Purpose:

Expand Down Expand Up @@ -1053,7 +1053,7 @@ same reasons they are excluded today.
- the `[app]` argument selects an `apps` target by key:
- without an `[app]` argument, a command run from inside a target's `root` selects that target, so `cd apps/api && prisma-cli app deploy` deploys `api`; the deepest matching root wins and an ambiguous tie selects nothing
- with multiple `apps` entries, no `[app]` argument, and no target inferred from the invocation directory, deploy deploys every target sequentially in declaration order — the config declares the system, and a bare `prisma-cli app deploy` ships it
- deploying all targets rejects per-app inputs (`--app`, `--framework`, `--entry`, `--http-port`, `--region`, `--env`, `PRISMA_APP_ID`) with a usage error; project- and branch-level flags (`--project`, `--create-project`, `--branch`, `--db`, `--no-db`, `--prod`, `--yes`) apply to the whole run, with `--create-project` creating and binding the Project once before the first target
- deploying all targets rejects per-app inputs (`--app`, `--framework`, `--entry`, `--http-port`, `--region`, `--env`, `PRISMA_APP_ID`) with a usage error; project- and branch-level flags (`--project`, `--create-project`, `--branch`, `--db`, `--no-db`, `--prod`, `--no-promote`, `--yes`) apply to the whole run, with `--create-project` creating and binding the Project once before the first target
- a deploy-all run stops at the first failure and reports the targets already live; `--json` output aggregates one full deploy result per target
- `app build` and `app run` still require a target in multi-app configs and fail with `COMPUTE_CONFIG_TARGET_REQUIRED` (a dev server cannot run N apps at once; build keeps the same shape)
- an `[app]` argument that matches no target fails with `COMPUTE_CONFIG_TARGET_UNKNOWN`
Expand Down Expand Up @@ -1120,6 +1120,8 @@ Behavior:
- resolves or creates app context inside the resolved branch from `--app`, `PRISMA_APP_ID`, `package.json#name`, or current directory name
- auto-promotes the first production deploy for an App without `--prod`
- requires `--prod` for subsequent deploys to a production Branch; `--yes` only skips the confirmation prompt when `--prod` is also present
- supports `--no-promote` to build a new deployment without promoting it to live: the previous deployment keeps serving, and the new deployment is reachable only at its own candidate URL until a later `app promote <deployment-id>` makes it live
- `--no-promote` never replaces the live deployment, so it bypasses the production gate: `app deploy --no-promote` on a production Branch builds a candidate without `--prod` and without confirmation. It is the CI build-then-verify path — build the candidate, health-check its URL, then `app promote`
- does not prompt when there is no real choice; zero matching apps creates the inferred app
- writes `.prisma/local.json` after Project binding succeeds and before build/deploy starts, so retries after a failed deploy do not repeat setup
- before asking `Customize build settings? (y/N)`, previews the detected framework and runtime so the user can see the defaults they are accepting or changing
Expand All @@ -1135,6 +1137,7 @@ Behavior:
- after setup, deploy prints `Deploying to <Project> / <Branch> / <App>`; later deploys print a compact target header such as `Deploying ./j1 to j1 / main / j1`
- deploy progress uses short stage copy (`Building locally...`, `Built <size>`, `Uploading...`, `Uploaded`, `Deploying...`, `Deployed`) and never prints `Status: running` or `Deployment is running at ...`
- success human output prints `Live in <duration>`, the URL on its own line, and `Logs prisma-cli app logs`
- with `--no-promote`, success human output instead prints `Built <deployment-id> in <duration> (not promoted)`, the candidate URL on its own line, a note that the live deployment is unchanged, and a `Promote prisma-cli app promote <deployment-id>` next step
- accepts repeated `--env NAME=VALUE` flags and dotenv file paths such as `--env .env`
- supports `--db` to create a new empty Prisma Postgres database and write `DATABASE_URL` and `DIRECT_URL` through the existing `project env` storage; the CLI never runs schema or migration commands — applying the schema stays with the user's own tooling
- `--db` is the opinionated single-database path: it creates **one** branch database and exposes `DATABASE_URL`/`DIRECT_URL` as branch-scoped env vars, so every app on the branch shares it. In a multi-app deploy-all run the database is created once (on the first target) and reused by the rest; the run never creates a database per app. Per-app or per-service database topologies are explicit-configuration territory — set each app's database env vars yourself rather than relying on `--db` to infer app-to-database ownership
Expand Down Expand Up @@ -1176,6 +1179,7 @@ prisma-cli app deploy --no-db
prisma-cli app deploy --framework nextjs --http-port 3000
prisma-cli app deploy --branch feat-login --framework hono --http-port 3000
prisma-cli app deploy --prod --yes
prisma-cli app deploy --no-promote
prisma-cli app deploy --framework bun --entry src/server.ts --http-port 3000
prisma-cli app deploy --entry src/server.ts --http-port 3000
prisma-cli app deploy web
Expand Down
10 changes: 9 additions & 1 deletion packages/cli/src/commands/app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,13 @@ function createDeployCommand(runtime: CliRuntime): Command {
),
)
.addOption(new Option("--no-db", "Skip database setup"))
.addOption(new Option("--prod", "Confirm intent to deploy to production"));
.addOption(new Option("--prod", "Confirm intent to deploy to production"))
.addOption(
new Option(
"--no-promote",
"Build the new deployment without promoting it to live (promote later with app promote <id>)",
),
);
addGlobalFlags(command);

command.action(async (configTarget: string | undefined, options) => {
Expand All @@ -262,6 +268,7 @@ function createDeployCommand(runtime: CliRuntime): Command {
const createProjectName = (options as { createProject?: string })
.createProject;
const prod = (options as { prod?: boolean }).prod;
const noPromote = (options as { promote?: boolean }).promote === false;
const db = (options as { db?: boolean }).db;
const hasDbConflict =
hasFlag(runtime.argv, "--db") && hasFlag(runtime.argv, "--no-db");
Expand Down Expand Up @@ -291,6 +298,7 @@ function createDeployCommand(runtime: CliRuntime): Command {
region,
envAssignments,
prod: prod === true,
noPromote,
db,
configTarget,
});
Expand Down
38 changes: 24 additions & 14 deletions packages/cli/src/controllers/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,7 @@
region?: string;
envAssignments?: string[];
prod?: boolean;
noPromote?: boolean;
db?: boolean;
configTarget?: string;
}
Expand Down Expand Up @@ -739,16 +740,18 @@
framework = customized.framework;
runtime = customized.runtime;

const productionDeployGate = await enforceProductionDeployGate(
context,
provider,
{
appId: selectedApp.appId,
appName: selectedApp.displayName,
branchKind: target.branch.kind,
prod: options?.prod === true,
},
);
const noPromote = options?.noPromote === true;
// A promotionless deploy never replaces the live deployment, so the
// production-confirmation gate does not apply: --no-promote on the production
// branch builds a candidate without --prod.
const productionDeployGate = noPromote
? { firstProductionDeploy: false }
: await enforceProductionDeployGate(context, provider, {
appId: selectedApp.appId,
appName: selectedApp.displayName,
branchKind: target.branch.kind,
prod: options?.prod === true,
});

// Customization can switch from a Bun-compatible framework to one that
// derives its entrypoint from build output, so validate --entry again after it.
Expand Down Expand Up @@ -801,6 +804,7 @@
buildSettings: buildSettingsResolution.settings,
portMapping,
envVars,
skipPromote: noPromote,
interaction: undefined,
signal: context.runtime.signal,
progress: createDeployProgress(
Expand Down Expand Up @@ -838,6 +842,7 @@
name: deployResult.app.name,
},
deployment: deployResult.deployment,
promoted: deployResult.promoted,
deploySettings: {
config: {
path: buildSettingsResolution.relativeConfigPath,
Expand Down Expand Up @@ -871,10 +876,15 @@
...legacyWarnings,
...branchDatabaseSetup.warnings,
],
nextSteps: [
"prisma-cli app list-deploys",
`prisma-cli app show-deploy ${deployResult.deployment.id}`,
],
nextSteps: deployResult.promoted
? [
"prisma-cli app list-deploys",
`prisma-cli app show-deploy ${deployResult.deployment.id}`,
]
: [
`prisma-cli app promote ${deployResult.deployment.id}`,
`prisma-cli app show-deploy ${deployResult.deployment.id}`,
],
};
}

Expand Down Expand Up @@ -1157,9 +1167,9 @@
...deployment.deployment,
live: providerLiveDeploymentId
? deployment.deployment.id === providerLiveDeploymentId
: knownLiveDeploymentId
? deployment.deployment.id === knownLiveDeploymentId
: deployment.deployment.live,

Check notice on line 1172 in packages/cli/src/controllers/app.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/style/noNestedTernary

Do not nest ternary expressions.
},
},
warnings: [],
Expand Down Expand Up @@ -1538,10 +1548,10 @@
});
}

await sleep(
Math.min(pollIntervalMs, Math.max(deadline - Date.now(), 0)),
context.runtime.signal,
);

Check notice on line 1554 in packages/cli/src/controllers/app.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/performance/noAwaitInLoops

Avoid using await inside loops.
current = await target.provider
.showDomain(current.id, { signal: context.runtime.signal })
.catch((error) => {
Expand Down Expand Up @@ -2103,7 +2113,7 @@
const compute = await resolveComputeManagementContext(
context,
options?.configTarget,
commandName.replace(/^app /, ""),

Check warning on line 2116 in packages/cli/src/controllers/app.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/performance/useTopLevelRegex

This regex literal is not defined in the top level scope. This can lead to performance issues if this function is called frequently.
);
const branch = resolveDomainBranch(options?.branchName);
if (toBranchKind(branch.name) !== "production") {
Expand Down Expand Up @@ -2236,7 +2246,7 @@
}

function normalizeDomainHostname(hostname: string): string {
const normalized = hostname.trim().replace(/\.$/, "").toLowerCase();

Check warning on line 2249 in packages/cli/src/controllers/app.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/performance/useTopLevelRegex

This regex literal is not defined in the top level scope. This can lead to performance issues if this function is called frequently.
if (!isValidDomainHostname(normalized)) {
throw new CliError({
code: "DOMAIN_HOSTNAME_INVALID",
Expand Down Expand Up @@ -2277,8 +2287,8 @@

function sameDomainHostname(left: string, right: string): boolean {
return (
left.trim().replace(/\.$/, "").toLowerCase() ===

Check warning on line 2290 in packages/cli/src/controllers/app.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/performance/useTopLevelRegex

This regex literal is not defined in the top level scope. This can lead to performance issues if this function is called frequently.
right.trim().replace(/\.$/, "").toLowerCase()

Check warning on line 2291 in packages/cli/src/controllers/app.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/performance/useTopLevelRegex

This regex literal is not defined in the top level scope. This can lead to performance issues if this function is called frequently.
);
}

Expand Down Expand Up @@ -2366,7 +2376,7 @@
}
}

function domainCommandError(

Check notice on line 2379 in packages/cli/src/controllers/app.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/complexity/noExcessiveCognitiveComplexity

Excessive complexity of 26 detected (max: 15).
command: AppDomainCommand,
error: unknown,
hostname: string,
Expand Down Expand Up @@ -2503,7 +2513,7 @@
text.includes("no cname") ||
text.includes("cname record") ||
text.includes("no a/aaaa") ||
/\bcname(?:s)?\s+to\b/.test(text)

Check warning on line 2516 in packages/cli/src/controllers/app.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/performance/useTopLevelRegex

This regex literal is not defined in the top level scope. This can lead to performance issues if this function is called frequently.
);
}

Expand Down Expand Up @@ -2532,7 +2542,7 @@

function extractDomainDnsTarget(error: DomainApiError): string | null {
const text = `${error.hint ?? ""} ${error.message}`;
const match = /\b((?:[a-z0-9-]+\.)+prisma\.build)\b/i.exec(text);

Check warning on line 2545 in packages/cli/src/controllers/app.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/performance/useTopLevelRegex

This regex literal is not defined in the top level scope. This can lead to performance issues if this function is called frequently.
return match?.[1]?.toLowerCase() ?? null;
}

Expand Down Expand Up @@ -2568,7 +2578,7 @@
return 0;
}

const match = /^(\d+)(ms|s|m|h)$/.exec(trimmed);

Check warning on line 2581 in packages/cli/src/controllers/app.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/performance/useTopLevelRegex

This regex literal is not defined in the top level scope. This can lead to performance issues if this function is called frequently.
if (!match) {
throw usageError(
`Invalid timeout "${value}"`,
Expand All @@ -2584,11 +2594,11 @@
const multiplier =
unit === "h"
? 60 * 60 * 1000
: unit === "m"
? 60 * 1000
: unit === "s"
? 1000
: 1;

Check notice on line 2601 in packages/cli/src/controllers/app.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/style/noNestedTernary

Do not nest ternary expressions.

Check notice on line 2601 in packages/cli/src/controllers/app.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/style/noNestedTernary

Do not nest ternary expressions.
return amount * multiplier;
}

Expand Down Expand Up @@ -3362,7 +3372,7 @@
listProjects: () =>
listRealWorkspaceProjects(
client,
authState.workspace!,

Check warning on line 3375 in packages/cli/src/controllers/app.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/style/noNonNullAssertion

Forbidden non-null assertion.
context.runtime.signal,
),
commandName: options?.commandName,
Expand Down Expand Up @@ -3396,7 +3406,7 @@
};
}

async function resolveDeployProjectContext(

Check notice on line 3409 in packages/cli/src/controllers/app.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/complexity/noExcessiveCognitiveComplexity

Excessive complexity of 18 detected (max: 15).
context: CommandContext,
client: ManagementApiClient,
provider: ReturnType<typeof createAppProvider>,
Expand Down Expand Up @@ -4071,7 +4081,7 @@
continue;
}

const configFile = await detectFrameworkConfigFile(cwd, framework, signal);

Check notice on line 4084 in packages/cli/src/controllers/app.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/performance/noAwaitInLoops

Avoid using await inside loops.
if (
!configFile.exists &&
!hasAnyPackageDependency(packageJson, framework.detectPackages)
Expand All @@ -4084,8 +4094,8 @@
const annotation =
framework.key === "nextjs" && configFile.standalone
? "standalone output detected"
: configFile.exists
? `detected from ${path.basename(configFile.path!)}`

Check warning on line 4098 in packages/cli/src/controllers/app.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/style/noNonNullAssertion

Forbidden non-null assertion.
: "detected from package.json";

Check notice on line 4099 in packages/cli/src/controllers/app.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/style/noNestedTernary

Do not nest ternary expressions.

return {
Expand All @@ -4108,10 +4118,10 @@
const filePath = path.join(cwd, candidate);
signal.throwIfAborted();
try {
const content = await readFile(filePath, { encoding: "utf8", signal });

Check notice on line 4121 in packages/cli/src/controllers/app.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/performance/noAwaitInLoops

Avoid using await inside loops.
return {
exists: true,
standalone: /\boutput\s*:\s*["'`]standalone["'`]/.test(content),

Check warning on line 4124 in packages/cli/src/controllers/app.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/performance/useTopLevelRegex

This regex literal is not defined in the top level scope. This can lead to performance issues if this function is called frequently.
path: filePath,
};
} catch (error) {
Expand Down Expand Up @@ -4785,7 +4795,7 @@
});
}

function appDeployFailedError(

Check notice on line 4798 in packages/cli/src/controllers/app.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/complexity/noExcessiveCognitiveComplexity

Excessive complexity of 21 detected (max: 15).
error: unknown,
progress: DeployProgressState,
): CliError {
Expand Down
13 changes: 12 additions & 1 deletion packages/cli/src/lib/app/app-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ export interface DeployRecord {
id: string;
status: string;
url: string | null;
live: boolean;
};
promoted: boolean;
}

export interface EnvRecord {
Expand Down Expand Up @@ -229,6 +231,7 @@ export interface AppProvider {
buildSettings?: AppBuildSettings;
portMapping?: PortMapping;
envVars?: Record<string, string>;
skipPromote?: boolean;
interaction?: unknown;
signal?: AbortSignal;
progress?: unknown;
Expand Down Expand Up @@ -511,6 +514,7 @@ export function createAppProvider(
region: resolvedApp.region,
portMapping: options.portMapping,
envVars: options.envVars,
skipPromote: options.skipPromote,
timeoutSeconds: 120,
pollIntervalMs: 2000,
interaction: options.interaction as never,
Expand All @@ -524,13 +528,18 @@ export function createAppProvider(

const deployed = deployResult.value;

// On a promotionless deploy the SDK leaves appEndpointDomain null and the
// previous deployment serving live, so the live pointer stays on the old
// deployment and both URL expressions resolve to the candidate endpoint.
return {
projectId: deployed.projectId,
app: {
id: deployed.appId,
name: deployed.appName,
region: deployed.region ?? null,
liveDeploymentId: deployed.deploymentId,
liveDeploymentId: deployed.promoted
? deployed.deploymentId
: deployed.previousDeploymentId,
liveUrl: toAbsoluteUrl(deployed.appEndpointDomain ?? null),
},
deployment: {
Expand All @@ -541,7 +550,9 @@ export function createAppProvider(
deployed.deploymentEndpointDomain ??
null,
),
live: deployed.promoted,
},
promoted: deployed.promoted,
};
},

Expand Down
25 changes: 23 additions & 2 deletions packages/cli/src/presenters/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,33 @@ export function renderAppDeploy(
const logsCommand = options?.logsTarget
? `prisma-cli app logs ${options.logsTarget}`
: "prisma-cli app logs";
const headline = result.promoted
? [
`Live in ${formatDuration(result.durationMs)}`,
...(result.deployment.url
? [context.ui.link(result.deployment.url)]
: []),
]
: [
`Built ${result.deployment.id} in ${formatDuration(result.durationMs)} (not promoted)`,
...(result.deployment.url
? [context.ui.link(result.deployment.url)]
: []),
context.ui.dim("The live deployment is unchanged."),
];
const lines = [
`Live in ${formatDuration(result.durationMs)}`,
...(result.deployment.url ? [context.ui.link(result.deployment.url)] : []),
...headline,
...renderBranchDatabaseDeploySummary(context, result),
"",
...renderDeployOutputRows(context.ui, [
...(result.promoted
? []
: [
{
label: "Promote",
value: `prisma-cli app promote ${result.deployment.id}`,
},
]),
{ label: "Logs", value: logsCommand },
]),
...renderDeployResolvedContextBlock(context, result),
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/types/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,10 @@ export interface AppDeployResult {
id: string;
status: string;
url: string | null;
live: boolean;
};
/** Whether the new deployment was promoted to live. False for --no-promote. */
promoted: boolean;
deploySettings: AppDeploySettings;
durationMs: number;
localPin?: {
Expand Down
Loading
Loading