From b1559fe6bea5e00a04b0e8f137cf648df6e59716 Mon Sep 17 00:00:00 2001 From: Luan van der Westhuizen Date: Fri, 29 May 2026 12:38:23 +0200 Subject: [PATCH 1/7] docs: define explicit deploy project setup --- docs/product/command-spec.md | 90 +++++++++++++++++++++++++----- docs/product/error-conventions.md | 4 +- docs/product/output-conventions.md | 36 +++++++----- docs/product/resource-model.md | 24 +++++--- 4 files changed, 117 insertions(+), 37 deletions(-) diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index d679f72..9b41565 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -81,16 +81,23 @@ Commands resolve project context in this order: 3. `.prisma/local.json` project pin when present, revalidated against platform data 4. durable platform mapping when available 5. remembered local project context, revalidated against platform data -6. `package.json` name matched exactly against accessible project id, name, or slug -7. unambiguous project creation for commands that are allowed to create projects -8. prompt in interactive mode, or structured failure in `--json` / `--no-interactive` mode - -`--project` is an escape hatch for ambiguous or unavailable automatic -resolution, not a setup step. Only `app deploy` may create a missing project, -and only when the inferred name is unambiguous. +6. `package.json` name matched exactly against an existing accessible Project for non-mutating resolution +7. explicit setup choice from `project link`, `project create`, an interactive setup picker, `app deploy --project`, or `app deploy --create-project` +8. structured failure in `--json` / `--no-interactive` mode + +`--project` is an explicit Project choice. When used from an unbound directory +with `app deploy`, it writes `.prisma/local.json` after validation and before +the deployment starts. `--create-project ` is the explicit deploy-time +choice to create and bind a new Project. Package names and directory names may +suggest setup defaults, but they never authorize Project creation by themselves. When `PRISMA_PROJECT_ID` is set, `app deploy` and `app domain` commands skip `.prisma/local.json` reads and do not write a new pin. +`app deploy` is stricter than general inspection commands: it does not use +package-name matching or remembered local context as Project scope. Without a +pin, durable mapping, env var, or explicit Project flag, it enters explicit +setup or fails with `PROJECT_SETUP_REQUIRED`. + ### App Selection Preview app commands that need an app resolve it in this order: @@ -104,9 +111,6 @@ Preview app commands that need an app resolve it in this order: 7. interactive picker only when multiple matching apps make the target ambiguous 8. `APP_AMBIGUOUS` in non-interactive or `--json` mode when unresolved -When `PRISMA_APP_ID` is set, `app deploy` and `app domain` commands skip -`.prisma/local.json` reads and do not write a new pin. - `.prisma/local.json` pins the directory to a Workspace and Project only. It does not pin an App ID. App services are branch-scoped; a service ID from `main` must not be reused automatically when the user deploys from `feat/billing`. @@ -392,6 +396,50 @@ prisma-cli project show --json prisma-cli project show --project proj_123 --json ``` +## `prisma-cli project create ` + +Purpose: + +- create a Prisma Project and bind the current directory to it + +Behavior: + +- requires auth +- creates a Project in the authenticated workspace +- writes `.prisma/local.json` with Workspace and Project IDs +- ensures `.prisma/` is ignored by Git +- does not create a Branch, App, Deployment, database, or Git repository connection +- fails if the platform rejects Project creation + +Examples: + +```bash +prisma-cli project create my-app +prisma-cli project create my-app --json +``` + +## `prisma-cli project link ` + +Purpose: + +- bind the current directory to an existing Prisma Project + +Behavior: + +- requires auth +- resolves exactly one Project by id or name in the authenticated workspace +- writes `.prisma/local.json` with Workspace and Project IDs +- ensures `.prisma/` is ignored by Git +- does not create remote resources +- fails with `PROJECT_NOT_FOUND` or `PROJECT_AMBIGUOUS` when the Project cannot be selected safely + +Examples: + +```bash +prisma-cli project link proj_123 +prisma-cli project link "Acme Dashboard" --json +``` + ## `prisma-cli git connect [git-url]` Purpose: @@ -543,7 +591,7 @@ prisma-cli app run --build-type nextjs prisma-cli app run --build-type bun --entry server.ts --port 3000 ``` -## `prisma-cli app deploy --project --app --branch --framework --entry --http-port --env ` +## `prisma-cli app deploy --project --create-project --app --branch --framework --entry --http-port --env ` Purpose: @@ -552,14 +600,26 @@ Purpose: Behavior: - requires auth -- resolves or creates project context from `--project`, `PRISMA_PROJECT_ID`, `.prisma/local.json`, `package.json#name`, or current directory name +- resolves project context from `--project`, `--create-project`, `PRISMA_PROJECT_ID`, `.prisma/local.json`, durable platform mapping, or an interactive setup choice +- does not infer and create Project context from `package.json#name` or current directory name without explicit setup +- when no Project is resolved in interactive mode, asks which Project the directory should use: + ```text + ? Which Project should this directory use? + ❯ Acme Dashboard + Billing API + Create a new Project + Cancel + ``` +- when "Create a new Project" is selected, prompts for a Project name with the package/directory name as a suggestion +- when no Project is resolved in `--json` / `--no-interactive` mode, fails with `PROJECT_SETUP_REQUIRED` +- `--yes` alone does not choose Project scope; use `--project` or `--create-project` +- `--project` and `--create-project` are mutually exclusive with each other and with `PRISMA_PROJECT_ID` - resolves or creates branch context from `--branch`, local Git branch, or `main` - resolves or creates app context inside the resolved branch from `--app`, `PRISMA_APP_ID`, `package.json#name`, or current directory name - does not prompt when there is no real choice; zero matching apps creates the inferred app -- detects supported frameworks and shows the resolved framework/runtime settings only while binding the directory for the first time - writes `.prisma/local.json` after Project binding succeeds and before build/deploy starts, so retries after a failed deploy do not repeat setup - asks `Customize settings? (y/N)` only while binding the directory for the first time, and only asks for Framework and HTTP port when the user opts in -- subsequent deploys print a compact target header such as `Deploying ./j1 to j1 / main / j1` +- after setup, deploy prints `Deploying to / / `; later deploys print a compact target header such as `Deploying ./j1 to j1 / main / j1` - deploy progress uses short stage copy (`Building locally...`, `Built `, `Uploading...`, `Uploaded`, `Deploying...`, `Deployed`) and never prints `Status: running` or `Deployment is running at ...` - success human output prints `Live in `, the URL on its own line, and `Logs prisma-cli app logs` - accepts repeated `--env NAME=VALUE` flags @@ -572,6 +632,8 @@ Examples: ```bash prisma-cli app deploy +prisma-cli app deploy --project proj_123 +prisma-cli app deploy --create-project my-app --yes prisma-cli app deploy --app my-app --env DATABASE_URL=postgresql://example prisma-cli app deploy --framework nextjs --http-port 3000 prisma-cli app deploy --branch feat-login --framework hono --http-port 3000 diff --git a/docs/product/error-conventions.md b/docs/product/error-conventions.md index 4e32ad3..54b7853 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -160,6 +160,7 @@ These codes are the minimum stable set for the MVP: - `USAGE_ERROR` - `AUTH_REQUIRED` - `PROJECT_UNRESOLVED` +- `PROJECT_SETUP_REQUIRED` - `PROJECT_NOT_FOUND` - `PROJECT_AMBIGUOUS` - `APP_AMBIGUOUS` @@ -198,10 +199,11 @@ Recommended meanings: - `USAGE_ERROR`: invalid arguments or invalid command combination - `AUTH_REQUIRED`: command needs an authenticated session - `PROJECT_UNRESOLVED`: command needs project context and none could be resolved +- `PROJECT_SETUP_REQUIRED`: `app deploy` needs an explicit Project setup choice before it can continue - `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 -- `LOCAL_STATE_STALE`: remembered local project context no longer matches platform data and continuing would be ambiguous +- `LOCAL_STATE_STALE`: local Project pin or remembered context no longer matches platform data and continuing would be ambiguous - `BRANCH_NOT_DEPLOYABLE`: command tried to deploy to a non-deployable branch context - `FRAMEWORK_NOT_DETECTED`: app deploy could not detect a supported Beta framework and no explicit framework/build type was provided - `DEPLOYMENT_NOT_FOUND`: requested deployment id does not exist diff --git a/docs/product/output-conventions.md b/docs/product/output-conventions.md index e63671f..6a23fa2 100644 --- a/docs/product/output-conventions.md +++ b/docs/product/output-conventions.md @@ -265,21 +265,30 @@ When a command acts on a project, branch, app, or deployment, the output should Examples: - `app deploy` should state the resolved target that matters in the current slice -- first local `app deploy` binding should show Workspace, Project, Branch, App, Framework, and Runtime with source annotations before work begins +- first local `app deploy` binding should make the Project choice explicit before work begins - subsequent `app deploy` calls should use a compact target header such as `Deploying ./j1 to j1 / main / j1` - `app logs` should state the deployment it resolved - `app list-deploys` should state which app or branch is being listed The CLI must not make users guess which target a command acted on. -For `app deploy`, the setup block is a one-time local binding surface, not a -per-run summary. Once `.prisma/local.json` has been written, retries and later -deploys should feel like deploys, not setup. Do not repeat source annotations or -ask `Customize settings?` again unless the user deletes the pin or passes a -flag that explicitly changes targeting/configuration. The first setup title -should read `Setting up your local directory `, followed by the resolved -table and then the plain-language note `This directory is now linked to project -.` +For `app deploy`, Project setup is a one-time local binding surface, not a +per-run summary. If no Project is resolved in interactive mode, ask which +Project the directory should use with an arrow-key selection prompt. The picker +lists existing Projects, then `Create a new Project`, then `Cancel`; do not add +a manual id/name entry to the picker. If the user chooses to create a Project, +prompt for a Project name using the package/directory name as an editable +suggestion. Once `.prisma/local.json` has been written, retries and later +deploys should feel like deploys, not setup. + +After setup, keep the confirmation compact: + +```text +✔ Linked "./my-app" to Project "Acme Dashboard" +Saved .prisma/local.json + +Deploying to Acme Dashboard / feat-login / my-app +``` Deploy progress should describe phases without claiming runtime success before health is known. Do not print `Status: running` or `Deployment is running at ...`. @@ -289,11 +298,10 @@ On success, print `Live in `, the URL on its own line, and `Logs prisma-cli app logs`. Human deploy output is stderr; `--json` is the machine-readable stdout path. -Deploy setup and result rows should share one table style: labels start two -spaces from the left margin, values align in one column, and optional origins -align in a dim third column prefixed with `·`. Values should be the strongest -part of the row; origins are secondary reassurance and must be dimmed only when -color is enabled. +Deploy result rows use one compact style: labels start two spaces from the left +margin and values align in one column. Avoid repeating a full Workspace / +Project / Branch / App / Framework / Runtime table in the setup path unless a +future command needs that extra detail. ## Action and Data Commands diff --git a/docs/product/resource-model.md b/docs/product/resource-model.md index 2b38fca..9413717 100644 --- a/docs/product/resource-model.md +++ b/docs/product/resource-model.md @@ -38,8 +38,8 @@ Rules: - `project` is not the same thing as `app` - Public Beta does not read or write committed config files such as `prisma.config.ts` or `.prisma/settings.json` for project resolution - `.prisma/local.json` is a gitignored local pin/cache for Workspace and Project IDs; it is not a declarative repo config file -- `app deploy` may create missing project context only when resolution is unambiguous -- other commands must not create project context implicitly +- 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 ### Branch @@ -190,15 +190,23 @@ Long-term, branch is where app and database relationships meet. Commands resolve project context in this order: 1. explicit `--project ` when present -2. durable platform mapping when available -3. remembered local project context, revalidated against platform data -4. `package.json` name matched exactly against accessible project id, name, or slug -5. unambiguous project creation for commands that are allowed to create projects -6. prompt in interactive mode, or structured failure in `--json` / `--no-interactive` mode +2. `PRISMA_PROJECT_ID` when set for headless deploy/domain commands +3. `.prisma/local.json` project pin when present, revalidated against platform data +4. durable platform mapping when available +5. remembered local project context, revalidated against platform data +6. `package.json` name matched exactly against an existing accessible Project for non-mutating resolution +7. explicit setup choice: `project link`, `project create`, interactive setup picker, `app deploy --project`, or `app deploy --create-project` +8. structured failure in `--json` / `--no-interactive` mode Remembered local project context is an internal convenience after successful resolution. It must be revalidated before use and must not be described to users -as durable linking. Only `app deploy` may create projects implicitly. +as durable linking. Package names and directory names may be suggested during +setup, but they do not authorize Project creation by themselves. + +`app deploy` is stricter than general inspection commands: if the directory is +not pinned and no explicit Project source is provided, it enters explicit setup +or fails with `PROJECT_SETUP_REQUIRED` instead of using package-name or +remembered-local inference. ### App Selection Resolution From c7b94f309c502c5e66fc2ad08900b7ac1fbd8b81 Mon Sep 17 00:00:00 2001 From: Luan van der Westhuizen Date: Fri, 29 May 2026 12:38:44 +0200 Subject: [PATCH 2/7] feat: require explicit project setup for deploy --- packages/cli/src/commands/app/index.ts | 3 + packages/cli/src/commands/project/index.ts | 52 ++- packages/cli/src/controllers/app.ts | 452 ++++++++++++++------- packages/cli/src/controllers/project.ts | 171 +++++++- packages/cli/src/lib/project/resolution.ts | 2 +- packages/cli/src/presenters/project.ts | 23 ++ packages/cli/src/shell/command-meta.ts | 16 +- packages/cli/src/types/project.ts | 11 + 8 files changed, 570 insertions(+), 160 deletions(-) diff --git a/packages/cli/src/commands/app/index.ts b/packages/cli/src/commands/app/index.ts index 8a8864b..ec59d23 100644 --- a/packages/cli/src/commands/app/index.ts +++ b/packages/cli/src/commands/app/index.ts @@ -170,6 +170,7 @@ function createDeployCommand(runtime: CliRuntime): Command { command .addOption(new Option("--app ", "App name")) .addOption(new Option("--project ", "Project id or name")) + .addOption(new Option("--create-project ", "Create and link a new Project before deploying")) .addOption(new Option("--branch ", "Branch name")) .addOption( new Option("--framework ", "Framework to deploy") @@ -198,6 +199,7 @@ function createDeployCommand(runtime: CliRuntime): Command { const httpPort = (options as { httpPort?: string }).httpPort; const envAssignments = (options as { env?: string[] }).env; const projectRef = (options as { project?: string }).project; + const createProjectName = (options as { createProject?: string }).createProject; await runCommand( runtime, @@ -205,6 +207,7 @@ function createDeployCommand(runtime: CliRuntime): Command { options as Record, (context) => runAppDeploy(context, appName, { projectRef, + createProjectName, branchName, entrypoint: entry, buildType, diff --git a/packages/cli/src/commands/project/index.ts b/packages/cli/src/commands/project/index.ts index 0726c95..f691a43 100644 --- a/packages/cli/src/commands/project/index.ts +++ b/packages/cli/src/commands/project/index.ts @@ -1,9 +1,11 @@ import { Command } from "commander"; -import { runProjectList, runProjectShow } from "../../controllers/project"; +import { runProjectCreate, runProjectLink, runProjectList, runProjectShow } from "../../controllers/project"; import { + renderProjectSetup, renderProjectList, renderProjectShow, + serializeProjectSetup, serializeProjectList, serializeProjectShow, } from "../../presenters/project"; @@ -11,7 +13,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, ProjectShowResult } from "../../types/project"; +import type { ProjectListResult, ProjectSetupResult, ProjectShowResult } from "../../types/project"; import { createEnvCommand } from "../env"; export function createProjectCommand(runtime: CliRuntime): Command { @@ -21,11 +23,57 @@ export function createProjectCommand(runtime: CliRuntime): Command { project.addCommand(createProjectListCommand(runtime)); project.addCommand(createProjectShowCommand(runtime)); + project.addCommand(createProjectCreateCommand(runtime)); + project.addCommand(createProjectLinkCommand(runtime)); project.addCommand(createEnvCommand(runtime)); return project; } +function createProjectCreateCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor(configureRuntimeCommand(new Command("create"), runtime), "project.create"); + + command.argument("", "Project name"); + addGlobalFlags(command); + + command.action(async (name, options) => { + await runCommand( + runtime, + "project.create", + options as Record, + (context) => runProjectCreate(context, String(name)), + { + renderHuman: (context, descriptor, result) => renderProjectSetup(context, descriptor, result), + renderJson: (result) => serializeProjectSetup(result), + }, + ); + }); + + return command; +} + +function createProjectLinkCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor(configureRuntimeCommand(new Command("link"), runtime), "project.link"); + + command.argument("", "Project id or name"); + addGlobalFlags(command); + + command.action(async (projectRef, options) => { + await runCommand( + runtime, + "project.link", + options as Record, + (context) => runProjectLink(context, String(projectRef)), + { + renderHuman: (context, descriptor, result) => renderProjectSetup(context, descriptor, result), + renderJson: (result) => serializeProjectSetup(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/app.ts b/packages/cli/src/controllers/app.ts index e03fda3..33456b3 100644 --- a/packages/cli/src/controllers/app.ts +++ b/packages/cli/src/controllers/app.ts @@ -49,10 +49,11 @@ import { readBunPackageJson, type BunPackageJsonLike } from "../lib/app/bun-proj import { inferTargetName, projectNotFoundError, + resolveDurablePlatformMapping, resolveProjectTarget, type InferredTargetName, - type InferredTargetNameSource, type ProjectCandidate, + sortProjects, } from "../lib/project/resolution"; import { ensureLocalResolutionPinGitignore, @@ -83,7 +84,7 @@ import { } from "../lib/app/preview-provider"; import { formatDomainFailureFix } from "../lib/app/domain-guidance"; import { requireAuthenticatedAuthState } from "./auth"; -import { listRealWorkspaceProjects } from "./project"; +import { listRealWorkspaceProjects, resolveProjectForLink } from "./project"; import { createSelectPromptPort } from "./select-prompt-port"; type AppDomainCommand = "add" | "show" | "remove" | "retry" | "wait"; @@ -200,6 +201,7 @@ export async function runAppDeploy( appName: string | undefined, options?: { projectRef?: string; + createProjectName?: string; branchName?: string; entrypoint?: string; buildType?: string; @@ -212,7 +214,13 @@ export async function runAppDeploy( const envProjectId = readDeployEnvOverride(context, PRISMA_PROJECT_ID_ENV_VAR); const envAppId = readDeployEnvOverride(context, PRISMA_APP_ID_ENV_VAR); - const skipLocalPin = Boolean(envProjectId || envAppId); + assertExclusiveDeployProjectInputs({ + projectRef: options?.projectRef, + createProjectName: options?.createProjectName, + envProjectId, + }); + + const skipLocalPin = Boolean(envProjectId || options?.projectRef || options?.createProjectName); const localPin = skipLocalPin ? ({ kind: "missing" } satisfies LocalResolutionPinReadResult) : await readLocalResolutionPin(context.runtime.cwd); @@ -221,10 +229,34 @@ export async function runAppDeploy( } const explicitBuildType = Boolean(options?.buildType && options.buildType !== "auto"); - const branch = await resolveDeployBranch(context, options?.branchName); if (options?.httpPort) { parseDeployHttpPort(options.httpPort); } + assertSupportedEntrypointForRequestedDeployShape({ + requestedFramework: options?.framework, + requestedBuildType: options?.buildType, + explicitBuildType, + entrypoint: options?.entrypoint, + }); + + const branch = await resolveDeployBranch(context, options?.branchName); + const { provider, target, projectId } = await requireProviderAndDeployProjectContext(context, options?.projectRef, { + branch, + createProjectName: options?.createProjectName, + envProjectId, + localPin, + }); + + const shouldWriteLocalPin = Boolean(target.localPinAction); + if (shouldWriteLocalPin) { + await writeLocalResolutionPin(context.runtime.cwd, { + workspaceId: target.workspace.id, + projectId: target.project.id, + }); + await ensureLocalResolutionPinGitignore(context.runtime.cwd); + maybeRenderProjectLinked(context, target.project.name); + } + let framework = await resolveDeployFramework(context, { requestedFramework: options?.framework, requestedBuildType: options?.buildType, @@ -237,32 +269,19 @@ export async function runAppDeploy( commandName: "deploy", }), ); - const firstDeploy = !skipLocalPin && localPin.kind === "missing"; - const { provider, target, projectId } = await requireProviderAndDeployProjectContext(context, options?.projectRef, { - allowCreate: true, - branch, - envProjectId, - localPin, - }); const apps = await listApps(context, provider, projectId, target.branch.name); const selectedApp = await resolveDeployAppSelection(context, projectId, apps, { explicitAppName: appName, explicitAppId: envAppId, - firstDeploy, + firstDeploy: shouldWriteLocalPin, inferName: () => inferTargetName(context.runtime.cwd), }); await maybeRenderDeploySetupBlock(context, { - firstDeploy: selectedApp.firstDeploy, - workspaceName: target.workspace.name, + includeDirectory: !shouldWriteLocalPin, projectName: target.project.name, - projectAnnotation: annotationForProjectResolution(target.resolution), branchName: target.branch.name, - branchAnnotation: branch.annotation, appName: selectedApp.displayName, - appAnnotation: selectedApp.annotation, - framework, - runtime, }); const customized = await maybeCustomizeDeploySettings(context, { @@ -281,15 +300,6 @@ export async function runAppDeploy( const buildType = framework.buildType; assertSupportedEntrypoint(buildType, options?.entrypoint, "deploy"); const portMapping = parseDeployPortMapping(String(runtime.port)); - const shouldWriteLocalPin = firstDeploy && !skipLocalPin; - if (shouldWriteLocalPin) { - await writeLocalResolutionPin(context.runtime.cwd, { - workspaceId: target.workspace.id, - projectId: target.project.id, - }); - await ensureLocalResolutionPinGitignore(context.runtime.cwd); - maybeRenderLocalPinBound(context, target.project.name); - } const progressState = createPreviewDeployProgressState(); const deployStartedAt = Date.now(); @@ -1193,7 +1203,7 @@ async function resolveAppDomainTarget( const envProjectId = readDeployEnvOverride(context, PRISMA_PROJECT_ID_ENV_VAR); const envAppId = readDeployEnvOverride(context, PRISMA_APP_ID_ENV_VAR); - const skipLocalPin = Boolean(envProjectId || envAppId); + const skipLocalPin = Boolean(envProjectId); const localPin = skipLocalPin ? ({ kind: "missing" } satisfies LocalResolutionPinReadResult) : await readLocalResolutionPin(context.runtime.cwd); @@ -1202,7 +1212,6 @@ async function resolveAppDomainTarget( } const { provider, target, projectId } = await requireProviderAndDeployProjectContext(context, options?.projectRef, { - allowCreate: false, branch, envProjectId, localPin, @@ -2151,6 +2160,7 @@ interface ResolvedAppProjectContext { kind: BranchKind; }; resolution: ProjectResolution; + localPinAction?: "created" | "linked"; } async function requireProviderAndProjectContext( @@ -2180,8 +2190,8 @@ async function requireProviderAndDeployProjectContext( context: CommandContext, explicitProject: string | undefined, options: { - allowCreate?: boolean; branch?: ResolvedDeployBranch; + createProjectName?: string; envProjectId?: string; localPin: LocalResolutionPinReadResult; }, @@ -2224,11 +2234,7 @@ async function resolveProjectContext( createProject: options?.allowCreate ? async (name) => { const project = await provider.createProject({ name }).catch((error) => { - throw createProjectOnFirstDeployError({ - error, - inferredName: name, - workspaceName: authState.workspace!.name, - }); + throw createProjectForSetupError(error, name, authState.workspace!); }); return { id: project.id, @@ -2258,8 +2264,8 @@ async function resolveDeployProjectContext( provider: ReturnType, explicitProject: string | undefined, options: { - allowCreate?: boolean; branch?: ResolvedDeployBranch; + createProjectName?: string; envProjectId?: string; localPin: LocalResolutionPinReadResult; }, @@ -2272,35 +2278,38 @@ async function resolveDeployProjectContext( const branch = options.branch ?? await resolveDeployBranch(context, undefined); const projects = await listRealWorkspaceProjects(client, workspace); - const createProject = options.allowCreate - ? async (name: string) => { - const project = await provider.createProject({ name }).catch((error) => { - throw createProjectOnFirstDeployError({ - error, - inferredName: name, - workspaceName: workspace.name, - }); - }); - return { - id: project.id, - name: project.name, - workspace, - }; - } - : undefined; if (explicitProject) { - const resolved = await resolveProjectTarget({ - context, + const project = resolveProjectForLink(explicitProject, projects, workspace); + return withDeployBranch({ workspace, - explicitProject, - listProjects: async () => projects, - createProject, - allowCreate: options.allowCreate, - prompt: createSelectPromptPort(context), - remember: true, - }); - return withDeployBranch(resolved, branch); + project: toProjectSummary(project), + resolution: { + projectSource: "explicit", + targetName: explicitProject, + targetNameSource: "explicit", + }, + localPinAction: "linked", + }, branch); + } + + if (options.createProjectName) { + const projectName = options.createProjectName.trim(); + if (!projectName) { + throw projectSetupNameRequiredError("app deploy --create-project"); + } + + const created = await createProjectForDeploySetup(provider, projectName, workspace); + return withDeployBranch({ + workspace, + project: toProjectSummary(created), + resolution: { + projectSource: "created", + targetName: projectName, + targetNameSource: "explicit", + }, + localPinAction: "created", + }, branch); } if (options.envProjectId) { @@ -2341,16 +2350,114 @@ async function resolveDeployProjectContext( }, branch); } - const resolved = await resolveProjectTarget({ - context, + const platformMapping = await resolveDurablePlatformMapping(); + if (platformMapping && platformMapping.workspace.id === workspace.id) { + return withDeployBranch({ + workspace, + project: toProjectSummary(platformMapping), + resolution: { + projectSource: "platform-mapping", + targetName: platformMapping.name, + targetNameSource: "platform-mapping", + }, + }, branch); + } + + if (canPrompt(context) && !context.flags.yes) { + const resolved = await resolveInteractiveDeployProjectSetup(context, provider, workspace, projects); + return withDeployBranch(resolved, branch); + } + + const suggestedName = await inferTargetName(context.runtime.cwd); + throw projectSetupRequiredError(projects, suggestedName); +} + +type DeployProjectSetupChoice = + | { kind: "project"; project: ProjectCandidate } + | { kind: "create" } + | { kind: "cancel" }; + +async function resolveInteractiveDeployProjectSetup( + context: CommandContext, + provider: ReturnType, + workspace: AuthWorkspace, + projects: ProjectCandidate[], +): Promise> { + const sortedProjects = sortProjects(projects); + const choice = await selectPrompt({ + input: context.runtime.stdin, + output: context.runtime.stderr, + message: "Which Project should this directory use?", + choices: [ + ...sortedProjects.map((project) => ({ + label: project.name, + value: { kind: "project" as const, project }, + })), + { label: "Create a new Project", value: { kind: "create" as const } }, + { label: "Cancel", value: { kind: "cancel" as const } }, + ], + }); + + if (choice.kind === "cancel") { + throw usageError( + "Project setup canceled", + "Deploy needs a Project before it can continue.", + "Choose an existing Project or create a new one, then rerun deploy.", + ["prisma-cli app deploy --project ", "prisma-cli app deploy --create-project "], + "project", + ); + } + + if (choice.kind === "project") { + return { + workspace, + project: toProjectSummary(choice.project), + resolution: { + projectSource: "prompt", + targetName: choice.project.name, + targetNameSource: "prompt", + }, + localPinAction: "linked", + }; + } + + const suggestedName = await inferTargetName(context.runtime.cwd); + const rawName = await textPrompt({ + input: context.runtime.stdin, + output: context.runtime.stderr, + message: "Project name", + placeholder: suggestedName.name, + validate: (value) => validateProjectSetupNameText(value, suggestedName.name), + }); + const projectName = rawName.trim() || suggestedName.name; + const created = await createProjectForDeploySetup(provider, projectName, workspace); + + return { workspace, - listProjects: async () => projects, - createProject, - allowCreate: options.allowCreate, - prompt: createSelectPromptPort(context), - remember: true, + project: toProjectSummary(created), + resolution: { + projectSource: "created", + targetName: projectName, + targetNameSource: rawName.trim() ? "prompt" : suggestedName.source, + }, + localPinAction: "created", + }; +} + +async function createProjectForDeploySetup( + provider: ReturnType, + projectName: string, + workspace: AuthWorkspace, +): Promise { + const created = await provider.createProject({ name: projectName }).catch((error) => { + throw createProjectForSetupError(error, projectName, workspace); }); - return withDeployBranch(resolved, branch); + + return { + id: created.id, + name: created.name, + workspace, + }; } function withDeployBranch( @@ -2377,6 +2484,52 @@ function toBranchKind(name: string): BranchKind { return name === "production" || name === "main" ? "production" : "preview"; } +function assertExclusiveDeployProjectInputs(options: { + projectRef: string | undefined; + createProjectName: string | undefined; + envProjectId: string | undefined; +}): void { + const provided = [ + options.projectRef ? "--project" : null, + options.createProjectName ? "--create-project" : null, + options.envProjectId ? PRISMA_PROJECT_ID_ENV_VAR : null, + ].filter((value): value is string => Boolean(value)); + + if (provided.length <= 1) { + return; + } + + throw usageError( + "Project selection is ambiguous", + `${provided.join(", ")} cannot be used together.`, + "Choose exactly one Project source for this deploy.", + [ + "prisma-cli app deploy --project ", + "prisma-cli app deploy --create-project ", + `unset ${PRISMA_PROJECT_ID_ENV_VAR}`, + ], + "project", + ); +} + +function validateProjectSetupNameText(value: string | undefined, fallback: string): string | undefined { + if ((value?.trim() || fallback).trim().length > 0) { + return undefined; + } + + return "Enter a Project name."; +} + +function projectSetupNameRequiredError(command: string): CliError { + return usageError( + "Project create requires a name", + "The project name must be a non-empty value.", + "Pass a Project name explicitly.", + [`prisma-cli ${command} my-app`], + "project", + ); +} + interface ResolvedDeployBranch { name: string; annotation: string; @@ -2505,6 +2658,26 @@ function resolveDeployRuntime( }; } +function assertSupportedEntrypointForRequestedDeployShape(options: { + requestedFramework: string | undefined; + requestedBuildType: string | undefined; + explicitBuildType: boolean; + entrypoint: string | undefined; +}): void { + if (options.requestedFramework) { + const framework = frameworkFromUserFacingValue(options.requestedFramework, "set by --framework"); + assertSupportedEntrypoint(framework.buildType, options.entrypoint, "deploy"); + return; + } + + if (!options.explicitBuildType) { + return; + } + + const buildType = normalizeBuildType(options.requestedBuildType); + assertSupportedEntrypoint(buildType, options.entrypoint, "deploy"); +} + async function detectDeployFramework(cwd: string): Promise { const packageJson = await readBunPackageJson(cwd); const nextConfig = await detectNextConfig(cwd); @@ -2641,16 +2814,10 @@ function frameworkNotDetectedError(cwd: string | undefined, requestedFramework?: async function maybeRenderDeploySetupBlock( context: CommandContext, details: { - firstDeploy: boolean; - workspaceName: string; + includeDirectory: boolean; projectName: string; - projectAnnotation: string; branchName: string; - branchAnnotation: string; appName: string; - appAnnotation: string; - framework: ResolvedDeployFramework; - runtime: ResolvedDeployRuntime; }, ): Promise { if (context.flags.json || context.flags.quiet) { @@ -2658,33 +2825,19 @@ async function maybeRenderDeploySetupBlock( } const directory = formatDeployDirectory(context.runtime.cwd); - if (!details.firstDeploy) { - context.output.stderr.write(`Deploying ${directory} to ${details.projectName} / ${details.branchName} / ${details.appName}\n\n`); - return; - } - - const title = `Setting up your local directory ${formatLocalDirectory(context.runtime.cwd, context.runtime.env)}`; - const rows = details.firstDeploy - ? [ - { label: "Workspace", value: details.workspaceName }, - { label: "Project", value: details.projectName, origin: details.projectAnnotation }, - { label: "Branch", value: details.branchName, origin: details.branchAnnotation }, - { label: "App", value: details.appName, origin: details.appAnnotation }, - { label: "Framework", value: details.framework.displayName, origin: details.framework.annotation }, - { label: "Runtime", value: `HTTP ${details.runtime.port}`, origin: details.runtime.annotation }, - ] - : []; - const lines = [title, "", ...renderDeployOutputRows(context.ui, rows), ""]; - - context.output.stderr.write(`${lines.join("\n")}\n`); + const prefix = details.includeDirectory ? `Deploying ${directory} to` : "Deploying to"; + context.output.stderr.write(`${prefix} ${details.projectName} / ${details.branchName} / ${details.appName}\n\n`); } -function maybeRenderLocalPinBound(context: CommandContext, projectName: string): void { +function maybeRenderProjectLinked(context: CommandContext, projectName: string): void { if (context.flags.json || context.flags.quiet) { return; } - context.output.stderr.write(`This directory is now linked to project ${projectName}.\n\n`); + context.output.stderr.write( + `${context.ui.success("✔")} Linked "${formatDeployDirectory(context.runtime.cwd)}" to Project "${projectName}"\n` + + `Saved ${LOCAL_RESOLUTION_PIN_RELATIVE_PATH}\n\n`, + ); } async function maybeCustomizeDeploySettings( @@ -2770,29 +2923,6 @@ async function maybeCustomizeDeploySettings( }; } -function annotationForProjectResolution(resolution: ProjectResolution): string { - switch (resolution.projectSource) { - case "explicit": - return "set by --project"; - case "env": - return `from ${PRISMA_PROJECT_ID_ENV_VAR}`; - case "local-pin": - return "from local pin"; - case "created": - return resolution.targetNameSource === "directory-name" - ? "created from directory name" - : "created from package.json"; - case "package-name": - case "directory-name": - return "linked to existing project"; - case "platform-mapping": - case "remembered-local": - return "linked to existing project"; - case "prompt": - return "selected by you"; - } -} - function frameworkDisplayName(framework: DeployFramework): string { switch (framework) { case "nextjs": @@ -2822,18 +2952,6 @@ function formatDeployDirectory(cwd: string): string { return basename ? `./${basename}` : "."; } -function formatLocalDirectory(cwd: string, env: NodeJS.ProcessEnv): string { - const resolved = path.resolve(cwd); - const home = env.HOME ? path.resolve(env.HOME) : null; - - if (home && (resolved === home || resolved.startsWith(`${home}${path.sep}`))) { - const relative = path.relative(home, resolved); - return relative ? `~/${relative}` : "~"; - } - - return resolved; -} - async function readCurrentWorkspaceId(context: CommandContext): Promise { const state = await context.stateStore.read(); if (state.auth?.workspaceId) { @@ -3089,35 +3207,55 @@ function readDeployEnvOverride(context: CommandContext, name: string): string | return value ? value : undefined; } -/** - * `app deploy` falls into "create a new project on first deploy" when no - * existing project matches the package.json name (or the cwd basename as a - * fallback). When the create call fails the user often doesn't realise the - * CLI was attempting to create a project at all — they thought the deploy - * would find an existing project. Surface that context, and recommend the - * explicit `--project` flag as the unambiguous way out. - */ -function createProjectOnFirstDeployError(options: { - error: unknown; - inferredName: string; - workspaceName: string; -}): CliError { - const { error, inferredName, workspaceName } = options; +function projectSetupRequiredError( + projects: ProjectCandidate[], + suggestedName: InferredTargetName, +): CliError { + const createCommand = `prisma-cli app deploy --create-project ${formatCommandArgument(suggestedName.name)}`; + + return new CliError({ + code: "PROJECT_SETUP_REQUIRED", + domain: "project", + summary: "Choose a Project before deploying this directory", + why: "This directory is not linked to a Prisma Project, and deploy will not choose or create one implicitly.", + fix: "Choose an existing Project with --project, create one with --create-project, or rerun interactively to pick from the setup list.", + meta: { + candidates: sortProjects(projects).map((project) => ({ + id: project.id, + name: project.name, + })), + suggestedProjectName: suggestedName.name, + suggestedProjectNameSource: suggestedName.source, + recoveryCommands: [ + "prisma-cli app deploy --project ", + createCommand, + ], + }, + exitCode: 1, + nextSteps: [ + "prisma-cli project list", + "prisma-cli app deploy --project ", + createCommand, + ], + }); +} + +function createProjectForSetupError(error: unknown, projectName: string, workspace: AuthWorkspace): CliError { const status = extractHttpStatus(error); const errorMessage = error instanceof Error ? error.message : String(error); - const inferredContext = `No existing project matched the package.json name \`${inferredName}\`, so the CLI attempted to create one.`; const nextSteps = [ "prisma-cli project list", "prisma-cli app deploy --project ", + `prisma-cli app deploy --create-project ${formatCommandArgument(projectName)}`, ]; if (status === 401 || status === 403) { return new CliError({ code: "AUTH_FORBIDDEN", domain: "auth", - summary: "Could not create a new project for this deploy", - why: `${inferredContext} The platform rejected the create (HTTP ${status}).`, - fix: `Pass --project to deploy into an existing project, or grant the service token project-create permission on workspace \`${workspaceName}\`.`, + summary: `Could not create Project "${projectName}"`, + why: `The platform rejected the Project create in workspace "${workspace.name}" (HTTP ${status}).`, + fix: "Choose an existing Project with --project, or grant the token permission to create Projects in this workspace.", debug: formatDebugDetails(error), exitCode: 1, nextSteps, @@ -3127,15 +3265,19 @@ function createProjectOnFirstDeployError(options: { return new CliError({ code: "DEPLOY_FAILED", domain: "app", - summary: "Could not create a new project for this deploy", - why: `${inferredContext} ${errorMessage}`.trim(), - fix: "Pass --project to deploy into an existing project, or retry after addressing the platform error above.", + summary: `Could not create Project "${projectName}"`, + why: errorMessage, + fix: "Choose an existing Project with --project, or retry after addressing the platform error above.", debug: formatDebugDetails(error), exitCode: 1, nextSteps, }); } +function formatCommandArgument(value: string): string { + return /^[A-Za-z0-9._/-]+$/.test(value) ? value : JSON.stringify(value); +} + function extractHttpStatus(error: unknown): number | null { if (!error || typeof error !== "object") { return null; diff --git a/packages/cli/src/controllers/project.ts b/packages/cli/src/controllers/project.ts index d510401..193951a 100644 --- a/packages/cli/src/controllers/project.ts +++ b/packages/cli/src/controllers/project.ts @@ -8,11 +8,19 @@ import { } from "../adapters/git"; import { requireComputeAuth } from "../lib/auth/guard"; import { + projectAmbiguousError, + projectNotFoundError, resolveProjectTarget, sortProjects, type ProjectCandidate, } from "../lib/project/resolution"; -import { authRequiredError, CliError, usageError, workspaceRequiredError } from "../shell/errors"; +import { + ensureLocalResolutionPinGitignore, + LOCAL_RESOLUTION_PIN_RELATIVE_PATH, + writeLocalResolutionPin, +} from "../lib/project/local-pin"; +import { createPreviewAppProvider } from "../lib/app/preview-provider"; +import { authRequiredError, CliError, featureUnavailableError, usageError, workspaceRequiredError } from "../shell/errors"; import type { CommandSuccess } from "../shell/output"; import { canPrompt, type CommandContext } from "../shell/runtime"; import { renderSummaryLine } from "../shell/ui"; @@ -21,6 +29,8 @@ import type { GitRepositoryConnection, ProjectListResult, ProjectRepositoryConnectionResult, + ProjectSummary, + ProjectSetupResult, ProjectShowResult, } from "../types/project"; import { createCliUseCaseGateways } from "../use-cases/create-cli-gateways"; @@ -99,6 +109,92 @@ export async function runProjectShow( }; } +export async function runProjectCreate( + context: CommandContext, + projectName: string, +): Promise> { + const authState = await requireAuthenticatedAuthState(context); + const workspace = authState.workspace; + if (!workspace) { + throw workspaceRequiredError(); + } + + if (!isValidProjectSetupName(projectName)) { + throw usageError( + "Project create requires a name", + "The project name must be a non-empty value.", + "Pass the Project name as the first argument.", + ["prisma-cli project create my-app"], + "project", + ); + } + + if (!isRealMode(context)) { + throw featureUnavailableError( + "Project create is not available in fixture mode", + "Creating Projects requires live platform integration.", + "Rerun without fixture mode enabled to create a Project.", + ["prisma-cli auth login"], + "project", + ); + } + + const client = await requireComputeAuth(context.runtime.env); + if (!client) { + throw authRequiredError(); + } + + const provider = createPreviewAppProvider(client); + const created = await provider.createProject({ name: projectName.trim() }).catch((error) => { + throw projectCreateFailedError(error); + }); + const result = await bindProjectToDirectory(context, workspace, { + id: created.id, + name: created.name, + }, "created"); + + return { + command: "project.create", + result, + warnings: [], + nextSteps: ["prisma-cli app deploy"], + }; +} + +export async function runProjectLink( + context: CommandContext, + projectRef: string, +): Promise> { + const authState = await requireAuthenticatedAuthState(context); + const workspace = authState.workspace; + if (!workspace) { + throw workspaceRequiredError(); + } + + if (!projectRef || !projectRef.trim()) { + throw usageError( + "Project link requires a Project id or name", + "The command cannot choose a Project without an explicit id or name.", + "Pass the Project id or name as the first argument.", + ["prisma-cli project link proj_123"], + "project", + ); + } + + const projects = isRealMode(context) + ? await listRealProjectsForLink(context, workspace) + : listFixtureWorkspaceProjects(context, workspace); + const project = resolveProjectForLink(projectRef.trim(), projects, workspace); + const result = await bindProjectToDirectory(context, workspace, toProjectSummary(project), "linked"); + + return { + command: "project.link", + result, + warnings: [], + nextSteps: ["prisma-cli app deploy"], + }; +} + export async function runGitConnect( context: CommandContext, gitUrl: string | undefined, @@ -283,6 +379,18 @@ async function resolveProjectShowInRealMode( }); } +async function listRealProjectsForLink( + context: CommandContext, + workspace: AuthWorkspace, +): Promise { + const client = await requireComputeAuth(context.runtime.env); + if (!client) { + throw authRequiredError(); + } + + return listRealWorkspaceProjects(client, workspace); +} + async function resolveProjectShowInFixtureMode( context: CommandContext, workspace: AuthWorkspace, @@ -331,6 +439,67 @@ export function listFixtureWorkspaceProjects( ); } +export function resolveProjectForLink( + projectRef: string, + projects: ProjectCandidate[], + workspace: AuthWorkspace, +): ProjectCandidate { + const matches = projects.filter((project) => project.id === projectRef || project.name === projectRef); + if (matches.length === 1) { + return matches[0]!; + } + if (matches.length > 1) { + throw projectAmbiguousError(projectRef, matches); + } + throw projectNotFoundError(projectRef, workspace); +} + +export async function bindProjectToDirectory( + context: CommandContext, + workspace: AuthWorkspace, + project: ProjectSummary, + action: ProjectSetupResult["action"], +): Promise { + await writeLocalResolutionPin(context.runtime.cwd, { + workspaceId: workspace.id, + projectId: project.id, + }); + await ensureLocalResolutionPinGitignore(context.runtime.cwd); + + return { + workspace, + project, + directory: formatSetupDirectory(context.runtime.cwd), + localPin: { + path: LOCAL_RESOLUTION_PIN_RELATIVE_PATH, + written: true, + }, + action, + }; +} + +function formatSetupDirectory(cwd: string): string { + const basename = cwd.split(/[\\/]/).filter(Boolean).pop(); + return basename ? `./${basename}` : "."; +} + +function isValidProjectSetupName(projectName: string): boolean { + return projectName.trim().length > 0; +} + +function projectCreateFailedError(error: unknown): CliError { + return new CliError({ + code: "DEPLOY_FAILED", + domain: "project", + summary: "Could not create Project", + why: error instanceof Error ? error.message : String(error), + fix: "Retry the command, or choose an existing Project with prisma-cli project link .", + debug: error instanceof Error ? error.stack ?? error.message : String(error), + exitCode: 1, + nextSteps: ["prisma-cli project list", "prisma-cli project link "], + }); +} + interface SourceRepositoryResponse { id: string; type?: "source-repository"; diff --git a/packages/cli/src/lib/project/resolution.ts b/packages/cli/src/lib/project/resolution.ts index 7c1811c..02ffe83 100644 --- a/packages/cli/src/lib/project/resolution.ts +++ b/packages/cli/src/lib/project/resolution.ts @@ -298,7 +298,7 @@ function projectMatchesPackageName(project: ProjectCandidate, packageName: strin return project.id === packageName || project.name === packageName || project.slug === packageName; } -async function resolveDurablePlatformMapping(): Promise { +export async function resolveDurablePlatformMapping(): Promise { return null; } diff --git a/packages/cli/src/presenters/project.ts b/packages/cli/src/presenters/project.ts index 997d402..3fd16e2 100644 --- a/packages/cli/src/presenters/project.ts +++ b/packages/cli/src/presenters/project.ts @@ -4,9 +4,11 @@ import type { GitRepositoryConnection, ProjectListResult, ProjectRepositoryConnectionResult, + ProjectSetupResult, ProjectShowResult, } from "../types/project"; import { renderList, renderMutate, renderShow, serializeList } from "../output/patterns"; +import { renderSummaryLine } from "../shell/ui"; export function renderProjectList( context: CommandContext, @@ -70,6 +72,27 @@ export function serializeProjectShow(result: ProjectShowResult) { return result; } +export function renderProjectSetup( + context: CommandContext, + _descriptor: CommandDescriptor, + result: ProjectSetupResult, +): string[] { + const lines = result.action === "created" + ? [renderSummaryLine(context.ui, "success", `Created Project "${result.project.name}"`)] + : []; + + lines.push( + renderSummaryLine(context.ui, "success", `Linked "${result.directory}" to Project "${result.project.name}"`), + `Saved ${result.localPin.path}`, + ); + + return lines; +} + +export function serializeProjectSetup(result: ProjectSetupResult) { + 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 ea364c7..380730c 100644 --- a/packages/cli/src/shell/command-meta.ts +++ b/packages/cli/src/shell/command-meta.ts @@ -54,7 +54,7 @@ const DESCRIPTORS: CommandDescriptor[] = [ id: "project", path: ["prisma", "project"], description: "Manage and inspect your Prisma projects", - examples: ["prisma-cli project list", "prisma-cli project show"], + examples: ["prisma-cli project list", "prisma-cli project link proj_123", "prisma-cli project create my-app"], }, { id: "app", @@ -89,6 +89,18 @@ const DESCRIPTORS: CommandDescriptor[] = [ description: "Show which project is active for this directory", examples: ["prisma-cli project show", "prisma-cli project show --project proj_123 --json"], }, + { + id: "project.create", + path: ["prisma", "project", "create"], + description: "Create a Project and link this directory", + examples: ["prisma-cli project create my-app", "prisma-cli project create my-app --json"], + }, + { + id: "project.link", + path: ["prisma", "project", "link"], + description: "Link this directory to an existing Project", + examples: ["prisma-cli project link proj_123", "prisma-cli project link \"Acme Dashboard\" --json"], + }, { id: "git.connect", path: ["prisma", "git", "connect"], @@ -141,6 +153,8 @@ const DESCRIPTORS: CommandDescriptor[] = [ description: "Creates a new deployment for the app", examples: [ "prisma-cli app deploy", + "prisma-cli app deploy --project proj_123", + "prisma-cli app deploy --create-project my-app --yes", "prisma-cli app deploy --app my-app --env DATABASE_URL=postgresql://example", "prisma-cli app deploy --app my-app --framework nextjs --http-port 3000", "prisma-cli app deploy --branch feat-login --framework hono", diff --git a/packages/cli/src/types/project.ts b/packages/cli/src/types/project.ts index 0763a6e..81ece5a 100644 --- a/packages/cli/src/types/project.ts +++ b/packages/cli/src/types/project.ts @@ -33,6 +33,17 @@ export interface ProjectShowResult { resolution: ProjectResolution; } +export interface ProjectSetupResult { + workspace: AuthWorkspace; + project: ProjectSummary; + directory: string; + localPin: { + path: string; + written: true; + }; + action: "created" | "linked"; +} + export interface GitRepositoryConnection { id: string | null; provider: "github"; From 4370d648976eb4719f1b6a8ce1b06bb5a4e1eb38 Mon Sep 17 00:00:00 2001 From: Luan van der Westhuizen Date: Fri, 29 May 2026 12:39:02 +0200 Subject: [PATCH 3/7] test: cover explicit deploy project setup --- packages/cli/tests/app-controller.test.ts | 358 ++++++++++++++++-- packages/cli/tests/app.test.ts | 3 + packages/cli/tests/project-controller.test.ts | 109 +++++- packages/cli/tests/project.test.ts | 22 +- 4 files changed, 449 insertions(+), 43 deletions(-) diff --git a/packages/cli/tests/app-controller.test.ts b/packages/cli/tests/app-controller.test.ts index 3da2345..f993682 100644 --- a/packages/cli/tests/app-controller.test.ts +++ b/packages/cli/tests/app-controller.test.ts @@ -836,7 +836,7 @@ describe("app controller", () => { expect(stdout.buffer).toContain("\"status\":\"verifying\""); }); - it("infers project, branch, app, framework, and runtime for a first deploy", async () => { + it("uses an explicit project, branch, app, framework, and runtime for a first deploy", async () => { const client = { token: "token", GET: vi.fn().mockImplementation((pathName: string) => { @@ -913,7 +913,9 @@ describe("app controller", () => { }, }); - const result = await runAppDeploy(context, undefined); + const result = await runAppDeploy(context, undefined, { + projectRef: "proj_my_app", + }); expect(listApps).toHaveBeenCalledWith("proj_my_app", { branchName: "feat-j1" }); expect(deployApp).toHaveBeenCalledWith( @@ -935,9 +937,9 @@ describe("app controller", () => { kind: "preview", }, resolution: { - projectSource: "package-name", - targetName: "my-app", - targetNameSource: "package-name", + projectSource: "explicit", + targetName: "proj_my_app", + targetNameSource: "explicit", }, app: { id: "app_new", @@ -948,11 +950,9 @@ describe("app controller", () => { written: true, }, }); - expect(stderr.buffer).toContain(`Setting up your local directory ${cwd}`); - expect(stderr.buffer).toContain("Project my-app"); - expect(stderr.buffer).toContain("Branch feat-j1"); - expect(stderr.buffer).toContain("Framework Next.js"); - expect(stderr.buffer).toContain("Runtime HTTP 3000"); + expect(stderr.buffer).toContain(`Linked "./${path.basename(cwd)}" to Project "my-app"`); + expect(stderr.buffer).toContain("Saved .prisma/local.json"); + expect(stderr.buffer).toContain("Deploying to my-app / feat-j1 / my-app"); await expect(readLocalPin(cwd)).resolves.toEqual({ workspaceId: "ws_123", projectId: "proj_my_app", @@ -1012,7 +1012,8 @@ describe("app controller", () => { workspaceId: "ws_123", projectId: "proj_123", }); - expect(stderr.buffer).toContain("This directory is now linked to project Acme Dashboard."); + expect(stderr.buffer).toContain(`Linked "./${path.basename(cwd)}" to Project "Acme Dashboard"`); + expect(stderr.buffer).toContain("Saved .prisma/local.json"); expect(stderr.buffer).toContain("Building locally..."); }); @@ -1301,7 +1302,279 @@ describe("app controller", () => { expect(stderr.buffer).not.toContain("from PRISMA_PROJECT_ID"); }); + it("returns PROJECT_SETUP_REQUIRED for non-interactive unbound deploy without mutating local state", async () => { + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const createProject = vi.fn(); + const listApps = vi.fn(); + const deployApp = vi.fn(); + + vi.doMock("../src/lib/auth/guard", () => ({ + requireComputeAuth, + })); + vi.doMock("../src/lib/app/preview-provider", () => ({ + createPreviewAppProvider: vi.fn(() => ({ + createProject, + listApps, + deployApp, + listDeployments: vi.fn(), + showDeployment: vi.fn(), + })), + })); + + const { createTempCwd, createTestCommandContext } = await import("./helpers"); + const { runAppDeploy } = await import("../src/controllers/app"); + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + const { context } = await createTestCommandContext({ + cwd, + stateDir, + isTTY: false, + flags: { + json: true, + yes: true, + }, + env: { + ...process.env, + PRISMA_CLI_TEST_REMEMBER_PROJECT_ID: "", + PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, + }, + }); + + await expect(runAppDeploy(context, "hello-world", { + framework: "hono", + })).rejects.toMatchObject({ + code: "PROJECT_SETUP_REQUIRED", + domain: "project", + meta: { + candidates: [ + { id: "proj_123", name: "Acme Dashboard" }, + ], + suggestedProjectName: path.basename(cwd), + suggestedProjectNameSource: "directory-name", + recoveryCommands: expect.arrayContaining([ + "prisma-cli app deploy --project ", + `prisma-cli app deploy --create-project ${path.basename(cwd)}`, + ]), + }, + }); + expect(createProject).not.toHaveBeenCalled(); + expect(listApps).not.toHaveBeenCalled(); + expect(deployApp).not.toHaveBeenCalled(); + await expect(readFile(path.join(cwd, ".prisma/local.json"), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + await expect(readFile(path.join(cwd, ".gitignore"), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("rejects mutually exclusive Project sources before resolving deploy context", async () => { + const { createTempCwd, createTestCommandContext } = await import("./helpers"); + const { runAppDeploy } = await import("../src/controllers/app"); + 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 expect(runAppDeploy(context, "hello-world", { + projectRef: "proj_123", + createProjectName: "new-project", + framework: "hono", + })).rejects.toMatchObject({ + code: "USAGE_ERROR", + domain: "project", + summary: "Project selection is ambiguous", + why: expect.stringContaining("--project, --create-project"), + }); + + const { context: envContext } = await createTestCommandContext({ + cwd, + stateDir, + isTTY: false, + env: { + ...process.env, + PRISMA_PROJECT_ID: "proj_123", + PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, + }, + }); + + await expect(runAppDeploy(envContext, "hello-world", { + projectRef: "proj_456", + framework: "hono", + })).rejects.toMatchObject({ + code: "USAGE_ERROR", + domain: "project", + summary: "Project selection is ambiguous", + why: expect.stringContaining("PRISMA_PROJECT_ID"), + }); + }); + + it("interactive first deploy can select an existing Project and write the local pin", async () => { + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const createProject = vi.fn(); + const listApps = vi.fn().mockResolvedValue([]); + const deployApp = vi.fn().mockResolvedValue({ + projectId: "proj_123", + app: { + id: "app_new", + name: "hello-world", + region: "eu-central-1", + liveDeploymentId: "dep_123", + }, + deployment: { + id: "dep_123", + status: "running", + url: "https://hello-world.prisma.app", + }, + }); + + vi.doMock("../src/lib/auth/guard", () => ({ + requireComputeAuth, + })); + vi.doMock("../src/lib/app/preview-provider", () => ({ + createPreviewAppProvider: vi.fn(() => ({ + createProject, + listApps, + deployApp, + listDeployments: vi.fn(), + showDeployment: vi.fn(), + })), + })); + + const { createTempCwd, createTestCommandContext } = await import("./helpers"); + const { runAppDeploy } = await import("../src/controllers/app"); + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + const { context, stderr } = await createTestCommandContext({ + cwd, + stateDir, + isTTY: true, + stdinText: "\r", + env: { + ...process.env, + PRISMA_CLI_TEST_REMEMBER_PROJECT_ID: "", + PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, + }, + }); + + const result = await runAppDeploy(context, "hello-world", { + framework: "hono", + }); + + expect(createProject).not.toHaveBeenCalled(); + expect(result.result.resolution.projectSource).toBe("prompt"); + expect(result.result.localPin).toEqual({ + path: ".prisma/local.json", + written: true, + }); + await expect(readLocalPin(cwd)).resolves.toEqual({ + workspaceId: "ws_123", + projectId: "proj_123", + }); + expect(stderr.buffer).toContain("Which Project should this directory use?"); + expect(stderr.buffer).toContain(`Linked "./${path.basename(cwd)}" to Project "Acme Dashboard"`); + }); + + it("interactive first deploy can create a new Project from an editable suggested name", async () => { + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const createProject = vi.fn().mockResolvedValue({ + id: "proj_new", + name: "interactive-project", + }); + const listApps = vi.fn().mockResolvedValue([]); + const deployApp = vi.fn().mockResolvedValue({ + projectId: "proj_new", + app: { + id: "app_new", + name: "hello-world", + region: "eu-central-1", + liveDeploymentId: "dep_123", + }, + deployment: { + id: "dep_123", + status: "running", + url: "https://hello-world.prisma.app", + }, + }); + + vi.doMock("../src/lib/auth/guard", () => ({ + requireComputeAuth, + })); + vi.doMock("../src/lib/app/preview-provider", () => ({ + createPreviewAppProvider: vi.fn(() => ({ + createProject, + listApps, + deployApp, + listDeployments: vi.fn(), + showDeployment: vi.fn(), + })), + })); + + const { createTempCwd, createTestCommandContext } = await import("./helpers"); + const { runAppDeploy } = await import("../src/controllers/app"); + const cwd = await createTempCwd(); + await writePackageJson(cwd, { + name: "suggested-name", + }); + const stateDir = path.join(cwd, ".state"); + const { context, stderr } = await createTestCommandContext({ + cwd, + stateDir, + isTTY: true, + stdinText: "\u001B[B\rinteractive-project\r", + env: { + ...process.env, + PRISMA_CLI_TEST_REMEMBER_PROJECT_ID: "", + PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, + }, + }); + + const result = await runAppDeploy(context, "hello-world", { + framework: "hono", + }); + + expect(createProject).toHaveBeenCalledWith({ name: "interactive-project" }); + expect(result.result).toMatchObject({ + project: { + id: "proj_new", + name: "interactive-project", + }, + resolution: { + projectSource: "created", + targetName: "interactive-project", + targetNameSource: "prompt", + }, + localPin: { + path: ".prisma/local.json", + written: true, + }, + }); + await expect(readLocalPin(cwd)).resolves.toEqual({ + workspaceId: "ws_123", + projectId: "proj_new", + }); + expect(stderr.buffer).toContain("Project name"); + expect(stderr.buffer).toContain("suggested-name"); + }); + it("returns FRAMEWORK_NOT_DETECTED before deploy when framework inference fails", async () => { + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + + vi.doMock("../src/lib/auth/guard", () => ({ + requireComputeAuth, + })); + vi.doMock("../src/lib/app/preview-provider", () => ({ + createPreviewAppProvider: vi.fn(() => ({ + listApps: vi.fn(), + deployApp: vi.fn(), + listDeployments: vi.fn(), + showDeployment: vi.fn(), + })), + })); + const { createTempCwd, createTestCommandContext } = await import("./helpers"); const { runAppDeploy } = await import("../src/controllers/app"); const cwd = await createTempCwd(); @@ -1315,7 +1588,9 @@ describe("app controller", () => { }, }); - await expect(runAppDeploy(context, "hello-world")).rejects.toMatchObject({ + await expect(runAppDeploy(context, "hello-world", { + projectRef: "proj_123", + })).rejects.toMatchObject({ code: "FRAMEWORK_NOT_DETECTED", domain: "app", exitCode: 2, @@ -1723,7 +1998,7 @@ describe("app controller", () => { ); }); - it("creates a project before first deploy when none is resolved", async () => { + it("creates a project before first deploy when --create-project is provided", async () => { const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const createProject = vi.fn(async (options: { name: string }) => ({ id: "proj_new", @@ -1774,11 +2049,12 @@ describe("app controller", () => { }); const result = await runAppDeploy(context, "hello-world", { + createProjectName: "launchpad", framework: "hono", }); expect(createProject).toHaveBeenCalledWith({ - name: path.basename(cwd), + name: "launchpad", }); expect(deployApp).toHaveBeenCalledWith( expect.objectContaining({ @@ -1791,7 +2067,21 @@ describe("app controller", () => { id: "app_new", name: "hello-world", }); - expect(result.result.project.id).toBe("proj_new"); + expect(result.result).toMatchObject({ + project: { + id: "proj_new", + name: "launchpad", + }, + resolution: { + projectSource: "created", + targetName: "launchpad", + targetNameSource: "explicit", + }, + localPin: { + path: ".prisma/local.json", + written: true, + }, + }); await expect(readLocalPin(cwd)).resolves.toEqual({ workspaceId: "ws_123", projectId: "proj_new", @@ -1872,6 +2162,7 @@ describe("app controller", () => { }); const firstResult = await runAppDeploy(context, "hello-world", { + createProjectName: "next-smoke", framework: "hono", }); expect(firstResult.result.localPin).toEqual({ @@ -1922,7 +2213,7 @@ describe("app controller", () => { ); }); - it("creates a missing project without depending on repo config preflight", async () => { + it("creates an explicit deploy-time project without depending on repo config preflight", async () => { const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const createProject = vi.fn().mockResolvedValue({ id: "proj_new", @@ -1972,6 +2263,7 @@ describe("app controller", () => { }); await expect(runAppDeploy(context, "hello-world", { + createProjectName: "next-smoke", framework: "hono", })).resolves.toMatchObject({ result: { @@ -1980,22 +2272,16 @@ describe("app controller", () => { }, resolution: { projectSource: "created", + targetName: "next-smoke", + targetNameSource: "explicit", }, }, }); - expect(createProject).toHaveBeenCalledWith({ name: path.basename(cwd) }); + expect(createProject).toHaveBeenCalledWith({ name: "next-smoke" }); await expect(readPrismaConfig(cwd)).rejects.toMatchObject({ code: "ENOENT" }); }); - it("surfaces AUTH_FORBIDDEN when create-on-first-deploy is rejected with 401", async () => { - // When the deploy lands in the auto-create-on-first-deploy branch - // (because no existing project matched the package.json name) and the - // platform rejects the create with 401/403, we don't want to surface - // the generic "Failed to create project for first deploy / retry the - // command" message — that hides that the CLI is inferring projects - // from package.json, and "retry" is unhelpful for a permissions - // failure. We surface an AUTH_FORBIDDEN error that explains the - // inference and recommends --project as the explicit fix. + it("surfaces AUTH_FORBIDDEN when explicit deploy-time project creation is rejected with 401", async () => { const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const createProject = vi.fn().mockRejectedValue(new Error("Authentication failed (HTTP 401)")); @@ -2028,22 +2314,19 @@ describe("app controller", () => { }); await expect(runAppDeploy(context, "hello-world", { + createProjectName: "next-smoke", framework: "hono", })).rejects.toMatchObject({ code: "AUTH_FORBIDDEN", domain: "auth", - summary: "Could not create a new project for this deploy", - why: expect.stringContaining("No existing project matched the package.json name"), - fix: expect.stringContaining("--project "), + summary: 'Could not create Project "next-smoke"', + why: expect.stringContaining("HTTP 401"), + fix: expect.stringContaining("--project"), nextSteps: expect.arrayContaining(["prisma-cli app deploy --project "]), }); }); - it("rewrites the DEPLOY_FAILED message when a non-auth error rejects create-on-first-deploy", async () => { - // For non-401/403 create failures, keep the existing DEPLOY_FAILED - // code but use the new wording so it's clear the CLI was trying to - // create a project (because nothing matched the package.json name) - // and suggest --project as an unambiguous fix. + it("returns DEPLOY_FAILED when explicit deploy-time project creation fails", async () => { const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const createProject = vi.fn().mockRejectedValue(new Error("Internal Server Error (HTTP 503)")); @@ -2076,13 +2359,14 @@ describe("app controller", () => { }); await expect(runAppDeploy(context, "hello-world", { + createProjectName: "next-smoke", framework: "hono", })).rejects.toMatchObject({ code: "DEPLOY_FAILED", domain: "app", - summary: "Could not create a new project for this deploy", - why: expect.stringContaining("No existing project matched the package.json name"), - fix: expect.stringContaining("--project "), + summary: 'Could not create Project "next-smoke"', + why: expect.stringContaining("Internal Server Error"), + fix: expect.stringContaining("--project"), nextSteps: expect.arrayContaining(["prisma-cli app deploy --project "]), }); }); diff --git a/packages/cli/tests/app.test.ts b/packages/cli/tests/app.test.ts index 58589b7..df89fb0 100644 --- a/packages/cli/tests/app.test.ts +++ b/packages/cli/tests/app.test.ts @@ -147,8 +147,11 @@ describe("app commands", () => { expect(deployHelp.exitCode).toBe(0); expect(deployHelp.stderr).toContain("Creates a new deployment for the app"); expect(deployHelp.stderr).toContain("$ prisma-cli app deploy"); + expect(deployHelp.stderr).toContain("$ prisma-cli app deploy --project proj_123"); + expect(deployHelp.stderr).toContain("$ prisma-cli app deploy --create-project my-app --yes"); expect(deployHelp.stderr).toContain("$ prisma-cli app deploy --app my-app --framework nextjs --http-port 3000"); expect(deployHelp.stderr).toContain("--entry "); + expect(deployHelp.stderr).toContain("--create-project "); expect(deployHelp.stderr).toContain("--framework "); expect(deployHelp.stderr).not.toContain("--build-type "); expect(deployHelp.stderr).toContain("--http-port "); diff --git a/packages/cli/tests/project-controller.test.ts b/packages/cli/tests/project-controller.test.ts index 762447a..b7af693 100644 --- a/packages/cli/tests/project-controller.test.ts +++ b/packages/cli/tests/project-controller.test.ts @@ -1,11 +1,19 @@ +import { readFile } from "node:fs/promises"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; -import { runProjectShow } from "../src/controllers/project"; import { createTempCwd, createTestCommandContext } from "./helpers"; const fixturePath = path.resolve("fixtures/mock-api.json"); +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(); +}); + describe("project controller", () => { it("returns PROJECT_UNRESOLVED when automatic resolution cannot choose a project", async () => { const cwd = await createTempCwd(); @@ -23,9 +31,106 @@ describe("project controller", () => { workspaceId: "ws_123", }); + const { runProjectShow } = await import("../src/controllers/project"); await expect(runProjectShow(context, undefined)).rejects.toMatchObject({ code: "PROJECT_UNRESOLVED", domain: "project", }); }); + + it("links an existing project and writes the local pin", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + const { context } = await createTestCommandContext({ + cwd, + stateDir, + fixturePath, + isTTY: false, + }); + + await context.stateStore.setAuthSession({ + provider: "github", + userId: "usr_456", + workspaceId: "ws_123", + }); + + const { runProjectLink } = await import("../src/controllers/project"); + const result = await runProjectLink(context, "proj_123"); + + expect(result.result).toMatchObject({ + project: { + id: "proj_123", + name: "Acme Dashboard", + }, + localPin: { + path: ".prisma/local.json", + written: true, + }, + action: "linked", + }); + await expect(readFile(path.join(cwd, ".prisma/local.json"), "utf8")).resolves.toContain('"projectId": "proj_123"'); + await expect(readFile(path.join(cwd, ".gitignore"), "utf8")).resolves.toBe(".prisma/\n"); + }); + + it("creates a project and writes the local pin", async () => { + const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + const createProject = vi.fn().mockResolvedValue({ + id: "proj_new", + name: "New Dashboard", + }); + + 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(() => ({ + createProject, + })), + })); + + 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, + }, + }); + + const { runProjectCreate } = await import("../src/controllers/project"); + const result = await runProjectCreate(context, "New Dashboard"); + + expect(createProject).toHaveBeenCalledWith({ name: "New Dashboard" }); + expect(result.result).toMatchObject({ + project: { + id: "proj_new", + name: "New Dashboard", + }, + localPin: { + path: ".prisma/local.json", + written: true, + }, + action: "created", + }); + await expect(readFile(path.join(cwd, ".prisma/local.json"), "utf8")).resolves.toContain('"projectId": "proj_new"'); + await expect(readFile(path.join(cwd, ".gitignore"), "utf8")).resolves.toBe(".prisma/\n"); + }); }); diff --git a/packages/cli/tests/project.test.ts b/packages/cli/tests/project.test.ts index 816e612..e767a07 100644 --- a/packages/cli/tests/project.test.ts +++ b/packages/cli/tests/project.test.ts @@ -425,7 +425,7 @@ describe("project commands", () => { expect(JSON.parse(result.stdout).error.code).toBe("PROJECT_UNRESOLVED"); }); - it("shows Public Beta project and git help without project link", async () => { + it("shows Public Beta project, setup, and git help", async () => { const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state"); @@ -441,6 +441,18 @@ describe("project commands", () => { stateDir, fixturePath, }); + const createHelp = await executeCli({ + argv: ["project", "create", "--help"], + cwd, + stateDir, + fixturePath, + }); + const linkHelp = await executeCli({ + argv: ["project", "link", "--help"], + cwd, + stateDir, + fixturePath, + }); const gitHelp = await executeCli({ argv: ["git", "--help"], cwd, @@ -459,17 +471,19 @@ describe("project commands", () => { stateDir, fixturePath, }); - const stderr = stripAnsi(`${projectHelp.stderr}\n${showHelp.stderr}\n${gitHelp.stderr}\n${connectRepoHelp.stderr}\n${disconnectRepoHelp.stderr}`); + const stderr = stripAnsi(`${projectHelp.stderr}\n${showHelp.stderr}\n${createHelp.stderr}\n${linkHelp.stderr}\n${gitHelp.stderr}\n${connectRepoHelp.stderr}\n${disconnectRepoHelp.stderr}`); expect(projectHelp.exitCode).toBe(0); + expect(createHelp.exitCode).toBe(0); + expect(linkHelp.exitCode).toBe(0); expect(gitHelp.exitCode).toBe(0); expect(stderr).toContain("project → Manage and inspect your Prisma projects"); expect(stderr).toContain("git → Manage Git repository connections for a project"); expect(stderr).toContain("Show which project is active for this directory"); + expect(stderr).toContain("Create a Project and link this directory"); + expect(stderr).toContain("Link this directory to an existing Project"); expect(stderr).toContain("Connect the resolved project to a GitHub repository"); expect(stderr).toContain("Disconnect the GitHub repository from the resolved project"); - expect(stderr).not.toContain("project link"); - expect(stderr).not.toContain("linked project"); }); it("registers project env remove and rm alias help", async () => { From a37e1c6b070e12fac1d1693eff7c0ada7bb485bf Mon Sep 17 00:00:00 2001 From: Luan van der Westhuizen Date: Fri, 29 May 2026 12:55:14 +0200 Subject: [PATCH 4/7] refactor: centralize project setup helpers --- docs/product/error-conventions.md | 2 + packages/cli/src/controllers/app.ts | 166 +++++------------- packages/cli/src/controllers/project.ts | 94 ++-------- packages/cli/src/lib/project/setup.ts | 151 ++++++++++++++++ packages/cli/tests/app-controller.test.ts | 6 +- packages/cli/tests/project-controller.test.ts | 50 ++++++ 6 files changed, 263 insertions(+), 206 deletions(-) create mode 100644 packages/cli/src/lib/project/setup.ts diff --git a/docs/product/error-conventions.md b/docs/product/error-conventions.md index 54b7853..72e3c3c 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -161,6 +161,7 @@ These codes are the minimum stable set for the MVP: - `AUTH_REQUIRED` - `PROJECT_UNRESOLVED` - `PROJECT_SETUP_REQUIRED` +- `PROJECT_CREATE_FAILED` - `PROJECT_NOT_FOUND` - `PROJECT_AMBIGUOUS` - `APP_AMBIGUOUS` @@ -200,6 +201,7 @@ Recommended meanings: - `AUTH_REQUIRED`: command needs an authenticated session - `PROJECT_UNRESOLVED`: command needs project context and none could be resolved - `PROJECT_SETUP_REQUIRED`: `app deploy` needs an explicit Project setup choice before it can continue +- `PROJECT_CREATE_FAILED`: Project creation failed before deployment or linking could continue - `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/controllers/app.ts b/packages/cli/src/controllers/app.ts index 33456b3..afc445b 100644 --- a/packages/cli/src/controllers/app.ts +++ b/packages/cli/src/controllers/app.ts @@ -56,10 +56,16 @@ import { sortProjects, } from "../lib/project/resolution"; import { - ensureLocalResolutionPinGitignore, + bindProjectToDirectory, + formatCommandArgument, + projectCreateFailedError, + projectSetupNameRequiredError, + resolveProjectForSetup, + toProjectSummary, +} from "../lib/project/setup"; +import { LOCAL_RESOLUTION_PIN_RELATIVE_PATH, readLocalResolutionPin, - writeLocalResolutionPin, type LocalResolutionPinReadResult, } from "../lib/project/local-pin"; import { @@ -84,7 +90,7 @@ import { } from "../lib/app/preview-provider"; import { formatDomainFailureFix } from "../lib/app/domain-guidance"; import { requireAuthenticatedAuthState } from "./auth"; -import { listRealWorkspaceProjects, resolveProjectForLink } from "./project"; +import { listRealWorkspaceProjects } from "./project"; import { createSelectPromptPort } from "./select-prompt-port"; type AppDomainCommand = "add" | "show" | "remove" | "retry" | "wait"; @@ -246,15 +252,16 @@ export async function runAppDeploy( envProjectId, localPin, }); - - const shouldWriteLocalPin = Boolean(target.localPinAction); - if (shouldWriteLocalPin) { - await writeLocalResolutionPin(context.runtime.cwd, { - workspaceId: target.workspace.id, - projectId: target.project.id, - }); - await ensureLocalResolutionPinGitignore(context.runtime.cwd); - maybeRenderProjectLinked(context, target.project.name); + let localPinResult: { path: string; written: true } | undefined; + if (target.localPinAction) { + const setupResult = await bindProjectToDirectory( + context, + target.workspace, + target.project, + target.localPinAction, + ); + localPinResult = setupResult.localPin; + maybeRenderProjectLinked(context, setupResult.directory, setupResult.project.name, setupResult.localPin.path); } let framework = await resolveDeployFramework(context, { @@ -273,12 +280,12 @@ export async function runAppDeploy( const selectedApp = await resolveDeployAppSelection(context, projectId, apps, { explicitAppName: appName, explicitAppId: envAppId, - firstDeploy: shouldWriteLocalPin, + firstDeploy: Boolean(target.localPinAction), inferName: () => inferTargetName(context.runtime.cwd), }); await maybeRenderDeploySetupBlock(context, { - includeDirectory: !shouldWriteLocalPin, + includeDirectory: !target.localPinAction, projectName: target.project.name, branchName: target.branch.name, appName: selectedApp.displayName, @@ -340,12 +347,7 @@ export async function runAppDeploy( }, deployment: deployResult.deployment, durationMs: deployDurationMs, - localPin: shouldWriteLocalPin - ? { - path: LOCAL_RESOLUTION_PIN_RELATIVE_PATH, - written: true, - } - : undefined, + localPin: localPinResult, }, warnings: [], nextSteps: ["prisma-cli app list-deploys", `prisma-cli app show-deploy ${deployResult.deployment.id}`], @@ -2167,7 +2169,6 @@ async function requireProviderAndProjectContext( context: CommandContext, explicitProject: string | undefined, options?: { - allowCreate?: boolean; branch?: ResolvedDeployBranch; }, ): Promise<{ @@ -2177,7 +2178,7 @@ async function requireProviderAndProjectContext( projectId: string; }> { const { client, provider } = await requirePreviewAppProviderWithClient(context); - const target = await resolveProjectContext(context, client, provider, explicitProject, options); + const target = await resolveProjectContext(context, client, explicitProject, options); return { client, provider, @@ -2214,10 +2215,8 @@ async function requireProviderAndDeployProjectContext( async function resolveProjectContext( context: CommandContext, client: ManagementApiClient, - provider: ReturnType, explicitProject: string | undefined, options?: { - allowCreate?: boolean; branch?: ResolvedDeployBranch; }, ): Promise { @@ -2231,20 +2230,6 @@ async function resolveProjectContext( workspace: authState.workspace, explicitProject, listProjects: () => listRealWorkspaceProjects(client, authState.workspace!), - createProject: options?.allowCreate - ? async (name) => { - const project = await provider.createProject({ name }).catch((error) => { - throw createProjectForSetupError(error, name, authState.workspace!); - }); - return { - id: project.id, - name: project.name, - workspace: authState.workspace!, - }; - } - : undefined, - allowCreate: options?.allowCreate, - prompt: createSelectPromptPort(context), remember: true, }); const branch = options?.branch ?? await resolveDeployBranch(context, undefined); @@ -2280,7 +2265,7 @@ async function resolveDeployProjectContext( const projects = await listRealWorkspaceProjects(client, workspace); if (explicitProject) { - const project = resolveProjectForLink(explicitProject, projects, workspace); + const project = resolveProjectForSetup(explicitProject, projects, workspace); return withDeployBranch({ workspace, project: toProjectSummary(project), @@ -2450,7 +2435,15 @@ async function createProjectForDeploySetup( workspace: AuthWorkspace, ): Promise { const created = await provider.createProject({ name: projectName }).catch((error) => { - throw createProjectForSetupError(error, projectName, workspace); + throw projectCreateFailedError(error, projectName, workspace, { + nextSteps: [ + "prisma-cli project list", + "prisma-cli app deploy --project ", + `prisma-cli app deploy --create-project ${formatCommandArgument(projectName)}`, + ], + permissionFix: "Choose an existing Project with --project, or grant the token permission to create Projects in this workspace.", + fallbackFix: "Choose an existing Project with --project, or retry after addressing the platform error above.", + }); }); return { @@ -2473,13 +2466,6 @@ function withDeployBranch( }; } -function toProjectSummary(project: Pick): ProjectSummary { - return { - id: project.id, - name: project.name, - }; -} - function toBranchKind(name: string): BranchKind { return name === "production" || name === "main" ? "production" : "preview"; } @@ -2520,16 +2506,6 @@ function validateProjectSetupNameText(value: string | undefined, fallback: strin return "Enter a Project name."; } -function projectSetupNameRequiredError(command: string): CliError { - return usageError( - "Project create requires a name", - "The project name must be a non-empty value.", - "Pass a Project name explicitly.", - [`prisma-cli ${command} my-app`], - "project", - ); -} - interface ResolvedDeployBranch { name: string; annotation: string; @@ -2829,14 +2805,19 @@ async function maybeRenderDeploySetupBlock( context.output.stderr.write(`${prefix} ${details.projectName} / ${details.branchName} / ${details.appName}\n\n`); } -function maybeRenderProjectLinked(context: CommandContext, projectName: string): void { +function maybeRenderProjectLinked( + context: CommandContext, + directory: string, + projectName: string, + localPinPath: string, +): void { if (context.flags.json || context.flags.quiet) { return; } context.output.stderr.write( - `${context.ui.success("✔")} Linked "${formatDeployDirectory(context.runtime.cwd)}" to Project "${projectName}"\n` - + `Saved ${LOCAL_RESOLUTION_PIN_RELATIVE_PATH}\n\n`, + `${context.ui.success("✔")} Linked "${directory}" to Project "${projectName}"\n` + + `Saved ${localPinPath}\n\n`, ); } @@ -3240,71 +3221,6 @@ function projectSetupRequiredError( }); } -function createProjectForSetupError(error: unknown, projectName: string, workspace: AuthWorkspace): CliError { - const status = extractHttpStatus(error); - const errorMessage = error instanceof Error ? error.message : String(error); - const nextSteps = [ - "prisma-cli project list", - "prisma-cli app deploy --project ", - `prisma-cli app deploy --create-project ${formatCommandArgument(projectName)}`, - ]; - - if (status === 401 || status === 403) { - return new CliError({ - code: "AUTH_FORBIDDEN", - domain: "auth", - summary: `Could not create Project "${projectName}"`, - why: `The platform rejected the Project create in workspace "${workspace.name}" (HTTP ${status}).`, - fix: "Choose an existing Project with --project, or grant the token permission to create Projects in this workspace.", - debug: formatDebugDetails(error), - exitCode: 1, - nextSteps, - }); - } - - return new CliError({ - code: "DEPLOY_FAILED", - domain: "app", - summary: `Could not create Project "${projectName}"`, - why: errorMessage, - fix: "Choose an existing Project with --project, or retry after addressing the platform error above.", - debug: formatDebugDetails(error), - exitCode: 1, - nextSteps, - }); -} - -function formatCommandArgument(value: string): string { - return /^[A-Za-z0-9._/-]+$/.test(value) ? value : JSON.stringify(value); -} - -function extractHttpStatus(error: unknown): number | null { - if (!error || typeof error !== "object") { - return null; - } - - const candidate = error as { statusCode?: unknown; status?: unknown; message?: unknown }; - if (typeof candidate.statusCode === "number") { - return candidate.statusCode; - } - if (typeof candidate.status === "number") { - return candidate.status; - } - - // The compute-sdk re-throws AuthenticationError / ApiError as plain - // Error instances whose `message` carries the "(HTTP )" suffix. - // Match that suffix as a last resort so this UX still triggers for - // service tokens running through that path. - if (typeof candidate.message === "string") { - const match = /\(HTTP (\d{3})\)/.exec(candidate.message); - if (match) { - return Number.parseInt(match[1], 10); - } - } - - return null; -} - function noDeploymentsError(summary: string, why: string): CliError { return new CliError({ code: "NO_DEPLOYMENTS", diff --git a/packages/cli/src/controllers/project.ts b/packages/cli/src/controllers/project.ts index 193951a..a460fa2 100644 --- a/packages/cli/src/controllers/project.ts +++ b/packages/cli/src/controllers/project.ts @@ -8,17 +8,18 @@ import { } from "../adapters/git"; import { requireComputeAuth } from "../lib/auth/guard"; import { - projectAmbiguousError, - projectNotFoundError, resolveProjectTarget, sortProjects, type ProjectCandidate, } from "../lib/project/resolution"; import { - ensureLocalResolutionPinGitignore, - LOCAL_RESOLUTION_PIN_RELATIVE_PATH, - writeLocalResolutionPin, -} from "../lib/project/local-pin"; + bindProjectToDirectory, + isValidProjectSetupName, + projectCreateFailedError, + projectSetupNameRequiredError, + resolveProjectForSetup, + toProjectSummary, +} from "../lib/project/setup"; import { createPreviewAppProvider } from "../lib/app/preview-provider"; import { authRequiredError, CliError, featureUnavailableError, usageError, workspaceRequiredError } from "../shell/errors"; import type { CommandSuccess } from "../shell/output"; @@ -29,7 +30,6 @@ import type { GitRepositoryConnection, ProjectListResult, ProjectRepositoryConnectionResult, - ProjectSummary, ProjectSetupResult, ProjectShowResult, } from "../types/project"; @@ -120,13 +120,7 @@ export async function runProjectCreate( } if (!isValidProjectSetupName(projectName)) { - throw usageError( - "Project create requires a name", - "The project name must be a non-empty value.", - "Pass the Project name as the first argument.", - ["prisma-cli project create my-app"], - "project", - ); + throw projectSetupNameRequiredError("project create"); } if (!isRealMode(context)) { @@ -145,8 +139,13 @@ export async function runProjectCreate( } const provider = createPreviewAppProvider(client); - const created = await provider.createProject({ name: projectName.trim() }).catch((error) => { - throw projectCreateFailedError(error); + const name = projectName.trim(); + const created = await provider.createProject({ name }).catch((error) => { + throw projectCreateFailedError(error, name, workspace, { + nextSteps: ["prisma-cli project list", "prisma-cli project link "], + permissionFix: "Grant the token permission to create Projects in this workspace, or link an existing Project.", + fallbackFix: "Retry the command, or choose an existing Project with prisma-cli project link .", + }); }); const result = await bindProjectToDirectory(context, workspace, { id: created.id, @@ -184,7 +183,7 @@ export async function runProjectLink( const projects = isRealMode(context) ? await listRealProjectsForLink(context, workspace) : listFixtureWorkspaceProjects(context, workspace); - const project = resolveProjectForLink(projectRef.trim(), projects, workspace); + const project = resolveProjectForSetup(projectRef.trim(), projects, workspace); const result = await bindProjectToDirectory(context, workspace, toProjectSummary(project), "linked"); return { @@ -439,67 +438,6 @@ export function listFixtureWorkspaceProjects( ); } -export function resolveProjectForLink( - projectRef: string, - projects: ProjectCandidate[], - workspace: AuthWorkspace, -): ProjectCandidate { - const matches = projects.filter((project) => project.id === projectRef || project.name === projectRef); - if (matches.length === 1) { - return matches[0]!; - } - if (matches.length > 1) { - throw projectAmbiguousError(projectRef, matches); - } - throw projectNotFoundError(projectRef, workspace); -} - -export async function bindProjectToDirectory( - context: CommandContext, - workspace: AuthWorkspace, - project: ProjectSummary, - action: ProjectSetupResult["action"], -): Promise { - await writeLocalResolutionPin(context.runtime.cwd, { - workspaceId: workspace.id, - projectId: project.id, - }); - await ensureLocalResolutionPinGitignore(context.runtime.cwd); - - return { - workspace, - project, - directory: formatSetupDirectory(context.runtime.cwd), - localPin: { - path: LOCAL_RESOLUTION_PIN_RELATIVE_PATH, - written: true, - }, - action, - }; -} - -function formatSetupDirectory(cwd: string): string { - const basename = cwd.split(/[\\/]/).filter(Boolean).pop(); - return basename ? `./${basename}` : "."; -} - -function isValidProjectSetupName(projectName: string): boolean { - return projectName.trim().length > 0; -} - -function projectCreateFailedError(error: unknown): CliError { - return new CliError({ - code: "DEPLOY_FAILED", - domain: "project", - summary: "Could not create Project", - why: error instanceof Error ? error.message : String(error), - fix: "Retry the command, or choose an existing Project with prisma-cli project link .", - debug: error instanceof Error ? error.stack ?? error.message : String(error), - exitCode: 1, - nextSteps: ["prisma-cli project list", "prisma-cli project link "], - }); -} - interface SourceRepositoryResponse { id: string; type?: "source-repository"; diff --git a/packages/cli/src/lib/project/setup.ts b/packages/cli/src/lib/project/setup.ts new file mode 100644 index 0000000..932b258 --- /dev/null +++ b/packages/cli/src/lib/project/setup.ts @@ -0,0 +1,151 @@ +import type { AuthWorkspace } from "../../types/auth"; +import type { ProjectSetupResult, ProjectSummary } from "../../types/project"; +import { CliError, usageError } from "../../shell/errors"; +import type { CommandContext } from "../../shell/runtime"; +import { + ensureLocalResolutionPinGitignore, + LOCAL_RESOLUTION_PIN_RELATIVE_PATH, + writeLocalResolutionPin, +} from "./local-pin"; +import { + projectAmbiguousError, + projectNotFoundError, + type ProjectCandidate, +} from "./resolution"; + +export function isValidProjectSetupName(projectName: string): boolean { + return projectName.trim().length > 0; +} + +export function resolveProjectForSetup( + projectRef: string, + projects: ProjectCandidate[], + workspace: AuthWorkspace, +): ProjectCandidate { + const matches = projects.filter((project) => project.id === projectRef || project.name === projectRef); + if (matches.length === 1) { + return matches[0]!; + } + if (matches.length > 1) { + throw projectAmbiguousError(projectRef, matches); + } + throw projectNotFoundError(projectRef, workspace); +} + +export async function bindProjectToDirectory( + context: CommandContext, + workspace: AuthWorkspace, + project: ProjectSummary, + action: ProjectSetupResult["action"], +): Promise { + await writeLocalResolutionPin(context.runtime.cwd, { + workspaceId: workspace.id, + projectId: project.id, + }); + await ensureLocalResolutionPinGitignore(context.runtime.cwd); + + return { + workspace, + project, + directory: formatSetupDirectory(context.runtime.cwd), + localPin: { + path: LOCAL_RESOLUTION_PIN_RELATIVE_PATH, + written: true, + }, + action, + }; +} + +export function toProjectSummary(project: Pick): ProjectSummary { + return { + id: project.id, + name: project.name, + }; +} + +export function projectSetupNameRequiredError(command: string): CliError { + return usageError( + "Project create requires a name", + "The project name must be a non-empty value.", + "Pass a Project name explicitly.", + [`prisma-cli ${command} my-app`], + "project", + ); +} + +export function projectCreateFailedError( + error: unknown, + projectName: string, + workspace: AuthWorkspace, + options: { + nextSteps: string[]; + permissionFix: string; + fallbackFix: string; + }, +): CliError { + const status = extractHttpStatus(error); + + if (status === 401 || status === 403) { + return new CliError({ + code: "AUTH_FORBIDDEN", + domain: "auth", + summary: `Could not create Project "${projectName}"`, + why: `The platform rejected the Project create in workspace "${workspace.name}" (HTTP ${status}).`, + fix: options.permissionFix, + debug: formatDebugDetails(error), + exitCode: 1, + nextSteps: options.nextSteps, + }); + } + + return new CliError({ + code: "PROJECT_CREATE_FAILED", + domain: "project", + summary: `Could not create Project "${projectName}"`, + why: error instanceof Error ? error.message : String(error), + fix: options.fallbackFix, + debug: formatDebugDetails(error), + exitCode: 1, + nextSteps: options.nextSteps, + }); +} + +export function formatCommandArgument(value: string): string { + return /^[A-Za-z0-9._/-]+$/.test(value) ? value : JSON.stringify(value); +} + +function formatSetupDirectory(cwd: string): string { + const basename = cwd.split(/[\\/]/).filter(Boolean).pop(); + return basename ? `./${basename}` : "."; +} + +function extractHttpStatus(error: unknown): number | null { + if (!error || typeof error !== "object") { + return null; + } + + const candidate = error as { statusCode?: unknown; status?: unknown; message?: unknown }; + if (typeof candidate.statusCode === "number") { + return candidate.statusCode; + } + if (typeof candidate.status === "number") { + return candidate.status; + } + + if (typeof candidate.message === "string") { + const match = /\(HTTP (\d{3})\)/.exec(candidate.message); + if (match) { + return Number.parseInt(match[1], 10); + } + } + + return null; +} + +function formatDebugDetails(error: unknown): string | null { + if (error instanceof Error) { + return error.stack ?? error.message; + } + + return typeof error === "string" ? error : null; +} diff --git a/packages/cli/tests/app-controller.test.ts b/packages/cli/tests/app-controller.test.ts index f993682..9b94ed9 100644 --- a/packages/cli/tests/app-controller.test.ts +++ b/packages/cli/tests/app-controller.test.ts @@ -2326,7 +2326,7 @@ describe("app controller", () => { }); }); - it("returns DEPLOY_FAILED when explicit deploy-time project creation fails", async () => { + it("returns PROJECT_CREATE_FAILED when explicit deploy-time project creation fails", async () => { const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const createProject = vi.fn().mockRejectedValue(new Error("Internal Server Error (HTTP 503)")); @@ -2362,8 +2362,8 @@ describe("app controller", () => { createProjectName: "next-smoke", framework: "hono", })).rejects.toMatchObject({ - code: "DEPLOY_FAILED", - domain: "app", + code: "PROJECT_CREATE_FAILED", + domain: "project", summary: 'Could not create Project "next-smoke"', why: expect.stringContaining("Internal Server Error"), fix: expect.stringContaining("--project"), diff --git a/packages/cli/tests/project-controller.test.ts b/packages/cli/tests/project-controller.test.ts index b7af693..f3a74ec 100644 --- a/packages/cli/tests/project-controller.test.ts +++ b/packages/cli/tests/project-controller.test.ts @@ -133,4 +133,54 @@ describe("project controller", () => { await expect(readFile(path.join(cwd, ".prisma/local.json"), "utf8")).resolves.toContain('"projectId": "proj_new"'); await expect(readFile(path.join(cwd, ".gitignore"), "utf8")).resolves.toBe(".prisma/\n"); }); + + 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)")); + + 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(() => ({ + createProject, + })), + })); + + 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, + }, + }); + + const { runProjectCreate } = await import("../src/controllers/project"); + await expect(runProjectCreate(context, "New Dashboard")).rejects.toMatchObject({ + code: "PROJECT_CREATE_FAILED", + domain: "project", + summary: 'Could not create Project "New Dashboard"', + why: expect.stringContaining("Internal Server Error"), + nextSteps: expect.arrayContaining(["prisma-cli project link "]), + }); + }); }); From 418ff12be53636b59c16cdb29e1a411a2b47b7bd Mon Sep 17 00:00:00 2001 From: Luan van der Westhuizen Date: Fri, 29 May 2026 13:06:55 +0200 Subject: [PATCH 5/7] fix: address project setup review feedback --- docs/product/command-spec.md | 2 ++ packages/cli/src/lib/project/setup.ts | 4 ++-- packages/cli/tests/app-controller.test.ts | 6 +++--- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index 9b41565..28a0d48 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -603,6 +603,7 @@ Behavior: - resolves project context from `--project`, `--create-project`, `PRISMA_PROJECT_ID`, `.prisma/local.json`, durable platform mapping, or an interactive setup choice - does not infer and create Project context from `package.json#name` or current directory name without explicit setup - when no Project is resolved in interactive mode, asks which Project the directory should use: + ```text ? Which Project should this directory use? ❯ Acme Dashboard @@ -610,6 +611,7 @@ Behavior: Create a new Project Cancel ``` + - when "Create a new Project" is selected, prompts for a Project name with the package/directory name as a suggestion - when no Project is resolved in `--json` / `--no-interactive` mode, fails with `PROJECT_SETUP_REQUIRED` - `--yes` alone does not choose Project scope; use `--project` or `--create-project` diff --git a/packages/cli/src/lib/project/setup.ts b/packages/cli/src/lib/project/setup.ts index 932b258..f9f46a2 100644 --- a/packages/cli/src/lib/project/setup.ts +++ b/packages/cli/src/lib/project/setup.ts @@ -87,8 +87,8 @@ export function projectCreateFailedError( if (status === 401 || status === 403) { return new CliError({ - code: "AUTH_FORBIDDEN", - domain: "auth", + code: "PROJECT_CREATE_FAILED", + domain: "project", summary: `Could not create Project "${projectName}"`, why: `The platform rejected the Project create in workspace "${workspace.name}" (HTTP ${status}).`, fix: options.permissionFix, diff --git a/packages/cli/tests/app-controller.test.ts b/packages/cli/tests/app-controller.test.ts index 9b94ed9..ec197fc 100644 --- a/packages/cli/tests/app-controller.test.ts +++ b/packages/cli/tests/app-controller.test.ts @@ -2281,7 +2281,7 @@ describe("app controller", () => { await expect(readPrismaConfig(cwd)).rejects.toMatchObject({ code: "ENOENT" }); }); - it("surfaces AUTH_FORBIDDEN when explicit deploy-time project creation is rejected with 401", async () => { + it("returns PROJECT_CREATE_FAILED when explicit deploy-time project creation is rejected with 401", async () => { const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const createProject = vi.fn().mockRejectedValue(new Error("Authentication failed (HTTP 401)")); @@ -2317,8 +2317,8 @@ describe("app controller", () => { createProjectName: "next-smoke", framework: "hono", })).rejects.toMatchObject({ - code: "AUTH_FORBIDDEN", - domain: "auth", + code: "PROJECT_CREATE_FAILED", + domain: "project", summary: 'Could not create Project "next-smoke"', why: expect.stringContaining("HTTP 401"), fix: expect.stringContaining("--project"), From 035f48919b6f86963df801ef99c2bfb4013ed353 Mon Sep 17 00:00:00 2001 From: Luan van der Westhuizen Date: Fri, 29 May 2026 13:09:51 +0200 Subject: [PATCH 6/7] ci: retry transient pkg.pr.new preview publish --- .github/workflows/preview-cli-package.yml | 2 +- packages/cli/tests/publish-prep.test.ts | 23 +++++ scripts/publish-cli-pr-preview.mjs | 119 ++++++++++++++++++++++ 3 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 scripts/publish-cli-pr-preview.mjs diff --git a/.github/workflows/preview-cli-package.yml b/.github/workflows/preview-cli-package.yml index 6306986..4904441 100644 --- a/.github/workflows/preview-cli-package.yml +++ b/.github/workflows/preview-cli-package.yml @@ -69,7 +69,7 @@ jobs: - name: Publish installable PR preview id: publish_preview continue-on-error: ${{ vars.CLI_PR_PREVIEW_REQUIRED != 'true' }} - run: pnpm exec pkg-pr-new publish --bin --comment=update .publish/cli + run: node scripts/publish-cli-pr-preview.mjs .publish/cli - name: Summarize PR preview publish if: ${{ always() }} diff --git a/packages/cli/tests/publish-prep.test.ts b/packages/cli/tests/publish-prep.test.ts index 257a668..06b5af5 100644 --- a/packages/cli/tests/publish-prep.test.ts +++ b/packages/cli/tests/publish-prep.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { stageCliPublishPackage } from "../../../scripts/prepare-cli-publish.mjs"; +import { isTransientPkgPrNewWorkflowError } from "../../../scripts/publish-cli-pr-preview.mjs"; function createTempCwd(): Promise { return mkdtemp(path.join(os.tmpdir(), "prisma-cli-")); @@ -164,3 +165,25 @@ describe("prepare cli publish", () => { expect(distFiles).toEqual(["cli.js"]); }); }); + +describe("publish cli pr preview", () => { + it("retries only the transient pkg.pr.new workflow registration race", () => { + expect( + isTransientPkgPrNewWorkflowError( + 'Check failed (404): {"url":"/check","statusCode":404,"statusMessage":"Not Found","message":"There is no workflow defined for yP4Cr6lrKy","stack":""}', + ), + ).toBe(true); + + expect( + isTransientPkgPrNewWorkflowError( + 'Publishing failed (400): {"message":"package.json is invalid"}', + ), + ).toBe(false); + + expect( + isTransientPkgPrNewWorkflowError( + 'Check failed (404): {"message":"The app https://github.com/apps/pkg-pr-new is not installed on prisma/prisma-cli."}', + ), + ).toBe(false); + }); +}); diff --git a/scripts/publish-cli-pr-preview.mjs b/scripts/publish-cli-pr-preview.mjs new file mode 100644 index 0000000..77c20f2 --- /dev/null +++ b/scripts/publish-cli-pr-preview.mjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const DEFAULT_MAX_ATTEMPTS = 4; +const DEFAULT_RETRY_DELAY_MS = 10_000; +const TRANSIENT_WORKFLOW_RECORD_ERROR = + /Check failed \(404\):.*There is no workflow defined for/s; + +export function isTransientPkgPrNewWorkflowError(output) { + return TRANSIENT_WORKFLOW_RECORD_ERROR.test(output); +} + +export async function publishCliPrPreview(options = {}) { + const cwd = options.cwd ?? process.cwd(); + const maxAttempts = + options.maxAttempts ?? readPositiveIntEnv("PKG_PR_NEW_MAX_ATTEMPTS", DEFAULT_MAX_ATTEMPTS); + const retryDelayMs = + options.retryDelayMs ?? readPositiveIntEnv("PKG_PR_NEW_RETRY_DELAY_MS", DEFAULT_RETRY_DELAY_MS); + const packageDir = options.packageDir ?? ".publish/cli"; + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const result = await runPkgPrNewPublish(cwd, packageDir); + if (result.exitCode === 0) { + return; + } + + if ( + attempt < maxAttempts && + isTransientPkgPrNewWorkflowError(result.output) + ) { + process.stderr.write( + `pkg.pr.new has not registered this workflow run yet; retrying in ${retryDelayMs / 1000}s (${attempt + 1}/${maxAttempts}).\n`, + ); + await sleep(retryDelayMs); + continue; + } + + process.exitCode = result.exitCode ?? 1; + return; + } +} + +function runPkgPrNewPublish(cwd, packageDir) { + return new Promise((resolve) => { + const child = spawn( + "pnpm", + [ + "exec", + "pkg-pr-new", + "publish", + "--bin", + "--comment=update", + packageDir, + ], + { + cwd, + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + let output = ""; + + child.stdout.on("data", (chunk) => { + process.stdout.write(chunk); + output += String(chunk); + }); + + child.stderr.on("data", (chunk) => { + process.stderr.write(chunk); + output += String(chunk); + }); + + child.on("error", (error) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`${message}\n`); + output += message; + resolve({ exitCode: 1, output }); + }); + + child.on("close", (exitCode) => { + resolve({ exitCode, output }); + }); + }); +} + +function readPositiveIntEnv(name, fallback) { + const raw = process.env[name]; + if (raw === undefined) { + return fallback; + } + + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function main() { + const packageDir = process.argv[2] ?? ".publish/cli"; + await publishCliPrPreview({ packageDir }); +} + +if ( + process.argv[1] && + fileURLToPath(import.meta.url) === path.resolve(process.argv[1]) +) { + main().catch((error) => { + const message = + error instanceof Error ? error.stack ?? error.message : String(error); + process.stderr.write(`${message}\n`); + process.exitCode = 1; + }); +} From d063b8316a588a9144e2c4c2325238ece3f1718f Mon Sep 17 00:00:00 2001 From: Luan van der Westhuizen Date: Fri, 29 May 2026 13:15:46 +0200 Subject: [PATCH 7/7] Revert "ci: retry transient pkg.pr.new preview publish" This reverts commit 035f48919b6f86963df801ef99c2bfb4013ed353. --- .github/workflows/preview-cli-package.yml | 2 +- packages/cli/tests/publish-prep.test.ts | 23 ----- scripts/publish-cli-pr-preview.mjs | 119 ---------------------- 3 files changed, 1 insertion(+), 143 deletions(-) delete mode 100644 scripts/publish-cli-pr-preview.mjs diff --git a/.github/workflows/preview-cli-package.yml b/.github/workflows/preview-cli-package.yml index 4904441..6306986 100644 --- a/.github/workflows/preview-cli-package.yml +++ b/.github/workflows/preview-cli-package.yml @@ -69,7 +69,7 @@ jobs: - name: Publish installable PR preview id: publish_preview continue-on-error: ${{ vars.CLI_PR_PREVIEW_REQUIRED != 'true' }} - run: node scripts/publish-cli-pr-preview.mjs .publish/cli + run: pnpm exec pkg-pr-new publish --bin --comment=update .publish/cli - name: Summarize PR preview publish if: ${{ always() }} diff --git a/packages/cli/tests/publish-prep.test.ts b/packages/cli/tests/publish-prep.test.ts index 06b5af5..257a668 100644 --- a/packages/cli/tests/publish-prep.test.ts +++ b/packages/cli/tests/publish-prep.test.ts @@ -5,7 +5,6 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { stageCliPublishPackage } from "../../../scripts/prepare-cli-publish.mjs"; -import { isTransientPkgPrNewWorkflowError } from "../../../scripts/publish-cli-pr-preview.mjs"; function createTempCwd(): Promise { return mkdtemp(path.join(os.tmpdir(), "prisma-cli-")); @@ -165,25 +164,3 @@ describe("prepare cli publish", () => { expect(distFiles).toEqual(["cli.js"]); }); }); - -describe("publish cli pr preview", () => { - it("retries only the transient pkg.pr.new workflow registration race", () => { - expect( - isTransientPkgPrNewWorkflowError( - 'Check failed (404): {"url":"/check","statusCode":404,"statusMessage":"Not Found","message":"There is no workflow defined for yP4Cr6lrKy","stack":""}', - ), - ).toBe(true); - - expect( - isTransientPkgPrNewWorkflowError( - 'Publishing failed (400): {"message":"package.json is invalid"}', - ), - ).toBe(false); - - expect( - isTransientPkgPrNewWorkflowError( - 'Check failed (404): {"message":"The app https://github.com/apps/pkg-pr-new is not installed on prisma/prisma-cli."}', - ), - ).toBe(false); - }); -}); diff --git a/scripts/publish-cli-pr-preview.mjs b/scripts/publish-cli-pr-preview.mjs deleted file mode 100644 index 77c20f2..0000000 --- a/scripts/publish-cli-pr-preview.mjs +++ /dev/null @@ -1,119 +0,0 @@ -#!/usr/bin/env node - -import { spawn } from "node:child_process"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -const DEFAULT_MAX_ATTEMPTS = 4; -const DEFAULT_RETRY_DELAY_MS = 10_000; -const TRANSIENT_WORKFLOW_RECORD_ERROR = - /Check failed \(404\):.*There is no workflow defined for/s; - -export function isTransientPkgPrNewWorkflowError(output) { - return TRANSIENT_WORKFLOW_RECORD_ERROR.test(output); -} - -export async function publishCliPrPreview(options = {}) { - const cwd = options.cwd ?? process.cwd(); - const maxAttempts = - options.maxAttempts ?? readPositiveIntEnv("PKG_PR_NEW_MAX_ATTEMPTS", DEFAULT_MAX_ATTEMPTS); - const retryDelayMs = - options.retryDelayMs ?? readPositiveIntEnv("PKG_PR_NEW_RETRY_DELAY_MS", DEFAULT_RETRY_DELAY_MS); - const packageDir = options.packageDir ?? ".publish/cli"; - - for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - const result = await runPkgPrNewPublish(cwd, packageDir); - if (result.exitCode === 0) { - return; - } - - if ( - attempt < maxAttempts && - isTransientPkgPrNewWorkflowError(result.output) - ) { - process.stderr.write( - `pkg.pr.new has not registered this workflow run yet; retrying in ${retryDelayMs / 1000}s (${attempt + 1}/${maxAttempts}).\n`, - ); - await sleep(retryDelayMs); - continue; - } - - process.exitCode = result.exitCode ?? 1; - return; - } -} - -function runPkgPrNewPublish(cwd, packageDir) { - return new Promise((resolve) => { - const child = spawn( - "pnpm", - [ - "exec", - "pkg-pr-new", - "publish", - "--bin", - "--comment=update", - packageDir, - ], - { - cwd, - env: process.env, - stdio: ["ignore", "pipe", "pipe"], - }, - ); - - let output = ""; - - child.stdout.on("data", (chunk) => { - process.stdout.write(chunk); - output += String(chunk); - }); - - child.stderr.on("data", (chunk) => { - process.stderr.write(chunk); - output += String(chunk); - }); - - child.on("error", (error) => { - const message = error instanceof Error ? error.message : String(error); - process.stderr.write(`${message}\n`); - output += message; - resolve({ exitCode: 1, output }); - }); - - child.on("close", (exitCode) => { - resolve({ exitCode, output }); - }); - }); -} - -function readPositiveIntEnv(name, fallback) { - const raw = process.env[name]; - if (raw === undefined) { - return fallback; - } - - const parsed = Number.parseInt(raw, 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; -} - -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function main() { - const packageDir = process.argv[2] ?? ".publish/cli"; - await publishCliPrPreview({ packageDir }); -} - -if ( - process.argv[1] && - fileURLToPath(import.meta.url) === path.resolve(process.argv[1]) -) { - main().catch((error) => { - const message = - error instanceof Error ? error.stack ?? error.message : String(error); - process.stderr.write(`${message}\n`); - process.exitCode = 1; - }); -}