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

## `prisma-cli project rename <name> --project <id-or-name>`
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Purpose:

- rename the resolved Prisma Project

Behavior:

- requires auth
- renames the resolved Project; accepts `--project <id-or-name>` as an explicit fallback and otherwise uses the directory's durable Project binding
- requires a non-empty `<name>`
- renames the remote Project only; `.prisma/local.json` pins Project IDs, so existing directory bindings stay valid without rewrite
- returns the previous and new name
- does not mutate any other remote resource
- fails with `PROJECT_NOT_FOUND`, `PROJECT_AMBIGUOUS`, or `PROJECT_SETUP_REQUIRED` when the Project cannot be resolved safely
- fails with `PROJECT_RENAME_FAILED` when the platform rejects the rename

Examples:

```bash
prisma-cli project rename "Acme Dashboard v2"
prisma-cli project rename billing-api --project proj_123
prisma-cli project rename billing-api --json
```

## `prisma-cli project remove <project> --confirm <project-id>`

Purpose:

- remove a Prisma Project permanently

Behavior:

- requires auth
- resolves `<project>` by exact Project id or exact Project name inside the active workspace
- never defaults to the directory's bound Project: the positional target is required, because removal is destructive
- requires `--confirm <project-id>` where the value exactly matches the resolved Project id; `--yes` does not satisfy this confirmation
- removal is permanent: the Project's databases are deleted and its Apps stop being served
- fails with `PROJECT_REMOVE_BLOCKED` when the platform reports the Project still has active deployments; remove or tear down the Apps first
- when this directory's `.prisma/local.json` pin points at the removed Project, the pin is cleared and the result reports it
- fails with `PROJECT_NOT_FOUND` or `PROJECT_AMBIGUOUS` when the target cannot be selected safely

Examples:

```bash
prisma-cli project remove proj_123 --confirm proj_123
prisma-cli project remove "Old Sandbox" --confirm proj_456
prisma-cli project remove proj_123 --confirm proj_123 --json
```

## `prisma-cli project transfer <project> (--to-workspace <id-or-name> | --recipient-token <token>) --confirm <project-id>`

Purpose:

- transfer a Prisma Project to another workspace

Behavior:

- requires auth
- resolves `<project>` by exact Project id or exact Project name inside the active workspace; the positional target is required and never defaults to the directory's bound Project
- exactly one recipient source is required:
- `--to-workspace <id-or-name>` resolves a locally authenticated OAuth workspace, the same targets `auth workspace use` accepts, and authorizes the transfer with that workspace's stored session; this is the same-user path
- `--recipient-token <token>` passes an access token for the receiving workspace directly; this is the cross-account and headless path
- `--to-workspace` and `--recipient-token` are mutually exclusive; passing neither fails with `TRANSFER_RECIPIENT_REQUIRED`
- `--to-workspace` fails with `WORKSPACE_NOT_AUTHENTICATED` or `WORKSPACE_AMBIGUOUS` when no unique local OAuth session matches, and with `TRANSFER_RECIPIENT_UNAVAILABLE` when `PRISMA_SERVICE_TOKEN` is set, because service-token mode does not read local OAuth sessions
- requires `--confirm <project-id>` where the value exactly matches the resolved Project id; `--yes` does not satisfy this confirmation
- after the transfer the Project belongs to the recipient workspace and the source workspace loses access; Project, Branch, App, and database ids are unchanged
- when this directory's `.prisma/local.json` pin points at the transferred Project: with `--to-workspace` the pin's workspace id is rewritten to the recipient workspace, otherwise the pin is cleared; the result reports which happened
- fails with `PROJECT_TRANSFER_REJECTED` when the platform rejects the transfer, for example an invalid or expired recipient token
- fails with `PROJECT_NOT_FOUND` or `PROJECT_AMBIGUOUS` when the target cannot be selected safely

Examples:

```bash
prisma-cli project transfer proj_123 --to-workspace "Prisma Labs" --confirm proj_123
prisma-cli project transfer proj_123 --recipient-token <token> --confirm proj_123
prisma-cli project transfer proj_123 --to-workspace wksp_456 --confirm proj_123 --json
```

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

Purpose:
Expand Down
4 changes: 4 additions & 0 deletions docs/product/resource-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ Rules:
- Project setup is explicit: users choose an existing Project or explicitly create a new one before remote work starts
- `app deploy` may orchestrate Project setup, but it must not silently choose or create Project scope
- everything under a project happens in a branch
- `project rename` mutates only the remote Project name; pins bind by id and stay valid
- `project remove` and `project transfer` take an explicit positional Project target and exact id confirmation with `--confirm <project-id>`; they never default to the directory's bound Project and `--yes` is not sufficient
- removal is permanent and takes the Project's databases with it; transfer moves ownership to another workspace without changing resource ids
- when a destructive Project command invalidates this directory's local pin, the CLI cleans the pin up (clear on remove; rewrite or clear on transfer) and reports it

### Branch

Expand Down
7 changes: 7 additions & 0 deletions packages/cli/fixtures/mock-api.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@
"slug": "website",
"url": "https://prisma.build/prisma/website",
"workspaceId": "ws_456"
},
{
"id": "proj_999",
"name": "Sandbox",
"slug": "sandbox",
"url": "https://prisma.build/acme/sandbox",
"workspaceId": "ws_123"
}
],
"branches": [
Expand Down
74 changes: 74 additions & 0 deletions packages/cli/src/adapters/mock-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,10 @@ export class MockApi {
);
}

listWorkspaces(): WorkspaceRecord[] {
return this.data.workspaces;
}

getWorkspace(workspaceId: string): WorkspaceRecord | undefined {
return this.data.workspaces.find(
(workspace) => workspace.id === workspaceId,
Expand Down Expand Up @@ -168,6 +172,76 @@ export class MockApi {
);
}

renameProject(projectId: string, name: string): ProjectRecord | undefined {
const project = this.getProject(projectId);
if (!project) {
return undefined;
}

project.name = name;
return project;
}

removeProject(
projectId: string,
):
| { outcome: "removed"; project: ProjectRecord }
| { outcome: "not-found" }
| { outcome: "blocked" } {
const project = this.getProject(projectId);
if (!project) {
return { outcome: "not-found" };
}

// Mirrors the platform rule: removal is blocked while the project still
// has active deployments.
const hasDeployments = this.data.deployments.some(
(deployment) => deployment.projectId === projectId,
);
if (hasDeployments) {
return { outcome: "blocked" };
}

const removedDatabaseIds = new Set(
(this.data.databases ?? [])
.filter((database) => database.projectId === projectId)
.map((database) => database.id),
);

this.data.projects = this.data.projects.filter(
(candidate) => candidate.id !== projectId,
);
this.data.branches = this.data.branches.filter(
(branch) => branch.projectId !== projectId,
);
this.data.databases = (this.data.databases ?? []).filter(
(database) => database.projectId !== projectId,
);
this.data.databaseConnections = (
this.data.databaseConnections ?? []
).filter((connection) => !removedDatabaseIds.has(connection.databaseId));
return { outcome: "removed", project };
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

transferProject(
projectId: string,
targetWorkspaceId: string,
):
| { outcome: "transferred"; project: ProjectRecord }
| { outcome: "not-found" }
| { outcome: "workspace-not-found" } {
const project = this.getProject(projectId);
if (!project) {
return { outcome: "not-found" };
}
if (!this.getWorkspace(targetWorkspaceId)) {
return { outcome: "workspace-not-found" };
}

project.workspaceId = targetWorkspaceId;
return { outcome: "transferred", project };
}

listBranchesForProject(projectId: string): BranchRecord[] {
return this.data.branches.filter(
(branch) => branch.projectId === projectId,
Expand Down
61 changes: 61 additions & 0 deletions packages/cli/src/adapters/token-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ export interface FileTokenStorageOptions {
lockRetryMs?: number;
lockStaleMs?: number;
lockWaitTimeoutMs?: number;
/**
* Pin this storage view to one workspace's credentials. getTokens then
* ignores the active-workspace pointer, so an SDK built on a pinned view
* authenticates (and refreshes) as that workspace without touching the
* user's selected workspace.
*/
pinnedWorkspaceId?: string;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

const REFRESH_LOCK_RETRY_MS = 100;
Expand Down Expand Up @@ -178,6 +185,14 @@ export class FileTokenStorage implements TokenStorage {
try {
// CredentialsStore does not accept AbortSignal; check immediately before and after the boundary.
const credentials = await this.readCredentialsFromDisk();

if (this.options.pinnedWorkspaceId) {
return findTokensForWorkspace(
credentials,
this.options.pinnedWorkspaceId,
);
}

const context = await this.readAuthContext();

if (context.state.activeWorkspaceId) {
Expand Down Expand Up @@ -273,6 +288,13 @@ export class FileTokenStorage implements TokenStorage {
const credentials = await this.readCredentialsFromDisk();
const context = await this.ensureMigratedAuthContext(credentials);

return this.workspacesFromState(credentials, context);
}

private workspacesFromState(
credentials: StoredCredential[],
context: AuthContextReadResult,
): StoredAuthWorkspace[] {
return credentials
.map((credential) => storedCredentialToTokens(credential))
.filter((tokens): tokens is Tokens => tokens !== null)
Expand Down Expand Up @@ -317,6 +339,38 @@ export class FileTokenStorage implements TokenStorage {
return this.withRefreshLock(() => this.useWorkspaceUnlocked(workspaceRef));
}

/**
* Resolve a workspace ref (id, credential workspace id, or cached name)
* against the locally stored sessions without changing the active
* workspace. Read-only counterpart of useWorkspace: it reads the auth
* context as-is and never runs the legacy-state migration, so it writes
* nothing.
*/
async resolveWorkspace(workspaceRef: string): Promise<StoredAuthWorkspace> {
const ref = workspaceRef.trim();
if (!ref) {
throw new WorkspaceSelectionError("missing");
}

this.signal?.throwIfAborted();
const credentials = await this.readCredentialsFromDisk();
const context = await this.readAuthContext();
const workspaces = this.workspacesFromState(credentials, context);
const matches = workspaces.filter((workspace) =>
workspaceMatchesRef(workspace, ref),
);

if (matches.length === 0) {
throw new WorkspaceSelectionError("not-found", ref);
}

if (matches.length > 1) {
throw new WorkspaceSelectionError("ambiguous", ref, matches);
}

return matches[0];
}

private async useWorkspaceUnlocked(workspaceRef: string): Promise<{
previous: StoredAuthWorkspace | null;
selected: StoredAuthWorkspace;
Expand Down Expand Up @@ -581,6 +635,13 @@ export class FileTokenStorage implements TokenStorage {
}

private async maybeActivateWorkspaceId(workspaceId: string): Promise<void> {
// A pinned view is a per-workspace read/refresh surface; its token writes
// must never move the user's workspace selection, even when no active
// workspace is set.
if (this.options.pinnedWorkspaceId) {
return;
}

const context = await this.readAuthContext();
if (
this.options.activateOnSetTokens === false &&
Expand Down
Loading
Loading