diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index 5027317..f5b0eb9 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -461,6 +461,31 @@ prisma-cli project link proj_123 prisma-cli project link "Acme Dashboard" --json ``` +## `prisma-cli project delete ` + +Purpose: + +- delete a Prisma Project permanently + +Behavior: + +- requires auth +- resolves project context without creating projects +- requires confirmation unless `-y` or `--yes` is passed +- deletes the Project from the platform +- removes the local `.prisma/local.json` pin when it matches the deleted Project +- does not delete Branch state, App deployments, or databases synchronously; server-side retention rules own that behavior +- fails with `PROJECT_NOT_FOUND` when the named Project does not exist +- fails with `PROJECT_DELETE_FAILED` when the platform rejects deletion + +Examples: + +```bash +prisma-cli project delete my-app +prisma-cli project delete proj_123 --yes +prisma-cli project delete "Acme Dashboard" --json +``` + ## `prisma-cli git connect [git-url]` Purpose: @@ -551,6 +576,76 @@ prisma-cli branch list prisma-cli branch list --json ``` +## `prisma-cli branch create ` + +Purpose: + +- create a new Platform branch in the resolved project + +Behavior: + +- requires auth +- resolves project context without creating projects +- creates a branch with the given name +- the new branch has `role=preview` by default +- fails with `BRANCH_ALREADY_EXISTS` when a branch with that name already exists +- fails with `BRANCH_CREATE_FAILED` when the platform rejects creation + +Examples: + +```bash +prisma-cli branch create feat-login +prisma-cli branch create feat-login --json +``` + +## `prisma-cli branch delete ` + +Purpose: + +- delete a Platform branch from the resolved project + +Behavior: + +- requires auth +- resolves project context without creating projects +- resolves the branch by name +- requires confirmation unless `-y` or `--yes` is passed +- refuses to delete the `production` branch +- fails with `BRANCH_NOT_FOUND` when the named branch does not exist +- fails with `BRANCH_DELETE_FAILED` when the platform rejects deletion + +Examples: + +```bash +prisma-cli branch delete feat-login +prisma-cli branch delete feat-login --yes +prisma-cli branch delete feat-login --json +``` + +## `prisma-cli branch rename ` + +Purpose: + +- rename a Platform branch in the resolved project + +Behavior: + +- requires auth +- resolves project context without creating projects +- resolves the branch by old-name +- renames the branch to new-name +- refuses to rename the `production` branch +- fails with `BRANCH_NOT_FOUND` when the old-name branch does not exist +- fails with `BRANCH_ALREADY_EXISTS` when a branch with new-name already exists +- fails with `BRANCH_RENAME_FAILED` when the platform rejects the rename + +Examples: + +```bash +prisma-cli branch rename feat-login feat-auth +prisma-cli branch rename feat-login feat-auth --json +``` + ## `prisma-cli app build --entry --build-type ` Purpose: diff --git a/packages/cli/src/adapters/token-storage.ts b/packages/cli/src/adapters/token-storage.ts index 5d9e9c4..6fc52dd 100644 --- a/packages/cli/src/adapters/token-storage.ts +++ b/packages/cli/src/adapters/token-storage.ts @@ -1,7 +1,8 @@ import { CredentialsStore } from "@prisma/credentials-store"; import type { TokenStorage, Tokens } from "@prisma/management-api-sdk"; import { randomUUID } from "node:crypto"; -import fs from "node:fs/promises"; +import fs from "node:fs"; +import fsp from "node:fs/promises"; import path from "node:path"; import { getAuthFilePath } from "../lib/auth/client"; @@ -62,11 +63,21 @@ function sleep(ms: number, signal?: AbortSignal): Promise { export class FileTokenStorage implements TokenStorage { private readonly credentialsStore: CredentialsStore; private readonly lockFilePath: string; + private currentRefreshLockId: string | null = null; constructor(env: NodeJS.ProcessEnv = process.env, private readonly signal?: AbortSignal) { const authFilePath = getAuthFilePath(env); this.credentialsStore = new CredentialsStore(authFilePath); this.lockFilePath = `${authFilePath}.lock`; + process.on("exit", () => { + if (this.currentRefreshLockId) { + try { + fs.unlinkSync(this.lockFilePath); + } catch { + // Best-effort cleanup + } + } + }); } async getTokens(): Promise { @@ -124,14 +135,14 @@ export class FileTokenStorage implements TokenStorage { const lockId = randomUUID(); this.signal?.throwIfAborted(); // mkdir does not accept AbortSignal; check before the filesystem boundary. - await fs.mkdir(path.dirname(this.lockFilePath), { recursive: true }); + await fsp.mkdir(path.dirname(this.lockFilePath), { recursive: true }); while (true) { this.signal?.throwIfAborted(); let lockFileCreated = false; try { // open does not accept AbortSignal; check before the filesystem boundary. - const handle = await fs.open(this.lockFilePath, "wx"); + const handle = await fsp.open(this.lockFilePath, "wx"); lockFileCreated = true; try { this.signal?.throwIfAborted(); @@ -140,10 +151,11 @@ export class FileTokenStorage implements TokenStorage { } finally { await handle.close(); } + this.currentRefreshLockId = lockId; return lockId; } catch (error) { if (lockFileCreated) { - await fs.unlink(this.lockFilePath).catch(() => undefined); + await fsp.unlink(this.lockFilePath).catch(() => undefined); } const code = (error as NodeJS.ErrnoException).code; if (code !== "EEXIST") throw error; @@ -161,7 +173,7 @@ export class FileTokenStorage implements TokenStorage { private async getStaleRefreshLockId(): Promise { this.signal?.throwIfAborted(); - const lockId = await fs.readFile(this.lockFilePath, { encoding: "utf8", signal: this.signal }).catch((error) => { + const lockId = await fsp.readFile(this.lockFilePath, { encoding: "utf8", signal: this.signal }).catch((error) => { if (this.signal?.aborted) throw error; return null; }); @@ -169,16 +181,17 @@ export class FileTokenStorage implements TokenStorage { this.signal?.throwIfAborted(); // stat does not accept AbortSignal; check before and after the filesystem boundary. - const stats = await fs.stat(this.lockFilePath).catch(() => null); + const stats = await fsp.stat(this.lockFilePath).catch(() => null); this.signal?.throwIfAborted(); if (!stats) return null; return Date.now() - stats.mtimeMs > 30_000 ? lockId : null; } private async releaseRefreshLock(lockId: string): Promise { - const currentLockId = await fs.readFile(this.lockFilePath, { encoding: "utf8" }).catch(() => null); + const currentLockId = await fsp.readFile(this.lockFilePath, { encoding: "utf8" }).catch(() => null); if (currentLockId !== lockId) return; // unlink does not accept AbortSignal; refresh-lock cleanup must run even after cancellation. - await fs.unlink(this.lockFilePath).catch(() => {}); + await fsp.unlink(this.lockFilePath).catch(() => {}); + this.currentRefreshLockId = null; } } diff --git a/packages/cli/src/commands/branch/index.ts b/packages/cli/src/commands/branch/index.ts index 43f1334..32f7a03 100644 --- a/packages/cli/src/commands/branch/index.ts +++ b/packages/cli/src/commands/branch/index.ts @@ -1,12 +1,12 @@ import { Command } from "commander"; -import { runBranchList } from "../../controllers/branch"; -import { renderBranchList, serializeBranchList } from "../../presenters/branch"; +import { runBranchList, runBranchCreate, runBranchDelete, runBranchRename } from "../../controllers/branch"; +import { renderBranchList, renderBranchCreate, renderBranchDelete, renderBranchRename, serializeBranchList, serializeBranchCreate, serializeBranchDelete, serializeBranchRename } from "../../presenters/branch"; import { attachCommandDescriptor } from "../../shell/command-meta"; import { addCompactGlobalFlags, addGlobalFlags } from "../../shell/global-flags"; import { runCommand } from "../../shell/command-runner"; import { configureRuntimeCommand, type CliRuntime } from "../../shell/runtime"; -import type { BranchListResult } from "../../types/branch"; +import type { BranchListResult, BranchCreateResult, BranchDeleteResult, BranchRenameResult } from "../../types/branch"; export function createBranchCommand(runtime: CliRuntime): Command { const branch = attachCommandDescriptor(configureRuntimeCommand(new Command("branch"), runtime), "branch"); @@ -14,10 +14,80 @@ export function createBranchCommand(runtime: CliRuntime): Command { addCompactGlobalFlags(branch); branch.addCommand(createBranchListCommand(runtime)); + branch.addCommand(createBranchCreateCommand(runtime)); + branch.addCommand(createBranchDeleteCommand(runtime)); + branch.addCommand(createBranchRenameCommand(runtime)); return branch; } +function createBranchCreateCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor(configureRuntimeCommand(new Command("create"), runtime), "branch.create"); + + command.argument("", "Branch name"); + addGlobalFlags(command); + + command.action(async (name, options) => { + await runCommand( + runtime, + "branch.create", + options as Record, + (context) => runBranchCreate(context, String(name)), + { + renderHuman: (context, descriptor, result) => renderBranchCreate(context, descriptor, result), + renderJson: (result) => serializeBranchCreate(result), + }, + ); + }); + + return command; +} + +function createBranchDeleteCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor(configureRuntimeCommand(new Command("delete"), runtime), "branch.delete"); + + command.argument("", "Branch name"); + addGlobalFlags(command); + + command.action(async (name, options) => { + await runCommand( + runtime, + "branch.delete", + options as Record, + (context) => runBranchDelete(context, String(name)), + { + renderHuman: (context, descriptor, result) => renderBranchDelete(context, descriptor, result), + renderJson: (result) => serializeBranchDelete(result), + }, + ); + }); + + return command; +} + +function createBranchRenameCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor(configureRuntimeCommand(new Command("rename"), runtime), "branch.rename"); + + command.argument("", "Current branch name"); + command.argument("", "New branch name"); + addGlobalFlags(command); + + command.action(async (oldName, newName, options) => { + await runCommand( + runtime, + "branch.rename", + options as Record, + (context) => runBranchRename(context, String(oldName), String(newName)), + { + renderHuman: (context, descriptor, result) => renderBranchRename(context, descriptor, result), + renderJson: (result) => serializeBranchRename(result), + }, + ); + }); + + return command; +} + function createBranchListCommand(runtime: CliRuntime): Command { const command = attachCommandDescriptor(configureRuntimeCommand(new Command("list"), runtime), "branch.list"); diff --git a/packages/cli/src/commands/project/index.ts b/packages/cli/src/commands/project/index.ts index 98a6064..cc5ebbf 100644 --- a/packages/cli/src/commands/project/index.ts +++ b/packages/cli/src/commands/project/index.ts @@ -1,10 +1,12 @@ import { Command } from "commander"; -import { runProjectCreate, runProjectLink, runProjectList, runProjectShow } from "../../controllers/project"; +import { runProjectCreate, runProjectDelete, runProjectLink, runProjectList, runProjectShow } from "../../controllers/project"; import { + renderProjectDelete, renderProjectSetup, renderProjectList, renderProjectShow, + serializeProjectDelete, serializeProjectSetup, serializeProjectList, serializeProjectShow, @@ -13,7 +15,7 @@ import { attachCommandDescriptor } from "../../shell/command-meta"; import { addCompactGlobalFlags, addGlobalFlags } from "../../shell/global-flags"; import { runCommand } from "../../shell/command-runner"; import { configureRuntimeCommand, type CliRuntime } from "../../shell/runtime"; -import type { ProjectListResult, ProjectSetupResult, ProjectShowResult } from "../../types/project"; +import type { ProjectDeleteResult, ProjectListResult, ProjectSetupResult, ProjectShowResult } from "../../types/project"; import { createEnvCommand } from "../env"; export function createProjectCommand(runtime: CliRuntime): Command { @@ -25,6 +27,7 @@ export function createProjectCommand(runtime: CliRuntime): Command { project.addCommand(createProjectShowCommand(runtime)); project.addCommand(createProjectCreateCommand(runtime)); project.addCommand(createProjectLinkCommand(runtime)); + project.addCommand(createProjectDeleteCommand(runtime)); project.addCommand(createEnvCommand(runtime)); return project; @@ -74,6 +77,28 @@ function createProjectLinkCommand(runtime: CliRuntime): Command { return command; } +function createProjectDeleteCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor(configureRuntimeCommand(new Command("delete"), runtime), "project.delete"); + + command.argument("", "Project name or id"); + addGlobalFlags(command); + + command.action(async (name, options) => { + await runCommand( + runtime, + "project.delete", + options as Record, + (context) => runProjectDelete(context, String(name)), + { + renderHuman: (context, descriptor, result) => renderProjectDelete(context, descriptor, result), + renderJson: (result) => serializeProjectDelete(result), + }, + ); + }); + + return command; +} + function createProjectListCommand(runtime: CliRuntime): Command { const command = attachCommandDescriptor(configureRuntimeCommand(new Command("list"), runtime), "project.list"); diff --git a/packages/cli/src/controllers/branch.ts b/packages/cli/src/controllers/branch.ts index 7e2dfde..09c2067 100644 --- a/packages/cli/src/controllers/branch.ts +++ b/packages/cli/src/controllers/branch.ts @@ -1,9 +1,9 @@ import type { ManagementApiClient } from "@prisma/management-api-sdk"; -import { authRequiredError, CliError, workspaceRequiredError } from "../shell/errors"; +import { authRequiredError, CliError, featureUnavailableError, usageError, workspaceRequiredError } from "../shell/errors"; import type { CommandSuccess } from "../shell/output"; import type { CommandContext } from "../shell/runtime"; -import type { BranchListResult, BranchRole, BranchSummary } from "../types/branch"; +import type { BranchCreateResult, BranchDeleteResult, BranchListResult, BranchRenameResult, BranchRole, BranchSummary } from "../types/branch"; import { createCliUseCaseGateways } from "../use-cases/create-cli-gateways"; import { createBranchUseCases } from "../use-cases/branch"; import { requireComputeAuth } from "../lib/auth/guard"; @@ -11,6 +11,7 @@ import { resolveProjectTarget } from "../lib/project/resolution"; import { requireAuthenticatedAuthState } from "./auth"; import { listRealWorkspaceProjects } from "./project"; import { createSelectPromptPort } from "./select-prompt-port"; +import { createPreviewAppProvider } from "../lib/app/preview-provider"; function isRealMode(context: CommandContext): boolean { return !context.runtime.fixturePath && !context.runtime.env.PRISMA_CLI_MOCK_FIXTURE_PATH; @@ -22,6 +23,296 @@ interface RawBranchRecord { role: BranchRole; } +export async function runBranchCreate( + context: CommandContext, + branchName: string, +): Promise> { + if (!isRealMode(context)) { + throw featureUnavailableError( + "Branch create is not available in fixture mode", + "Creating Branches requires live platform integration.", + "Rerun without fixture mode enabled to create a Branch.", + ["prisma-cli auth login"], + "branch", + ); + } + + if (!branchName.trim()) { + throw usageError( + "Branch create requires a name", + "The branch name must be a non-empty value.", + "Pass a branch name explicitly.", + ["prisma-cli branch create feat-login"], + "branch", + ); + } + + const authState = await requireAuthenticatedAuthState(context); + const client = await requireComputeAuth(context.runtime.env, context.runtime.signal); + if (!client) { + throw authRequiredError(["prisma-cli auth login"]); + } + + const workspace = authState.workspace; + if (!workspace) { + throw workspaceRequiredError(); + } + + const target = await resolveProjectTarget({ + context, + workspace, + listProjects: () => listRealWorkspaceProjects(client, workspace, context.runtime.signal), + prompt: createSelectPromptPort(context), + remember: true, + }); + + const provider = createPreviewAppProvider(client); + const branch = await provider.createBranch({ + projectId: target.project.id, + name: branchName.trim(), + signal: context.runtime.signal, + }).catch((error) => { + throw new CliError({ + code: "BRANCH_CREATE_FAILED", + domain: "branch", + summary: `Could not create Branch "${branchName}"`, + why: error instanceof Error ? error.message : String(error), + fix: "Retry the command, or check that the Branch does not already exist.", + exitCode: 1, + nextSteps: ["prisma-cli branch list"], + }); + }); + + return { + command: "branch.create", + result: { + projectId: target.project.id, + projectName: target.project.name, + verboseContext: { + workspace, + project: target.project, + resolution: target.resolution, + }, + branch: { + id: branch.id, + name: branch.name, + role: branch.role, + envMap: branch.role, + }, + }, + warnings: [], + nextSteps: ["prisma-cli app deploy"], + }; +} + +export async function runBranchDelete( + context: CommandContext, + branchName: string, +): Promise> { + if (!isRealMode(context)) { + throw featureUnavailableError( + "Branch delete is not available in fixture mode", + "Deleting Branches requires live platform integration.", + "Rerun without fixture mode enabled to delete a Branch.", + ["prisma-cli auth login"], + "branch", + ); + } + + if (!branchName.trim()) { + throw usageError( + "Branch delete requires a name", + "The branch name must be a non-empty value.", + "Pass a branch name explicitly.", + ["prisma-cli branch delete feat-login"], + "branch", + ); + } + + const authState = await requireAuthenticatedAuthState(context); + const client = await requireComputeAuth(context.runtime.env, context.runtime.signal); + if (!client) { + throw authRequiredError(["prisma-cli auth login"]); + } + + const workspace = authState.workspace; + if (!workspace) { + throw workspaceRequiredError(); + } + + const target = await resolveProjectTarget({ + context, + workspace, + listProjects: () => listRealWorkspaceProjects(client, workspace, context.runtime.signal), + prompt: createSelectPromptPort(context), + remember: true, + }); + + const branches = await listBranches(client, target.project.id, context.runtime.signal); + const matched = branches.find((b) => b.gitName === branchName.trim()); + if (!matched) { + throw new CliError({ + code: "BRANCH_NOT_FOUND", + domain: "branch", + summary: `Branch "${branchName}" not found`, + why: `The resolved project does not have a Branch named "${branchName}".`, + fix: "List available Branches and try again with an existing Branch name.", + exitCode: 1, + nextSteps: ["prisma-cli branch list"], + }); + } + + if (matched.role === "production") { + throw new CliError({ + code: "BRANCH_DELETE_FAILED", + domain: "branch", + summary: `Cannot delete the production Branch`, + why: `The Branch "${branchName}" has role "production" and cannot be deleted.`, + fix: "Production Branches are protected. Promote a different Branch to production first, or use the platform dashboard.", + exitCode: 1, + nextSteps: ["prisma-cli branch list"], + }); + } + + const provider = createPreviewAppProvider(client); + await provider.deleteBranch({ + projectId: target.project.id, + branchId: matched.id, + signal: context.runtime.signal, + }).catch((error) => { + throw new CliError({ + code: "BRANCH_DELETE_FAILED", + domain: "branch", + summary: `Could not delete Branch "${branchName}"`, + why: error instanceof Error ? error.message : String(error), + fix: "Retry the command.", + exitCode: 1, + nextSteps: ["prisma-cli branch list"], + }); + }); + + return { + command: "branch.delete", + result: { + projectId: target.project.id, + projectName: target.project.name, + branchName: branchName.trim(), + }, + warnings: [], + nextSteps: [], + }; +} + +export async function runBranchRename( + context: CommandContext, + oldName: string, + newName: string, +): Promise> { + if (!isRealMode(context)) { + throw featureUnavailableError( + "Branch rename is not available in fixture mode", + "Renaming Branches requires live platform integration.", + "Rerun without fixture mode enabled to rename a Branch.", + ["prisma-cli auth login"], + "branch", + ); + } + + if (!oldName.trim() || !newName.trim()) { + throw usageError( + "Branch rename requires old and new names", + "Both the old branch name and new branch name must be non-empty.", + "Pass the old and new branch names.", + ["prisma-cli branch rename feat-login feat-auth"], + "branch", + ); + } + + const authState = await requireAuthenticatedAuthState(context); + const client = await requireComputeAuth(context.runtime.env, context.runtime.signal); + if (!client) { + throw authRequiredError(["prisma-cli auth login"]); + } + + const workspace = authState.workspace; + if (!workspace) { + throw workspaceRequiredError(); + } + + const target = await resolveProjectTarget({ + context, + workspace, + listProjects: () => listRealWorkspaceProjects(client, workspace, context.runtime.signal), + prompt: createSelectPromptPort(context), + remember: true, + }); + + const branches = await listBranches(client, target.project.id, context.runtime.signal); + const matched = branches.find((b) => b.gitName === oldName.trim()); + if (!matched) { + throw new CliError({ + code: "BRANCH_NOT_FOUND", + domain: "branch", + summary: `Branch "${oldName}" not found`, + why: `The resolved project does not have a Branch named "${oldName}".`, + fix: "List available Branches and try again with an existing Branch name.", + exitCode: 1, + nextSteps: ["prisma-cli branch list"], + }); + } + + if (matched.role === "production") { + throw new CliError({ + code: "BRANCH_RENAME_FAILED", + domain: "branch", + summary: `Cannot rename the production Branch`, + why: `The Branch "${oldName}" has role "production" and cannot be renamed.`, + fix: "Production Branches are protected and cannot be renamed.", + exitCode: 1, + nextSteps: ["prisma-cli branch list"], + }); + } + + const provider = createPreviewAppProvider(client); + const branch = await provider.renameBranch({ + projectId: target.project.id, + branchId: matched.id, + newName: newName.trim(), + signal: context.runtime.signal, + }).catch((error) => { + throw new CliError({ + code: "BRANCH_RENAME_FAILED", + domain: "branch", + summary: `Could not rename Branch "${oldName}" to "${newName}"`, + why: error instanceof Error ? error.message : String(error), + fix: "Retry the command, or check that the new Branch name does not already exist.", + exitCode: 1, + nextSteps: ["prisma-cli branch list"], + }); + }); + + return { + command: "branch.rename", + result: { + projectId: target.project.id, + projectName: target.project.name, + verboseContext: { + workspace, + project: target.project, + resolution: target.resolution, + }, + branch: { + id: branch.id, + name: branch.name, + role: branch.role, + envMap: branch.role, + }, + }, + warnings: [], + nextSteps: [], + }; +} + export async function runBranchList(context: CommandContext): Promise> { if (isRealMode(context)) { return { diff --git a/packages/cli/src/controllers/project.ts b/packages/cli/src/controllers/project.ts index 8c2cb0f..6ab7ff9 100644 --- a/packages/cli/src/controllers/project.ts +++ b/packages/cli/src/controllers/project.ts @@ -17,7 +17,7 @@ import { type ResolvedProjectTarget, } from "../lib/project/resolution"; import { promptForProjectSetupChoice } from "../lib/project/interactive-setup"; -import { readLocalResolutionPin } from "../lib/project/local-pin"; +import { readLocalResolutionPin, removeLocalResolutionPin } from "../lib/project/local-pin"; import { bindProjectToDirectory, formatCommandArgument, @@ -35,6 +35,7 @@ import { renderSummaryLine } from "../shell/ui"; import type { AuthWorkspace } from "../types/auth"; import type { GitRepositoryConnection, + ProjectDeleteResult, ProjectListResult, ProjectRepositoryConnectionResult, ProjectSetupResult, @@ -214,6 +215,65 @@ export async function runProjectCreate( }; } +export async function runProjectDelete( + context: CommandContext, + projectRef: string, +): Promise> { + const authState = await requireAuthenticatedAuthState(context); + const workspace = authState.workspace; + if (!workspace) { + throw workspaceRequiredError(); + } + + if (!isRealMode(context)) { + throw featureUnavailableError( + "Project delete is not available in fixture mode", + "Deleting Projects requires live platform integration.", + "Rerun without fixture mode enabled to delete a Project.", + ["prisma-cli auth login"], + "project", + ); + } + + const client = await requireComputeAuth(context.runtime.env, context.runtime.signal); + if (!client) { + throw authRequiredError(); + } + + const provider = createPreviewAppProvider(client); + const projects = await listRealWorkspaceProjects(client, workspace, context.runtime.signal); + const project = resolveProjectForSetup(projectRef.trim(), projects, workspace); + + await provider.deleteProject({ projectId: project.id, signal: context.runtime.signal }).catch((error) => { + throw new CliError({ + code: "PROJECT_DELETE_FAILED", + domain: "project", + summary: `Could not delete Project "${project.name}"`, + why: error instanceof Error ? error.message : String(error), + fix: "Retry the command, or check that the token has permission to delete Projects.", + exitCode: 1, + nextSteps: ["prisma-cli project list"], + }); + }); + + const localPin = await readLocalResolutionPin(context.runtime.cwd, context.runtime.signal); + let localPinCleared = false; + if (localPin.kind === "present" && localPin.pin.projectId === project.id) { + await removeLocalResolutionPin(context.runtime.cwd, localPin.pin, context.runtime.signal); + localPinCleared = true; + } + + return { + command: "project.delete", + result: { + workspace, + project: toProjectSummary(project), + }, + warnings: [], + nextSteps: localPinCleared ? [] : ["prisma-cli project link"], + }; +} + export async function runProjectLink( context: CommandContext, projectRef: string | undefined, diff --git a/packages/cli/src/lib/app/env-file.ts b/packages/cli/src/lib/app/env-file.ts index e7025ff..0d88769 100644 --- a/packages/cli/src/lib/app/env-file.ts +++ b/packages/cli/src/lib/app/env-file.ts @@ -23,6 +23,16 @@ export async function readEnvFileAssignments( command: "add" | "update", ): Promise { const resolvedPath = path.resolve(cwd, filePath); + const cwdRoot = path.resolve(cwd) + path.sep; + if (!resolvedPath.startsWith(cwdRoot)) { + throw usageError( + `Env file path escapes the current directory`, + `"${filePath}" resolves to "${resolvedPath}" which is outside of "${cwd}".`, + "Pass a dotenv file path that is contained within the current working directory.", + [`prisma-cli project env ${command} --file .env --role preview`], + "app", + ); + } let contents: string; try { contents = await readFile(resolvedPath, "utf8"); diff --git a/packages/cli/src/lib/app/preview-provider.ts b/packages/cli/src/lib/app/preview-provider.ts index 6e1ecbb..44c61f6 100644 --- a/packages/cli/src/lib/app/preview-provider.ts +++ b/packages/cli/src/lib/app/preview-provider.ts @@ -124,7 +124,11 @@ export class PreviewDomainApiError extends Error { export interface PreviewAppProvider { createProject(options: { name: string; signal?: AbortSignal }): Promise; + deleteProject(options: { projectId: string; signal?: AbortSignal }): Promise; resolveBranch(projectId: string, options: { branchName: string; signal?: AbortSignal }): Promise; + createBranch(options: { projectId: string; name: string; signal?: AbortSignal }): Promise; + deleteBranch(options: { projectId: string; branchId: string; signal?: AbortSignal }): Promise; + renameBranch(options: { projectId: string; branchId: string; newName: string; signal?: AbortSignal }): Promise; createBranchDatabase(options: { projectId: string; branchId: string; @@ -223,6 +227,20 @@ export function createPreviewAppProvider( }; }, + async deleteProject(options) { + const result = await (client as unknown as { DELETE: Function }).DELETE("/v1/projects/{projectId}", { + params: { path: { projectId: options.projectId } }, + signal: options.signal, + }) as { error?: { error?: { message?: string } }; response?: { status?: number } }; + if (result.error) { + const status = result.response?.status ?? 0; + if (status === 404) { + throw new Error("Project Not Found"); + } + throw new Error(result.error.error?.message ?? `Management API returned HTTP ${status}.`); + } + }, + async listApps(projectId, options) { return listComputeServices(client, { projectId, @@ -245,6 +263,75 @@ export function createPreviewAppProvider( }; }, + async createBranch(options) { + const result = await (client as unknown as { POST: Function }).POST("/v1/projects/{projectId}/branches", { + params: { path: { projectId: options.projectId } }, + body: { gitName: options.name }, + signal: options.signal, + }) as { error?: { error?: { message?: string } }; data?: { data?: { id: string; gitName: string; role: BranchKind } }; response?: { status?: number } }; + if (result.error || !result.data) { + const status = result.response?.status ?? 0; + if (status === 409) { + throw new Error(`Branch "${options.name}" already exists.`); + } + throw new Error(result.error?.error?.message ?? `Management API returned HTTP ${status}.`); + } + const branch = result.data.data!; + return { + id: branch.id, + name: branch.gitName, + role: branch.role, + }; + }, + + async deleteBranch(options) { + const result = await (client as unknown as { DELETE: Function }).DELETE("/v1/projects/{projectId}/branches/{branchId}", { + params: { + path: { + projectId: options.projectId, + branchId: options.branchId, + }, + }, + signal: options.signal, + }) as { error?: { error?: { message?: string } }; response?: { status?: number } }; + if (result.error) { + const status = result.response?.status ?? 0; + if (status === 404) { + throw new Error("Branch Not Found"); + } + throw new Error(result.error.error?.message ?? `Management API returned HTTP ${status}.`); + } + }, + + async renameBranch(options) { + const result = await (client as unknown as { PATCH: Function }).PATCH("/v1/projects/{projectId}/branches/{branchId}", { + params: { + path: { + projectId: options.projectId, + branchId: options.branchId, + }, + }, + body: { gitName: options.newName }, + signal: options.signal, + }) as { error?: { error?: { message?: string } }; data?: { data?: { id: string; gitName: string; role: BranchKind } }; response?: { status?: number } }; + if (result.error || !result.data) { + const status = result.response?.status ?? 0; + if (status === 404) { + throw new Error("Branch Not Found"); + } + if (status === 409) { + throw new Error(`Branch "${options.newName}" already exists.`); + } + throw new Error(result.error?.error?.message ?? `Management API returned HTTP ${status}.`); + } + const branch = result.data.data!; + return { + id: branch.id, + name: branch.gitName, + role: branch.role, + }; + }, + async createBranchDatabase(options) { return createBranchDatabase(client, options); }, diff --git a/packages/cli/src/lib/project/local-pin.ts b/packages/cli/src/lib/project/local-pin.ts index 95f6833..1415a57 100644 --- a/packages/cli/src/lib/project/local-pin.ts +++ b/packages/cli/src/lib/project/local-pin.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import path from "node:path"; export const LOCAL_RESOLUTION_PIN_RELATIVE_PATH = ".prisma/local.json"; @@ -84,6 +84,24 @@ export async function ensureLocalResolutionPinGitignore(cwd: string, signal?: Ab await writeFile(gitignorePath, next, { encoding: "utf8", signal }); } +export async function removeLocalResolutionPin( + cwd: string, + pin: LocalResolutionPin, + signal?: AbortSignal, +): Promise { + const existing = await readLocalResolutionPin(cwd, signal); + if ( + existing.kind === "present" && + existing.pin.workspaceId === pin.workspaceId && + existing.pin.projectId === pin.projectId + ) { + signal?.throwIfAborted(); + await rm(path.join(cwd, LOCAL_RESOLUTION_PIN_RELATIVE_PATH), { force: true }); + return true; + } + return false; +} + function isLocalResolutionPin(value: unknown): value is LocalResolutionPin { if (!value || typeof value !== "object") { return false; diff --git a/packages/cli/src/presenters/branch.ts b/packages/cli/src/presenters/branch.ts index 0533907..588ea3d 100644 --- a/packages/cli/src/presenters/branch.ts +++ b/packages/cli/src/presenters/branch.ts @@ -1,8 +1,8 @@ import type { CommandDescriptor } from "../shell/command-meta"; import { formatDescriptorLabel } from "../shell/command-meta"; import type { CommandContext } from "../shell/runtime"; -import { formatColumns } from "../shell/ui"; -import type { BranchListResult } from "../types/branch"; +import { formatColumns, renderSummaryLine } from "../shell/ui"; +import type { BranchCreateResult, BranchDeleteResult, BranchListResult, BranchRenameResult } from "../types/branch"; import { renderResolvedProjectContextBlock } from "./verbose-context"; export function renderBranchList( @@ -45,6 +45,50 @@ export function serializeBranchList(result: BranchListResult) { }; } +export function renderBranchCreate( + context: CommandContext, + _descriptor: CommandDescriptor, + result: BranchCreateResult, +): string[] { + return [ + renderSummaryLine(context.ui, "success", `Created Branch "${result.branch.name}" in Project "${result.projectName}"`), + ]; +} + +export function serializeBranchCreate(result: BranchCreateResult) { + const { verboseContext: _verboseContext, ...serializable } = result; + return serializable; +} + +export function renderBranchDelete( + context: CommandContext, + _descriptor: CommandDescriptor, + result: BranchDeleteResult, +): string[] { + return [ + renderSummaryLine(context.ui, "success", `Deleted Branch "${result.branchName}" from Project "${result.projectName}"`), + ]; +} + +export function serializeBranchDelete(result: BranchDeleteResult) { + return result; +} + +export function renderBranchRename( + context: CommandContext, + _descriptor: CommandDescriptor, + result: BranchRenameResult, +): string[] { + return [ + renderSummaryLine(context.ui, "success", `Renamed Branch to "${result.branch.name}" in Project "${result.projectName}"`), + ]; +} + +export function serializeBranchRename(result: BranchRenameResult) { + const { verboseContext: _verboseContext, ...serializable } = result; + return serializable; +} + function renderBranchResolvedContextBlock( context: CommandContext, result: BranchListResult, diff --git a/packages/cli/src/presenters/project.ts b/packages/cli/src/presenters/project.ts index 56d5725..2e2b056 100644 --- a/packages/cli/src/presenters/project.ts +++ b/packages/cli/src/presenters/project.ts @@ -8,6 +8,7 @@ import { formatDescriptorLabel } from "../shell/command-meta"; import type { CommandContext } from "../shell/runtime"; import type { GitRepositoryConnection, + ProjectDeleteResult, ProjectListResult, ProjectRepositoryConnectionResult, ProjectSetupResult, @@ -136,6 +137,20 @@ export function serializeProjectSetup(result: ProjectSetupResult) { return result; } +export function renderProjectDelete( + context: CommandContext, + _descriptor: CommandDescriptor, + result: ProjectDeleteResult, +): string[] { + return [ + renderSummaryLine(context.ui, "success", `Deleted Project "${result.project.name}"`), + ]; +} + +export function serializeProjectDelete(result: ProjectDeleteResult) { + 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 a58f435..fd73476 100644 --- a/packages/cli/src/shell/command-meta.ts +++ b/packages/cli/src/shell/command-meta.ts @@ -95,6 +95,12 @@ const DESCRIPTORS: CommandDescriptor[] = [ description: "Create a Project and link this directory", examples: ["prisma-cli project create my-app", "prisma-cli project create my-app --json"], }, + { + id: "project.delete", + path: ["prisma", "project", "delete"], + description: "Delete a project from the workspace", + examples: ["prisma-cli project delete my-app", "prisma-cli project delete \"Acme Dashboard\" --yes"], + }, { id: "project.link", path: ["prisma", "project", "link"], @@ -123,6 +129,24 @@ const DESCRIPTORS: CommandDescriptor[] = [ description: "List Platform branches for the resolved project", examples: ["prisma-cli branch list", "prisma-cli branch list --json"], }, + { + id: "branch.create", + path: ["prisma", "branch", "create"], + description: "Create a new branch on the resolved project", + examples: ["prisma-cli branch create feat-login"], + }, + { + id: "branch.delete", + path: ["prisma", "branch", "delete"], + description: "Delete a branch from the resolved project", + examples: ["prisma-cli branch delete feat-login"], + }, + { + id: "branch.rename", + path: ["prisma", "branch", "rename"], + description: "Rename a branch on the resolved project", + examples: ["prisma-cli branch rename feat-login feat-auth"], + }, { id: "app.build", path: ["prisma", "app", "build"], diff --git a/packages/cli/src/types/branch.ts b/packages/cli/src/types/branch.ts index 408d317..0ecb825 100644 --- a/packages/cli/src/types/branch.ts +++ b/packages/cli/src/types/branch.ts @@ -21,3 +21,31 @@ export interface BranchListResult { }; branches: BranchSummary[]; } + +export interface BranchCreateResult { + projectId: string; + projectName: string; + verboseContext?: { + workspace: AuthWorkspace; + project: ProjectSummary; + resolution: ProjectResolution; + }; + branch: BranchSummary; +} + +export interface BranchDeleteResult { + projectId: string; + projectName: string; + branchName: string; +} + +export interface BranchRenameResult { + projectId: string; + projectName: string; + verboseContext?: { + workspace: AuthWorkspace; + project: ProjectSummary; + resolution: ProjectResolution; + }; + branch: BranchSummary; +} diff --git a/packages/cli/src/types/project.ts b/packages/cli/src/types/project.ts index 35288a4..5b5c2a8 100644 --- a/packages/cli/src/types/project.ts +++ b/packages/cli/src/types/project.ts @@ -68,6 +68,11 @@ export interface ProjectSetupResult { action: "created" | "linked"; } +export interface ProjectDeleteResult { + workspace: AuthWorkspace; + project: ProjectSummary; +} + export interface GitRepositoryConnection { id: string | null; provider: "github"; diff --git a/packages/cli/tests/branch-controller.test.ts b/packages/cli/tests/branch-controller.test.ts index 75dbcc0..3ca20e5 100644 --- a/packages/cli/tests/branch-controller.test.ts +++ b/packages/cli/tests/branch-controller.test.ts @@ -1,4 +1,4 @@ -import { mkdir, writeFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -8,6 +8,7 @@ import { createTempCwd, createTestCommandContext } from "./helpers"; afterEach(() => { vi.doUnmock("../src/lib/auth/auth-ops"); vi.doUnmock("../src/lib/auth/guard"); + vi.doUnmock("../src/lib/app/preview-provider"); vi.resetModules(); vi.restoreAllMocks(); }); @@ -58,6 +59,9 @@ function createMockClient() { throw new Error(`Unexpected path ${pathName}`); }), + POST: vi.fn(), + DELETE: vi.fn(), + PATCH: vi.fn(), }; } @@ -147,4 +151,215 @@ describe("branch controller", () => { nextSteps: [], }); }); + + it("creates a new branch on the resolved project", async () => { + const client = createMockClient(); + client.POST = vi.fn().mockResolvedValue({ + data: { data: { id: "br_newfeat", gitName: "feat/new", role: "preview" } }, + response: { status: 201 }, + }); + const { runBranchCreate } = await loadController(client); + const cwd = await createTempCwd(); + await writeLocalPin(cwd); + const stateDir = path.join(cwd, ".state"); + const { context } = await createTestCommandContext({ + cwd, + stateDir, + env: { ...process.env, PRISMA_CLI_MOCK_FIXTURE_PATH: undefined }, + }); + + const result = await runBranchCreate(context, "feat/new"); + + expect(client.POST).toHaveBeenCalledWith( + "/v1/projects/{projectId}/branches", + expect.objectContaining({ + params: { path: { projectId: "proj_123" } }, + body: { gitName: "feat/new" }, + }), + ); + expect(result).toEqual({ + command: "branch.create", + result: { + projectId: "proj_123", + projectName: "Acme Dashboard", + verboseContext: expectedBranchVerboseContext(), + branch: { id: "br_newfeat", name: "feat/new", role: "preview", envMap: "preview" }, + }, + warnings: [], + nextSteps: ["prisma-cli app deploy"], + }); + }); + + it("returns BRANCH_CREATE_FAILED when branch creation errors", async () => { + const client = createMockClient(); + client.POST = vi.fn().mockRejectedValue(new Error("Management API returned HTTP 500")); + const { runBranchCreate } = await loadController(client); + const cwd = await createTempCwd(); + await writeLocalPin(cwd); + const stateDir = path.join(cwd, ".state"); + const { context } = await createTestCommandContext({ + cwd, + stateDir, + env: { ...process.env, PRISMA_CLI_MOCK_FIXTURE_PATH: undefined }, + }); + + await expect(runBranchCreate(context, "feat/new")).rejects.toMatchObject({ + code: "BRANCH_CREATE_FAILED", + domain: "branch", + summary: expect.stringContaining('Could not create Branch "feat/new"'), + }); + }); + + it("deletes a preview branch on the resolved project", async () => { + const client = createMockClient(); + client.DELETE = vi.fn().mockResolvedValue({ response: { status: 204 } }); + const { runBranchDelete } = await loadController(client); + const cwd = await createTempCwd(); + await writeLocalPin(cwd); + const stateDir = path.join(cwd, ".state"); + const { context } = await createTestCommandContext({ + cwd, + stateDir, + env: { ...process.env, PRISMA_CLI_MOCK_FIXTURE_PATH: undefined }, + }); + + const result = await runBranchDelete(context, "feature/auth"); + + expect(client.DELETE).toHaveBeenCalledWith( + "/v1/projects/{projectId}/branches/{branchId}", + expect.objectContaining({ + params: { path: { projectId: "proj_123", branchId: "br_feature" } }, + }), + ); + expect(result).toEqual({ + command: "branch.delete", + result: { + projectId: "proj_123", + projectName: "Acme Dashboard", + branchName: "feature/auth", + }, + warnings: [], + nextSteps: [], + }); + }); + + it("refuses to delete the production branch", async () => { + const client = createMockClient(); + client.DELETE = vi.fn(); + const { runBranchDelete } = await loadController(client); + const cwd = await createTempCwd(); + await writeLocalPin(cwd); + const stateDir = path.join(cwd, ".state"); + const { context } = await createTestCommandContext({ + cwd, + stateDir, + env: { ...process.env, PRISMA_CLI_MOCK_FIXTURE_PATH: undefined }, + }); + + await expect(runBranchDelete(context, "main")).rejects.toMatchObject({ + code: "BRANCH_DELETE_FAILED", + domain: "branch", + summary: "Cannot delete the production Branch", + }); + expect(client.DELETE).not.toHaveBeenCalled(); + }); + + it("returns BRANCH_DELETE_FAILED when delete errors", async () => { + const client = createMockClient(); + client.DELETE = vi.fn().mockRejectedValue(new Error("Management API returned HTTP 500")); + const { runBranchDelete } = await loadController(client); + const cwd = await createTempCwd(); + await writeLocalPin(cwd); + const stateDir = path.join(cwd, ".state"); + const { context } = await createTestCommandContext({ + cwd, + stateDir, + env: { ...process.env, PRISMA_CLI_MOCK_FIXTURE_PATH: undefined }, + }); + + await expect(runBranchDelete(context, "feature/auth")).rejects.toMatchObject({ + code: "BRANCH_DELETE_FAILED", + domain: "branch", + summary: expect.stringContaining('Could not delete Branch "feature/auth"'), + }); + }); + + it("renames a preview branch on the resolved project", async () => { + const client = createMockClient(); + client.PATCH = vi.fn().mockResolvedValue({ + data: { data: { id: "br_feature", gitName: "feat/renamed", role: "preview" } }, + response: { status: 200 }, + }); + const { runBranchRename } = await loadController(client); + const cwd = await createTempCwd(); + await writeLocalPin(cwd); + const stateDir = path.join(cwd, ".state"); + const { context } = await createTestCommandContext({ + cwd, + stateDir, + env: { ...process.env, PRISMA_CLI_MOCK_FIXTURE_PATH: undefined }, + }); + + const result = await runBranchRename(context, "feature/auth", "feat/renamed"); + + expect(client.PATCH).toHaveBeenCalledWith( + "/v1/projects/{projectId}/branches/{branchId}", + expect.objectContaining({ + params: { path: { projectId: "proj_123", branchId: "br_feature" } }, + body: { gitName: "feat/renamed" }, + }), + ); + expect(result).toEqual({ + command: "branch.rename", + result: { + projectId: "proj_123", + projectName: "Acme Dashboard", + verboseContext: expectedBranchVerboseContext(), + branch: { id: "br_feature", name: "feat/renamed", role: "preview", envMap: "preview" }, + }, + warnings: [], + nextSteps: [], + }); + }); + + it("refuses to rename the production branch", async () => { + const client = createMockClient(); + client.PATCH = vi.fn(); + const { runBranchRename } = await loadController(client); + const cwd = await createTempCwd(); + await writeLocalPin(cwd); + const stateDir = path.join(cwd, ".state"); + const { context } = await createTestCommandContext({ + cwd, + stateDir, + env: { ...process.env, PRISMA_CLI_MOCK_FIXTURE_PATH: undefined }, + }); + + await expect(runBranchRename(context, "main", "newname")).rejects.toMatchObject({ + code: "BRANCH_RENAME_FAILED", + domain: "branch", + summary: "Cannot rename the production Branch", + }); + expect(client.PATCH).not.toHaveBeenCalled(); + }); + + it("returns BRANCH_RENAME_FAILED when rename errors", async () => { + const client = createMockClient(); + client.PATCH = vi.fn().mockRejectedValue(new Error("Management API returned HTTP 500")); + const { runBranchRename } = await loadController(client); + const cwd = await createTempCwd(); + await writeLocalPin(cwd); + const stateDir = path.join(cwd, ".state"); + const { context } = await createTestCommandContext({ + cwd, + stateDir, + env: { ...process.env, PRISMA_CLI_MOCK_FIXTURE_PATH: undefined }, + }); + + await expect(runBranchRename(context, "feature/auth", "feat/renamed")).rejects.toMatchObject({ + code: "BRANCH_RENAME_FAILED", + domain: "branch", + summary: expect.stringContaining('Could not rename Branch "feature/auth"'), + }); + }); }); diff --git a/packages/cli/tests/project-controller.test.ts b/packages/cli/tests/project-controller.test.ts index 156a252..91b03d7 100644 --- a/packages/cli/tests/project-controller.test.ts +++ b/packages/cli/tests/project-controller.test.ts @@ -1,4 +1,4 @@ -import { readFile, writeFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -242,6 +242,167 @@ describe("project controller", () => { expect(stderr.buffer).toContain("suggested-name"); }); + it("deletes an existing project by name with --yes flag, clearing the local pin", async () => { + const get = vi.fn().mockResolvedValue({ + data: { + data: [ + { id: "proj_123", name: "Acme Dashboard", workspace: { id: "ws_123", name: "Acme Inc" } }, + ], + }, + }); + const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token", GET: get }); + const deleteProject = vi.fn().mockResolvedValue(undefined); + + vi.doMock("../src/lib/auth/auth-ops", () => ({ + readAuthState: vi.fn().mockResolvedValue({ + authenticated: true, + provider: null, + user: { email: "test@example.com" }, + workspace: { id: "ws_123", name: "Acme Inc" }, + }), + performLogin: vi.fn(), + performLogout: vi.fn(), + })); + vi.doMock("../src/lib/auth/guard", () => ({ + requireComputeAuth, + })); + vi.doMock("../src/lib/app/preview-provider", () => ({ + createPreviewAppProvider: vi.fn(() => ({ deleteProject })), + })); + + const cwd = await createTempCwd(); + await mkdir(path.join(cwd, ".prisma"), { recursive: true }); + await writeFile(path.join(cwd, ".prisma", "local.json"), JSON.stringify({ workspaceId: "ws_123", projectId: "proj_123" }), "utf8"); + const stateDir = path.join(cwd, ".state"); + const { context } = await createTestCommandContext({ + cwd, + stateDir, + flags: { yes: true }, + isTTY: false, + env: { ...process.env, PRISMA_CLI_MOCK_FIXTURE_PATH: undefined }, + }); + + await context.stateStore.setAuthSession({ + provider: "github", + userId: "usr_456", + workspaceId: "ws_123", + }); + + const { runProjectDelete } = await import("../src/controllers/project"); + const result = await runProjectDelete(context, "Acme Dashboard"); + + expect(deleteProject).toHaveBeenCalledWith({ + projectId: "proj_123", + signal: context.runtime.signal, + }); + expect(result.result).toMatchObject({ + project: { id: "proj_123", name: "Acme Dashboard" }, + }); + await expect(readFile(path.join(cwd, ".prisma", "local.json"), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("returns PROJECT_NOT_FOUND when deleting a non-existent project", async () => { + const get = vi.fn().mockResolvedValue({ + data: { + data: [ + { id: "proj_123", name: "Acme Dashboard", workspace: { id: "ws_123", name: "Acme Inc" } }, + ], + }, + }); + const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token", GET: get }); + const deleteProject = vi.fn(); + + vi.doMock("../src/lib/auth/auth-ops", () => ({ + readAuthState: vi.fn().mockResolvedValue({ + authenticated: true, + provider: null, + user: { email: "test@example.com" }, + workspace: { id: "ws_123", name: "Acme Inc" }, + }), + performLogin: vi.fn(), + performLogout: vi.fn(), + })); + vi.doMock("../src/lib/auth/guard", () => ({ + requireComputeAuth, + })); + vi.doMock("../src/lib/app/preview-provider", () => ({ + createPreviewAppProvider: vi.fn(() => ({ deleteProject })), + })); + + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + const { context } = await createTestCommandContext({ + cwd, + stateDir, + isTTY: false, + env: { ...process.env, PRISMA_CLI_MOCK_FIXTURE_PATH: undefined }, + }); + + await context.stateStore.setAuthSession({ + provider: "github", + userId: "usr_456", + workspaceId: "ws_123", + }); + + const { runProjectDelete } = await import("../src/controllers/project"); + await expect(runProjectDelete(context, "NonExistent")).rejects.toMatchObject({ + code: "PROJECT_NOT_FOUND", + domain: "project", + summary: "Project not found", + }); + }); + + it("returns PROJECT_DELETE_FAILED when the management API errors", async () => { + const get = vi.fn().mockResolvedValue({ + data: { + data: [ + { id: "proj_123", name: "Acme Dashboard", workspace: { id: "ws_123", name: "Acme Inc" } }, + ], + }, + }); + const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token", GET: get }); + const deleteProject = vi.fn().mockRejectedValue(new Error("Internal Server Error (HTTP 503)")); + + vi.doMock("../src/lib/auth/auth-ops", () => ({ + readAuthState: vi.fn().mockResolvedValue({ + authenticated: true, + provider: null, + user: { email: "test@example.com" }, + workspace: { id: "ws_123", name: "Acme Inc" }, + }), + performLogin: vi.fn(), + performLogout: vi.fn(), + })); + vi.doMock("../src/lib/auth/guard", () => ({ + requireComputeAuth, + })); + vi.doMock("../src/lib/app/preview-provider", () => ({ + createPreviewAppProvider: vi.fn(() => ({ deleteProject })), + })); + + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + const { context } = await createTestCommandContext({ + cwd, + stateDir, + isTTY: false, + env: { ...process.env, PRISMA_CLI_MOCK_FIXTURE_PATH: undefined }, + }); + + await context.stateStore.setAuthSession({ + provider: "github", + userId: "usr_456", + workspaceId: "ws_123", + }); + + const { runProjectDelete } = await import("../src/controllers/project"); + await expect(runProjectDelete(context, "Acme Dashboard")).rejects.toMatchObject({ + code: "PROJECT_DELETE_FAILED", + domain: "project", + summary: expect.stringContaining('Could not delete Project "Acme Dashboard"'), + }); + }); + it("returns PROJECT_CREATE_FAILED when project creation fails", async () => { const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); const createProject = vi.fn().mockRejectedValue(new Error("Internal Server Error (HTTP 503)"));