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
95 changes: 95 additions & 0 deletions docs/product/command-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,31 @@ prisma-cli project link proj_123
prisma-cli project link "Acme Dashboard" --json
```

## `prisma-cli project delete <name>`

Purpose:

- delete a Prisma Project permanently

Behavior:

- requires auth
- resolves project context without creating projects
- requires confirmation unless `-y` or `--yes` is passed
- deletes the Project from the platform
- removes the local `.prisma/local.json` pin when it matches the deleted Project
- does not delete Branch state, App deployments, or databases synchronously; server-side retention rules own that behavior
- fails with `PROJECT_NOT_FOUND` when the named Project does not exist
- fails with `PROJECT_DELETE_FAILED` when the platform rejects deletion

Examples:

```bash
prisma-cli project delete my-app
prisma-cli project delete proj_123 --yes
prisma-cli project delete "Acme Dashboard" --json
```

## `prisma-cli git connect [git-url]`

Purpose:
Expand Down Expand Up @@ -551,6 +576,76 @@ prisma-cli branch list
prisma-cli branch list --json
```

## `prisma-cli branch create <name>`

Purpose:

- create a new Platform branch in the resolved project

Behavior:

- requires auth
- resolves project context without creating projects
- creates a branch with the given name
- the new branch has `role=preview` by default
- fails with `BRANCH_ALREADY_EXISTS` when a branch with that name already exists
- fails with `BRANCH_CREATE_FAILED` when the platform rejects creation

Examples:

```bash
prisma-cli branch create feat-login
prisma-cli branch create feat-login --json
```

## `prisma-cli branch delete <name>`

Purpose:

- delete a Platform branch from the resolved project

Behavior:

- requires auth
- resolves project context without creating projects
- resolves the branch by name
- requires confirmation unless `-y` or `--yes` is passed
- refuses to delete the `production` branch
- fails with `BRANCH_NOT_FOUND` when the named branch does not exist
- fails with `BRANCH_DELETE_FAILED` when the platform rejects deletion

Examples:

```bash
prisma-cli branch delete feat-login
prisma-cli branch delete feat-login --yes
prisma-cli branch delete feat-login --json
```

## `prisma-cli branch rename <old-name> <new-name>`

Purpose:

- rename a Platform branch in the resolved project

Behavior:

- requires auth
- resolves project context without creating projects
- resolves the branch by old-name
- renames the branch to new-name
- refuses to rename the `production` branch
- fails with `BRANCH_NOT_FOUND` when the old-name branch does not exist
- fails with `BRANCH_ALREADY_EXISTS` when a branch with new-name already exists
- fails with `BRANCH_RENAME_FAILED` when the platform rejects the rename

Examples:

```bash
prisma-cli branch rename feat-login feat-auth
prisma-cli branch rename feat-login feat-auth --json
```

## `prisma-cli app build --entry <path> --build-type <auto|bun|nextjs|nuxt|astro|tanstack-start>`

Purpose:
Expand Down
29 changes: 21 additions & 8 deletions packages/cli/src/adapters/token-storage.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { CredentialsStore } from "@prisma/credentials-store";
import type { TokenStorage, Tokens } from "@prisma/management-api-sdk";
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
import { getAuthFilePath } from "../lib/auth/client";

Expand Down Expand Up @@ -62,11 +63,21 @@ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
export class FileTokenStorage implements TokenStorage {
private readonly credentialsStore: CredentialsStore;
private readonly lockFilePath: string;
private currentRefreshLockId: string | null = null;

constructor(env: NodeJS.ProcessEnv = process.env, private readonly signal?: AbortSignal) {
const authFilePath = getAuthFilePath(env);
this.credentialsStore = new CredentialsStore(authFilePath);
this.lockFilePath = `${authFilePath}.lock`;
process.on("exit", () => {
if (this.currentRefreshLockId) {
try {
fs.unlinkSync(this.lockFilePath);
} catch {
// Best-effort cleanup
}
}
});
Comment on lines +72 to +80
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Exit handler accumulates without cleanup.

Each FileTokenStorage instance registers a new exit listener in its constructor, but this listener is never removed. When multiple instances are created (as shown in tests with firstStorage and secondStorage), listeners accumulate. This can trigger Node's MaxListenersExceededWarning after 10 instances and keeps each instance referenced indefinitely.

Consider storing the handler reference and removing it when the instance is no longer needed, or use a module-level singleton pattern for the exit handler.

♻️ Proposed fix using module-level handler
+const activeStorages = new Set<FileTokenStorage>();
+let exitHandlerInstalled = false;
+
+function installExitHandler(): void {
+  if (exitHandlerInstalled) return;
+  exitHandlerInstalled = true;
+  process.on("exit", () => {
+    for (const storage of activeStorages) {
+      storage.cleanupLockSync();
+    }
+  });
+}
+
 export class FileTokenStorage implements TokenStorage {
   private readonly credentialsStore: CredentialsStore;
   private readonly lockFilePath: string;
   private currentRefreshLockId: string | null = null;
 
   constructor(env: NodeJS.ProcessEnv = process.env, private readonly signal?: AbortSignal) {
     const authFilePath = getAuthFilePath(env);
     this.credentialsStore = new CredentialsStore(authFilePath);
     this.lockFilePath = `${authFilePath}.lock`;
-    process.on("exit", () => {
-      if (this.currentRefreshLockId) {
-        try {
-          fs.unlinkSync(this.lockFilePath);
-        } catch {
-          // Best-effort cleanup
-        }
-      }
-    });
+    activeStorages.add(this);
+    installExitHandler();
+  }
+
+  cleanupLockSync(): void {
+    if (this.currentRefreshLockId) {
+      try {
+        fs.unlinkSync(this.lockFilePath);
+      } catch {
+        // Best-effort cleanup
+      }
+    }
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/adapters/token-storage.ts` around lines 72 - 80, The
constructor of FileTokenStorage registers a process.on("exit") listener that
closes the lock file but never removes the listener, causing accumulation;
update FileTokenStorage to either (A) register a single module-level exit
handler that cleans any instance locks (preferable) or (B) store the handler
function returned when adding the listener and expose a dispose/destroy method
on FileTokenStorage that calls process.removeListener("exit", handler) and
clears references; ensure you reference currentRefreshLockId and lockFilePath
inside the centralized handler or removed listener so each instance can be GC'd
when disposed.

}

async getTokens(): Promise<Tokens | null> {
Expand Down Expand Up @@ -124,14 +135,14 @@ export class FileTokenStorage implements TokenStorage {
const lockId = randomUUID();
this.signal?.throwIfAborted();
// mkdir does not accept AbortSignal; check before the filesystem boundary.
await fs.mkdir(path.dirname(this.lockFilePath), { recursive: true });
await fsp.mkdir(path.dirname(this.lockFilePath), { recursive: true });

while (true) {
this.signal?.throwIfAborted();
let lockFileCreated = false;
try {
// open does not accept AbortSignal; check before the filesystem boundary.
const handle = await fs.open(this.lockFilePath, "wx");
const handle = await fsp.open(this.lockFilePath, "wx");
lockFileCreated = true;
try {
this.signal?.throwIfAborted();
Expand All @@ -140,10 +151,11 @@ export class FileTokenStorage implements TokenStorage {
} finally {
await handle.close();
}
this.currentRefreshLockId = lockId;
return lockId;
} catch (error) {
if (lockFileCreated) {
await fs.unlink(this.lockFilePath).catch(() => undefined);
await fsp.unlink(this.lockFilePath).catch(() => undefined);
}
const code = (error as NodeJS.ErrnoException).code;
if (code !== "EEXIST") throw error;
Expand All @@ -161,24 +173,25 @@ export class FileTokenStorage implements TokenStorage {

private async getStaleRefreshLockId(): Promise<string | null> {
this.signal?.throwIfAborted();
const lockId = await fs.readFile(this.lockFilePath, { encoding: "utf8", signal: this.signal }).catch((error) => {
const lockId = await fsp.readFile(this.lockFilePath, { encoding: "utf8", signal: this.signal }).catch((error) => {
if (this.signal?.aborted) throw error;
return null;
});
if (lockId === null) return null;

this.signal?.throwIfAborted();
// stat does not accept AbortSignal; check before and after the filesystem boundary.
const stats = await fs.stat(this.lockFilePath).catch(() => null);
const stats = await fsp.stat(this.lockFilePath).catch(() => null);
this.signal?.throwIfAborted();
if (!stats) return null;
return Date.now() - stats.mtimeMs > 30_000 ? lockId : null;
}

private async releaseRefreshLock(lockId: string): Promise<void> {
const currentLockId = await fs.readFile(this.lockFilePath, { encoding: "utf8" }).catch(() => null);
const currentLockId = await fsp.readFile(this.lockFilePath, { encoding: "utf8" }).catch(() => null);
if (currentLockId !== lockId) return;
// unlink does not accept AbortSignal; refresh-lock cleanup must run even after cancellation.
await fs.unlink(this.lockFilePath).catch(() => {});
await fsp.unlink(this.lockFilePath).catch(() => {});
this.currentRefreshLockId = null;
}
}
76 changes: 73 additions & 3 deletions packages/cli/src/commands/branch/index.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,93 @@
import { Command } from "commander";

import { runBranchList } from "../../controllers/branch";
import { renderBranchList, serializeBranchList } from "../../presenters/branch";
import { runBranchList, runBranchCreate, runBranchDelete, runBranchRename } from "../../controllers/branch";
import { renderBranchList, renderBranchCreate, renderBranchDelete, renderBranchRename, serializeBranchList, serializeBranchCreate, serializeBranchDelete, serializeBranchRename } from "../../presenters/branch";
import { attachCommandDescriptor } from "../../shell/command-meta";
import { addCompactGlobalFlags, addGlobalFlags } from "../../shell/global-flags";
import { runCommand } from "../../shell/command-runner";
import { configureRuntimeCommand, type CliRuntime } from "../../shell/runtime";
import type { BranchListResult } from "../../types/branch";
import type { BranchListResult, BranchCreateResult, BranchDeleteResult, BranchRenameResult } from "../../types/branch";

export function createBranchCommand(runtime: CliRuntime): Command {
const branch = attachCommandDescriptor(configureRuntimeCommand(new Command("branch"), runtime), "branch");

addCompactGlobalFlags(branch);

branch.addCommand(createBranchListCommand(runtime));
branch.addCommand(createBranchCreateCommand(runtime));
branch.addCommand(createBranchDeleteCommand(runtime));
branch.addCommand(createBranchRenameCommand(runtime));

return branch;
}

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

command.argument("<name>", "Branch name");
addGlobalFlags(command);

command.action(async (name, options) => {
await runCommand<BranchCreateResult>(
runtime,
"branch.create",
options as Record<string, unknown>,
(context) => runBranchCreate(context, String(name)),
{
renderHuman: (context, descriptor, result) => renderBranchCreate(context, descriptor, result),
renderJson: (result) => serializeBranchCreate(result),
},
);
});

return command;
}

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

command.argument("<name>", "Branch name");
addGlobalFlags(command);

command.action(async (name, options) => {
await runCommand<BranchDeleteResult>(
runtime,
"branch.delete",
options as Record<string, unknown>,
(context) => runBranchDelete(context, String(name)),
{
renderHuman: (context, descriptor, result) => renderBranchDelete(context, descriptor, result),
renderJson: (result) => serializeBranchDelete(result),
},
);
});

return command;
}

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

command.argument("<old-name>", "Current branch name");
command.argument("<new-name>", "New branch name");
addGlobalFlags(command);

command.action(async (oldName, newName, options) => {
await runCommand<BranchRenameResult>(
runtime,
"branch.rename",
options as Record<string, unknown>,
(context) => runBranchRename(context, String(oldName), String(newName)),
{
renderHuman: (context, descriptor, result) => renderBranchRename(context, descriptor, result),
renderJson: (result) => serializeBranchRename(result),
},
);
});

return command;
}

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

Expand Down
29 changes: 27 additions & 2 deletions packages/cli/src/commands/project/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { Command } from "commander";

import { runProjectCreate, runProjectLink, runProjectList, runProjectShow } from "../../controllers/project";
import { runProjectCreate, runProjectDelete, runProjectLink, runProjectList, runProjectShow } from "../../controllers/project";
import {
renderProjectDelete,
renderProjectSetup,
renderProjectList,
renderProjectShow,
serializeProjectDelete,
serializeProjectSetup,
serializeProjectList,
serializeProjectShow,
Expand All @@ -13,7 +15,7 @@ import { attachCommandDescriptor } from "../../shell/command-meta";
import { addCompactGlobalFlags, addGlobalFlags } from "../../shell/global-flags";
import { runCommand } from "../../shell/command-runner";
import { configureRuntimeCommand, type CliRuntime } from "../../shell/runtime";
import type { ProjectListResult, ProjectSetupResult, ProjectShowResult } from "../../types/project";
import type { ProjectDeleteResult, ProjectListResult, ProjectSetupResult, ProjectShowResult } from "../../types/project";
import { createEnvCommand } from "../env";

export function createProjectCommand(runtime: CliRuntime): Command {
Expand All @@ -25,6 +27,7 @@ export function createProjectCommand(runtime: CliRuntime): Command {
project.addCommand(createProjectShowCommand(runtime));
project.addCommand(createProjectCreateCommand(runtime));
project.addCommand(createProjectLinkCommand(runtime));
project.addCommand(createProjectDeleteCommand(runtime));
project.addCommand(createEnvCommand(runtime));

return project;
Expand Down Expand Up @@ -74,6 +77,28 @@ function createProjectLinkCommand(runtime: CliRuntime): Command {
return command;
}

function createProjectDeleteCommand(runtime: CliRuntime): Command {
const command = attachCommandDescriptor(configureRuntimeCommand(new Command("delete"), runtime), "project.delete");

command.argument("<name>", "Project name or id");
addGlobalFlags(command);

command.action(async (name, options) => {
await runCommand<ProjectDeleteResult>(
runtime,
"project.delete",
options as Record<string, unknown>,
(context) => runProjectDelete(context, String(name)),
{
renderHuman: (context, descriptor, result) => renderProjectDelete(context, descriptor, result),
renderJson: (result) => serializeProjectDelete(result),
},
);
});

return command;
}

function createProjectListCommand(runtime: CliRuntime): Command {
const command = attachCommandDescriptor(configureRuntimeCommand(new Command("list"), runtime), "project.list");

Expand Down
Loading