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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 33 additions & 3 deletions docs/product/command-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -865,6 +865,34 @@ prisma-cli branch list
prisma-cli branch list --json
```

## `prisma-cli branch remove <branch> --project <id-or-name> --confirm <branch-id> --cascade`

Purpose:

- remove a preview Branch from the resolved project

Behavior:

- requires auth and resolved project context; accepts `--project <id-or-name>` as an explicit fallback
- resolves `<branch>` by exact Branch id or exact git name within the resolved project
- requires `--confirm <branch-id>` where the value exactly matches the resolved Branch id; `--yes` does not satisfy this confirmation
- production Branches and the project's default Branch are protected: removal fails with `BRANCH_PROTECTED`, and `--cascade` never widens that; the protection check runs before any member resource is touched
- without `--cascade`, a Branch that still has live Apps or databases fails with `BRANCH_NOT_EMPTY`; plain removal never deletes member resources, and the recovery steps offer both the cascade rerun and individual `app remove --branch` / `database` cleanup
- with `--cascade`, the CLI removes the Branch's Apps, then its databases, then the Branch itself; the result lists every removed resource so the blast radius is explicit, in human output and in `result.removed` for `--json`
- cascade is client-orchestrated because the platform's branch delete refuses non-empty Branches; a mid-cascade failure stops immediately and fails with `BRANCH_CASCADE_INCOMPLETE`, whose `meta` lists what was already removed (removed resources are not restored, and the Branch itself remains)
- removal is a platform soft-delete: the Branch disappears from `branch list`, and the platform owns retention of soft-deleted Branches; cascaded member resources are removed through their own APIs
- Branch creation stays implicit (git-push automation and `app deploy`); there is deliberately no `branch create`
- never touches local Git branches
- fails with `BRANCH_NOT_FOUND` when no Branch matches

Examples:

```bash
prisma-cli branch remove feat-login --confirm br_123
prisma-cli branch remove feat-login --confirm br_123 --cascade
prisma-cli branch remove br_123 --confirm br_123 --json
```

## `prisma-cli database list --project <id-or-name> --branch <git-name>`

Purpose:
Expand Down Expand Up @@ -1829,16 +1857,18 @@ prisma-cli app rollback
prisma-cli app rollback --app hello-world --to dep_123
```

## `prisma-cli app remove [app] --app <name> -y --yes`
## `prisma-cli app remove [app] --app <name> --branch <name> -y --yes`

Purpose:

- remove the app from the current branch
- remove the app from the resolved branch

Behavior:

- requires auth and project context
- resolves the selected app
- resolves the branch it reads like the other management commands: explicit `--branch` honored as-is, then the active Git branch when it exists in the project, then the project's default branch
- `--branch <name>` makes branch cleanup possible for branches that are not checked out locally, such as a teammate's preview branch
- resolves the selected app within that branch
- requires confirmation unless `-y` or `--yes` is passed
- clears local selected app state when the removed app was selected

Expand Down
8 changes: 8 additions & 0 deletions docs/product/error-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,10 @@ These codes are the minimum stable set for the MVP:
- `LOCAL_STATE_WRITE_FAILED`
- `LOCAL_STATE_STALE`
- `BRANCH_NOT_DEPLOYABLE`
- `BRANCH_NOT_FOUND`
- `BRANCH_PROTECTED`
- `BRANCH_NOT_EMPTY`
- `BRANCH_CASCADE_INCOMPLETE`
- `COMPUTE_CONFIG_INVALID`
- `COMPUTE_CONFIG_TARGET_REQUIRED`
- `COMPUTE_CONFIG_TARGET_UNKNOWN`
Expand Down Expand Up @@ -252,6 +256,10 @@ Recommended meanings:
- `LOCAL_STATE_WRITE_FAILED`: the CLI could not save local Project binding state such as `.prisma/local.json` or the matching `.gitignore` entry; callers should fix directory permissions or filesystem state before retrying
- `LOCAL_STATE_STALE`: local Project pin no longer matches platform data and continuing would be ambiguous
- `BRANCH_NOT_DEPLOYABLE`: command tried to deploy to a non-deployable branch context
- `BRANCH_NOT_FOUND`: requested branch id or git name does not exist in the resolved project
- `BRANCH_PROTECTED`: branch removal refused because the branch is the project's production or default branch
- `BRANCH_NOT_EMPTY`: branch removal refused because the branch still has live apps or databases
- `BRANCH_CASCADE_INCOMPLETE`: a --cascade branch removal failed partway; meta lists the resources already removed and the branch remains
- `COMPUTE_CONFIG_INVALID`: `prisma.compute.ts` failed to load or validate
- `COMPUTE_CONFIG_TARGET_REQUIRED`: a multi-app compute config needs an `[app]` target and none was given or inferred
- `COMPUTE_CONFIG_TARGET_UNKNOWN`: the `[app]` target matches no configured app
Expand Down
8 changes: 8 additions & 0 deletions docs/product/resource-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ Rules:
- every other named branch is a preview branch by default
- preview branches are disposable by default
- non-production branches can become durable later
- Branch creation is implicit (git-push automation and `app deploy`); the CLI
has no `branch create`
- `branch remove` removes a preview Branch with exact id confirmation; the
platform refuses production/default Branches outright, and plain removal
refuses Branches that still have live Apps or databases
- `branch remove --cascade` removes a preview Branch's Apps and databases with
it, with the blast radius listed explicitly; production/default Branches stay
refused regardless of flags
- `local` is local CLI context only, not a branch
- branch context comes from explicit targeting, Git, or safe command defaults,
not `prisma.config.ts`
Expand Down
89 changes: 89 additions & 0 deletions packages/cli/src/adapters/mock-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ interface BranchRecord {
projectId: string;
name: string;
role: "production" | "preview";
isDefault?: boolean;
currentDeploymentId: string | null;
}

Expand Down Expand Up @@ -279,6 +280,94 @@ export class MockApi {
);
}

cascadeBranchResources(branchId: string): {
apps: Array<{ id: string; name: string }>;
databases: Array<{ id: string; name: string }>;
} {
const branch = this.data.branches.find(
(candidate) => candidate.id === branchId,
);
if (!branch) {
return { apps: [], databases: [] };
}

const databases = (this.data.databases ?? []).filter(
(database) => database.branchId === branchId,
);
const databaseIds = new Set(databases.map((database) => database.id));
// The fixture has no standalone app records; deployments on the branch
// stand in for its apps.
const apps = this.data.deployments.filter(
(deployment) =>
deployment.projectId === branch.projectId &&
deployment.branch === branch.name,
);

this.data.databases = (this.data.databases ?? []).filter(
(database) => database.branchId !== branchId,
);
this.data.databaseConnections = (
this.data.databaseConnections ?? []
).filter((connection) => !databaseIds.has(connection.databaseId));
this.data.deployments = this.data.deployments.filter(
(deployment) =>
!(
deployment.projectId === branch.projectId &&
deployment.branch === branch.name
),
);

return {
apps: apps.map((deployment) => ({
id: deployment.id,
name: deployment.id,
})),
databases: databases.map((database) => ({
id: database.id,
name: database.name,
})),
};
}

removeBranch(
branchId: string,
):
| { outcome: "removed"; branch: BranchRecord }
| { outcome: "not-found" }
| { outcome: "protected" }
| { outcome: "not-empty" } {
const branch = this.data.branches.find(
(candidate) => candidate.id === branchId,
);
if (!branch) {
return { outcome: "not-found" };
}
// Mirrors the platform rule: production and default branches are
// protected outright.
if (branch.role === "production" || branch.isDefault) {
return { outcome: "protected" };
}

// Mirrors the platform rule: removal is refused while the branch still
// has live member resources.
const hasDatabases = (this.data.databases ?? []).some(
(database) => database.branchId === branchId,
);
const hasDeployments = this.data.deployments.some(
(deployment) =>
deployment.projectId === branch.projectId &&
deployment.branch === branch.name,
);
if (hasDatabases || hasDeployments) {
return { outcome: "not-empty" };
}

this.data.branches = this.data.branches.filter(
(candidate) => candidate.id !== branchId,
);
return { outcome: "removed", branch };
}

getDeployment(deploymentId: string): DeploymentRecord | undefined {
return this.data.deployments.find(
(deployment) => deployment.id === deploymentId,
Expand Down
11 changes: 9 additions & 2 deletions packages/cli/src/commands/app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -835,18 +835,25 @@ function createRemoveCommand(runtime: CliRuntime): Command {
"App target from prisma.compute.ts when the config defines multiple apps",
)
.addOption(new Option("--app <name>", "App name"))
.addOption(new Option("--project <id-or-name>", "Project id or name"));
.addOption(new Option("--project <id-or-name>", "Project id or name"))
.addOption(new Option("--branch <name>", "Branch name"));
addGlobalFlags(command);

command.action(async (configTarget: string | undefined, options) => {
const appName = (options as { app?: string }).app;
const projectRef = (options as { project?: string }).project;
const branchName = (options as { branch?: string }).branch;

await runCommand<AppRemoveResult>(
runtime,
"app.remove",
options as Record<string, unknown>,
(context) => runAppRemove(context, appName, projectRef, configTarget),
(context) =>
runAppRemove(context, appName, {
projectRef,
configTarget,
branchName,
}),
{
renderHuman: (context, descriptor, result) =>
renderAppRemove(context, descriptor, result),
Expand Down
56 changes: 52 additions & 4 deletions packages/cli/src/commands/branch/index.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import { Command } from "commander";
import { Command, Option } from "commander";

import { runBranchList } from "../../controllers/branch";
import { renderBranchList, serializeBranchList } from "../../presenters/branch";
import { runBranchList, runBranchRemove } from "../../controllers/branch";
import {
renderBranchList,
renderBranchRemove,
serializeBranchList,
serializeBranchRemove,
} from "../../presenters/branch";
import { attachCommandDescriptor } from "../../shell/command-meta";
import { runCommand } from "../../shell/command-runner";
import {
addCompactGlobalFlags,
addGlobalFlags,
} from "../../shell/global-flags";
import { type CliRuntime, configureRuntimeCommand } from "../../shell/runtime";
import type { BranchListResult } from "../../types/branch";
import type { BranchListResult, BranchRemoveResult } from "../../types/branch";

export function createBranchCommand(runtime: CliRuntime): Command {
const branch = attachCommandDescriptor(
Expand All @@ -20,10 +25,53 @@ export function createBranchCommand(runtime: CliRuntime): Command {
addCompactGlobalFlags(branch);

branch.addCommand(createBranchListCommand(runtime));
branch.addCommand(createBranchRemoveCommand(runtime));

return branch;
}

function createBranchRemoveCommand(runtime: CliRuntime): Command {
const command = attachCommandDescriptor(
configureRuntimeCommand(new Command("remove"), runtime),
"branch.remove",
);

command
.argument("<branch>", "Branch id or git name")
.addOption(new Option("--project <id-or-name>", "Project id or name"))
.addOption(
new Option("--confirm <branch-id>", "Exact branch id required to remove"),
)
.addOption(
new Option(
"--cascade",
"Also remove the branch's apps and databases (preview branches only)",
),
);
addGlobalFlags(command);

command.action(async (branchRef: string, options) => {
const projectRef = (options as { project?: string }).project;
const confirm = (options as { confirm?: string }).confirm;
const cascade = (options as { cascade?: boolean }).cascade;

await runCommand<BranchRemoveResult>(
runtime,
"branch.remove",
options as Record<string, unknown>,
(context) =>
runBranchRemove(context, branchRef, { projectRef, confirm, cascade }),
{
renderHuman: (context, descriptor, result) =>
renderBranchRemove(context, descriptor, result),
renderJson: (result) => serializeBranchRemove(result),
},
);
});

return command;
}

function createBranchListCommand(runtime: CliRuntime): Command {
const command = attachCommandDescriptor(
configureRuntimeCommand(new Command("list"), runtime),
Expand Down
14 changes: 12 additions & 2 deletions packages/cli/src/controllers/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -584,7 +584,7 @@
}

async function runSingleAppDeploy(
context: CommandContext,

Check notice on line 587 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).
appName: string | undefined,
options: AppDeployOptions | undefined,
preloadedConfig: LoadedComputeConfig | null,
Expand Down Expand Up @@ -1179,9 +1179,9 @@
: deployment.deployment.live,
},
},
warnings: [],
nextSteps: [],
};

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

View workflow job for this annotation

GitHub Actions / Lint

lint/style/noNestedTernary

Do not nest ternary expressions.
}

export async function runAppOpen(
Expand Down Expand Up @@ -1560,10 +1560,10 @@
context.runtime.signal,
);
current = await target.provider
.showDomain(current.id, { signal: context.runtime.signal })
.catch((error) => {
throw domainCommandError("wait", error, normalizedHostname);
});

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

View workflow job for this annotation

GitHub Actions / Lint

lint/performance/noAwaitInLoops

Avoid using await inside loops.
}
}

Expand Down Expand Up @@ -2041,9 +2041,13 @@
export async function runAppRemove(
context: CommandContext,
appName: string | undefined,
projectRef?: string,
configTarget?: string,
options: {
projectRef?: string;
configTarget?: string;
branchName?: string;
} = {},
): Promise<CommandSuccess<AppRemoveResult>> {
const { projectRef, configTarget, branchName } = options;
ensurePreviewAppMode(context);

const compute = await resolveComputeManagementContext(
Expand All @@ -2056,6 +2060,12 @@
await requireProviderAndProjectContext(context, projectRef, {
commandName: "app remove",
projectDir: compute.projectDir,
// Branch cleanup needs "remove this app from that branch" even when the
// branch is not checked out locally, so an explicit --branch is honored
// as-is like the other read-branch commands.
branch: branchName
? await resolveDeployBranch(context, branchName)
: undefined,
});
const apps = await listApps(context, provider, projectId, target.branch.name);
const selectedApp = await requireReleaseAppSelection(
Expand Down Expand Up @@ -2125,7 +2135,7 @@
const branch = resolveDomainBranch(options?.branchName);
if (toBranchKind(branch.name) !== "production") {
throw new CliError({
code: "BRANCH_NOT_DEPLOYABLE",

Check warning on line 2138 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.
domain: "branch",
summary: "Custom domains require the production branch",
why: `Custom domains on preview branch "${branch.name}" are not supported in Public Beta.`,
Expand Down Expand Up @@ -2258,7 +2268,7 @@
throw new CliError({
code: "DOMAIN_HOSTNAME_INVALID",
domain: "app",
summary: `Invalid custom domain "${hostname}"`,

Check warning on line 2271 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.
why: "Custom domains must be valid hostnames without protocol, path, wildcard, or port.",
fix: "Pass a hostname like shop.acme.com.",
exitCode: 2,
Expand Down Expand Up @@ -2293,13 +2303,13 @@
}

function sameDomainHostname(left: string, right: string): boolean {
return (

Check warning on line 2306 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.
left.trim().replace(/\.$/, "").toLowerCase() ===
right.trim().replace(/\.$/, "").toLowerCase()
);
}

function toAppDomainSummary(domain: DomainRecord): AppDomainSummary {

Check warning on line 2312 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 {
id: domain.id,
type: domain.type,
Expand Down Expand Up @@ -2388,7 +2398,7 @@
error: unknown,
hostname: string,
): CliError {
if (error instanceof DomainApiError) {

Check notice on line 2401 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).
if (
command === "add" &&
(error.status === 400 || error.status === 422) &&
Expand Down Expand Up @@ -2525,7 +2535,7 @@
}

function domainDnsNotConfiguredError(
hostname: string,

Check warning on line 2538 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.
error: DomainApiError,
): CliError {
const target = extractDomainDnsTarget(error);
Expand Down Expand Up @@ -2554,7 +2564,7 @@
}

function domainNotFoundError(hostname: string): CliError {
return new CliError({

Check warning on line 2567 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.
code: "DOMAIN_NOT_FOUND",
domain: "app",
summary: `Custom domain "${hostname}" not found`,
Expand Down Expand Up @@ -2590,7 +2600,7 @@
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.",

Check warning on line 2603 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.
["prisma-cli app domain wait shop.acme.com --timeout 15m"],
"app",
);
Expand All @@ -2606,11 +2616,11 @@
: unit === "s"
? 1000
: 1;
return amount * multiplier;
}

function readDomainWaitPollIntervalMs(context: CommandContext): number {
const raw = context.runtime.env.PRISMA_CLI_DOMAIN_WAIT_POLL_MS;

Check notice on line 2623 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 2623 in packages/cli/src/controllers/app.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/style/noNestedTernary

Do not nest ternary expressions.
if (!raw) {
return 5_000;
}
Expand Down Expand Up @@ -3384,7 +3394,7 @@
),
commandName: options?.commandName,
});
if (resolvedResult.isErr()) {

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

View workflow job for this annotation

GitHub Actions / Lint

lint/style/noNonNullAssertion

Forbidden non-null assertion.
throw projectResolutionErrorToCliError(resolvedResult.error);
}
const resolved = resolvedResult.value;
Expand Down Expand Up @@ -3418,7 +3428,7 @@
client: ManagementApiClient,
provider: ReturnType<typeof createAppProvider>,
explicitProject: string | undefined,
options: {

Check notice on line 3431 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).
branch?: ResolvedDeployBranch;
createProjectName?: string;
envProjectId?: string;
Expand Down Expand Up @@ -4093,7 +4103,7 @@
!configFile.exists &&
!hasAnyPackageDependency(packageJson, framework.detectPackages)
) {
continue;

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

View workflow job for this annotation

GitHub Actions / Lint

lint/performance/noAwaitInLoops

Avoid using await inside loops.
}

// Next.js standalone output gets a richer annotation; everything else is
Expand All @@ -4106,8 +4116,8 @@
: "detected from package.json";

return {
key: framework.key,
buildType: framework.buildType,

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

View workflow job for this annotation

GitHub Actions / Lint

lint/style/noNonNullAssertion

Forbidden non-null assertion.
displayName: framework.displayName,

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

View workflow job for this annotation

GitHub Actions / Lint

lint/style/noNestedTernary

Do not nest ternary expressions.
annotation,
};
Expand All @@ -4130,10 +4140,10 @@
exists: true,
standalone: /\boutput\s*:\s*["'`]standalone["'`]/.test(content),
path: filePath,
};

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

View workflow job for this annotation

GitHub Actions / Lint

lint/performance/noAwaitInLoops

Avoid using await inside loops.
} catch (error) {
if (signal.aborted) throw error;
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {

Check warning on line 4146 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.
throw error;
}
}
Expand Down
Loading
Loading