From c7cd6ea90920a1cd1574e80a9bc15e96de22ac11 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Thu, 2 Jul 2026 14:27:01 +0530 Subject: [PATCH 1/5] feat(cli): add project rename, remove, and transfer Completes the project group's remote lifecycle: - project rename : renames the resolved Project (durable binding or --project); pins bind by id, so directory bindings stay valid - project remove --confirm : permanent removal with the exact-id confirmation convention; surfaces the platform's active-deployments block as PROJECT_REMOVE_BLOCKED and clears this directory's local pin when it pointed at the removed project - project transfer (--to-workspace | --recipient-token ) --confirm : moves a Project to another workspace. --to-workspace resolves a locally stored OAuth session (the auth workspace use targets) and authorizes the transfer with it, refreshing through the SDK via a workspace-pinned token storage view that never touches the active-workspace pointer. --recipient-token is the cross-account and headless path. A matching local pin is rewritten to the recipient workspace when known, otherwise cleared. Structured recovery codes for agents: PROJECT_RENAME_FAILED, PROJECT_REMOVE_BLOCKED, PROJECT_TRANSFER_REJECTED, TRANSFER_RECIPIENT_REQUIRED, TRANSFER_RECIPIENT_UNAVAILABLE, plus the existing workspace selection errors and CONFIRMATION_REQUIRED with expectedConfirm/receivedConfirm meta. Spec-first: command-spec.md and resource-model.md updated alongside the implementation. --- docs/product/command-spec.md | 79 +++ docs/product/resource-model.md | 4 + packages/cli/fixtures/mock-api.json | 7 + packages/cli/src/adapters/mock-api.ts | 65 +++ packages/cli/src/adapters/token-storage.ts | 42 ++ packages/cli/src/commands/project/index.ts | 137 ++++- packages/cli/src/controllers/project.ts | 567 +++++++++++++++++++ packages/cli/src/lib/auth/recipient.ts | 68 +++ packages/cli/src/lib/project/provider.ts | 176 ++++++ packages/cli/src/presenters/project.ts | 105 ++++ packages/cli/src/shell/command-meta.ts | 25 + packages/cli/src/types/project.ts | 27 + packages/cli/tests/project-mutations.test.ts | 400 +++++++++++++ packages/cli/tests/project-usecases.test.ts | 5 + packages/cli/tests/project.test.ts | 4 +- 15 files changed, 1708 insertions(+), 3 deletions(-) create mode 100644 packages/cli/src/lib/auth/recipient.ts create mode 100644 packages/cli/src/lib/project/provider.ts create mode 100644 packages/cli/tests/project-mutations.test.ts diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index 1db1105a..4150a180 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -696,6 +696,85 @@ prisma-cli project link proj_123 prisma-cli project link "Acme Dashboard" --json ``` +## `prisma-cli project rename --project ` + +Purpose: + +- rename the resolved Prisma Project + +Behavior: + +- requires auth +- renames the resolved Project; accepts `--project ` as an explicit fallback and otherwise uses the directory's durable Project binding +- requires a non-empty `` +- 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 --confirm ` + +Purpose: + +- remove a Prisma Project permanently + +Behavior: + +- requires auth +- resolves `` 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 ` 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 (--to-workspace | --recipient-token ) --confirm ` + +Purpose: + +- transfer a Prisma Project to another workspace + +Behavior: + +- requires auth +- resolves `` 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 ` 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 ` 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 ` 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 --confirm proj_123 +prisma-cli project transfer proj_123 --to-workspace wksp_456 --confirm proj_123 --json +``` + ## `prisma-cli git connect [git-url]` Purpose: diff --git a/docs/product/resource-model.md b/docs/product/resource-model.md index 643e14d8..db73619f 100644 --- a/docs/product/resource-model.md +++ b/docs/product/resource-model.md @@ -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 `; 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 diff --git a/packages/cli/fixtures/mock-api.json b/packages/cli/fixtures/mock-api.json index abe2791a..6749e8b6 100644 --- a/packages/cli/fixtures/mock-api.json +++ b/packages/cli/fixtures/mock-api.json @@ -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": [ diff --git a/packages/cli/src/adapters/mock-api.ts b/packages/cli/src/adapters/mock-api.ts index 7d851ddb..7bf2026e 100644 --- a/packages/cli/src/adapters/mock-api.ts +++ b/packages/cli/src/adapters/mock-api.ts @@ -156,6 +156,10 @@ export class MockApi { ); } + listWorkspaces(): WorkspaceRecord[] { + return this.data.workspaces; + } + getWorkspace(workspaceId: string): WorkspaceRecord | undefined { return this.data.workspaces.find( (workspace) => workspace.id === workspaceId, @@ -190,6 +194,67 @@ 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" }; + } + + 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, + ); + return { outcome: "removed", project }; + } + + 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, diff --git a/packages/cli/src/adapters/token-storage.ts b/packages/cli/src/adapters/token-storage.ts index 2619e745..68cc01b7 100644 --- a/packages/cli/src/adapters/token-storage.ts +++ b/packages/cli/src/adapters/token-storage.ts @@ -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; } const REFRESH_LOCK_RETRY_MS = 100; @@ -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) { @@ -317,6 +332,33 @@ 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. + */ + async resolveWorkspace(workspaceRef: string): Promise { + const ref = workspaceRef.trim(); + if (!ref) { + throw new WorkspaceSelectionError("missing"); + } + + const workspaces = await this.listWorkspaces(); + 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; diff --git a/packages/cli/src/commands/project/index.ts b/packages/cli/src/commands/project/index.ts index b0a96a2f..cadfd37b 100644 --- a/packages/cli/src/commands/project/index.ts +++ b/packages/cli/src/commands/project/index.ts @@ -1,18 +1,27 @@ -import { Command } from "commander"; +import { Command, Option } from "commander"; import { runProjectCreate, runProjectLink, runProjectList, + runProjectRemove, + runProjectRename, runProjectShow, + runProjectTransfer, } from "../../controllers/project"; import { renderProjectList, + renderProjectRemove, + renderProjectRename, renderProjectSetup, renderProjectShow, + renderProjectTransfer, serializeProjectList, + serializeProjectRemove, + serializeProjectRename, serializeProjectSetup, serializeProjectShow, + serializeProjectTransfer, } from "../../presenters/project"; import { attachCommandDescriptor } from "../../shell/command-meta"; import { runCommand } from "../../shell/command-runner"; @@ -23,8 +32,11 @@ import { import { type CliRuntime, configureRuntimeCommand } from "../../shell/runtime"; import type { ProjectListResult, + ProjectRemoveResult, + ProjectRenameResult, ProjectSetupResult, ProjectShowResult, + ProjectTransferResult, } from "../../types/project"; import { createEnvCommand } from "../env"; @@ -40,11 +52,134 @@ export function createProjectCommand(runtime: CliRuntime): Command { project.addCommand(createProjectShowCommand(runtime)); project.addCommand(createProjectCreateCommand(runtime)); project.addCommand(createProjectLinkCommand(runtime)); + project.addCommand(createProjectRenameCommand(runtime)); + project.addCommand(createProjectRemoveCommand(runtime)); + project.addCommand(createProjectTransferCommand(runtime)); project.addCommand(createEnvCommand(runtime)); return project; } +function createProjectRenameCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor( + configureRuntimeCommand(new Command("rename"), runtime), + "project.rename", + ); + + command + .argument("", "New project name") + .addOption(new Option("--project ", "Project id or name")); + addGlobalFlags(command); + + command.action(async (name: string, options) => { + const projectRef = (options as { project?: string }).project; + + await runCommand( + runtime, + "project.rename", + options as Record, + (context) => runProjectRename(context, name, { project: projectRef }), + { + renderHuman: (context, descriptor, result) => + renderProjectRename(context, descriptor, result), + renderJson: (result) => serializeProjectRename(result), + }, + ); + }); + + return command; +} + +function createProjectRemoveCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor( + configureRuntimeCommand(new Command("remove"), runtime), + "project.remove", + ); + + command + .argument("", "Project id or name") + .addOption( + new Option( + "--confirm ", + "Exact project id required to remove", + ), + ); + addGlobalFlags(command); + + command.action(async (projectRef: string, options) => { + const confirm = (options as { confirm?: string }).confirm; + + await runCommand( + runtime, + "project.remove", + options as Record, + (context) => runProjectRemove(context, projectRef, { confirm }), + { + renderHuman: (context, descriptor, result) => + renderProjectRemove(context, descriptor, result), + renderJson: (result) => serializeProjectRemove(result), + }, + ); + }); + + return command; +} + +function createProjectTransferCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor( + configureRuntimeCommand(new Command("transfer"), runtime), + "project.transfer", + ); + + command + .argument("", "Project id or name") + .addOption( + new Option( + "--to-workspace ", + "Locally authenticated workspace to receive the project", + ), + ) + .addOption( + new Option( + "--recipient-token ", + "Access token for the receiving workspace", + ), + ) + .addOption( + new Option( + "--confirm ", + "Exact project id required to transfer", + ), + ); + addGlobalFlags(command); + + command.action(async (projectRef: string, options) => { + const toWorkspace = (options as { toWorkspace?: string }).toWorkspace; + const recipientToken = (options as { recipientToken?: string }) + .recipientToken; + const confirm = (options as { confirm?: string }).confirm; + + await runCommand( + runtime, + "project.transfer", + options as Record, + (context) => + runProjectTransfer(context, projectRef, { + toWorkspace, + recipientToken, + confirm, + }), + { + renderHuman: (context, descriptor, result) => + renderProjectTransfer(context, descriptor, result), + renderJson: (result) => serializeProjectTransfer(result), + }, + ); + }); + + return command; +} + function createProjectCreateCommand(runtime: CliRuntime): Command { const command = attachCommandDescriptor( configureRuntimeCommand(new Command("create"), runtime), diff --git a/packages/cli/src/controllers/project.ts b/packages/cli/src/controllers/project.ts index c43a9de4..3dcddf9f 100644 --- a/packages/cli/src/controllers/project.ts +++ b/packages/cli/src/controllers/project.ts @@ -1,3 +1,6 @@ +import { unlink } from "node:fs/promises"; +import path from "node:path"; + import type { ManagementApiClient } from "@prisma/management-api-sdk"; import { matchError } from "better-result"; import open from "open"; @@ -7,13 +10,31 @@ import { parseGitHubRepositoryUrl, readGitOriginRemote, } from "../adapters/git"; +import { + FileTokenStorage, + WorkspaceSelectionError, +} from "../adapters/token-storage"; import { createAppProvider } from "../lib/app/app-provider"; +import { SERVICE_TOKEN_ENV_VAR } from "../lib/auth/client"; import { requireComputeAuth } from "../lib/auth/guard"; +import { + RecipientSessionInvalidError, + resolveRecipientWorkspaceSession, +} from "../lib/auth/recipient"; import { promptForProjectSetupChoice } from "../lib/project/interactive-setup"; import { + LOCAL_RESOLUTION_PIN_RELATIVE_PATH, type LocalResolutionPinReadError, readLocalResolutionPin, + writeLocalResolutionPin, } from "../lib/project/local-pin"; +import { + createManagementProjectProvider, + type ProjectProvider, + projectRemoveBlockedError, + projectRenameFailedError, + projectTransferRejectedError, +} from "../lib/project/provider"; import { buildProjectSetupNextActions, inferTargetName, @@ -39,6 +60,8 @@ import { CliError, featureUnavailableError, usageError, + workspaceAmbiguousError, + workspaceNotAuthenticatedError, workspaceRequiredError, } from "../shell/errors"; import type { CommandSuccess } from "../shell/output"; @@ -48,10 +71,13 @@ import type { AuthWorkspace } from "../types/auth"; import type { GitRepositoryConnection, ProjectListResult, + ProjectRemoveResult, + ProjectRenameResult, ProjectRepositoryConnectionResult, ProjectSetupResult, ProjectShowResult, ProjectSummary, + ProjectTransferResult, } from "../types/project"; import { createCliUseCaseGateways } from "../use-cases/create-cli-gateways"; import { createProjectUseCases } from "../use-cases/project"; @@ -491,6 +517,547 @@ async function projectLinkTargetRequiredError( }); } +export interface ProjectRenameOptions { + project?: string; +} + +export interface ProjectRemoveOptions { + confirm?: string; +} + +export interface ProjectTransferOptions { + toWorkspace?: string; + recipientToken?: string; + confirm?: string; +} + +export async function runProjectRename( + context: CommandContext, + newName: string, + options: ProjectRenameOptions, +): Promise> { + const authState = await requireAuthenticatedAuthState(context); + const workspace = authState.workspace; + if (!workspace) { + throw workspaceRequiredError(); + } + + const name = newName.trim(); + if (!isValidProjectSetupName(name)) { + throw projectSetupNameRequiredError("project rename"); + } + + const { provider, target } = await requireProjectCommandContext( + context, + workspace, + options.project, + "project rename", + ); + + const previousName = target.project.name; + const renamed = await provider.renameProject({ + projectId: target.project.id, + name, + signal: context.runtime.signal, + }); + + return { + command: "project.rename", + result: { + workspace, + project: renamed, + previousName, + }, + warnings: [], + nextSteps: [], + }; +} + +export async function runProjectRemove( + context: CommandContext, + projectRef: string, + options: ProjectRemoveOptions, +): Promise> { + const authState = await requireAuthenticatedAuthState(context); + const workspace = authState.workspace; + if (!workspace) { + throw workspaceRequiredError(); + } + + const { provider, projects } = await requireProjectMutationContext( + context, + workspace, + ); + const project = toProjectSummary( + resolveProjectForSetup(projectRef.trim(), projects, workspace), + ); + + requireProjectExactConfirmation({ + id: project.id, + confirm: options.confirm, + summary: "Confirm project removal", + why: "Removing a project is permanent, deletes its databases, and stops its apps, so it requires the exact project id.", + nextStep: `prisma-cli project remove ${project.id} --confirm ${project.id}`, + }); + + await provider.removeProject({ + projectId: project.id, + signal: context.runtime.signal, + }); + + const warnings: string[] = []; + const cleared = await cleanupLocalPinForProject(context, project.id, { + onError: (message) => warnings.push(message), + }); + + return { + command: "project.remove", + result: { + workspace, + project, + localPin: { cleared }, + }, + warnings, + nextSteps: [], + }; +} + +export async function runProjectTransfer( + context: CommandContext, + projectRef: string, + options: ProjectTransferOptions, +): Promise> { + const authState = await requireAuthenticatedAuthState(context); + const workspace = authState.workspace; + if (!workspace) { + throw workspaceRequiredError(); + } + + if (options.toWorkspace && options.recipientToken) { + throw usageError( + "Choose one transfer recipient source", + "--to-workspace and --recipient-token are mutually exclusive.", + "Pass either --to-workspace or --recipient-token .", + [ + "prisma-cli project transfer --to-workspace --confirm ", + ], + "project", + ); + } + if (!options.toWorkspace?.trim() && !options.recipientToken?.trim()) { + throw transferRecipientRequiredError(); + } + + const { provider, projects } = await requireProjectMutationContext( + context, + workspace, + ); + const project = toProjectSummary( + resolveProjectForSetup(projectRef.trim(), projects, workspace), + ); + + requireProjectExactConfirmation({ + id: project.id, + confirm: options.confirm, + summary: "Confirm project transfer", + why: "Transferring moves the project to another workspace and this workspace loses access, so it requires the exact project id.", + nextStep: `prisma-cli project transfer ${project.id} ${ + options.toWorkspace + ? `--to-workspace ${formatCommandArgument(options.toWorkspace)}` + : "--recipient-token " + } --confirm ${project.id}`, + }); + + const recipient = await resolveTransferRecipient(context, options); + await provider.transferProject({ + projectId: project.id, + recipientAccessToken: recipient.accessToken, + signal: context.runtime.signal, + }); + + const warnings: string[] = []; + const pinAction = await rewriteOrClearLocalPinForProject( + context, + project.id, + recipient.workspaceId, + { onError: (message) => warnings.push(message) }, + ); + + return { + command: "project.transfer", + result: { + workspace, + project, + recipient: { + workspaceId: recipient.workspaceId, + workspaceName: recipient.workspaceName, + source: recipient.source, + }, + localPin: { action: pinAction }, + }, + warnings, + nextSteps: options.toWorkspace + ? [ + `prisma-cli auth workspace use ${formatCommandArgument(options.toWorkspace)}`, + ] + : [], + }; +} + +interface ResolvedTransferRecipient { + accessToken: string; + workspaceId: string | null; + workspaceName: string | null; + source: "workspace-session" | "recipient-token"; +} + +async function resolveTransferRecipient( + context: CommandContext, + options: ProjectTransferOptions, +): Promise { + const recipientToken = options.recipientToken?.trim(); + if (recipientToken) { + return { + accessToken: recipientToken, + workspaceId: isRealMode(context) + ? null + : // Fixture convention: the recipient token is the target workspace id. + recipientToken, + workspaceName: null, + source: "recipient-token", + }; + } + + const workspaceRef = options.toWorkspace?.trim(); + if (!workspaceRef) { + throw transferRecipientRequiredError(); + } + + if (!isRealMode(context)) { + const workspaces = context.api.listWorkspaces(); + const matches = workspaces.filter( + (candidate) => + candidate.id === workspaceRef || + candidate.name.toLowerCase() === workspaceRef.toLowerCase(), + ); + if (matches.length === 0) { + throw workspaceNotAuthenticatedError(workspaceRef); + } + if (matches.length > 1) { + throw workspaceAmbiguousError( + workspaceRef, + matches.map((match) => ({ + id: match.id, + name: match.name, + credentialWorkspaceId: match.id, + })), + ); + } + return { + // Fixture transfers authorize by workspace id instead of a real token. + accessToken: matches[0]!.id, + workspaceId: matches[0]!.id, + workspaceName: matches[0]!.name, + source: "workspace-session", + }; + } + + if (context.runtime.env[SERVICE_TOKEN_ENV_VAR] !== undefined) { + throw transferRecipientUnavailableError(); + } + + try { + const session = await resolveRecipientWorkspaceSession( + workspaceRef, + context.runtime.env, + context.runtime.signal, + ); + return { + accessToken: session.accessToken, + workspaceId: session.workspace.id, + workspaceName: session.workspace.name, + source: "workspace-session", + }; + } catch (error) { + if (error instanceof WorkspaceSelectionError) { + if (error.reason === "ambiguous") { + throw workspaceAmbiguousError( + error.workspaceRef ?? workspaceRef, + error.matches.map((match) => ({ + id: match.id, + name: match.name, + credentialWorkspaceId: match.credentialWorkspaceId, + })), + ); + } + throw workspaceNotAuthenticatedError(error.workspaceRef ?? workspaceRef); + } + if (error instanceof RecipientSessionInvalidError) { + throw workspaceNotAuthenticatedError(error.workspaceRef); + } + throw error; + } +} + +interface ProjectMutationContext { + provider: ProjectProvider; + projects: ProjectCandidate[]; +} + +async function requireProjectMutationContext( + context: CommandContext, + workspace: AuthWorkspace, +): Promise { + if (isRealMode(context)) { + const client = await requireComputeAuth( + context.runtime.env, + context.runtime.signal, + ); + if (!client) { + throw authRequiredError(); + } + return { + provider: createManagementProjectProvider(client), + projects: await listRealWorkspaceProjects( + client, + workspace, + context.runtime.signal, + ), + }; + } + + return { + provider: createFixtureProjectProvider(context), + projects: listFixtureWorkspaceProjects(context, workspace), + }; +} + +async function requireProjectCommandContext( + context: CommandContext, + workspace: AuthWorkspace, + explicitProject: string | undefined, + commandName: string, +): Promise<{ provider: ProjectProvider; target: ResolvedProjectTarget }> { + const listProjects = async () => + isRealMode(context) + ? listRealWorkspaceProjects( + await requireProjectClient(context), + workspace, + context.runtime.signal, + ) + : listFixtureWorkspaceProjects(context, workspace); + + const targetResult = await resolveProjectTarget({ + context, + workspace, + explicitProject, + listProjects, + commandName, + }); + if (targetResult.isErr()) { + throw projectResolutionErrorToCliError(targetResult.error); + } + + const provider = isRealMode(context) + ? createManagementProjectProvider(await requireProjectClient(context)) + : createFixtureProjectProvider(context); + + return { provider, target: targetResult.value }; +} + +async function requireProjectClient( + context: CommandContext, +): Promise { + const client = await requireComputeAuth( + context.runtime.env, + context.runtime.signal, + ); + if (!client) { + throw authRequiredError(); + } + return client; +} + +function createFixtureProjectProvider( + context: CommandContext, +): ProjectProvider { + return { + async renameProject(options) { + const renamed = context.api.renameProject( + options.projectId, + options.name, + ); + if (!renamed) { + throw projectRenameFailedError(options.name, undefined); + } + return { + id: renamed.id, + name: renamed.name, + ...(renamed.url ? { url: renamed.url } : {}), + }; + }, + + async removeProject(options) { + const removed = context.api.removeProject(options.projectId); + if (removed.outcome === "blocked") { + throw projectRemoveBlockedError(options.projectId, undefined); + } + if (removed.outcome === "not-found") { + throw new CliError({ + code: "PROJECT_NOT_FOUND", + domain: "project", + summary: "Project not found", + why: `No project matched "${options.projectId}".`, + fix: "Pass a project id or name from prisma-cli project list.", + exitCode: 1, + nextSteps: ["prisma-cli project list"], + }); + } + }, + + async transferProject(options) { + const transferred = context.api.transferProject( + options.projectId, + options.recipientAccessToken, + ); + if (transferred.outcome !== "transferred") { + throw projectTransferRejectedError(options.projectId, undefined); + } + }, + }; +} + +function requireProjectExactConfirmation(options: { + id: string; + confirm: string | undefined; + summary: string; + why: string; + nextStep: string; +}): void { + if (options.confirm === options.id) { + return; + } + + throw new CliError({ + code: "CONFIRMATION_REQUIRED", + domain: "project", + summary: options.summary, + why: options.why, + fix: `Rerun with --confirm ${options.id}.`, + exitCode: 2, + nextSteps: [options.nextStep], + meta: { + expectedConfirm: options.id, + receivedConfirm: options.confirm ?? null, + }, + }); +} + +function transferRecipientRequiredError(): CliError { + return new CliError({ + code: "TRANSFER_RECIPIENT_REQUIRED", + domain: "project", + summary: "Transfer recipient required", + why: "Project transfer needs the receiving workspace.", + fix: "Pass --to-workspace for a locally authenticated workspace, or --recipient-token for a cross-account transfer.", + exitCode: 2, + nextSteps: [ + "prisma-cli auth workspace list", + "prisma-cli project transfer --to-workspace --confirm ", + ], + }); +} + +function transferRecipientUnavailableError(): CliError { + return new CliError({ + code: "TRANSFER_RECIPIENT_UNAVAILABLE", + domain: "project", + summary: "Local workspace sessions are unavailable", + why: `--to-workspace resolves locally stored OAuth sessions, but ${SERVICE_TOKEN_ENV_VAR} is set and service-token mode does not read them.`, + fix: "Pass --recipient-token with an access token for the receiving workspace, or unset the service token.", + exitCode: 1, + nextSteps: [ + "prisma-cli project transfer --recipient-token --confirm ", + ], + }); +} + +async function cleanupLocalPinForProject( + context: CommandContext, + projectId: string, + hooks: { onError: (message: string) => void }, +): Promise { + const pinResult = await readLocalResolutionPin( + context.runtime.cwd, + context.runtime.signal, + ); + if (pinResult.isErr()) { + return false; + } + const pin = pinResult.value; + if (pin.kind !== "present" || pin.pin.projectId !== projectId) { + return false; + } + + try { + await unlink( + path.join(context.runtime.cwd, LOCAL_RESOLUTION_PIN_RELATIVE_PATH), + ); + return true; + } catch { + hooks.onError( + `The local pin ${LOCAL_RESOLUTION_PIN_RELATIVE_PATH} points at the removed project but could not be deleted.`, + ); + return false; + } +} + +async function rewriteOrClearLocalPinForProject( + context: CommandContext, + projectId: string, + recipientWorkspaceId: string | null, + hooks: { onError: (message: string) => void }, +): Promise<"rewritten" | "cleared" | "none"> { + const pinResult = await readLocalResolutionPin( + context.runtime.cwd, + context.runtime.signal, + ); + if (pinResult.isErr()) { + return "none"; + } + const pin = pinResult.value; + if (pin.kind !== "present" || pin.pin.projectId !== projectId) { + return "none"; + } + + if (recipientWorkspaceId) { + const writeResult = await writeLocalResolutionPin( + context.runtime.cwd, + { workspaceId: recipientWorkspaceId, projectId }, + context.runtime.signal, + ); + if (writeResult.isOk()) { + return "rewritten"; + } + hooks.onError( + `The local pin ${LOCAL_RESOLUTION_PIN_RELATIVE_PATH} points at the transferred project but could not be rewritten.`, + ); + return "none"; + } + + try { + await unlink( + path.join(context.runtime.cwd, LOCAL_RESOLUTION_PIN_RELATIVE_PATH), + ); + return "cleared"; + } catch { + hooks.onError( + `The local pin ${LOCAL_RESOLUTION_PIN_RELATIVE_PATH} points at the transferred project but could not be cleared.`, + ); + return "none"; + } +} + export async function runGitConnect( context: CommandContext, gitUrl: string | undefined, diff --git a/packages/cli/src/lib/auth/recipient.ts b/packages/cli/src/lib/auth/recipient.ts new file mode 100644 index 00000000..fe946201 --- /dev/null +++ b/packages/cli/src/lib/auth/recipient.ts @@ -0,0 +1,68 @@ +import { createManagementApiSdk } from "@prisma/management-api-sdk"; + +import { + FileTokenStorage, + type StoredAuthWorkspace, +} from "../../adapters/token-storage"; +import { CLIENT_ID, getApiBaseUrl } from "./client"; + +export interface RecipientWorkspaceSession { + workspace: StoredAuthWorkspace; + accessToken: string; +} + +export class RecipientSessionInvalidError extends Error { + constructor(readonly workspaceRef: string) { + super( + `The stored session for workspace "${workspaceRef}" could not be validated.`, + ); + this.name = "RecipientSessionInvalidError"; + } +} + +/** + * Resolve a locally stored OAuth workspace session and return a validated + * access token for it, refreshing through the SDK when the stored token has + * expired. The active workspace pointer is never touched. + * + * Throws WorkspaceSelectionError when the ref does not match exactly one + * stored session, and RecipientSessionInvalidError when the session cannot + * be validated or refreshed. + */ +export async function resolveRecipientWorkspaceSession( + workspaceRef: string, + env: NodeJS.ProcessEnv = process.env, + signal?: AbortSignal, +): Promise { + const storage = new FileTokenStorage(env, signal); + const workspace = await storage.resolveWorkspace(workspaceRef); + + const pinnedStorage = new FileTokenStorage(env, signal, { + activateOnSetTokens: false, + // createManagementApiSdk wraps refresh writes in withRefreshLock; see + // requireComputeAuth for why setTokens must not re-acquire the lock. + lockSetTokens: false, + pinnedWorkspaceId: workspace.credentialWorkspaceId, + }); + + const sdk = createManagementApiSdk({ + clientId: CLIENT_ID, + redirectUri: "http://localhost:0/auth/callback", + tokenStorage: pinnedStorage, + apiBaseUrl: getApiBaseUrl(env), + }); + + // A cheap authenticated call proves the session works and triggers the + // SDK's refresh flow when the stored access token has expired. + const probe = await sdk.client.GET("/v1/workspaces", { signal }); + if (probe.error) { + throw new RecipientSessionInvalidError(workspaceRef); + } + + const tokens = await pinnedStorage.getTokens(); + if (!tokens) { + throw new RecipientSessionInvalidError(workspaceRef); + } + + return { workspace, accessToken: tokens.accessToken }; +} diff --git a/packages/cli/src/lib/project/provider.ts b/packages/cli/src/lib/project/provider.ts new file mode 100644 index 00000000..fd5d4223 --- /dev/null +++ b/packages/cli/src/lib/project/provider.ts @@ -0,0 +1,176 @@ +import type { ManagementApiClient } from "@prisma/management-api-sdk"; + +import { CliError } from "../../shell/errors"; +import type { ProjectSummary } from "../../types/project"; + +export interface ProjectProvider { + renameProject(options: { + projectId: string; + name: string; + signal?: AbortSignal; + }): Promise; + removeProject(options: { + projectId: string; + signal?: AbortSignal; + }): Promise; + transferProject(options: { + projectId: string; + recipientAccessToken: string; + signal?: AbortSignal; + }): Promise; +} + +interface RawApiErrorBody { + error?: { + code?: string; + message?: string; + hint?: string; + }; +} + +interface RawProjectRecord { + id: string; + name: string; + url?: string | null; +} + +export function createManagementProjectProvider( + client: ManagementApiClient, +): ProjectProvider { + return { + async renameProject(options) { + const result = await client.PATCH("/v1/projects/{id}", { + params: { + path: { id: options.projectId }, + }, + body: { + name: options.name, + }, + signal: options.signal, + }); + if (result.error || !result.data) { + throw projectRenameFailedError(options.name, result.error); + } + + const project = result.data.data as RawProjectRecord; + return { + id: project.id, + name: project.name, + ...(project.url ? { url: project.url } : {}), + }; + }, + + async removeProject(options) { + const result = await client.DELETE("/v1/projects/{id}", { + params: { + path: { id: options.projectId }, + }, + signal: options.signal, + }); + if (result.response?.status === 400) { + throw projectRemoveBlockedError(options.projectId, result.error); + } + if (result.error) { + throw projectApiError( + "Failed to remove project", + result.response, + result.error, + ); + } + }, + + async transferProject(options) { + const result = await client.POST("/v1/projects/{id}/transfer", { + params: { + path: { id: options.projectId }, + }, + body: { + recipientAccessToken: options.recipientAccessToken, + }, + signal: options.signal, + }); + if (result.response?.status === 400) { + throw projectTransferRejectedError(options.projectId, result.error); + } + if (result.error) { + throw projectApiError( + "Failed to transfer project", + result.response, + result.error, + ); + } + }, + }; +} + +export function projectRenameFailedError( + name: string, + error: RawApiErrorBody | undefined, +): CliError { + return new CliError({ + code: "PROJECT_RENAME_FAILED", + domain: "project", + summary: "Project rename failed", + why: error?.error?.message ?? `The platform rejected the name "${name}".`, + fix: + error?.error?.hint ?? + "Pass a different project name and retry the rename.", + exitCode: 1, + nextSteps: [], + }); +} + +export function projectRemoveBlockedError( + projectId: string, + error: RawApiErrorBody | undefined, +): CliError { + return new CliError({ + code: "PROJECT_REMOVE_BLOCKED", + domain: "project", + summary: "Project cannot be removed yet", + why: + error?.error?.message ?? + `Project "${projectId}" still has active deployments.`, + fix: "Remove the project's apps first, then retry the removal.", + exitCode: 1, + nextSteps: ["prisma-cli app remove --app "], + }); +} + +export function projectTransferRejectedError( + projectId: string, + error: RawApiErrorBody | undefined, +): CliError { + return new CliError({ + code: "PROJECT_TRANSFER_REJECTED", + domain: "project", + summary: "Project transfer was rejected", + why: + error?.error?.message ?? + `The platform rejected the transfer of project "${projectId}", for example because the recipient token is invalid or expired.`, + fix: "Check the recipient workspace session or token and retry the transfer.", + exitCode: 1, + nextSteps: [], + }); +} + +function projectApiError( + summary: string, + response: Response | undefined, + error: RawApiErrorBody | undefined, +): CliError { + const status = response?.status ?? 0; + return new CliError({ + code: error?.error?.code ?? "PROJECT_API_ERROR", + domain: "project", + summary, + why: + error?.error?.message ?? + `The Management API returned status ${status || "unknown"}.`, + fix: + error?.error?.hint ?? + "Re-run with --trace for the underlying API response details.", + exitCode: 1, + nextSteps: [], + }); +} diff --git a/packages/cli/src/presenters/project.ts b/packages/cli/src/presenters/project.ts index fa928fca..a160ff9e 100644 --- a/packages/cli/src/presenters/project.ts +++ b/packages/cli/src/presenters/project.ts @@ -15,9 +15,12 @@ import { import type { GitRepositoryConnection, ProjectListResult, + ProjectRemoveResult, + ProjectRenameResult, ProjectRepositoryConnectionResult, ProjectSetupResult, ProjectShowResult, + ProjectTransferResult, } from "../types/project"; import { renderResolvedProjectContextBlock } from "./verbose-context"; @@ -179,6 +182,108 @@ export function serializeProjectSetup(result: ProjectSetupResult) { return result; } +export function renderProjectRename( + context: CommandContext, + descriptor: CommandDescriptor, + result: ProjectRenameResult, +): string[] { + return renderMutate( + { + title: "Renaming project.", + descriptor, + context: [ + { key: "workspace", value: result.workspace.name }, + { key: "project", value: result.previousName }, + { key: "id", value: result.project.id, tone: "dim" }, + ], + operationDescription: "Renaming project", + operationCount: 1, + details: [ + `The project is now named "${result.project.name}". Directory bindings pin the project id, so they stay valid.`, + ], + }, + context.ui, + ); +} + +export function serializeProjectRename(result: ProjectRenameResult) { + return result; +} + +export function renderProjectRemove( + context: CommandContext, + descriptor: CommandDescriptor, + result: ProjectRemoveResult, +): string[] { + return renderMutate( + { + title: "Removing project.", + descriptor, + context: [ + { key: "workspace", value: result.workspace.name }, + { key: "project", value: result.project.name }, + { key: "id", value: result.project.id, tone: "dim" }, + ], + operationDescription: "Removing project", + operationCount: 1, + details: [ + "The project, its databases, and its apps were removed.", + ...(result.localPin.cleared + ? ["This directory's local project binding was cleared."] + : []), + ], + }, + context.ui, + ); +} + +export function serializeProjectRemove(result: ProjectRemoveResult) { + return result; +} + +export function renderProjectTransfer( + context: CommandContext, + descriptor: CommandDescriptor, + result: ProjectTransferResult, +): string[] { + return renderMutate( + { + title: "Transferring project.", + descriptor, + context: [ + { key: "workspace", value: result.workspace.name }, + { key: "project", value: result.project.name }, + { key: "id", value: result.project.id, tone: "dim" }, + { + key: "recipient", + value: + result.recipient.workspaceName ?? + result.recipient.workspaceId ?? + "workspace of the provided recipient token", + }, + ], + operationDescription: "Transferring project", + operationCount: 1, + details: [ + "The project now belongs to the recipient workspace; this workspace no longer has access.", + ...(result.localPin.action === "rewritten" + ? [ + "This directory's local project binding now points at the recipient workspace.", + ] + : []), + ...(result.localPin.action === "cleared" + ? ["This directory's local project binding was cleared."] + : []), + ], + }, + context.ui, + ); +} + +export function serializeProjectTransfer(result: ProjectTransferResult) { + return result; +} + export function renderGitConnect( context: CommandContext, descriptor: CommandDescriptor, diff --git a/packages/cli/src/shell/command-meta.ts b/packages/cli/src/shell/command-meta.ts index 4db1f4d4..3091b284 100644 --- a/packages/cli/src/shell/command-meta.ts +++ b/packages/cli/src/shell/command-meta.ts @@ -242,6 +242,31 @@ const DESCRIPTORS: CommandDescriptor[] = [ 'prisma-cli project link "Acme Dashboard" --json', ], }, + { + id: "project.rename", + path: ["prisma", "project", "rename"], + description: "Rename the resolved Project", + examples: [ + 'prisma-cli project rename "Acme Dashboard v2"', + "prisma-cli project rename billing-api --project proj_123", + ], + }, + { + id: "project.remove", + path: ["prisma", "project", "remove"], + description: "Remove a Project permanently after exact id confirmation", + examples: ["prisma-cli project remove proj_123 --confirm proj_123"], + }, + { + id: "project.transfer", + path: ["prisma", "project", "transfer"], + description: + "Transfer a Project to another workspace after exact id confirmation", + examples: [ + 'prisma-cli project transfer proj_123 --to-workspace "Prisma Labs" --confirm proj_123', + "prisma-cli project transfer proj_123 --recipient-token --confirm proj_123", + ], + }, { id: "git.connect", path: ["prisma", "git", "connect"], diff --git a/packages/cli/src/types/project.ts b/packages/cli/src/types/project.ts index f3582a0c..fa424e3a 100644 --- a/packages/cli/src/types/project.ts +++ b/packages/cli/src/types/project.ts @@ -77,6 +77,33 @@ export interface ProjectSetupResult { action: "created" | "linked"; } +export interface ProjectRenameResult { + workspace: AuthWorkspace; + project: ProjectSummary; + previousName: string; +} + +export interface ProjectRemoveResult { + workspace: AuthWorkspace; + project: ProjectSummary; + localPin: { + cleared: boolean; + }; +} + +export interface ProjectTransferResult { + workspace: AuthWorkspace; + project: ProjectSummary; + recipient: { + workspaceId: string | null; + workspaceName: string | null; + source: "workspace-session" | "recipient-token"; + }; + localPin: { + action: "rewritten" | "cleared" | "none"; + }; +} + export interface GitRepositoryConnection { id: string | null; provider: "github"; diff --git a/packages/cli/tests/project-mutations.test.ts b/packages/cli/tests/project-mutations.test.ts new file mode 100644 index 00000000..663e7ef6 --- /dev/null +++ b/packages/cli/tests/project-mutations.test.ts @@ -0,0 +1,400 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +import { createTempCwd, executeCli } from "./helpers"; + +const fixturePath = path.resolve("fixtures/mock-api.json"); + +async function login(cwd: string, stateDir: string) { + await executeCli({ + argv: ["auth", "login", "--provider", "github", "--user", "usr_456"], + cwd, + stateDir, + fixturePath, + }); +} + +async function writeLocalPin(cwd: string, projectId = "proj_123") { + await mkdir(path.join(cwd, ".prisma"), { recursive: true }); + await writeFile( + path.join(cwd, ".prisma/local.json"), + `${JSON.stringify({ workspaceId: "ws_123", projectId }, null, 2)}\n`, + "utf8", + ); +} + +async function setupLinkedProject(projectId = "proj_123") { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + await login(cwd, stateDir); + await writeLocalPin(cwd, projectId); + return { cwd, stateDir }; +} + +async function readPinFile(cwd: string): Promise { + try { + return await readFile(path.join(cwd, ".prisma/local.json"), "utf8"); + } catch { + return null; + } +} + +describe("project rename", () => { + it("renames the bound project and reports the previous name", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: ["project", "rename", "Acme Dashboard v2", "--json"], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(0); + expect(payload).toMatchObject({ + ok: true, + command: "project.rename", + result: { + project: { + id: "proj_123", + name: "Acme Dashboard v2", + }, + previousName: "Acme Dashboard", + }, + }); + }); + + it("renames an explicit --project target", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: [ + "project", + "rename", + "billing-core", + "--project", + "proj_456", + "--json", + ], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(0); + expect(payload.result).toMatchObject({ + project: { id: "proj_456", name: "billing-core" }, + previousName: "Billing API", + }); + }); + + it("rejects an empty project name", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: ["project", "rename", " ", "--json"], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(2); + expect(payload).toMatchObject({ + ok: false, + command: "project.rename", + }); + }); +}); + +describe("project remove", () => { + it("requires exact project id confirmation", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: ["project", "remove", "Sandbox", "--confirm", "Sandbox", "--json"], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(2); + expect(payload).toMatchObject({ + ok: false, + command: "project.remove", + error: { + code: "CONFIRMATION_REQUIRED", + domain: "project", + meta: { + expectedConfirm: "proj_999", + receivedConfirm: "Sandbox", + }, + }, + }); + }); + + it("blocks removal while the project has deployments", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: [ + "project", + "remove", + "proj_123", + "--confirm", + "proj_123", + "--json", + ], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(1); + expect(payload).toMatchObject({ + ok: false, + command: "project.remove", + error: { code: "PROJECT_REMOVE_BLOCKED" }, + }); + }); + + it("removes a project without deployments and keeps an unrelated pin", async () => { + const { cwd, stateDir } = await setupLinkedProject("proj_123"); + + const result = await executeCli({ + argv: [ + "project", + "remove", + "proj_999", + "--confirm", + "proj_999", + "--json", + ], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(0); + expect(payload).toMatchObject({ + ok: true, + command: "project.remove", + result: { + project: { id: "proj_999", name: "Sandbox" }, + localPin: { cleared: false }, + }, + }); + expect(await readPinFile(cwd)).toContain("proj_123"); + }); + + it("clears the local pin when it points at the removed project", async () => { + const { cwd, stateDir } = await setupLinkedProject("proj_999"); + + const result = await executeCli({ + argv: [ + "project", + "remove", + "proj_999", + "--confirm", + "proj_999", + "--json", + ], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(0); + expect(payload.result.localPin).toMatchObject({ cleared: true }); + expect(await readPinFile(cwd)).toBeNull(); + }); + + it("fails with PROJECT_NOT_FOUND for unknown targets", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: ["project", "remove", "nope", "--confirm", "nope", "--json"], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).not.toBe(0); + expect(payload.error.code).toBe("PROJECT_NOT_FOUND"); + }); +}); + +describe("project transfer", () => { + it("requires a recipient source", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: [ + "project", + "transfer", + "proj_123", + "--confirm", + "proj_123", + "--json", + ], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(2); + expect(payload.error.code).toBe("TRANSFER_RECIPIENT_REQUIRED"); + }); + + it("rejects passing both recipient sources", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: [ + "project", + "transfer", + "proj_123", + "--to-workspace", + "ws_456", + "--recipient-token", + "tok", + "--confirm", + "proj_123", + "--json", + ], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(2); + expect(payload.error.code).toBe("USAGE_ERROR"); + }); + + it("requires exact project id confirmation", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: [ + "project", + "transfer", + "proj_123", + "--to-workspace", + "ws_456", + "--json", + ], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(2); + expect(payload).toMatchObject({ + ok: false, + error: { + code: "CONFIRMATION_REQUIRED", + domain: "project", + meta: { expectedConfirm: "proj_123" }, + }, + }); + }); + + it("transfers to a workspace and rewrites the matching local pin", async () => { + const { cwd, stateDir } = await setupLinkedProject("proj_123"); + + const result = await executeCli({ + argv: [ + "project", + "transfer", + "proj_123", + "--to-workspace", + "Prisma Labs", + "--confirm", + "proj_123", + "--json", + ], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(0); + expect(payload).toMatchObject({ + ok: true, + command: "project.transfer", + result: { + project: { id: "proj_123" }, + recipient: { + workspaceId: "ws_456", + workspaceName: "Prisma Labs", + source: "workspace-session", + }, + localPin: { action: "rewritten" }, + }, + }); + const pin = await readPinFile(cwd); + expect(pin).toContain("ws_456"); + expect(pin).toContain("proj_123"); + }); + + it("transfers with a recipient token", async () => { + const { cwd, stateDir } = await setupLinkedProject("proj_123"); + + const result = await executeCli({ + argv: [ + "project", + "transfer", + "proj_123", + "--recipient-token", + "ws_456", + "--confirm", + "proj_123", + "--json", + ], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).toBe(0); + expect(payload.result.recipient.source).toBe("recipient-token"); + expect(payload.result.localPin.action).toBe("rewritten"); + }); + + it("fails when the target workspace is unknown", async () => { + const { cwd, stateDir } = await setupLinkedProject(); + + const result = await executeCli({ + argv: [ + "project", + "transfer", + "proj_123", + "--to-workspace", + "Nowhere Inc", + "--confirm", + "proj_123", + "--json", + ], + cwd, + stateDir, + fixturePath, + }); + const payload = JSON.parse(result.stdout); + + expect(result.exitCode).not.toBe(0); + expect(payload.error.code).toBe("WORKSPACE_NOT_AUTHENTICATED"); + }); +}); diff --git a/packages/cli/tests/project-usecases.test.ts b/packages/cli/tests/project-usecases.test.ts index d62f5baa..fba604f8 100644 --- a/packages/cli/tests/project-usecases.test.ts +++ b/packages/cli/tests/project-usecases.test.ts @@ -37,6 +37,11 @@ describe("project use cases", () => { name: "Billing API", url: "https://prisma.build/acme/billing-api", }, + { + id: "proj_999", + name: "Sandbox", + url: "https://prisma.build/acme/sandbox", + }, ], }); }); diff --git a/packages/cli/tests/project.test.ts b/packages/cli/tests/project.test.ts index b30b2fc0..3a347de0 100644 --- a/packages/cli/tests/project.test.ts +++ b/packages/cli/tests/project.test.ts @@ -133,7 +133,7 @@ describe("project commands", () => { expect(result.exitCode).toBe(0); expect(result.stdout).toBe(""); expect(result.stderr).toBe( - "project list → Listing projects for the authenticated workspace.\n\n│ workspace: Acme Inc\n│\n│ name id\n│ Acme Dashboard proj_123\n│ Billing API proj_456\n\nNext steps:\n- Link an existing Project you choose: prisma-cli project link \n- Create a new Project: prisma-cli project create \n", + "project list → Listing projects for the authenticated workspace.\n\n│ workspace: Acme Inc\n│\n│ name id\n│ Acme Dashboard proj_123\n│ Billing API proj_456\n│ Sandbox proj_999\n\nNext steps:\n- Link an existing Project you choose: prisma-cli project link \n- Create a new Project: prisma-cli project create \n", ); }); @@ -287,7 +287,7 @@ describe("project commands", () => { stateDir, fixturePath, isTTY: true, - stdinText: "\u001B[B\u001B[B\u001B[B\r", + stdinText: "\u001B[B\u001B[B\u001B[B\u001B[B\r", }); const stderr = stripAnsi(result.stderr); From 7029cc23fe0ef8156c0ec995a2273d583ba127d2 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Thu, 2 Jul 2026 15:28:14 +0530 Subject: [PATCH 2/5] fix(cli): address review feedback on project commands - resolveWorkspace no longer runs the legacy auth-context migration, so it is genuinely read-only, and pinned token-storage views skip workspace activation entirely, even when no active workspace is set; the transfer contract no longer depends on call order - renameProject maps only 400/422 to PROJECT_RENAME_FAILED and surfaces other API failures (401/403/5xx) through the generic error passthrough instead of misreporting them as a rejected name - requireProjectCommandContext builds the ManagementApiClient once per invocation; requireProjectMutationContext reuses requireProjectClient - mock removeProject also deletes the removed databases' connections - drop a redundant non-null assertion in resolveWorkspace --- packages/cli/src/adapters/mock-api.ts | 9 ++++++++ packages/cli/src/adapters/token-storage.ts | 25 +++++++++++++++++++--- packages/cli/src/controllers/project.ts | 22 ++++++------------- packages/cli/src/lib/project/provider.ts | 10 ++++++++- 4 files changed, 47 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/adapters/mock-api.ts b/packages/cli/src/adapters/mock-api.ts index 7bf2026e..8b43bc7e 100644 --- a/packages/cli/src/adapters/mock-api.ts +++ b/packages/cli/src/adapters/mock-api.ts @@ -224,6 +224,12 @@ export class MockApi { 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, ); @@ -233,6 +239,9 @@ export class MockApi { 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 }; } diff --git a/packages/cli/src/adapters/token-storage.ts b/packages/cli/src/adapters/token-storage.ts index 68cc01b7..7435d5fd 100644 --- a/packages/cli/src/adapters/token-storage.ts +++ b/packages/cli/src/adapters/token-storage.ts @@ -288,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) @@ -335,7 +342,9 @@ export class FileTokenStorage implements TokenStorage { /** * 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. + * 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 { const ref = workspaceRef.trim(); @@ -343,7 +352,10 @@ export class FileTokenStorage implements TokenStorage { throw new WorkspaceSelectionError("missing"); } - const workspaces = await this.listWorkspaces(); + 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), ); @@ -356,7 +368,7 @@ export class FileTokenStorage implements TokenStorage { throw new WorkspaceSelectionError("ambiguous", ref, matches); } - return matches[0]!; + return matches[0]; } private async useWorkspaceUnlocked(workspaceRef: string): Promise<{ @@ -623,6 +635,13 @@ export class FileTokenStorage implements TokenStorage { } private async maybeActivateWorkspaceId(workspaceId: string): Promise { + // 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 && diff --git a/packages/cli/src/controllers/project.ts b/packages/cli/src/controllers/project.ts index 3dcddf9f..b2e9683e 100644 --- a/packages/cli/src/controllers/project.ts +++ b/packages/cli/src/controllers/project.ts @@ -809,13 +809,7 @@ async function requireProjectMutationContext( workspace: AuthWorkspace, ): Promise { if (isRealMode(context)) { - const client = await requireComputeAuth( - context.runtime.env, - context.runtime.signal, - ); - if (!client) { - throw authRequiredError(); - } + const client = await requireProjectClient(context); return { provider: createManagementProjectProvider(client), projects: await listRealWorkspaceProjects( @@ -838,13 +832,11 @@ async function requireProjectCommandContext( explicitProject: string | undefined, commandName: string, ): Promise<{ provider: ProjectProvider; target: ResolvedProjectTarget }> { + const realMode = isRealMode(context); + const client = realMode ? await requireProjectClient(context) : null; const listProjects = async () => - isRealMode(context) - ? listRealWorkspaceProjects( - await requireProjectClient(context), - workspace, - context.runtime.signal, - ) + client + ? listRealWorkspaceProjects(client, workspace, context.runtime.signal) : listFixtureWorkspaceProjects(context, workspace); const targetResult = await resolveProjectTarget({ @@ -858,8 +850,8 @@ async function requireProjectCommandContext( throw projectResolutionErrorToCliError(targetResult.error); } - const provider = isRealMode(context) - ? createManagementProjectProvider(await requireProjectClient(context)) + const provider = client + ? createManagementProjectProvider(client) : createFixtureProjectProvider(context); return { provider, target: targetResult.value }; diff --git a/packages/cli/src/lib/project/provider.ts b/packages/cli/src/lib/project/provider.ts index fd5d4223..d76536c6 100644 --- a/packages/cli/src/lib/project/provider.ts +++ b/packages/cli/src/lib/project/provider.ts @@ -48,9 +48,17 @@ export function createManagementProjectProvider( }, signal: options.signal, }); - if (result.error || !result.data) { + const status = result.response?.status ?? 0; + if (status === 400 || status === 422) { throw projectRenameFailedError(options.name, result.error); } + if (result.error || !result.data) { + throw projectApiError( + "Failed to rename project", + result.response, + result.error, + ); + } const project = result.data.data as RawProjectRecord; return { From c485e141bfb24639fb0eb2cdc8b494f4a900993c Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Thu, 2 Jul 2026 17:24:45 +0530 Subject: [PATCH 3/5] fix(cli): format new project command hints with the detected package runner remove/transfer confirmation nextSteps, transfer recipient errors, the post-transfer workspace-use hint, and provider recovery commands now render through the project's package runner (pnpm dlx / bunx / npx -y with @prisma/cli@latest) instead of the hardcoded prisma-cli bin name, matching the agent group's convention. --- packages/cli/src/controllers/project.ts | 78 +++++++++++++++++++----- packages/cli/src/lib/project/provider.ts | 3 +- 2 files changed, 66 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/controllers/project.ts b/packages/cli/src/controllers/project.ts index b2e9683e..a0033321 100644 --- a/packages/cli/src/controllers/project.ts +++ b/packages/cli/src/controllers/project.ts @@ -14,6 +14,10 @@ import { FileTokenStorage, WorkspaceSelectionError, } from "../adapters/token-storage"; +import { + type PrismaCliPackageCommandFormatter, + resolvePrismaCliPackageCommandFormatterSync, +} from "../lib/agent/cli-command"; import { createAppProvider } from "../lib/app/app-provider"; import { SERVICE_TOKEN_ENV_VAR } from "../lib/auth/client"; import { requireComputeAuth } from "../lib/auth/guard"; @@ -578,6 +582,9 @@ export async function runProjectRemove( projectRef: string, options: ProjectRemoveOptions, ): Promise> { + const formatCommand = resolvePrismaCliPackageCommandFormatterSync( + context.runtime.cwd, + ); const authState = await requireAuthenticatedAuthState(context); const workspace = authState.workspace; if (!workspace) { @@ -597,7 +604,13 @@ export async function runProjectRemove( confirm: options.confirm, summary: "Confirm project removal", why: "Removing a project is permanent, deletes its databases, and stops its apps, so it requires the exact project id.", - nextStep: `prisma-cli project remove ${project.id} --confirm ${project.id}`, + nextStep: formatCommand([ + "project", + "remove", + project.id, + "--confirm", + project.id, + ]), }); await provider.removeProject({ @@ -627,6 +640,9 @@ export async function runProjectTransfer( projectRef: string, options: ProjectTransferOptions, ): Promise> { + const formatCommand = resolvePrismaCliPackageCommandFormatterSync( + context.runtime.cwd, + ); const authState = await requireAuthenticatedAuthState(context); const workspace = authState.workspace; if (!workspace) { @@ -639,13 +655,21 @@ export async function runProjectTransfer( "--to-workspace and --recipient-token are mutually exclusive.", "Pass either --to-workspace or --recipient-token .", [ - "prisma-cli project transfer --to-workspace --confirm ", + formatCommand([ + "project", + "transfer", + "", + "--to-workspace", + "", + "--confirm", + "", + ]), ], "project", ); } if (!options.toWorkspace?.trim() && !options.recipientToken?.trim()) { - throw transferRecipientRequiredError(); + throw transferRecipientRequiredError(formatCommand); } const { provider, projects } = await requireProjectMutationContext( @@ -661,7 +685,7 @@ export async function runProjectTransfer( confirm: options.confirm, summary: "Confirm project transfer", why: "Transferring moves the project to another workspace and this workspace loses access, so it requires the exact project id.", - nextStep: `prisma-cli project transfer ${project.id} ${ + nextStep: `${formatCommand(["project", "transfer", project.id])} ${ options.toWorkspace ? `--to-workspace ${formatCommandArgument(options.toWorkspace)}` : "--recipient-token " @@ -698,7 +722,7 @@ export async function runProjectTransfer( warnings, nextSteps: options.toWorkspace ? [ - `prisma-cli auth workspace use ${formatCommandArgument(options.toWorkspace)}`, + `${formatCommand(["auth", "workspace", "use"])} ${formatCommandArgument(options.toWorkspace)}`, ] : [], }; @@ -715,6 +739,9 @@ async function resolveTransferRecipient( context: CommandContext, options: ProjectTransferOptions, ): Promise { + const formatCommand = resolvePrismaCliPackageCommandFormatterSync( + context.runtime.cwd, + ); const recipientToken = options.recipientToken?.trim(); if (recipientToken) { return { @@ -730,7 +757,7 @@ async function resolveTransferRecipient( const workspaceRef = options.toWorkspace?.trim(); if (!workspaceRef) { - throw transferRecipientRequiredError(); + throw transferRecipientRequiredError(formatCommand); } if (!isRealMode(context)) { @@ -763,7 +790,7 @@ async function resolveTransferRecipient( } if (context.runtime.env[SERVICE_TOKEN_ENV_VAR] !== undefined) { - throw transferRecipientUnavailableError(); + throw transferRecipientUnavailableError(formatCommand); } try { @@ -873,6 +900,9 @@ async function requireProjectClient( function createFixtureProjectProvider( context: CommandContext, ): ProjectProvider { + const fixtureFormatCommand = resolvePrismaCliPackageCommandFormatterSync( + context.runtime.cwd, + ); return { async renameProject(options) { const renamed = context.api.renameProject( @@ -900,9 +930,9 @@ function createFixtureProjectProvider( domain: "project", summary: "Project not found", why: `No project matched "${options.projectId}".`, - fix: "Pass a project id or name from prisma-cli project list.", + fix: `Pass a project id or name from ${fixtureFormatCommand(["project", "list"])}.`, exitCode: 1, - nextSteps: ["prisma-cli project list"], + nextSteps: [fixtureFormatCommand(["project", "list"])], }); } }, @@ -945,7 +975,9 @@ function requireProjectExactConfirmation(options: { }); } -function transferRecipientRequiredError(): CliError { +function transferRecipientRequiredError( + formatCommand: PrismaCliPackageCommandFormatter, +): CliError { return new CliError({ code: "TRANSFER_RECIPIENT_REQUIRED", domain: "project", @@ -954,13 +986,23 @@ function transferRecipientRequiredError(): CliError { fix: "Pass --to-workspace for a locally authenticated workspace, or --recipient-token for a cross-account transfer.", exitCode: 2, nextSteps: [ - "prisma-cli auth workspace list", - "prisma-cli project transfer --to-workspace --confirm ", + formatCommand(["auth", "workspace", "list"]), + formatCommand([ + "project", + "transfer", + "", + "--to-workspace", + "", + "--confirm", + "", + ]), ], }); } -function transferRecipientUnavailableError(): CliError { +function transferRecipientUnavailableError( + formatCommand: PrismaCliPackageCommandFormatter, +): CliError { return new CliError({ code: "TRANSFER_RECIPIENT_UNAVAILABLE", domain: "project", @@ -969,7 +1011,15 @@ function transferRecipientUnavailableError(): CliError { fix: "Pass --recipient-token with an access token for the receiving workspace, or unset the service token.", exitCode: 1, nextSteps: [ - "prisma-cli project transfer --recipient-token --confirm ", + formatCommand([ + "project", + "transfer", + "", + "--recipient-token", + "", + "--confirm", + "", + ]), ], }); } diff --git a/packages/cli/src/lib/project/provider.ts b/packages/cli/src/lib/project/provider.ts index d76536c6..e5521907 100644 --- a/packages/cli/src/lib/project/provider.ts +++ b/packages/cli/src/lib/project/provider.ts @@ -1,5 +1,6 @@ import type { ManagementApiClient } from "@prisma/management-api-sdk"; +import { formatPrismaCliCommand } from "../../shell/cli-command"; import { CliError } from "../../shell/errors"; import type { ProjectSummary } from "../../types/project"; @@ -141,7 +142,7 @@ export function projectRemoveBlockedError( `Project "${projectId}" still has active deployments.`, fix: "Remove the project's apps first, then retry the removal.", exitCode: 1, - nextSteps: ["prisma-cli app remove --app "], + nextSteps: [formatPrismaCliCommand(["app", "remove", "--app", ""])], }); } From 2ae6a2fb7f431b0cbcb81c69937967fc148a8223 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Fri, 3 Jul 2026 13:57:30 +0530 Subject: [PATCH 4/5] fix(cli): render success warnings in human output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Best-effort steps (local pin cleanup on remove/transfer) collected warnings that only reached the JSON envelope; human users saw a clean success even when their local binding was left stale. The command runner now renders success warnings as ⚠ lines in human mode for every command, so partial local-state failures are visible. Also registers the new project/transfer error codes in error-conventions.md. --- docs/product/error-conventions.md | 12 +++++++++++ packages/cli/src/shell/command-runner.ts | 9 +++++++- packages/cli/tests/command-runner.test.ts | 26 +++++++++++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/docs/product/error-conventions.md b/docs/product/error-conventions.md index 83fdd2ac..76f610ec 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -168,6 +168,12 @@ These codes are the minimum stable set for the MVP: - `PROJECT_SETUP_REQUIRED` - `PROJECT_LINK_TARGET_REQUIRED` - `PROJECT_CREATE_FAILED` +- `PROJECT_RENAME_FAILED` +- `PROJECT_REMOVE_BLOCKED` +- `PROJECT_TRANSFER_REJECTED` +- `PROJECT_API_ERROR` +- `TRANSFER_RECIPIENT_REQUIRED` +- `TRANSFER_RECIPIENT_UNAVAILABLE` - `PROJECT_NOT_FOUND` - `PROJECT_AMBIGUOUS` - `APP_AMBIGUOUS` @@ -233,6 +239,12 @@ Recommended meanings: - `PROJECT_SETUP_REQUIRED`: command needs explicit or durable Project context before it can continue - `PROJECT_LINK_TARGET_REQUIRED`: `project link` needs the user to choose an existing Project or create a new one - `PROJECT_CREATE_FAILED`: Project creation failed before deployment or linking could continue +- `PROJECT_RENAME_FAILED`: the platform rejected the new project name +- `PROJECT_REMOVE_BLOCKED`: project removal is blocked while it still has active deployments +- `PROJECT_TRANSFER_REJECTED`: the platform rejected the transfer, for example an invalid or expired recipient token +- `PROJECT_API_ERROR`: project Management API request failed without a more specific CLI error code +- `TRANSFER_RECIPIENT_REQUIRED`: project transfer needs --to-workspace or --recipient-token +- `TRANSFER_RECIPIENT_UNAVAILABLE`: --to-workspace cannot resolve local OAuth sessions while PRISMA_SERVICE_TOKEN is set - `PROJECT_NOT_FOUND`: requested project does not exist or is not accessible - `PROJECT_AMBIGUOUS`: multiple safe project candidates matched - `APP_AMBIGUOUS`: multiple apps matched the inferred or explicit app target diff --git a/packages/cli/src/shell/command-runner.ts b/packages/cli/src/shell/command-runner.ts index ac5f009e..6e125908 100644 --- a/packages/cli/src/shell/command-runner.ts +++ b/packages/cli/src/shell/command-runner.ts @@ -20,6 +20,7 @@ import { writeJsonSuccess, } from "./output"; import { type CliRuntime, createCommandContext } from "./runtime"; +import { renderSummaryLine } from "./ui"; interface CommandPresenter { renderStdout?: ( @@ -126,11 +127,17 @@ async function writeCommandSuccess( } const rendered = presenter.renderHuman(context, descriptor, success.result); + // Warnings are part of the success contract in JSON; render them in human + // mode too so partial failures (best-effort cleanup, degraded steps) are + // never silent. + const warningLines = success.warnings.map((warning) => + renderSummaryLine(context.ui, "warning", warning), + ); const diagnostics = await renderBestEffortCommandDiagnostics(context, { enabled: context.flags.verbose && rendered.length > 0, durationMs, }); - const humanLines = [...rendered, ...diagnostics]; + const humanLines = [...rendered, ...warningLines, ...diagnostics]; if (stdout.length > 0 && humanLines.length > 0) { humanLines.push(""); } diff --git a/packages/cli/tests/command-runner.test.ts b/packages/cli/tests/command-runner.test.ts index 07f39fe0..f28944e8 100644 --- a/packages/cli/tests/command-runner.test.ts +++ b/packages/cli/tests/command-runner.test.ts @@ -88,6 +88,32 @@ afterEach(() => { }); describe("command runner success output", () => { + it("renders success warnings in human output", async () => { + const { runtime, stderr } = await createRuntime(["project", "remove"]); + + await runCommand( + runtime, + "project.remove", + {}, + async () => ({ + command: "project.remove", + result: { ok: true }, + warnings: [ + "The local pin .prisma/local.json points at the removed project but could not be deleted.", + ], + nextSteps: [], + }), + { + renderHuman: () => ["Project removed"], + }, + ); + + expect(process.exitCode).toBeUndefined(); + expect(stderr.buffer).toContain("Project removed"); + expect(stderr.buffer).toContain("⚠"); + expect(stderr.buffer).toContain("could not be deleted"); + }); + it("adds local diagnostics to successful verbose human output", async () => { const { runtime, stderr } = await createRuntime([ "project", From 31eb1ec79080c4789ed6de6b62f2861fb399d5f5 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Fri, 3 Jul 2026 14:02:02 +0530 Subject: [PATCH 5/5] test(cli): use a branch-independent descriptor in the warning test --- packages/cli/tests/command-runner.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/tests/command-runner.test.ts b/packages/cli/tests/command-runner.test.ts index f28944e8..a57441b8 100644 --- a/packages/cli/tests/command-runner.test.ts +++ b/packages/cli/tests/command-runner.test.ts @@ -89,14 +89,14 @@ afterEach(() => { describe("command runner success output", () => { it("renders success warnings in human output", async () => { - const { runtime, stderr } = await createRuntime(["project", "remove"]); + const { runtime, stderr } = await createRuntime(["project", "show"]); await runCommand( runtime, - "project.remove", + "project.show", {}, async () => ({ - command: "project.remove", + command: "project.show", result: { ok: true }, warnings: [ "The local pin .prisma/local.json points at the removed project but could not be deleted.",