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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions docs/product/command-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -2228,16 +2228,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, and every deployment it owns, from the resolved branch

Behavior:

- requires auth and project context
- resolves the selected app
- resolves the selected app on the resolved branch
- `--branch <name>` scopes the removal to that branch, honored as-is; an empty value is rejected rather than inferred
- without `--branch`, the branch is inferred (active Git branch, else the project's default production branch), so automation that must not touch production should always pass `--branch`
- requires confirmation unless `-y` or `--yes` is passed
- clears local selected app state when the removed app was selected

Expand All @@ -2246,4 +2248,5 @@ Examples:
```bash
prisma-cli app remove --app hello-world
prisma-cli app remove --app hello-world --yes
prisma-cli app remove --app hello-world --branch feature/foo --yes
```
7 changes: 5 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,21 @@ 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
22 changes: 22 additions & 0 deletions packages/cli/src/controllers/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -574,7 +574,7 @@
});
}

async function runSingleAppDeploy(

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

View workflow job for this annotation

GitHub Actions / Lint

lint/complexity/noExcessiveCognitiveComplexity

Excessive complexity of 19 detected (max: 15).
context: CommandContext,
appName: string | undefined,
options: AppDeployOptions | undefined,
Expand Down Expand Up @@ -1171,9 +1171,9 @@
...deployment.deployment,
live: providerLiveDeploymentId
? deployment.deployment.id === providerLiveDeploymentId
: knownLiveDeploymentId
? deployment.deployment.id === knownLiveDeploymentId
: deployment.deployment.live,

Check notice on line 1176 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 @@ -1552,10 +1552,10 @@
});
}

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

Check notice on line 1558 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 @@ -2035,11 +2035,18 @@
};
}

/**
* Removes an app and every deployment it owns in the resolved branch.
*
* @param branchName Scopes the removal to this branch. When omitted the branch
* is inferred from the local Git branch, falling back to the production branch.
*/
export async function runAppRemove(
context: CommandContext,
appName: string | undefined,
projectRef?: string,
configTarget?: string,
branchName?: string,
): Promise<CommandSuccess<AppRemoveResult>> {
ensurePreviewAppMode(context);

Expand All @@ -2049,8 +2056,23 @@
"remove",
);
appName = appName ?? compute.configAppName;
// A blank --branch must not fall through to inference, which can reach production.
if (branchName !== undefined && branchName.trim() === "") {
throw usageError(
"The --branch value cannot be empty",
"app remove scopes the removal to the given branch; an empty --branch would silently fall back to the inferred (possibly production) branch.",
"Pass a non-empty branch name, e.g. --branch <branch>, or omit --branch to use the inferred branch.",
["prisma-cli app remove --app <name> --branch <branch>"],
"app",
);
}
const branch =
branchName !== undefined
? await resolveDeployBranch(context, branchName)
: undefined;
const { provider, target, projectId } =
await requireProviderAndProjectContext(context, projectRef, {
branch,
commandName: "app remove",
projectDir: compute.projectDir,
});
Expand Down Expand Up @@ -2117,7 +2139,7 @@
const compute = await resolveComputeManagementContext(
context,
options?.configTarget,
commandName.replace(/^app /, ""),

Check warning on line 2142 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 @@ -2250,7 +2272,7 @@
}

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

Check warning on line 2275 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 @@ -2285,14 +2307,14 @@
}

return labels.every((label) =>
/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label),

Check warning on line 2310 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.
);
}

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

Check warning on line 2316 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 2317 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 @@ -2380,7 +2402,7 @@
}
}

function domainCommandError(

Check notice on line 2405 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 @@ -2517,7 +2539,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 2542 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 @@ -2582,7 +2604,7 @@
return 0;
}

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

Check warning on line 2607 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 @@ -2598,11 +2620,11 @@
const multiplier =
unit === "h"
? 60 * 60 * 1000
: unit === "m"
? 60 * 1000
: unit === "s"
? 1000
: 1;

Check notice on line 2627 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 2627 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 @@ -3377,7 +3399,7 @@
listProjects: () =>
listRealWorkspaceProjects(
client,
authState.workspace!,

Check warning on line 3402 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 @@ -3411,7 +3433,7 @@
};
}

async function resolveDeployProjectContext(

Check notice on line 3436 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 @@ -4095,7 +4117,7 @@
continue;
}

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

Check notice on line 4120 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 @@ -4108,8 +4130,8 @@
const annotation =
framework.key === "nextjs" && configFile.standalone
? "standalone output detected"
: configFile.exists
? `detected from ${path.basename(configFile.path!)}`

Check warning on line 4134 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 4135 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 @@ -4132,10 +4154,10 @@
const filePath = path.join(cwd, candidate);
signal.throwIfAborted();
try {
const content = await readFile(filePath, { encoding: "utf8", signal });

Check notice on line 4157 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 4160 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
3 changes: 2 additions & 1 deletion packages/cli/src/shell/command-meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -639,10 +639,11 @@ const DESCRIPTORS: CommandDescriptor[] = [
{
id: "app.remove",
path: ["prisma", "app", "remove"],
description: "Remove the app from the current branch",
description: "Remove the app from the resolved branch",
examples: [
"prisma-cli app remove --app hello-world",
"prisma-cli app remove --app hello-world --yes",
"prisma-cli app remove --app hello-world --branch feature/foo --yes",
],
},
{
Expand Down
203 changes: 203 additions & 0 deletions packages/cli/tests/app-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6761,6 +6761,209 @@ describe("app controller", () => {
).resolves.toBeNull();
});

it("remove scopes the app lookup to an explicit --branch", async () => {
const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient());
const listApps = vi.fn().mockResolvedValue([
{
id: "app_1",
name: "hello-world",
region: "eu-central-1",
liveDeploymentId: null,
},
]);
const removeApp = vi.fn().mockResolvedValue({
id: "app_1",
name: "hello-world",
});

vi.doMock("../src/lib/auth/guard", () => ({
requireComputeAuth,
}));
vi.doMock("../src/lib/app/app-provider", () => ({
createAppProvider: vi.fn(() =>
withBranchDatabaseProviderDefaults({
resolveBranch: createResolveBranch(),
createProject: vi.fn(),
listApps,
removeApp,
promoteDeployment: vi.fn(),
deployApp: vi.fn(),
listDeployments: vi.fn(),
showDeployment: vi.fn(),
}),
),
}));

const { createTempCwd, createTestCommandContext } = await import(
"./helpers"
);
const { runAppRemove } = await import("../src/controllers/app");
const cwd = await createTempCwd();
await writeLocalPin(cwd, { workspaceId: "ws_123", projectId: "proj_123" });
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 runAppRemove(context, "hello-world", undefined, undefined, "pr-42");

expect(listApps).toHaveBeenCalledWith(
"proj_123",
expect.objectContaining({ branchName: "pr-42" }),
);
expect(removeApp).toHaveBeenCalledWith("app_1", {
signal: context.runtime.signal,
});
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it("remove rejects an explicitly empty --branch instead of inferring a branch", async () => {
const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient());
const listApps = vi.fn();
const removeApp = vi.fn();

vi.doMock("../src/lib/auth/guard", () => ({
requireComputeAuth,
}));
vi.doMock("../src/lib/app/app-provider", () => ({
createAppProvider: vi.fn(() =>
withBranchDatabaseProviderDefaults({
resolveBranch: createResolveBranch(),
createProject: vi.fn(),
listApps,
removeApp,
promoteDeployment: vi.fn(),
deployApp: vi.fn(),
listDeployments: vi.fn(),
showDeployment: vi.fn(),
}),
),
}));

const { createTempCwd, createTestCommandContext } = await import(
"./helpers"
);
const { runAppRemove } = await import("../src/controllers/app");
const cwd = await createTempCwd();
await writeLocalPin(cwd, { workspaceId: "ws_123", projectId: "proj_123" });
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(
runAppRemove(context, "hello-world", undefined, undefined, " "),
).rejects.toThrow(/branch value cannot be empty/i);
expect(listApps).not.toHaveBeenCalled();
expect(removeApp).not.toHaveBeenCalled();
});

it("app remove registers a --branch option (regression guard for the CLI wiring)", async () => {
const { createAppCommand } = await import("../src/commands/app");
const { createTempCwd, createTestCommandContext } = await import(
"./helpers"
);
const cwd = await createTempCwd();
const { context } = await createTestCommandContext({
cwd,
stateDir: path.join(cwd, ".state"),
});

const app = createAppCommand(context.runtime);
const remove = app.commands.find((command) => command.name() === "remove");

expect(remove).toBeDefined();
expect(remove?.options.some((option) => option.long === "--branch")).toBe(
true,
);
});

it("app remove forwards --branch from the parsed command through to the provider", async () => {
const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient());
const listApps = vi.fn().mockResolvedValue([
{
id: "app_1",
name: "hello-world",
region: "eu-central-1",
liveDeploymentId: null,
},
]);
const removeApp = vi.fn().mockResolvedValue({
id: "app_1",
name: "hello-world",
});

vi.doMock("../src/lib/auth/guard", () => ({
requireComputeAuth,
}));
vi.doMock("../src/lib/app/app-provider", () => ({
createAppProvider: vi.fn(() =>
withBranchDatabaseProviderDefaults({
resolveBranch: createResolveBranch(),
createProject: vi.fn(),
listApps,
removeApp,
promoteDeployment: vi.fn(),
deployApp: vi.fn(),
listDeployments: vi.fn(),
showDeployment: vi.fn(),
}),
),
}));

const { createAppCommand } = await import("../src/commands/app");
const { createTempCwd, createTestCommandContext } = await import(
"./helpers"
);
const cwd = await createTempCwd();
await writeLocalPin(cwd, { workspaceId: "ws_123", projectId: "proj_123" });
const { context } = await createTestCommandContext({
cwd,
stateDir: path.join(cwd, ".state"),
flags: {
yes: true,
},
env: {
...process.env,
PRISMA_CLI_MOCK_FIXTURE_PATH: undefined,
},
});

const app = createAppCommand(context.runtime);
await app.parseAsync(
[
"remove",
"--app",
"hello-world",
"--branch",
"pr-42",
"--yes",
"--json",
],
{ from: "user" },
);

expect(listApps).toHaveBeenCalledWith(
"proj_123",
expect.objectContaining({ branchName: "pr-42" }),
);
});

it("remove prompts for confirmation in interactive mode", async () => {
const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient());
const listApps = vi.fn().mockResolvedValue([
Expand Down
5 changes: 4 additions & 1 deletion packages/cli/tests/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,11 +254,14 @@ describe("app commands", () => {

expect(removeHelp.exitCode).toBe(0);
expect(removeHelp.stderr).toContain(
"Remove the app from the current branch",
"Remove the app from the resolved branch",
);
expect(removeHelp.stderr).toContain(
"$ prisma-cli app remove --app hello-world",
);
expect(removeHelp.stderr).toContain(
"$ prisma-cli app remove --app hello-world --branch feature/foo --yes",
);
});

it("does not register legacy app env commands", async () => {
Expand Down
Loading