Skip to content

Commit df4ad95

Browse files
feat(cli): add project rename, remove, and transfer (#107)
Completes the `project` group's remote lifecycle: rename, remove, and transfer. Spec-first (`command-spec.md` + `resource-model.md` updated in the same change). ```bash $ prisma-cli project rename "Acme Dashboard v2" ✔ Renamed. Directory bindings pin the project id, so they stay valid. $ prisma-cli project remove proj_999 --confirm proj_999 ✔ The project, its databases, and its apps were removed. This directory's local project binding was cleared. $ prisma-cli project transfer proj_123 --to-workspace "Prisma Labs" --confirm proj_123 ✔ The project now belongs to the recipient workspace. This directory's local project binding now points at the recipient workspace. ``` Design notes - The transfer API authorizes with a recipient *access token*, not a workspace id. `--to-workspace` resolves a locally stored OAuth session (same targets as `auth workspace use`) through a new workspace-pinned `FileTokenStorage` view that refreshes via the SDK and can never move the active-workspace selection; `--recipient-token` is the cross-account/headless path. Under `PRISMA_SERVICE_TOKEN`, `--to-workspace` fails with `TRANSFER_RECIPIENT_UNAVAILABLE`. - Remove and transfer take an explicit positional target (never the bound project) plus exact-id `--confirm`. - Local pin hygiene: remove clears a matching `.prisma/local.json`; transfer rewrites it to the recipient workspace when known, else clears it; both best-effort with warnings. - Structured codes: `PROJECT_REMOVE_BLOCKED` (active deployments), `PROJECT_TRANSFER_REJECTED`, `PROJECT_RENAME_FAILED`, `TRANSFER_RECIPIENT_REQUIRED`/`_UNAVAILABLE`, `CONFIRMATION_REQUIRED` with meta. Testing: 14 new integration tests, full suite green, and all three commands verified against the live Management API with scratch projects, including a real cross-workspace transfer (project visible in the recipient workspace, pin rewritten, cleaned up afterwards). Note: merge after #106 or rebase; both touch `mock-api.ts`/fixtures.
1 parent c14accc commit df4ad95

18 files changed

Lines changed: 1833 additions & 4 deletions

File tree

docs/product/command-spec.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -696,6 +696,85 @@ prisma-cli project link proj_123
696696
prisma-cli project link "Acme Dashboard" --json
697697
```
698698

699+
## `prisma-cli project rename <name> --project <id-or-name>`
700+
701+
Purpose:
702+
703+
- rename the resolved Prisma Project
704+
705+
Behavior:
706+
707+
- requires auth
708+
- renames the resolved Project; accepts `--project <id-or-name>` as an explicit fallback and otherwise uses the directory's durable Project binding
709+
- requires a non-empty `<name>`
710+
- renames the remote Project only; `.prisma/local.json` pins Project IDs, so existing directory bindings stay valid without rewrite
711+
- returns the previous and new name
712+
- does not mutate any other remote resource
713+
- fails with `PROJECT_NOT_FOUND`, `PROJECT_AMBIGUOUS`, or `PROJECT_SETUP_REQUIRED` when the Project cannot be resolved safely
714+
- fails with `PROJECT_RENAME_FAILED` when the platform rejects the rename
715+
716+
Examples:
717+
718+
```bash
719+
prisma-cli project rename "Acme Dashboard v2"
720+
prisma-cli project rename billing-api --project proj_123
721+
prisma-cli project rename billing-api --json
722+
```
723+
724+
## `prisma-cli project remove <project> --confirm <project-id>`
725+
726+
Purpose:
727+
728+
- remove a Prisma Project permanently
729+
730+
Behavior:
731+
732+
- requires auth
733+
- resolves `<project>` by exact Project id or exact Project name inside the active workspace
734+
- never defaults to the directory's bound Project: the positional target is required, because removal is destructive
735+
- requires `--confirm <project-id>` where the value exactly matches the resolved Project id; `--yes` does not satisfy this confirmation
736+
- removal is permanent: the Project's databases are deleted and its Apps stop being served
737+
- fails with `PROJECT_REMOVE_BLOCKED` when the platform reports the Project still has active deployments; remove or tear down the Apps first
738+
- when this directory's `.prisma/local.json` pin points at the removed Project, the pin is cleared and the result reports it
739+
- fails with `PROJECT_NOT_FOUND` or `PROJECT_AMBIGUOUS` when the target cannot be selected safely
740+
741+
Examples:
742+
743+
```bash
744+
prisma-cli project remove proj_123 --confirm proj_123
745+
prisma-cli project remove "Old Sandbox" --confirm proj_456
746+
prisma-cli project remove proj_123 --confirm proj_123 --json
747+
```
748+
749+
## `prisma-cli project transfer <project> (--to-workspace <id-or-name> | --recipient-token <token>) --confirm <project-id>`
750+
751+
Purpose:
752+
753+
- transfer a Prisma Project to another workspace
754+
755+
Behavior:
756+
757+
- requires auth
758+
- 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
759+
- exactly one recipient source is required:
760+
- `--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
761+
- `--recipient-token <token>` passes an access token for the receiving workspace directly; this is the cross-account and headless path
762+
- `--to-workspace` and `--recipient-token` are mutually exclusive; passing neither fails with `TRANSFER_RECIPIENT_REQUIRED`
763+
- `--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
764+
- requires `--confirm <project-id>` where the value exactly matches the resolved Project id; `--yes` does not satisfy this confirmation
765+
- after the transfer the Project belongs to the recipient workspace and the source workspace loses access; Project, Branch, App, and database ids are unchanged
766+
- 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
767+
- fails with `PROJECT_TRANSFER_REJECTED` when the platform rejects the transfer, for example an invalid or expired recipient token
768+
- fails with `PROJECT_NOT_FOUND` or `PROJECT_AMBIGUOUS` when the target cannot be selected safely
769+
770+
Examples:
771+
772+
```bash
773+
prisma-cli project transfer proj_123 --to-workspace "Prisma Labs" --confirm proj_123
774+
prisma-cli project transfer proj_123 --recipient-token <token> --confirm proj_123
775+
prisma-cli project transfer proj_123 --to-workspace wksp_456 --confirm proj_123 --json
776+
```
777+
699778
## `prisma-cli git connect [git-url]`
700779

701780
Purpose:

docs/product/error-conventions.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,12 @@ These codes are the minimum stable set for the MVP:
168168
- `PROJECT_SETUP_REQUIRED`
169169
- `PROJECT_LINK_TARGET_REQUIRED`
170170
- `PROJECT_CREATE_FAILED`
171+
- `PROJECT_RENAME_FAILED`
172+
- `PROJECT_REMOVE_BLOCKED`
173+
- `PROJECT_TRANSFER_REJECTED`
174+
- `PROJECT_API_ERROR`
175+
- `TRANSFER_RECIPIENT_REQUIRED`
176+
- `TRANSFER_RECIPIENT_UNAVAILABLE`
171177
- `PROJECT_NOT_FOUND`
172178
- `PROJECT_AMBIGUOUS`
173179
- `APP_AMBIGUOUS`
@@ -233,6 +239,12 @@ Recommended meanings:
233239
- `PROJECT_SETUP_REQUIRED`: command needs explicit or durable Project context before it can continue
234240
- `PROJECT_LINK_TARGET_REQUIRED`: `project link` needs the user to choose an existing Project or create a new one
235241
- `PROJECT_CREATE_FAILED`: Project creation failed before deployment or linking could continue
242+
- `PROJECT_RENAME_FAILED`: the platform rejected the new project name
243+
- `PROJECT_REMOVE_BLOCKED`: project removal is blocked while it still has active deployments
244+
- `PROJECT_TRANSFER_REJECTED`: the platform rejected the transfer, for example an invalid or expired recipient token
245+
- `PROJECT_API_ERROR`: project Management API request failed without a more specific CLI error code
246+
- `TRANSFER_RECIPIENT_REQUIRED`: project transfer needs --to-workspace or --recipient-token
247+
- `TRANSFER_RECIPIENT_UNAVAILABLE`: --to-workspace cannot resolve local OAuth sessions while PRISMA_SERVICE_TOKEN is set
236248
- `PROJECT_NOT_FOUND`: requested project does not exist or is not accessible
237249
- `PROJECT_AMBIGUOUS`: multiple safe project candidates matched
238250
- `APP_AMBIGUOUS`: multiple apps matched the inferred or explicit app target

docs/product/resource-model.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ Rules:
4242
- Project setup is explicit: users choose an existing Project or explicitly create a new one before remote work starts
4343
- `app deploy` may orchestrate Project setup, but it must not silently choose or create Project scope
4444
- everything under a project happens in a branch
45+
- `project rename` mutates only the remote Project name; pins bind by id and stay valid
46+
- `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
47+
- removal is permanent and takes the Project's databases with it; transfer moves ownership to another workspace without changing resource ids
48+
- 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
4549

4650
### Branch
4751

packages/cli/fixtures/mock-api.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,13 @@
6464
"slug": "website",
6565
"url": "https://prisma.build/prisma/website",
6666
"workspaceId": "ws_456"
67+
},
68+
{
69+
"id": "proj_999",
70+
"name": "Sandbox",
71+
"slug": "sandbox",
72+
"url": "https://prisma.build/acme/sandbox",
73+
"workspaceId": "ws_123"
6774
}
6875
],
6976
"branches": [

packages/cli/src/adapters/mock-api.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,10 @@ export class MockApi {
156156
);
157157
}
158158

159+
listWorkspaces(): WorkspaceRecord[] {
160+
return this.data.workspaces;
161+
}
162+
159163
getWorkspace(workspaceId: string): WorkspaceRecord | undefined {
160164
return this.data.workspaces.find(
161165
(workspace) => workspace.id === workspaceId,
@@ -190,6 +194,76 @@ export class MockApi {
190194
);
191195
}
192196

197+
renameProject(projectId: string, name: string): ProjectRecord | undefined {
198+
const project = this.getProject(projectId);
199+
if (!project) {
200+
return undefined;
201+
}
202+
203+
project.name = name;
204+
return project;
205+
}
206+
207+
removeProject(
208+
projectId: string,
209+
):
210+
| { outcome: "removed"; project: ProjectRecord }
211+
| { outcome: "not-found" }
212+
| { outcome: "blocked" } {
213+
const project = this.getProject(projectId);
214+
if (!project) {
215+
return { outcome: "not-found" };
216+
}
217+
218+
// Mirrors the platform rule: removal is blocked while the project still
219+
// has active deployments.
220+
const hasDeployments = this.data.deployments.some(
221+
(deployment) => deployment.projectId === projectId,
222+
);
223+
if (hasDeployments) {
224+
return { outcome: "blocked" };
225+
}
226+
227+
const removedDatabaseIds = new Set(
228+
(this.data.databases ?? [])
229+
.filter((database) => database.projectId === projectId)
230+
.map((database) => database.id),
231+
);
232+
233+
this.data.projects = this.data.projects.filter(
234+
(candidate) => candidate.id !== projectId,
235+
);
236+
this.data.branches = this.data.branches.filter(
237+
(branch) => branch.projectId !== projectId,
238+
);
239+
this.data.databases = (this.data.databases ?? []).filter(
240+
(database) => database.projectId !== projectId,
241+
);
242+
this.data.databaseConnections = (
243+
this.data.databaseConnections ?? []
244+
).filter((connection) => !removedDatabaseIds.has(connection.databaseId));
245+
return { outcome: "removed", project };
246+
}
247+
248+
transferProject(
249+
projectId: string,
250+
targetWorkspaceId: string,
251+
):
252+
| { outcome: "transferred"; project: ProjectRecord }
253+
| { outcome: "not-found" }
254+
| { outcome: "workspace-not-found" } {
255+
const project = this.getProject(projectId);
256+
if (!project) {
257+
return { outcome: "not-found" };
258+
}
259+
if (!this.getWorkspace(targetWorkspaceId)) {
260+
return { outcome: "workspace-not-found" };
261+
}
262+
263+
project.workspaceId = targetWorkspaceId;
264+
return { outcome: "transferred", project };
265+
}
266+
193267
listBranchesForProject(projectId: string): BranchRecord[] {
194268
return this.data.branches.filter(
195269
(branch) => branch.projectId === projectId,

packages/cli/src/adapters/token-storage.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@ export interface FileTokenStorageOptions {
4848
lockRetryMs?: number;
4949
lockStaleMs?: number;
5050
lockWaitTimeoutMs?: number;
51+
/**
52+
* Pin this storage view to one workspace's credentials. getTokens then
53+
* ignores the active-workspace pointer, so an SDK built on a pinned view
54+
* authenticates (and refreshes) as that workspace without touching the
55+
* user's selected workspace.
56+
*/
57+
pinnedWorkspaceId?: string;
5158
}
5259

5360
const REFRESH_LOCK_RETRY_MS = 100;
@@ -178,6 +185,14 @@ export class FileTokenStorage implements TokenStorage {
178185
try {
179186
// CredentialsStore does not accept AbortSignal; check immediately before and after the boundary.
180187
const credentials = await this.readCredentialsFromDisk();
188+
189+
if (this.options.pinnedWorkspaceId) {
190+
return findTokensForWorkspace(
191+
credentials,
192+
this.options.pinnedWorkspaceId,
193+
);
194+
}
195+
181196
const context = await this.readAuthContext();
182197

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

291+
return this.workspacesFromState(credentials, context);
292+
}
293+
294+
private workspacesFromState(
295+
credentials: StoredCredential[],
296+
context: AuthContextReadResult,
297+
): StoredAuthWorkspace[] {
276298
return credentials
277299
.map((credential) => storedCredentialToTokens(credential))
278300
.filter((tokens): tokens is Tokens => tokens !== null)
@@ -317,6 +339,38 @@ export class FileTokenStorage implements TokenStorage {
317339
return this.withRefreshLock(() => this.useWorkspaceUnlocked(workspaceRef));
318340
}
319341

342+
/**
343+
* Resolve a workspace ref (id, credential workspace id, or cached name)
344+
* against the locally stored sessions without changing the active
345+
* workspace. Read-only counterpart of useWorkspace: it reads the auth
346+
* context as-is and never runs the legacy-state migration, so it writes
347+
* nothing.
348+
*/
349+
async resolveWorkspace(workspaceRef: string): Promise<StoredAuthWorkspace> {
350+
const ref = workspaceRef.trim();
351+
if (!ref) {
352+
throw new WorkspaceSelectionError("missing");
353+
}
354+
355+
this.signal?.throwIfAborted();
356+
const credentials = await this.readCredentialsFromDisk();
357+
const context = await this.readAuthContext();
358+
const workspaces = this.workspacesFromState(credentials, context);
359+
const matches = workspaces.filter((workspace) =>
360+
workspaceMatchesRef(workspace, ref),
361+
);
362+
363+
if (matches.length === 0) {
364+
throw new WorkspaceSelectionError("not-found", ref);
365+
}
366+
367+
if (matches.length > 1) {
368+
throw new WorkspaceSelectionError("ambiguous", ref, matches);
369+
}
370+
371+
return matches[0];
372+
}
373+
320374
private async useWorkspaceUnlocked(workspaceRef: string): Promise<{
321375
previous: StoredAuthWorkspace | null;
322376
selected: StoredAuthWorkspace;
@@ -581,6 +635,13 @@ export class FileTokenStorage implements TokenStorage {
581635
}
582636

583637
private async maybeActivateWorkspaceId(workspaceId: string): Promise<void> {
638+
// A pinned view is a per-workspace read/refresh surface; its token writes
639+
// must never move the user's workspace selection, even when no active
640+
// workspace is set.
641+
if (this.options.pinnedWorkspaceId) {
642+
return;
643+
}
644+
584645
const context = await this.readAuthContext();
585646
if (
586647
this.options.activateOnSetTokens === false &&

0 commit comments

Comments
 (0)