From 06d05f90f1fcf9acb6540b68f67b6bc7a27a04b7 Mon Sep 17 00:00:00 2001 From: Luan van der Westhuizen Date: Wed, 13 May 2026 15:48:36 +0200 Subject: [PATCH 1/4] fix(project): resolve projects automatically --- packages/cli/src/adapters/config.ts | 91 ----- packages/cli/src/adapters/local-state.ts | 36 ++ packages/cli/src/commands/app/index.ts | 57 ++- packages/cli/src/commands/env.ts | 30 +- packages/cli/src/commands/project/index.ts | 33 +- packages/cli/src/controllers/app-env.ts | 53 +-- packages/cli/src/controllers/app.ts | 242 ++++++----- packages/cli/src/controllers/project.ts | 378 +++++------------- packages/cli/src/lib/app/env-config.ts | 20 + packages/cli/src/lib/auth/auth-ops.ts | 2 - packages/cli/src/lib/project/resolution.ts | 248 ++++++++++++ packages/cli/src/output/patterns.ts | 6 +- packages/cli/src/presenters/app.ts | 4 +- packages/cli/src/presenters/branch.ts | 12 +- packages/cli/src/presenters/project.ts | 76 ++-- packages/cli/src/shell/command-meta.ts | 17 +- packages/cli/src/types/app.ts | 12 +- packages/cli/src/types/auth.ts | 1 - packages/cli/src/types/branch.ts | 4 +- packages/cli/src/types/project.ts | 19 +- packages/cli/src/use-cases/auth.ts | 11 +- packages/cli/src/use-cases/branch.ts | 48 +-- packages/cli/src/use-cases/contracts.ts | 8 +- .../cli/src/use-cases/create-cli-gateways.ts | 35 +- packages/cli/src/use-cases/project.ts | 58 +-- 25 files changed, 743 insertions(+), 758 deletions(-) delete mode 100644 packages/cli/src/adapters/config.ts create mode 100644 packages/cli/src/lib/project/resolution.ts diff --git a/packages/cli/src/adapters/config.ts b/packages/cli/src/adapters/config.ts deleted file mode 100644 index d768f2d..0000000 --- a/packages/cli/src/adapters/config.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { copyFile, mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; - -import { updateConfig } from "c12/update"; - -const PROJECT_FIELD_PATTERN = /project\s*:\s*["']([^"']+)["']/; - -export async function readLinkedProjectId(cwd: string): Promise { - const configPath = path.join(cwd, "prisma.config.ts"); - - try { - const contents = await readFile(configPath, "utf8"); - const match = contents.match(PROJECT_FIELD_PATTERN); - return match?.[1] ?? null; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return null; - } - - throw error; - } -} - -export class UnsafeConfigWriteError extends Error { - constructor(message: string) { - super(message); - this.name = "UnsafeConfigWriteError"; - } -} - -export async function assertLinkedProjectIdWritable(cwd: string): Promise { - const tempDir = await mkdtemp(path.join(os.tmpdir(), "prisma-cli-config-")); - const sourceConfigPath = path.join(cwd, "prisma.config.ts"); - const tempConfigPath = path.join(tempDir, "prisma.config.ts"); - - try { - try { - await copyFile(sourceConfigPath, tempConfigPath); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - throw error; - } - } - - await applyLinkedProjectIdUpdate(tempDir, "proj_preflight"); - } catch (error) { - throw toUnsafeConfigWriteError(error); - } finally { - await rm(tempDir, { recursive: true, force: true }); - } -} - -export async function writeLinkedProjectId(cwd: string, projectId: string): Promise { - try { - await applyLinkedProjectIdUpdate(cwd, projectId); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - await mkdir(cwd, { recursive: true }); - await applyLinkedProjectIdUpdate(cwd, projectId); - return; - } - - throw toUnsafeConfigWriteError(error); - } -} - -async function applyLinkedProjectIdUpdate(cwd: string, projectId: string): Promise { - await updateConfig({ - cwd, - configFile: "prisma.config", - onUpdate(config) { - config.project = projectId; - }, - onCreate() { - return renderProjectConfig(projectId); - }, - }); -} - -function renderProjectConfig(projectId: string): string { - return `export default {\n project: "${projectId}",\n};\n`; -} - -function toUnsafeConfigWriteError(error: unknown): UnsafeConfigWriteError { - if (error instanceof UnsafeConfigWriteError) { - return error; - } - - return new UnsafeConfigWriteError("The existing prisma.config.ts file could not be updated safely."); -} diff --git a/packages/cli/src/adapters/local-state.ts b/packages/cli/src/adapters/local-state.ts index 5da7930..7a09bb4 100644 --- a/packages/cli/src/adapters/local-state.ts +++ b/packages/cli/src/adapters/local-state.ts @@ -9,6 +9,10 @@ export interface LocalState { userId: string; workspaceId: string; } | null; + project: { + rememberedByWorkspace: Record; + lastResolved: RememberedProjectState | null; + }; branch: { active: string; }; @@ -23,8 +27,18 @@ export interface SelectedAppState { name: string; } +export interface RememberedProjectState { + id: string; + name: string; + workspaceId: string; +} + const DEFAULT_STATE: LocalState = { auth: null, + project: { + rememberedByWorkspace: {}, + lastResolved: null, + }, branch: { active: "preview", }, @@ -53,6 +67,10 @@ export class LocalStateStore { const parsed = JSON.parse(raw) as Partial; return { auth: parsed.auth ?? structuredClone(DEFAULT_STATE.auth), + project: { + rememberedByWorkspace: parsed.project?.rememberedByWorkspace ?? {}, + lastResolved: parsed.project?.lastResolved ?? null, + }, branch: { active: parsed.branch?.active ?? DEFAULT_STATE.branch.active, }, @@ -96,6 +114,24 @@ export class LocalStateStore { return state; } + async readRememberedProject(workspaceId: string): Promise { + const state = await this.read(); + return state.project.rememberedByWorkspace[workspaceId] ?? null; + } + + async readLastResolvedProject(): Promise { + const state = await this.read(); + return state.project.lastResolved; + } + + async setRememberedProject(project: RememberedProjectState): Promise { + const state = await this.read(); + state.project.rememberedByWorkspace[project.workspaceId] = project; + state.project.lastResolved = project; + await this.write(state); + return state; + } + async readSelectedApp(projectId: string): Promise { const state = await this.read(); return state.app.selectedByProject[projectId] ?? null; diff --git a/packages/cli/src/commands/app/index.ts b/packages/cli/src/commands/app/index.ts index 2cbcf1c..8674bcf 100644 --- a/packages/cli/src/commands/app/index.ts +++ b/packages/cli/src/commands/app/index.ts @@ -161,6 +161,7 @@ function createDeployCommand(runtime: CliRuntime): Command { command .addOption(new Option("--app ", "App name")) + .addOption(new Option("--project ", "Project id or name")) .addOption(new Option("--entry ", "Entrypoint path for Bun or auto deploys")) .addOption( new Option("--build-type ", "Deploy build type") @@ -180,12 +181,14 @@ function createDeployCommand(runtime: CliRuntime): Command { const buildType = (options as { buildType?: string }).buildType; const httpPort = (options as { httpPort?: string }).httpPort; const envAssignments = (options as { env?: string[] }).env; + const projectRef = (options as { project?: string }).project; await runCommand( runtime, "app.deploy", options as Record, (context) => runAppDeploy(context, appName, { + projectRef, entrypoint: entry, buildType, httpPort, @@ -209,6 +212,7 @@ function createUpdateEnvCommand(runtime: CliRuntime): Command { command .addOption(new Option("--app ", "App name")) + .addOption(new Option("--project ", "Project id or name")) .addOption( new Option("--env ", "Environment variable") .argParser(collectRepeatableValues), @@ -218,12 +222,13 @@ function createUpdateEnvCommand(runtime: CliRuntime): Command { command.action(async (options) => { const appName = (options as { app?: string }).app; const envAssignments = (options as { env?: string[] }).env; + const projectRef = (options as { project?: string }).project; await runCommand( runtime, "app.update-env", options as Record, - (context) => runAppUpdateEnv(context, appName, envAssignments), + (context) => runAppUpdateEnv(context, appName, envAssignments, projectRef), { renderHuman: (context, descriptor, result) => renderAppUpdateEnv(context, descriptor, result), renderJson: (result) => serializeAppUpdateEnv(result), @@ -240,17 +245,20 @@ function createListEnvCommand(runtime: CliRuntime): Command { "app.list-env", ); - command.addOption(new Option("--app ", "App name")); + command + .addOption(new Option("--app ", "App name")) + .addOption(new Option("--project ", "Project id or name")); addGlobalFlags(command); command.action(async (options) => { const appName = (options as { app?: string }).app; + const projectRef = (options as { project?: string }).project; await runCommand( runtime, "app.list-env", options as Record, - (context) => runAppListEnv(context, appName), + (context) => runAppListEnv(context, appName, projectRef), { renderHuman: (context, descriptor, result) => renderAppListEnv(context, descriptor, result), renderJson: (result) => serializeAppListEnv(result), @@ -267,17 +275,20 @@ function createShowCommand(runtime: CliRuntime): Command { "app.show", ); - command.addOption(new Option("--app ", "App name")); + command + .addOption(new Option("--app ", "App name")) + .addOption(new Option("--project ", "Project id or name")); addGlobalFlags(command); command.action(async (options) => { const appName = (options as { app?: string }).app; + const projectRef = (options as { project?: string }).project; await runCommand( runtime, "app.show", options as Record, - (context) => runAppShow(context, appName), + (context) => runAppShow(context, appName, projectRef), { renderHuman: (context, descriptor, result) => renderAppShow(context, descriptor, result), renderJson: (result) => serializeAppShow(result), @@ -294,17 +305,20 @@ function createOpenCommand(runtime: CliRuntime): Command { "app.open", ); - command.addOption(new Option("--app ", "App name")); + command + .addOption(new Option("--app ", "App name")) + .addOption(new Option("--project ", "Project id or name")); addGlobalFlags(command); command.action(async (options) => { const appName = (options as { app?: string }).app; + const projectRef = (options as { project?: string }).project; await runCommand( runtime, "app.open", options as Record, - (context) => runAppOpen(context, appName), + (context) => runAppOpen(context, appName, projectRef), { renderHuman: (context, descriptor, result) => renderAppOpen(context, descriptor, result), renderJson: (result) => serializeAppOpen(result), @@ -323,18 +337,20 @@ function createLogsCommand(runtime: CliRuntime): Command { command .addOption(new Option("--app ", "App name")) + .addOption(new Option("--project ", "Project id or name")) .addOption(new Option("--deployment ", "Deployment id")); addGlobalFlags(command); command.action(async (options) => { const appName = (options as { app?: string }).app; const deploymentId = (options as { deployment?: string }).deployment; + const projectRef = (options as { project?: string }).project; await runStreamingCommand( runtime, "app.logs", options as Record, - (context) => runAppLogs(context, appName, deploymentId), + (context) => runAppLogs(context, appName, deploymentId, projectRef), ); }); @@ -351,17 +367,20 @@ function createListDeploysCommand(runtime: CliRuntime): Command { "app.list-deploys", ); - command.addOption(new Option("--app ", "App name")); + command + .addOption(new Option("--app ", "App name")) + .addOption(new Option("--project ", "Project id or name")); addGlobalFlags(command); command.action(async (options) => { const appName = (options as { app?: string }).app; + const projectRef = (options as { project?: string }).project; await runCommand( runtime, "app.list-deploys", options as Record, - (context) => runAppListDeploys(context, appName), + (context) => runAppListDeploys(context, appName, projectRef), { renderHuman: (context, descriptor, result) => renderAppListDeploys(context, descriptor, result), renderJson: (result) => serializeAppListDeploys(result), @@ -404,17 +423,20 @@ function createPromoteCommand(runtime: CliRuntime): Command { ); command.argument("", "Deployment id"); - command.addOption(new Option("--app ", "App name")); + command + .addOption(new Option("--app ", "App name")) + .addOption(new Option("--project ", "Project id or name")); addGlobalFlags(command); command.action(async (deploymentId: string, options) => { const appName = (options as { app?: string }).app; + const projectRef = (options as { project?: string }).project; await runCommand( runtime, "app.promote", options as Record, - (context) => runAppPromote(context, deploymentId, appName), + (context) => runAppPromote(context, deploymentId, appName, projectRef), { renderHuman: (context, descriptor, result) => renderAppPromote(context, descriptor, result), renderJson: (result) => serializeAppPromote(result), @@ -433,18 +455,20 @@ function createRollbackCommand(runtime: CliRuntime): Command { command .addOption(new Option("--app ", "App name")) + .addOption(new Option("--project ", "Project id or name")) .addOption(new Option("--to ", "Deployment id")); addGlobalFlags(command); command.action(async (options) => { const appName = (options as { app?: string }).app; const deploymentId = (options as { to?: string }).to; + const projectRef = (options as { project?: string }).project; await runCommand( runtime, "app.rollback", options as Record, - (context) => runAppRollback(context, appName, deploymentId), + (context) => runAppRollback(context, appName, deploymentId, projectRef), { renderHuman: (context, descriptor, result) => renderAppRollback(context, descriptor, result), renderJson: (result) => serializeAppRollback(result), @@ -461,17 +485,20 @@ function createRemoveCommand(runtime: CliRuntime): Command { "app.remove", ); - command.addOption(new Option("--app ", "App name")); + command + .addOption(new Option("--app ", "App name")) + .addOption(new Option("--project ", "Project id or name")); addGlobalFlags(command); command.action(async (options) => { const appName = (options as { app?: string }).app; + const projectRef = (options as { project?: string }).project; await runCommand( runtime, "app.remove", options as Record, - (context) => runAppRemove(context, appName), + (context) => runAppRemove(context, appName, projectRef), { renderHuman: (context, descriptor, result) => renderAppRemove(context, descriptor, result), renderJson: (result) => serializeAppRemove(result), diff --git a/packages/cli/src/commands/env.ts b/packages/cli/src/commands/env.ts index 59ce30b..0e73f10 100644 --- a/packages/cli/src/commands/env.ts +++ b/packages/cli/src/commands/env.ts @@ -28,7 +28,7 @@ export function createEnvCommand(runtime: CliRuntime): Command { "project.env", ); - env.description("Manage environment variables for the linked project."); + env.description("Manage environment variables for the active project"); env.addCommand(createEnvAddCommand(runtime)); env.addCommand(createEnvUpdateCommand(runtime)); env.addCommand(createEnvListCommand(runtime)); @@ -44,23 +44,25 @@ function createEnvAddCommand(runtime: CliRuntime): Command { ); command - .argument("", "Variable assignment in KEY=VALUE form") + .argument("", "Variable assignment as KEY=VALUE or KEY from the current environment") .addOption( new Option( "--role ", "Project template scope (production or preview)", ).choices(["production", "preview"]), - ); + ) + .addOption(new Option("--project ", "Project id or name")); addGlobalFlags(command); command.action(async (assignment: string, options) => { const roleName = (options as { role?: string }).role; + const projectRef = (options as { project?: string }).project; await runCommand( runtime, "project.env.add", options as Record, - (context) => runEnvAdd(context, assignment, { roleName }), + (context) => runEnvAdd(context, assignment, { roleName, projectRef }), { renderHuman: (context, descriptor, result) => renderEnvAdd(context, descriptor, result), renderJson: (result) => serializeEnvAdd(result), @@ -78,23 +80,25 @@ function createEnvUpdateCommand(runtime: CliRuntime): Command { ); command - .argument("", "Variable assignment in KEY=VALUE form") + .argument("", "Variable assignment as KEY=VALUE or KEY from the current environment") .addOption( new Option( "--role ", "Project template scope (production or preview)", ).choices(["production", "preview"]), - ); + ) + .addOption(new Option("--project ", "Project id or name")); addGlobalFlags(command); command.action(async (assignment: string, options) => { const roleName = (options as { role?: string }).role; + const projectRef = (options as { project?: string }).project; await runCommand( runtime, "project.env.update", options as Record, - (context) => runEnvUpdate(context, assignment, { roleName }), + (context) => runEnvUpdate(context, assignment, { roleName, projectRef }), { renderHuman: (context, descriptor, result) => renderEnvUpdate(context, descriptor, result), renderJson: (result) => serializeEnvUpdate(result), @@ -117,17 +121,19 @@ function createEnvListCommand(runtime: CliRuntime): Command { "--role ", "Project template scope", ).choices(["production", "preview"]), - ); + ) + .addOption(new Option("--project ", "Project id or name")); addGlobalFlags(command); command.action(async (options) => { const roleName = (options as { role?: string }).role; + const projectRef = (options as { project?: string }).project; await runCommand( runtime, "project.env.list", options as Record, - (context) => runEnvList(context, { roleName }), + (context) => runEnvList(context, { roleName, projectRef }), { renderHuman: (context, descriptor, result) => renderEnvList(context, descriptor, result), renderJson: (result) => serializeEnvList(result), @@ -151,17 +157,19 @@ function createEnvRmCommand(runtime: CliRuntime): Command { "--role ", "Project template scope (production or preview)", ).choices(["production", "preview"]), - ); + ) + .addOption(new Option("--project ", "Project id or name")); addGlobalFlags(command); command.action(async (key: string, options) => { const roleName = (options as { role?: string }).role; + const projectRef = (options as { project?: string }).project; await runCommand( runtime, "project.env.rm", options as Record, - (context) => runEnvRm(context, key, { roleName }), + (context) => runEnvRm(context, key, { roleName, projectRef }), { renderHuman: (context, descriptor, result) => renderEnvRm(context, descriptor, result), renderJson: (result) => serializeEnvRm(result), diff --git a/packages/cli/src/commands/project/index.ts b/packages/cli/src/commands/project/index.ts index 58d6e30..0726c95 100644 --- a/packages/cli/src/commands/project/index.ts +++ b/packages/cli/src/commands/project/index.ts @@ -1,11 +1,11 @@ import { Command } from "commander"; -import { runProjectLink, runProjectList, runProjectShow } from "../../controllers/project"; +import { runProjectList, runProjectShow } from "../../controllers/project"; import { - renderProjectLink, renderProjectList, renderProjectShow, serializeProjectList, + serializeProjectShow, } from "../../presenters/project"; import { attachCommandDescriptor } from "../../shell/command-meta"; import { addCompactGlobalFlags, addGlobalFlags } from "../../shell/global-flags"; @@ -21,7 +21,6 @@ export function createProjectCommand(runtime: CliRuntime): Command { project.addCommand(createProjectListCommand(runtime)); project.addCommand(createProjectShowCommand(runtime)); - project.addCommand(createProjectLinkCommand(runtime)); project.addCommand(createEnvCommand(runtime)); return project; @@ -51,38 +50,20 @@ function createProjectListCommand(runtime: CliRuntime): Command { function createProjectShowCommand(runtime: CliRuntime): Command { const command = attachCommandDescriptor(configureRuntimeCommand(new Command("show"), runtime), "project.show"); + command.option("--project ", "Project id or name"); addGlobalFlags(command); command.action(async (options) => { + const projectRef = (options as { project?: string }).project; + await runCommand( runtime, "project.show", options as Record, - (context) => runProjectShow(context), + (context) => runProjectShow(context, projectRef), { renderHuman: (context, descriptor, result) => renderProjectShow(context, descriptor, result), - }, - ); - }); - - return command; -} - -function createProjectLinkCommand(runtime: CliRuntime): Command { - const command = attachCommandDescriptor(configureRuntimeCommand(new Command("link"), runtime), "project.link"); - - command.argument("[project]", "Project id"); - - addGlobalFlags(command); - - command.action(async (projectId: string | undefined, options) => { - await runCommand( - runtime, - "project.link", - options as Record, - (context) => runProjectLink(context, projectId), - { - renderHuman: (context, descriptor, result) => renderProjectLink(context, descriptor, result), + renderJson: (result) => serializeProjectShow(result), }, ); }); diff --git a/packages/cli/src/controllers/app-env.ts b/packages/cli/src/controllers/app-env.ts index bcf4486..04b7384 100644 --- a/packages/cli/src/controllers/app-env.ts +++ b/packages/cli/src/controllers/app-env.ts @@ -1,6 +1,5 @@ import type { ManagementApiClient } from "@prisma/management-api-sdk"; -import { readLinkedProjectId } from "../adapters/config"; import { formatScopeLabel, parseKeyValuePositional, @@ -12,6 +11,7 @@ import { requireComputeAuth } from "../lib/auth/guard"; import { authRequiredError, CliError, usageError } from "../shell/errors"; import type { CommandSuccess } from "../shell/output"; import type { CommandContext } from "../shell/runtime"; +import { resolveProjectTarget } from "../lib/project/resolution"; import type { EnvAddResult, EnvListResult, @@ -20,6 +20,9 @@ import type { EnvUpdateResult, EnvVariableMetadata, } from "../types/app-env"; +import { requireAuthenticatedAuthState } from "./auth"; +import { createSelectPromptPort } from "./select-prompt-port"; +import { listRealWorkspaceProjects } from "./project"; interface ResolvedScope { scope: EnvScope; @@ -43,9 +46,9 @@ function defaultRoleScope(): EnvScope { export async function runEnvAdd( context: CommandContext, rawAssignment: string | undefined, - flags: { roleName?: string }, + flags: { roleName?: string; projectRef?: string }, ): Promise> { - const { key, value } = parseKeyValuePositional(rawAssignment, "add"); + const { key, value } = parseKeyValuePositional(rawAssignment, "add", context.runtime.env); const scope = resolveEnvScope(flags, { requireExplicit: true, command: "add" }); if (!scope) { throw usageError( @@ -57,7 +60,7 @@ export async function runEnvAdd( ); } - const { client, projectId } = await requireClientAndProject(context); + const { client, projectId } = await requireClientAndProject(context, flags.projectRef); const resolved = resolveScopeToApi(scope); const existing = await findVariableByNaturalKey(client, projectId, key, resolved); @@ -106,9 +109,9 @@ export async function runEnvAdd( export async function runEnvUpdate( context: CommandContext, rawAssignment: string | undefined, - flags: { roleName?: string }, + flags: { roleName?: string; projectRef?: string }, ): Promise> { - const { key, value } = parseKeyValuePositional(rawAssignment, "update"); + const { key, value } = parseKeyValuePositional(rawAssignment, "update", context.runtime.env); const scope = resolveEnvScope(flags, { requireExplicit: true, command: "update" }); if (!scope) { throw usageError( @@ -120,7 +123,7 @@ export async function runEnvUpdate( ); } - const { client, projectId } = await requireClientAndProject(context); + const { client, projectId } = await requireClientAndProject(context, flags.projectRef); const resolved = resolveScopeToApi(scope); const existing = await findVariableByNaturalKey(client, projectId, key, resolved); @@ -164,12 +167,12 @@ export async function runEnvUpdate( export async function runEnvList( context: CommandContext, - flags: { roleName?: string }, + flags: { roleName?: string; projectRef?: string }, ): Promise> { const explicit = resolveEnvScope(flags, { requireExplicit: false, command: "list" }); const scope = explicit ?? defaultRoleScope(); - const { client, projectId } = await requireClientAndProject(context); + const { client, projectId } = await requireClientAndProject(context, flags.projectRef); const resolved = resolveScopeToApi(scope); const variables = await listVariables(client, projectId, resolved); @@ -190,7 +193,7 @@ export async function runEnvList( export async function runEnvRm( context: CommandContext, key: string | undefined, - flags: { roleName?: string }, + flags: { roleName?: string; projectRef?: string }, ): Promise> { if (!key) { throw usageError( @@ -213,7 +216,7 @@ export async function runEnvRm( ); } - const { client, projectId } = await requireClientAndProject(context); + const { client, projectId } = await requireClientAndProject(context, flags.projectRef); const resolved = resolveScopeToApi(scope); const existing = await findVariableByNaturalKey(client, projectId, key, resolved); if (!existing) { @@ -254,26 +257,24 @@ export async function runEnvRm( async function requireClientAndProject( context: CommandContext, + explicitProject: string | undefined, ): Promise<{ client: ManagementApiClient; projectId: string }> { - const projectId = await readLinkedProjectId(context.runtime.cwd); - if (!projectId) { - throw new CliError({ - code: "PROJECT_NOT_LINKED", - domain: "project", - summary: "Project link required", - why: "prisma-cli project env needs a linked project for the current repo.", - fix: "Run prisma project link before managing environment variables.", - exitCode: 1, - nextSteps: ["prisma project link"], - }); - } - + const authState = await requireAuthenticatedAuthState(context); const client = await requireComputeAuth(context.runtime.env); - if (!client) { + if (!client || !authState.workspace) { throw authRequiredError(["prisma auth login"]); } - return { client, projectId }; + const target = await resolveProjectTarget({ + context, + workspace: authState.workspace, + explicitProject, + listProjects: () => listRealWorkspaceProjects(client, authState.workspace!), + prompt: createSelectPromptPort(context), + remember: true, + }); + + return { client, projectId: target.project.id }; } function resolveScopeToApi(scope: EnvScope): ResolvedScope { diff --git a/packages/cli/src/controllers/app.ts b/packages/cli/src/controllers/app.ts index b38dcd8..3dbecd0 100644 --- a/packages/cli/src/controllers/app.ts +++ b/packages/cli/src/controllers/app.ts @@ -1,9 +1,7 @@ -import path from "node:path"; - import open from "open"; import type { PortMapping, StreamRecord } from "@prisma/compute-sdk"; +import type { ManagementApiClient } from "@prisma/management-api-sdk"; -import { UnsafeConfigWriteError, assertLinkedProjectIdWritable, readLinkedProjectId, writeLinkedProjectId } from "../adapters/config"; import { FileTokenStorage } from "../adapters/token-storage"; import { authRequiredError, CliError, featureUnavailableError, usageError } from "../shell/errors"; import { writeJsonEvent, type CommandSuccess } from "../shell/output"; @@ -25,6 +23,9 @@ import type { AppShowDeployResult, AppUpdateEnvResult, } from "../types/app"; +import type { AuthWorkspace } from "../types/auth"; +import type { BranchKind } from "../types/branch"; +import type { ProjectResolution, ProjectSummary } from "../types/project"; import { requireComputeAuth } from "../lib/auth/guard"; import { getApiBaseUrl, SERVICE_TOKEN_ENV_VAR } from "../lib/auth/client"; import { parseEnvAssignments } from "../lib/app/env-vars"; @@ -33,7 +34,7 @@ import { resolveLocalBuildType, runLocalApp, } from "../lib/app/local-dev"; -import { projectNotFoundError } from "../use-cases/project"; +import { resolveProjectTarget } from "../lib/project/resolution"; import { executePreviewBuild, PREVIEW_BUILD_TYPES, @@ -50,6 +51,8 @@ import { createPreviewUpdateEnvProgress, } from "../lib/app/preview-progress"; import { createPreviewAppProvider, type PreviewAppRecord } from "../lib/app/preview-provider"; +import { requireAuthenticatedAuthState } from "./auth"; +import { listRealWorkspaceProjects } from "./project"; import { createSelectPromptPort } from "./select-prompt-port"; function isRealMode(context: CommandContext): boolean { @@ -157,6 +160,7 @@ export async function runAppDeploy( context: CommandContext, appName: string | undefined, options?: { + projectRef?: string; entrypoint?: string; buildType?: string; httpPort?: string; @@ -173,8 +177,11 @@ export async function runAppDeploy( commandName: "deploy", }), ); - const provider = await requirePreviewAppProvider(context); - const projectId = await resolveProjectIdForDeploy(context, provider); + const { client, provider } = await requirePreviewAppProviderWithClient(context); + const target = await resolveProjectContext(context, client, provider, options?.projectRef, { + allowCreate: true, + }); + const projectId = target.project.id; const apps = await listApps(context, provider, projectId); const selectedApp = await resolveDeploySelection(context, projectId, apps, appName); @@ -203,7 +210,10 @@ export async function runAppDeploy( return { command: "app.deploy", result: { - projectId: deployResult.projectId, + workspace: target.workspace, + project: target.project, + branch: target.branch, + resolution: target.resolution, app: { id: deployResult.app.id, name: deployResult.app.name, @@ -219,6 +229,7 @@ export async function runAppUpdateEnv( context: CommandContext, appName: string | undefined, envAssignments: string[] | undefined, + projectRef?: string, ): Promise> { ensurePreviewAppMode(context); emitLegacyEnvDeprecationWarning(context, "app update-env", "project env add"); @@ -227,15 +238,14 @@ export async function runAppUpdateEnv( commandName: "update-env", requireAtLeastOne: true, }); - const projectId = await requireLinkedProjectId(context); - const provider = await requirePreviewAppProvider(context); + const { provider, projectId } = await requireProviderAndProjectContext(context, projectRef); const apps = await listApps(context, provider, projectId); const selectedApp = await resolveExistingAppSelection(context, projectId, apps, appName); if (!selectedApp) { throw noDeploymentsError( "No deployments available to update environment variables", - "The linked project does not have any deployed app yet.", + "The resolved project does not have any deployed app yet.", ); } @@ -284,12 +294,12 @@ export async function runAppUpdateEnv( export async function runAppListEnv( context: CommandContext, appName: string | undefined, + projectRef?: string, ): Promise> { ensurePreviewAppMode(context); emitLegacyEnvDeprecationWarning(context, "app list-env", "project env list"); - const projectId = await requireLinkedProjectId(context); - const provider = await requirePreviewAppProvider(context); + const { provider, projectId } = await requireProviderAndProjectContext(context, projectRef); const apps = await listApps(context, provider, projectId); const selectedApp = await resolveExistingAppSelection(context, projectId, apps, appName); @@ -403,11 +413,11 @@ export async function runAppListEnv( export async function runAppListDeploys( context: CommandContext, appName: string | undefined, + projectRef?: string, ): Promise> { ensurePreviewAppMode(context); - const projectId = await requireLinkedProjectId(context); - const provider = await requirePreviewAppProvider(context); + const { provider, projectId } = await requireProviderAndProjectContext(context, projectRef); const apps = await listApps(context, provider, projectId); const selectedApp = await resolveExistingAppSelection(context, projectId, apps, appName); @@ -462,11 +472,11 @@ export async function runAppListDeploys( export async function runAppShow( context: CommandContext, appName: string | undefined, + projectRef?: string, ): Promise> { ensurePreviewAppMode(context); - const projectId = await requireLinkedProjectId(context); - const provider = await requirePreviewAppProvider(context); + const { provider, projectId } = await requireProviderAndProjectContext(context, projectRef); const apps = await listApps(context, provider, projectId); const selectedApp = await resolveExistingAppSelection(context, projectId, apps, appName); @@ -546,9 +556,9 @@ export async function runAppShowDeploy( }); } - const linkedProjectId = deployment?.app ? await readLinkedProjectId(context.runtime.cwd) : null; - const knownLiveDeploymentId = deployment?.app && linkedProjectId - ? await context.stateStore.readKnownLiveDeployment(linkedProjectId, deployment.app.id) + const rememberedProject = deployment?.app ? await context.stateStore.readLastResolvedProject() : null; + const knownLiveDeploymentId = deployment?.app && rememberedProject + ? await context.stateStore.readKnownLiveDeployment(rememberedProject.id, deployment.app.id) : null; const providerLiveDeploymentId = deployment.app?.liveDeploymentId ?? null; @@ -578,18 +588,18 @@ export async function runAppShowDeploy( export async function runAppOpen( context: CommandContext, appName: string | undefined, + projectRef?: string, ): Promise> { ensurePreviewAppMode(context); - const projectId = await requireLinkedProjectId(context); - const provider = await requirePreviewAppProvider(context); + const { provider, projectId } = await requireProviderAndProjectContext(context, projectRef); const apps = await listApps(context, provider, projectId); const selectedApp = await resolveExistingAppSelection(context, projectId, apps, appName); if (!selectedApp) { throw noDeploymentsError( "No deployments available to open", - "The linked project does not have any deployed app yet.", + "The resolved project does not have any deployed app yet.", ); } @@ -656,11 +666,11 @@ export async function runAppLogs( context: CommandContext, appName: string | undefined, deploymentId: string | undefined, + projectRef?: string, ): Promise { ensurePreviewAppMode(context); - const projectId = await requireLinkedProjectId(context); - const provider = await requirePreviewAppProvider(context); + const { provider, projectId } = await requireProviderAndProjectContext(context, projectRef); const target = deploymentId ? await resolveExplicitLogDeployment(context, provider, projectId, appName, deploymentId) : await resolveLiveLogDeployment(context, provider, projectId, appName); @@ -706,7 +716,7 @@ async function resolveExplicitLogDeployment( if (!selectedApp) { throw noDeploymentsError( "No deployments available to stream logs", - "The linked project does not have any deployed app yet.", + "The resolved project does not have any deployed app yet.", ); } @@ -755,13 +765,13 @@ async function resolveExplicitLogDeployment( } const apps = await listApps(context, provider, projectId); - const linkedProjectApp = apps.find((app) => app.id === shown.app?.id); - if (!linkedProjectApp) { + const resolvedProjectApp = apps.find((app) => app.id === shown.app?.id); + if (!resolvedProjectApp) { throw new CliError({ code: "DEPLOYMENT_NOT_FOUND", domain: "app", - summary: `Deployment "${deploymentId}" not found in the linked project`, - why: "The requested deployment does not belong to an app in the linked project.", + summary: `Deployment "${deploymentId}" not found in the resolved project`, + why: "The requested deployment does not belong to an app in the resolved project.", fix: "Run prisma-cli app list-deploys to choose an available deployment id for this project.", exitCode: 1, nextSteps: ["prisma-cli app list-deploys"], @@ -769,12 +779,12 @@ async function resolveExplicitLogDeployment( } await context.stateStore.setSelectedApp(projectId, { - id: linkedProjectApp.id, - name: linkedProjectApp.name, + id: resolvedProjectApp.id, + name: resolvedProjectApp.name, }); return { - app: linkedProjectApp, + app: resolvedProjectApp, deployment: shown.deployment, }; } @@ -791,7 +801,7 @@ async function resolveLiveLogDeployment( if (!selectedApp) { throw noDeploymentsError( "No deployments available to stream logs", - "The linked project does not have any deployed app yet.", + "The resolved project does not have any deployed app yet.", ); } @@ -850,11 +860,11 @@ export async function runAppPromote( context: CommandContext, deploymentId: string, appName: string | undefined, + projectRef?: string, ): Promise> { ensurePreviewAppMode(context); - const projectId = await requireLinkedProjectId(context); - const provider = await requirePreviewAppProvider(context); + const { provider, projectId } = await requireProviderAndProjectContext(context, projectRef); const apps = await listApps(context, provider, projectId); const selectedApp = await requireReleaseAppSelection(context, projectId, apps, appName, "promote"); const deploymentsResult = await provider.listDeployments(selectedApp.id).catch((error) => { @@ -916,11 +926,11 @@ export async function runAppRollback( context: CommandContext, appName: string | undefined, deploymentId: string | undefined, + projectRef?: string, ): Promise> { ensurePreviewAppMode(context); - const projectId = await requireLinkedProjectId(context); - const provider = await requirePreviewAppProvider(context); + const { provider, projectId } = await requireProviderAndProjectContext(context, projectRef); const apps = await listApps(context, provider, projectId); const selectedApp = await requireReleaseAppSelection(context, projectId, apps, appName, "rollback"); const deploymentsResult = await provider.listDeployments(selectedApp.id).catch((error) => { @@ -983,11 +993,11 @@ export async function runAppRollback( export async function runAppRemove( context: CommandContext, appName: string | undefined, + projectRef?: string, ): Promise> { ensurePreviewAppMode(context); - const projectId = await requireLinkedProjectId(context); - const provider = await requirePreviewAppProvider(context); + const { provider, projectId } = await requireProviderAndProjectContext(context, projectRef); const apps = await listApps(context, provider, projectId); const selectedApp = await requireReleaseAppSelection(context, projectId, apps, appName, "remove"); @@ -1056,7 +1066,7 @@ async function resolveDeploySelection( if (!canPrompt(context)) { throw usageError( "Saved app selection is no longer available", - "The locally selected app could not be found in the linked project.", + "The locally selected app could not be found in the resolved project.", "Pass --app , or rerun prisma-cli app deploy in a TTY to choose or create an app again.", ["prisma-cli app deploy"], "app", @@ -1089,8 +1099,8 @@ async function resolveExistingAppSelection( const matched = findAppByName(apps, explicitAppName); if (!matched) { throw usageError( - "Selected app does not exist in the linked project", - `The app "${explicitAppName}" could not be found in linked project "${projectId}".`, + "Selected app does not exist in the resolved project", + `The app "${explicitAppName}" could not be found in resolved project "${projectId}".`, "Pass the name of an existing app, or rerun prisma-cli app list-deploys in a TTY to choose one.", ["prisma-cli app list-deploys"], "app", @@ -1110,7 +1120,7 @@ async function resolveExistingAppSelection( if (!canPrompt(context)) { throw usageError( "Saved app selection is no longer available", - "The locally selected app could not be found in the linked project.", + "The locally selected app could not be found in the resolved project.", "Pass --app , or rerun prisma-cli app list-deploys in a TTY to choose an available app.", ["prisma-cli app list-deploys"], "app", @@ -1158,7 +1168,7 @@ async function requireReleaseAppSelection( throw usageError( `App ${commandName} requires an existing app`, - "The linked project does not have an app that can be selected for this command.", + "The resolved project does not have an app that can be selected for this command.", `Deploy an app first, or rerun prisma-cli app ${commandName} with --app after an app exists.`, ["prisma-cli app deploy", "prisma-cli app list-deploys"], "app", @@ -1326,11 +1336,15 @@ async function listApps( ) { return provider.listApps(projectId).then(sortApps).catch((error) => { if (isMissingProjectError(error)) { - throw projectNotFoundError( - `The linked project "${projectId}" does not exist in the authenticated workspace or is no longer accessible.`, - "Run prisma-cli project show to inspect the current link, then relink the repo or rerun prisma-cli app deploy to bootstrap a new project.", - ["prisma-cli project show", "prisma-cli project link", "prisma-cli app deploy"], - ); + throw new CliError({ + code: "PROJECT_NOT_FOUND", + domain: "project", + summary: "Project not found", + why: `The resolved project "${projectId}" does not exist in the authenticated workspace or is no longer accessible.`, + fix: "Pass --project , or run prisma-cli project show to inspect resolution for this directory.", + exitCode: 1, + nextSteps: ["prisma-cli project show", "prisma-cli app deploy --project "], + }); } throw deployFailedError("Failed to list apps", error, ["prisma-cli project show"]); @@ -1338,12 +1352,22 @@ async function listApps( } async function requirePreviewAppProvider(context: CommandContext) { + const { provider } = await requirePreviewAppProviderWithClient(context); + return provider; +} + +async function requirePreviewAppProviderWithClient( + context: CommandContext, +): Promise<{ client: ManagementApiClient; provider: ReturnType }> { const client = await requireComputeAuth(context.runtime.env); if (!client) { throw authRequiredError(["prisma-cli auth login"]); } - return createPreviewAppProvider(client, createPreviewLogAuthOptions(context.runtime.env)); + return { + client, + provider: createPreviewAppProvider(client, createPreviewLogAuthOptions(context.runtime.env)), + }; } function createPreviewLogAuthOptions(env: NodeJS.ProcessEnv) { @@ -1368,70 +1392,80 @@ function createPreviewLogAuthOptions(env: NodeJS.ProcessEnv) { }; } -async function requireLinkedProjectId(context: CommandContext): Promise { - const projectId = await readLinkedProjectId(context.runtime.cwd); - - if (!projectId) { - throw new CliError({ - code: "PROJECT_NOT_LINKED", - domain: "project", - summary: "Project link required", - why: "This command needs a linked project for the current repo.", - fix: "Run prisma-cli project link before deploying or inspecting app deployments.", - exitCode: 1, - nextSteps: ["prisma-cli project link"], - }); - } +interface ResolvedAppProjectContext { + workspace: AuthWorkspace; + project: ProjectSummary; + branch: { + name: string; + kind: BranchKind; + }; + resolution: ProjectResolution; +} - return projectId; +async function requireProviderAndProjectContext( + context: CommandContext, + explicitProject: string | undefined, + options?: { allowCreate?: boolean }, +): Promise<{ + provider: ReturnType; + target: ResolvedAppProjectContext; + projectId: string; +}> { + const { client, provider } = await requirePreviewAppProviderWithClient(context); + const target = await resolveProjectContext(context, client, provider, explicitProject, options); + return { + provider, + target, + projectId: target.project.id, + }; } -async function resolveProjectIdForDeploy( +async function resolveProjectContext( context: CommandContext, + client: ManagementApiClient, provider: ReturnType, -): Promise { - const linkedProjectId = await readLinkedProjectId(context.runtime.cwd); - if (linkedProjectId) { - return linkedProjectId; + explicitProject: string | undefined, + options?: { allowCreate?: boolean }, +): Promise { + const authState = await requireAuthenticatedAuthState(context); + if (!authState.workspace) { + throw authRequiredError(["prisma-cli auth login"]); } - await assertProjectLinkWritableForDeploy(context); - - const projectName = path.basename(context.runtime.cwd); - const project = await provider.createProject({ name: projectName }).catch((error) => { - throw deployFailedError("Failed to create project for first deploy", error, ["prisma-cli app deploy"]); + const resolved = await resolveProjectTarget({ + context, + workspace: authState.workspace, + explicitProject, + listProjects: () => listRealWorkspaceProjects(client, authState.workspace!), + createProject: options?.allowCreate + ? async (name) => { + const project = await provider.createProject({ name }).catch((error) => { + throw deployFailedError("Failed to create project for first deploy", error, ["prisma-cli app deploy"]); + }); + return { + id: project.id, + name: project.name, + workspace: authState.workspace!, + }; + } + : undefined, + allowCreate: options?.allowCreate, + prompt: createSelectPromptPort(context), + remember: true, }); + const branchName = await context.stateStore.read().then((state) => state.branch.active); - try { - await writeLinkedProjectId(context.runtime.cwd, project.id); - } catch (error) { - const cause = error instanceof Error ? error.message : String(error); - throw deployFailedError( - "Failed to link created project", - `Project "${project.name}" (${project.id}) was created remotely but could not be linked locally: ${cause}`, - ["prisma-cli project show", "prisma-cli app deploy"], - ); - } - - return project.id; + return { + ...resolved, + branch: { + name: branchName, + kind: toBranchKind(branchName), + }, + }; } -async function assertProjectLinkWritableForDeploy(context: CommandContext): Promise { - try { - await assertLinkedProjectIdWritable(context.runtime.cwd); - } catch (error) { - if (error instanceof UnsafeConfigWriteError) { - throw usageError( - "Project bootstrap requires a writable Prisma config", - error.message, - "Update prisma.config.ts to use a recognizable project field, or remove it and rerun prisma-cli app deploy.", - ["prisma-cli app deploy --app hello-world"], - "app", - ); - } - - throw error; - } +function toBranchKind(name: string): BranchKind { + return name === "production" ? "production" : "preview"; } function normalizeBuildType(requestedBuildType: string | undefined): PreviewBuildType { @@ -1556,7 +1590,7 @@ function ensurePreviewAppMode(context: CommandContext) { "App commands are not available in fixture mode", "Preview app commands require live app deployment integration.", "Rerun without fixture mode enabled to use preview app deployment workflows.", - ["prisma-cli auth login", "prisma-cli project link"], + ["prisma-cli auth login", "prisma-cli project show"], "app", ); } diff --git a/packages/cli/src/controllers/project.ts b/packages/cli/src/controllers/project.ts index 64dcc83..47f68e7 100644 --- a/packages/cli/src/controllers/project.ts +++ b/packages/cli/src/controllers/project.ts @@ -1,54 +1,48 @@ -import { authRequiredError, CliError, usageError } from "../shell/errors"; +import type { ManagementApiClient } from "@prisma/management-api-sdk"; + +import { authRequiredError } from "../shell/errors"; import type { CommandSuccess } from "../shell/output"; -import { canPrompt, type CommandContext } from "../shell/runtime"; -import type { AuthStateResult } from "../types/auth"; -import type { ProjectListResult, ProjectShowResult, ProjectSummary } from "../types/project"; -import { createAuthUseCases } from "../use-cases/auth"; +import type { CommandContext } from "../shell/runtime"; +import type { AuthWorkspace } from "../types/auth"; +import type { ProjectListResult, ProjectShowResult } from "../types/project"; +import { requireComputeAuth } from "../lib/auth/guard"; +import { + resolveProjectTarget, + sortProjects, + type ProjectCandidate, +} from "../lib/project/resolution"; +import { createProjectUseCases } from "../use-cases/project"; import { createCliUseCaseGateways } from "../use-cases/create-cli-gateways"; -import { createProjectUseCases, projectNotFoundError } from "../use-cases/project"; import { requireAuthenticatedAuthState } from "./auth"; -import { createSelectPromptPort } from "./select-prompt-port"; -import { UnsafeConfigWriteError, readLinkedProjectId, writeLinkedProjectId } from "../adapters/config"; -import { readAuthState } from "../lib/auth/auth-ops"; -import { requireComputeAuth } from "../lib/auth/guard"; function isRealMode(context: CommandContext): boolean { return !context.runtime.fixturePath && !context.runtime.env.PRISMA_CLI_MOCK_FIXTURE_PATH; } export async function runProjectList(context: CommandContext): Promise> { + const authState = await requireAuthenticatedAuthState(context); + const workspace = authState.workspace; + if (!workspace) { + throw authRequiredError(); + } + if (isRealMode(context)) { - const authState = await requireAuthenticatedAuthState(context); const client = await requireComputeAuth(context.runtime.env); - const workspace = authState.workspace; - - if (!client || !workspace) { + if (!client) { throw authRequiredError(); } - const { data: projectsData } = await client.GET("/v1/projects", {}); - const linkedProjectId = await readLinkedProjectId(context.runtime.cwd); - const projects = (projectsData?.data ?? []) - .filter((project) => project.workspace.id === workspace.id) - .map((project) => ({ - id: project.id, - name: project.name, - })) - .sort((left, right) => left.name.localeCompare(right.name) || left.id.localeCompare(right.id)); - return { command: "project.list", result: { workspace, - linkedProjectId, - projects, + projects: sortProjects(await listRealWorkspaceProjects(client, workspace)).map(toProjectSummary), }, warnings: [], - nextSteps: ["prisma-cli project link"], + nextSteps: [], }; } - const authState = await requireAuthenticatedAuthState(context); const projectUseCases = createProjectUseCases(createCliUseCaseGateways(context)); const result = await projectUseCases.list(authState); @@ -56,286 +50,102 @@ export async function runProjectList(context: CommandContext): Promise> { - if (isRealMode(context)) { - const linkedProjectId = await readLinkedProjectId(context.runtime.cwd); - - if (!linkedProjectId) { - return { - command: "project.show", - result: { - linkedProjectId: null, - workspace: null, - project: null, - }, - warnings: [], - nextSteps: ["prisma-cli project link"], - }; - } - - const authState = await readAuthState(context.runtime.env); - - if (!authState.authenticated || !authState.workspace) { - return { - command: "project.show", - result: { - linkedProjectId, - workspace: null, - project: null, - }, - warnings: [], - nextSteps: ["prisma-cli auth login"], - }; - } - - const client = await requireComputeAuth(context.runtime.env); - - if (!client) { - return { - command: "project.show", - result: { - linkedProjectId, - workspace: null, - project: null, - }, - warnings: [], - nextSteps: ["prisma-cli auth login"], - }; - } - - try { - const { data } = await client.GET("/v1/projects/{id}", { - params: { path: { id: linkedProjectId } }, - }); - const project = data?.data; - - if (!project || project.workspace.id !== authState.workspace.id) { - return { - command: "project.show", - result: { - linkedProjectId, - workspace: null, - project: null, - }, - warnings: [], - nextSteps: [], - }; - } - - return { - command: "project.show", - result: { - linkedProjectId, - workspace: { - id: project.workspace.id, - name: project.workspace.name, - }, - project: { - id: project.id, - name: project.name, - }, - }, - warnings: [], - nextSteps: [], - }; - } catch { - return { - command: "project.show", - result: { - linkedProjectId, - workspace: null, - project: null, - }, - warnings: [], - nextSteps: [], - }; - } +export async function runProjectShow( + context: CommandContext, + explicitProject: string | undefined, +): Promise> { + const authState = await requireAuthenticatedAuthState(context); + const workspace = authState.workspace; + if (!workspace) { + throw authRequiredError(); } - const gateways = createCliUseCaseGateways(context); - const authUseCases = createAuthUseCases(gateways); - const projectUseCases = createProjectUseCases(gateways); - const authState = await authUseCases.whoami(); - const result = await projectUseCases.show(authState); + const result = isRealMode(context) + ? await resolveProjectShowInRealMode(context, workspace, explicitProject) + : await resolveProjectShowInFixtureMode(context, workspace, explicitProject); return { command: "project.show", result, warnings: [], - nextSteps: result.linkedProjectId ? (authState.authenticated ? [] : ["prisma-cli auth login"]) : ["prisma-cli project link"], + nextSteps: [], }; } -export async function runProjectLink( +async function resolveProjectShowInRealMode( context: CommandContext, - projectId: string | undefined, -): Promise> { - if (!projectId && !canPrompt(context)) { - throw projectSelectionRequiredError(); + workspace: AuthWorkspace, + explicitProject: string | undefined, +): Promise { + const client = await requireComputeAuth(context.runtime.env); + if (!client) { + throw authRequiredError(); } - if (isRealMode(context)) { - const authState = await requireAuthenticatedAuthState(context); - const client = await requireComputeAuth(context.runtime.env); - const workspace = authState.workspace; - - if (!client || !workspace) { - throw authRequiredError(); - } - - let selectedProject: - | { - id: string; - name: string; - workspace: { - id: string; - name: string; - }; - } - | undefined; - - if (projectId) { - try { - const { data } = await client.GET("/v1/projects/{id}", { - params: { path: { id: projectId } }, - }); - - if (!data?.data || data.data.workspace.id !== workspace.id) { - throw projectNotFoundError( - `The project "${projectId}" does not exist in workspace "${workspace.name}".`, - "Run prisma-cli project list and choose a project id from the active workspace.", - ); - } - - selectedProject = data.data; - } catch (error) { - if (error instanceof CliError) { - throw error; - } - - throw projectNotFoundError( - `The project "${projectId}" does not exist in workspace "${workspace.name}".`, - "Run prisma-cli project list and choose a project id from the active workspace.", - ); - } - } else { - const { data: projectsData } = await client.GET("/v1/projects", {}); - const projects = (projectsData?.data ?? []) - .filter((project) => project.workspace.id === workspace.id) - .map((project) => ({ - id: project.id, - name: project.name, - workspace: project.workspace, - })) - .sort((left, right) => left.name.localeCompare(right.name) || left.id.localeCompare(right.id)); - - if (projects.length === 0) { - throw projectNotFoundError( - `No projects are available in workspace "${workspace.name}".`, - "Use prisma-cli app deploy to create project context, or switch workspaces and try again.", - [], - ); - } - - const prompt = createSelectPromptPort(context); - selectedProject = await prompt.select({ - message: "Select a project", - choices: projects.map((project) => ({ - label: `${project.name} (${project.id})`, - value: project, - })), - }); - } - - try { - await writeLinkedProjectId(context.runtime.cwd, selectedProject.id); - } catch (error) { - if (error instanceof UnsafeConfigWriteError) { - throw usageError( - "Project link requires a writable Prisma config", - error.message, - "Update prisma.config.ts to use a recognizable project field, or remove it and rerun prisma-cli project link.", - ["prisma-cli project link proj_123"], - "project", - ); - } + return resolveProjectTarget({ + context, + workspace, + explicitProject, + listProjects: () => listRealWorkspaceProjects(client, workspace), + remember: false, + }); +} - throw error; - } +async function resolveProjectShowInFixtureMode( + context: CommandContext, + workspace: AuthWorkspace, + explicitProject: string | undefined, +): Promise { + return resolveProjectTarget({ + context, + workspace, + explicitProject, + listProjects: async () => listFixtureWorkspaceProjects(context, workspace), + remember: false, + }); +} - return { - command: "project.link", - result: { - linkedProjectId: selectedProject.id, +export async function listRealWorkspaceProjects( + client: ManagementApiClient, + workspace: AuthWorkspace, +): Promise { + const { data } = await client.GET("/v1/projects", {}); + return sortProjects( + (data?.data ?? []) + .filter((project) => project.workspace.id === workspace.id) + .map((project) => ({ + id: project.id, + name: project.name, + slug: "slug" in project && typeof project.slug === "string" ? project.slug : null, workspace: { - id: selectedProject.workspace.id, - name: selectedProject.workspace.name, + id: project.workspace.id, + name: project.workspace.name, }, - project: { - id: selectedProject.id, - name: selectedProject.name, - }, - }, - warnings: [], - nextSteps: ["prisma-cli project show", "prisma-cli app deploy"], - }; - } - - const gateways = createCliUseCaseGateways(context); - const projectUseCases = createProjectUseCases(gateways); - const authState = await requireAuthenticatedAuthState(context); - const resolvedProjectId = projectId ?? (await resolveProjectIdForLink(context, authState, projectUseCases)); - const result = await projectUseCases.link(authState, resolvedProjectId); - - return { - command: "project.link", - result, - warnings: [], - nextSteps: ["prisma-cli project show", "prisma-cli app deploy"], - }; + })), + ); } -async function resolveProjectIdForLink( +export function listFixtureWorkspaceProjects( context: CommandContext, - authState: AuthStateResult, - projectUseCases: ReturnType, -): Promise { - if (!authState.workspace) { - throw projectSelectionRequiredError(); - } - - const projects = await projectUseCases.listProjectsForWorkspace(authState.workspace.id); - - if (projects.length === 0) { - throw projectNotFoundError( - `No projects are available in workspace "${authState.workspace.name}".`, - "Use prisma-cli app deploy to create project context, or switch workspaces and try again.", - [], - ); - } - - const prompt = createSelectPromptPort(context); - const selectedProject = await prompt.select({ - message: "Select a project", - choices: projects.map((project) => ({ - label: `${project.name} (${project.id})`, - value: project, + workspace: AuthWorkspace, +): ProjectCandidate[] { + return sortProjects( + context.api.listProjectsForWorkspace(workspace.id).map((project) => ({ + id: project.id, + name: project.name, + slug: project.slug, + workspace, })), - }); - - return selectedProject.id; + ); } -function projectSelectionRequiredError() { - return usageError( - "Project link requires a project target in non-interactive mode", - "This command cannot prompt for project selection in the current mode.", - "Re-run prisma-cli project link in a TTY, or pass a project id explicitly.", - ["prisma-cli project list"], - "project", - ); +function toProjectSummary(project: ProjectCandidate) { + return { + id: project.id, + name: project.name, + }; } diff --git a/packages/cli/src/lib/app/env-config.ts b/packages/cli/src/lib/app/env-config.ts index e9faeaa..e2951c7 100644 --- a/packages/cli/src/lib/app/env-config.ts +++ b/packages/cli/src/lib/app/env-config.ts @@ -62,6 +62,7 @@ export function resolveEnvScope( export function parseKeyValuePositional( raw: string | undefined, command: "add" | "update", + env: NodeJS.ProcessEnv = process.env, ): { key: string; value: string } { if (!raw) { throw usageError( @@ -75,6 +76,25 @@ export function parseKeyValuePositional( const separatorIndex = raw.indexOf("="); if (separatorIndex === -1) { + if (KEY_SHAPE.test(raw)) { + validateKey(raw, command); + const value = env[raw]; + if (typeof value === "string" && value.length > 0) { + return { key: raw, value }; + } + + throw usageError( + `Value for "${raw}" was not provided`, + `No KEY=VALUE assignment was supplied, and ${raw} is not set in the current environment.`, + "Pass KEY=VALUE or export the variable before running the command.", + [ + `prisma-cli project env ${command} ${raw}=value --role production`, + `${raw}=value prisma-cli project env ${command} ${raw} --role production`, + ], + "app", + ); + } + throw usageError( `KEY=VALUE argument is missing the = separator`, `"${raw}" does not contain an = character.`, diff --git a/packages/cli/src/lib/auth/auth-ops.ts b/packages/cli/src/lib/auth/auth-ops.ts index 1dbc99e..45b5d07 100644 --- a/packages/cli/src/lib/auth/auth-ops.ts +++ b/packages/cli/src/lib/auth/auth-ops.ts @@ -32,7 +32,6 @@ export async function readAuthState(env: NodeJS.ProcessEnv): Promise; + createProject?: (name: string) => Promise; + prompt?: SelectPromptPort; + allowCreate?: boolean; + remember?: boolean; +} + +export async function resolveProjectTarget(options: ResolveProjectOptions): Promise { + const projects = await options.listProjects(); + + if (options.explicitProject) { + return rememberIfRequested( + options, + resolveExplicitProject(options.explicitProject, projects, options.workspace), + "explicit", + ); + } + + const platformMapping = await resolveDurablePlatformMapping(); + if (platformMapping) { + return rememberIfRequested(options, platformMapping, "platform-mapping"); + } + + const remembered = await options.context.stateStore.readRememberedProject(options.workspace.id); + let staleRemembered = false; + if (remembered) { + const matched = projects.find((project) => project.id === remembered.id); + if (matched) { + return rememberIfRequested(options, matched, "remembered-local"); + } + staleRemembered = true; + } + + const packageName = await readPackageName(options.context.runtime.cwd); + if (packageName) { + const matches = projects.filter((project) => projectMatchesPackageName(project, packageName)); + if (matches.length === 1) { + return rememberIfRequested(options, matches[0], "package-name"); + } + if (matches.length > 1) { + return resolveAmbiguousProject(options, matches, "package-name"); + } + } + + if (options.allowCreate && options.createProject) { + const inferredName = packageName ?? path.basename(options.context.runtime.cwd); + if (inferredName) { + const existing = projects.filter((project) => projectMatchesPackageName(project, inferredName)); + if (existing.length === 1) { + return rememberIfRequested(options, existing[0], "package-name"); + } + if (existing.length > 1) { + return resolveAmbiguousProject(options, existing, "package-name"); + } + + const created = await options.createProject(inferredName); + return rememberIfRequested(options, created, "created"); + } + } + + if (options.prompt && canPrompt(options.context) && projects.length > 0) { + const selected = await options.prompt.select({ + message: "Select a project", + choices: sortProjects(projects).map((project) => ({ + label: `${project.name} (${project.id})`, + value: project, + })), + }); + return rememberIfRequested(options, selected, "prompt"); + } + + if (staleRemembered && projects.length > 1) { + throw localStateStaleError(); + } + + throw projectUnresolvedError(); +} + +export function projectNotFoundError(projectRef: string, workspace: AuthWorkspace): CliError { + return new CliError({ + code: "PROJECT_NOT_FOUND", + domain: "project", + summary: "Project not found", + why: `The project "${projectRef}" does not exist in workspace "${workspace.name}" or is not accessible.`, + fix: "Pass a project id or name from prisma-cli project list.", + exitCode: 1, + nextSteps: ["prisma-cli project list"], + }); +} + +export function projectAmbiguousError(projectRef: string | null, matches: ProjectCandidate[]): CliError { + return new CliError({ + code: "PROJECT_AMBIGUOUS", + domain: "project", + summary: "Project resolution is ambiguous", + why: projectRef + ? `Multiple projects matched "${projectRef}".` + : "Multiple projects matched the current directory context.", + fix: "Pass --project to choose the project explicitly.", + meta: { + matches: matches.map((project) => ({ id: project.id, name: project.name })), + }, + exitCode: 1, + nextSteps: ["prisma-cli project list"], + }); +} + +export function projectUnresolvedError(): CliError { + return new CliError({ + code: "PROJECT_UNRESOLVED", + domain: "project", + summary: "No project is resolved for this directory", + why: "No project could be resolved from explicit input, platform mappings, remembered local context, or package metadata.", + fix: "Pass --project on the command that needs a project, or add a package.json name that matches an accessible project.", + exitCode: 1, + nextSteps: ["prisma-cli project list", "prisma-cli project show --project "], + }); +} + +export function localStateStaleError(): CliError { + return new CliError({ + code: "LOCAL_STATE_STALE", + domain: "project", + summary: "Remembered project context is stale", + why: "The remembered project is no longer available in the selected workspace, and automatic resolution would be ambiguous.", + fix: "Pass --project to choose the project explicitly.", + exitCode: 1, + nextSteps: ["prisma-cli project list"], + }); +} + +export async function readPackageName(cwd: string): Promise { + try { + const raw = await readFile(path.join(cwd, "package.json"), "utf8"); + const parsed = JSON.parse(raw) as { name?: unknown }; + return typeof parsed.name === "string" && parsed.name.trim().length > 0 ? parsed.name.trim() : null; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return null; + } + if (error instanceof SyntaxError) { + return null; + } + throw error; + } +} + +export function sortProjects>(projects: T[]): T[] { + return projects + .slice() + .sort((left, right) => left.name.localeCompare(right.name) || left.id.localeCompare(right.id)); +} + +function resolveExplicitProject( + 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); +} + +function resolveAmbiguousProject( + options: ResolveProjectOptions, + matches: ProjectCandidate[], + projectRef: string, +): Promise { + if (options.prompt && canPrompt(options.context)) { + return options.prompt + .select({ + message: "Select a project", + choices: sortProjects(matches).map((project) => ({ + label: `${project.name} (${project.id})`, + value: project, + })), + }) + .then((selected) => rememberIfRequested(options, selected, "prompt")); + } + + throw projectAmbiguousError(projectRef, matches); +} + +function projectMatchesPackageName(project: ProjectCandidate, packageName: string): boolean { + return project.id === packageName || project.name === packageName || project.slug === packageName; +} + +async function resolveDurablePlatformMapping(): Promise { + return null; +} + +async function rememberIfRequested( + options: ResolveProjectOptions, + project: ProjectCandidate, + projectSource: ProjectSource, +): Promise { + if (options.remember) { + await options.context.stateStore.setRememberedProject({ + id: project.id, + name: project.name, + workspaceId: options.workspace.id, + }); + } + + return { + workspace: options.workspace, + project: toProjectSummary(project), + resolution: { + projectSource, + }, + }; +} + +function toProjectSummary(project: Pick): ProjectSummary { + return { + id: project.id, + name: project.name, + }; +} diff --git a/packages/cli/src/output/patterns.ts b/packages/cli/src/output/patterns.ts index e20c0d1..9b8d45f 100644 --- a/packages/cli/src/output/patterns.ts +++ b/packages/cli/src/output/patterns.ts @@ -6,7 +6,7 @@ import type { ShellUi } from "../shell/ui"; import { maskValue, padDisplay, renderSummaryLine } from "../shell/ui"; type ValueTone = "default" | "dim" | "success" | "warning" | "error" | "link"; -type AnnotationStatus = "active" | "linked" | "default" | null; +type AnnotationStatus = "active" | "default" | null; interface CardRow { key: string; @@ -186,10 +186,6 @@ function renderAnnotation(ui: ShellUi, status: AnnotationStatus): string { return ui.success("(active)"); } - if (status === "linked") { - return ui.accent("(linked)"); - } - if (status === "default") { return ui.dim("(default)"); } diff --git a/packages/cli/src/presenters/app.ts b/packages/cli/src/presenters/app.ts index 347cc5b..19f7b8f 100644 --- a/packages/cli/src/presenters/app.ts +++ b/packages/cli/src/presenters/app.ts @@ -49,7 +49,9 @@ export function renderAppDeploy( title: "Deploying the selected app.", descriptor, fields: [ - { key: "project", value: result.projectId }, + { key: "workspace", value: result.workspace.name }, + { key: "project", value: result.project.name }, + { key: "branch", value: result.branch.name }, { key: "app", value: result.app.name }, { key: "deployment", value: result.deployment.id }, { key: "status", value: result.deployment.status, tone: toneForStatus(result.deployment.status) }, diff --git a/packages/cli/src/presenters/branch.ts b/packages/cli/src/presenters/branch.ts index 5b9c95c..3f20f48 100644 --- a/packages/cli/src/presenters/branch.ts +++ b/packages/cli/src/presenters/branch.ts @@ -10,11 +10,11 @@ export function renderBranchList( ): string[] { return renderList( { - title: "Listing branches for the linked project.", + title: "Listing branches for the resolved project.", descriptor, parentContext: { key: "project", - value: result.projectName ?? "not linked", + value: result.projectName ?? "not resolved", }, items: result.branches.map((branch) => ({ noun: "branch", @@ -31,7 +31,7 @@ export function renderBranchList( export function serializeBranchList(result: BranchListResult) { return serializeList({ context: { - project: result.projectName ?? "not linked", + project: result.projectName ?? "not resolved", }, items: result.branches.map((branch) => ({ noun: "branch", @@ -44,7 +44,7 @@ export function serializeBranchList(result: BranchListResult) { export function serializeBranchShow(result: BranchShowResult) { return { - linkedProjectId: result.linkedProjectId, + projectId: result.projectId, projectName: result.projectName, branch: { name: result.branch.name, @@ -68,7 +68,7 @@ export function renderBranchShow( }> = [ { key: "project", - value: result.projectName ?? "not linked", + value: result.projectName ?? "not resolved", tone: result.projectName ? ("default" as const) : ("dim" as const), }, { @@ -129,7 +129,7 @@ export function renderBranchUse( title: "Changing the local default branch context.", descriptor, context: [ - { key: "project", value: result.projectName ?? "not linked", tone: result.projectName ? "default" : "dim" }, + { key: "project", value: result.projectName ?? "not resolved", tone: result.projectName ? "default" : "dim" }, { key: "branch", value: result.branch.name }, ], operationDescription: "Applying active branch change", diff --git a/packages/cli/src/presenters/project.ts b/packages/cli/src/presenters/project.ts index ddd093e..797cc8f 100644 --- a/packages/cli/src/presenters/project.ts +++ b/packages/cli/src/presenters/project.ts @@ -1,7 +1,7 @@ import type { CommandDescriptor } from "../shell/command-meta"; import type { CommandContext } from "../shell/runtime"; import type { ProjectListResult, ProjectShowResult } from "../types/project"; -import { renderList, renderMutate, renderShow, serializeList } from "../output/patterns"; +import { renderList, renderShow, serializeList } from "../output/patterns"; export function renderProjectList( context: CommandContext, @@ -20,7 +20,7 @@ export function renderProjectList( noun: "project", label: project.name, id: project.id, - status: result.linkedProjectId === project.id ? "linked" : null, + status: null, })), emptyMessage: "No projects found.", }, @@ -37,7 +37,7 @@ export function serializeProjectList(result: ProjectListResult) { noun: "project", label: project.name, id: project.id, - status: result.linkedProjectId === project.id ? "linked" : null, + status: null, })), }); } @@ -47,65 +47,37 @@ export function renderProjectShow( descriptor: CommandDescriptor, result: ProjectShowResult, ): string[] { - if (!result.linkedProjectId) { - return renderShow( - { - title: "Showing the linked project for the current repo.", - descriptor, - fields: [{ key: "project", value: "not linked", tone: "dim" }], - }, - context.ui, - ); - } - - if (!result.project || !result.workspace) { - return renderShow( - { - title: "Showing the linked project for the current repo.", - descriptor, - fields: [ - { key: "project", value: "linked", tone: "success" }, - { key: "remote details", value: "unavailable until you sign in", tone: "dim" }, - ], - }, - context.ui, - ); - } - return renderShow( { - title: "Showing the linked project for the current repo.", + title: "Showing the project Prisma resolves for this directory.", descriptor, fields: [ - { key: "project", value: result.project.name }, { key: "workspace", value: result.workspace.name }, + { key: "project", value: result.project.name }, + { key: "resolution", value: formatProjectSource(result.resolution.projectSource) }, ], }, context.ui, ); } -export function renderProjectLink( - context: CommandContext, - descriptor: CommandDescriptor, - result: ProjectShowResult, -): string[] { - if (!result.project || !result.workspace) { - throw new Error("Linked project result must be enriched for human output."); - } +export function serializeProjectShow(result: ProjectShowResult) { + return result; +} - return renderMutate( - { - title: "Linking the current repo to an existing project.", - descriptor, - context: [ - { key: "project", value: result.project.name }, - { key: "workspace", value: result.workspace.name }, - ], - operationDescription: "Applying local project link", - operationCount: 1, - details: ["Project link written to local repo config."], - }, - context.ui, - ); +function formatProjectSource(source: ProjectShowResult["resolution"]["projectSource"]): string { + switch (source) { + case "explicit": + return "explicit"; + case "platform-mapping": + return "platform mapping"; + case "remembered-local": + return "remembered local context"; + case "package-name": + return "package name"; + case "created": + return "created"; + case "prompt": + return "prompt"; + } } diff --git a/packages/cli/src/shell/command-meta.ts b/packages/cli/src/shell/command-meta.ts index 5120578..8287e14 100644 --- a/packages/cli/src/shell/command-meta.ts +++ b/packages/cli/src/shell/command-meta.ts @@ -47,7 +47,7 @@ const DESCRIPTORS: CommandDescriptor[] = [ { id: "project", path: ["prisma", "project"], - description: "Manage the link between this directory and a Prisma project", + description: "Manage and inspect your Prisma projects", examples: ["prisma-cli project list", "prisma-cli project show"], }, { @@ -74,19 +74,13 @@ const DESCRIPTORS: CommandDescriptor[] = [ { id: "project.show", path: ["prisma", "project", "show"], - description: "Show the Prisma project linked to this directory", - examples: ["prisma-cli project show", "prisma-cli project show --json"], - }, - { - id: "project.link", - path: ["prisma", "project", "link"], - description: "Link this directory to a Prisma project", - examples: ["prisma-cli project link", "prisma-cli project link proj_123"], + description: "Show which project is active for this directory", + examples: ["prisma-cli project show", "prisma-cli project show --project proj_123 --json"], }, { id: "branch.list", path: ["prisma", "branch", "list"], - description: "List active Platform branches linked to this project", + description: "List active Platform branches for the resolved project", examples: ["prisma-cli branch list", "prisma-cli branch list --json"], }, { @@ -187,7 +181,7 @@ const DESCRIPTORS: CommandDescriptor[] = [ { id: "project.env", path: ["prisma", "project", "env"], - description: "Manage environment variables for the linked project.", + description: "Manage environment variables for the active project", examples: [ "prisma-cli project env list --role production", "prisma-cli project env add STRIPE_KEY=sk_test_xxx --role production", @@ -201,6 +195,7 @@ const DESCRIPTORS: CommandDescriptor[] = [ examples: [ "prisma-cli project env add STRIPE_KEY=sk_test_xxx --role production", "prisma-cli project env add STRIPE_KEY=sk_test_xxx --role preview", + "API_URL=https://api.example prisma-cli project env add API_URL --project proj_123 --role preview", ], }, { diff --git a/packages/cli/src/types/app.ts b/packages/cli/src/types/app.ts index 7774eba..e453dfe 100644 --- a/packages/cli/src/types/app.ts +++ b/packages/cli/src/types/app.ts @@ -1,3 +1,7 @@ +import type { AuthWorkspace } from "./auth"; +import type { BranchKind } from "./branch"; +import type { ProjectResolution, ProjectSummary } from "./project"; + export interface AppSummary { id: string; name: string; @@ -12,7 +16,13 @@ export interface AppDeploymentSummary { } export interface AppDeployResult { - projectId: string; + workspace: AuthWorkspace; + project: ProjectSummary; + branch: { + name: string; + kind: BranchKind; + }; + resolution: ProjectResolution; app: AppSummary; deployment: { id: string; diff --git a/packages/cli/src/types/auth.ts b/packages/cli/src/types/auth.ts index 930ab53..72472b8 100644 --- a/packages/cli/src/types/auth.ts +++ b/packages/cli/src/types/auth.ts @@ -14,5 +14,4 @@ export interface AuthStateResult { provider: AuthProviderId | null; user: AuthUser | null; workspace: AuthWorkspace | null; - linkedProjectId: string | null; } diff --git a/packages/cli/src/types/branch.ts b/packages/cli/src/types/branch.ts index 231e705..d83b712 100644 --- a/packages/cli/src/types/branch.ts +++ b/packages/cli/src/types/branch.ts @@ -23,14 +23,14 @@ export interface BranchDetail { } export interface BranchListResult { - linkedProjectId: string | null; + projectId: string | null; projectName: string | null; activeBranch: string; branches: BranchSummary[]; } export interface BranchShowResult { - linkedProjectId: string | null; + projectId: string | null; projectName: string | null; branch: BranchDetail; } diff --git a/packages/cli/src/types/project.ts b/packages/cli/src/types/project.ts index 4dc2313..b0e8851 100644 --- a/packages/cli/src/types/project.ts +++ b/packages/cli/src/types/project.ts @@ -5,14 +5,25 @@ export interface ProjectSummary { name: string; } +export type ProjectSource = + | "explicit" + | "platform-mapping" + | "remembered-local" + | "package-name" + | "created" + | "prompt"; + +export interface ProjectResolution { + projectSource: ProjectSource; +} + export interface ProjectListResult { workspace: AuthWorkspace; - linkedProjectId: string | null; projects: ProjectSummary[]; } export interface ProjectShowResult { - linkedProjectId: string | null; - workspace: AuthWorkspace | null; - project: ProjectSummary | null; + workspace: AuthWorkspace; + project: ProjectSummary; + resolution: ProjectResolution; } diff --git a/packages/cli/src/use-cases/auth.ts b/packages/cli/src/use-cases/auth.ts index ae8316c..e88d075 100644 --- a/packages/cli/src/use-cases/auth.ts +++ b/packages/cli/src/use-cases/auth.ts @@ -1,10 +1,9 @@ import { usageError } from "../shell/errors"; import type { AuthProviderId, AuthStateResult } from "../types/auth"; -import type { AuthUseCases, IdentityGateway, LoginSelection, ProjectConfigGateway, SessionGateway } from "./contracts"; +import type { AuthUseCases, IdentityGateway, LoginSelection, SessionGateway } from "./contracts"; interface AuthUseCaseDependencies { identityGateway: IdentityGateway; - projectConfigGateway: ProjectConfigGateway; sessionGateway: SessionGateway; } @@ -90,10 +89,7 @@ export function createAuthUseCases(dependencies: AuthUseCaseDependencies): AuthU } async function resolveCurrentAuthState(dependencies: AuthUseCaseDependencies): Promise { - const [session, linkedProjectId] = await Promise.all([ - dependencies.sessionGateway.readAuthSession(), - dependencies.projectConfigGateway.readLinkedProjectId(), - ]); + const session = await dependencies.sessionGateway.readAuthSession(); if (!session) { return { @@ -101,7 +97,6 @@ async function resolveCurrentAuthState(dependencies: AuthUseCaseDependencies): P provider: null, user: null, workspace: null, - linkedProjectId, }; } @@ -115,7 +110,6 @@ async function resolveCurrentAuthState(dependencies: AuthUseCaseDependencies): P provider: null, user: null, workspace: null, - linkedProjectId, }; } @@ -126,6 +120,5 @@ async function resolveCurrentAuthState(dependencies: AuthUseCaseDependencies): P email: user.email, }, workspace, - linkedProjectId, }; } diff --git a/packages/cli/src/use-cases/branch.ts b/packages/cli/src/use-cases/branch.ts index 991a712..f83a350 100644 --- a/packages/cli/src/use-cases/branch.ts +++ b/packages/cli/src/use-cases/branch.ts @@ -5,7 +5,7 @@ import type { BranchGateway, BranchStateGateway, ProjectGateway, - ProjectConfigGateway, + ProjectStateGateway, RemoteBranchRecord, } from "./contracts"; @@ -13,39 +13,39 @@ interface BranchUseCaseDependencies { branchGateway: BranchGateway; branchStateGateway: BranchStateGateway; projectGateway: ProjectGateway; - projectConfigGateway: ProjectConfigGateway; + projectStateGateway: ProjectStateGateway; } export function createBranchUseCases(dependencies: BranchUseCaseDependencies): BranchUseCases { return { list: async (): Promise => { - const [linkedProjectId, activeBranch] = await Promise.all([ - dependencies.projectConfigGateway.readLinkedProjectId(), + const [projectId, activeBranch] = await Promise.all([ + dependencies.projectStateGateway.readRememberedProjectId(), dependencies.branchStateGateway.readActiveBranch(), ]); - const remoteBranches = await listRemoteBranches(dependencies.branchGateway, linkedProjectId); - const projectName = resolveProjectName(dependencies.projectGateway, linkedProjectId); + const remoteBranches = await listRemoteBranches(dependencies.branchGateway, projectId); + const projectName = resolveProjectName(dependencies.projectGateway, projectId); return { - linkedProjectId, + projectId, projectName, activeBranch, branches: buildBranchSummaries(activeBranch, remoteBranches), }; }, show: async (): Promise => { - const [linkedProjectId, activeBranch] = await Promise.all([ - dependencies.projectConfigGateway.readLinkedProjectId(), + const [projectId, activeBranch] = await Promise.all([ + dependencies.projectStateGateway.readRememberedProjectId(), dependencies.branchStateGateway.readActiveBranch(), ]); - const projectName = resolveProjectName(dependencies.projectGateway, linkedProjectId); + const projectName = resolveProjectName(dependencies.projectGateway, projectId); return { - linkedProjectId, + projectId, projectName, branch: buildBranchDetail( dependencies.branchGateway, - linkedProjectId, + projectId, activeBranch, ), }; @@ -53,15 +53,15 @@ export function createBranchUseCases(dependencies: BranchUseCaseDependencies): B use: async (branchName: string): Promise => { await dependencies.branchStateGateway.writeActiveBranch(branchName); - const linkedProjectId = await dependencies.projectConfigGateway.readLinkedProjectId(); - const projectName = resolveProjectName(dependencies.projectGateway, linkedProjectId); + const projectId = await dependencies.projectStateGateway.readRememberedProjectId(); + const projectName = resolveProjectName(dependencies.projectGateway, projectId); return { - linkedProjectId, + projectId, projectName, branch: buildBranchDetail( dependencies.branchGateway, - linkedProjectId, + projectId, branchName, ), }; @@ -69,23 +69,23 @@ export function createBranchUseCases(dependencies: BranchUseCaseDependencies): B }; } -function resolveProjectName(projectGateway: ProjectGateway, linkedProjectId: string | null): string | null { - if (!linkedProjectId) { +function resolveProjectName(projectGateway: ProjectGateway, projectId: string | null): string | null { + if (!projectId) { return null; } - return projectGateway.getProject(linkedProjectId)?.name ?? null; + return projectGateway.getProject(projectId)?.name ?? null; } async function listRemoteBranches( branchGateway: BranchGateway, - linkedProjectId: string | null, + projectId: string | null, ): Promise { - if (!linkedProjectId) { + if (!projectId) { return []; } - return branchGateway.listBranchesForProject(linkedProjectId); + return branchGateway.listBranchesForProject(projectId); } function buildBranchSummaries( @@ -119,12 +119,12 @@ function buildBranchSummaries( function buildBranchDetail( branchGateway: BranchGateway, - linkedProjectId: string | null, + projectId: string | null, branchName: string, ): BranchDetail { const kind = toBranchKind(branchName); const remoteBranch = - linkedProjectId ? branchGateway.getBranchForProject(linkedProjectId, branchName) : undefined; + projectId ? branchGateway.getBranchForProject(projectId, branchName) : undefined; return { name: branchName, diff --git a/packages/cli/src/use-cases/contracts.ts b/packages/cli/src/use-cases/contracts.ts index 775fdce..ac5583b 100644 --- a/packages/cli/src/use-cases/contracts.ts +++ b/packages/cli/src/use-cases/contracts.ts @@ -69,9 +69,9 @@ export interface BranchStateGateway { writeActiveBranch(branchName: string): Promise; } -export interface ProjectConfigGateway { - readLinkedProjectId(): Promise; - writeLinkedProjectId(projectId: string): Promise; +export interface ProjectStateGateway { + readRememberedProjectId(): Promise; + rememberProjectId(projectId: string): Promise; } export interface LoginSelection { @@ -106,8 +106,6 @@ export interface AuthUseCases { export interface ProjectUseCases { list(authState: AuthStateResult): Promise; - show(authState: AuthStateResult): Promise; - link(authState: AuthStateResult, projectId: string): Promise; listProjectsForWorkspace(workspaceId: string): Promise; } diff --git a/packages/cli/src/use-cases/create-cli-gateways.ts b/packages/cli/src/use-cases/create-cli-gateways.ts index 227a399..37f8d10 100644 --- a/packages/cli/src/use-cases/create-cli-gateways.ts +++ b/packages/cli/src/use-cases/create-cli-gateways.ts @@ -1,12 +1,10 @@ -import { UnsafeConfigWriteError, readLinkedProjectId, writeLinkedProjectId } from "../adapters/config"; import type { CommandContext } from "../shell/runtime"; -import { usageError } from "../shell/errors"; import type { BranchGateway, BranchStateGateway, IdentityGateway, - ProjectConfigGateway, ProjectGateway, + ProjectStateGateway, SessionGateway, } from "./contracts"; @@ -14,7 +12,7 @@ export interface CliUseCaseGateways { identityGateway: IdentityGateway; projectGateway: ProjectGateway; branchGateway: BranchGateway; - projectConfigGateway: ProjectConfigGateway; + projectStateGateway: ProjectStateGateway; sessionGateway: SessionGateway; branchStateGateway: BranchStateGateway; } @@ -65,24 +63,17 @@ export function createCliUseCaseGateways(context: CommandContext): CliUseCaseGat }, getDeployment: (deploymentId) => context.api.getDeployment(deploymentId), }, - projectConfigGateway: { - readLinkedProjectId: () => readLinkedProjectId(context.runtime.cwd), - writeLinkedProjectId: async (projectId) => { - try { - await writeLinkedProjectId(context.runtime.cwd, projectId); - } catch (error) { - if (error instanceof UnsafeConfigWriteError) { - throw usageError( - "Project link requires a writable Prisma config", - error.message, - "Update prisma.config.ts to use a recognizable project field, or remove it and rerun prisma-cli project link.", - ["prisma-cli project link proj_123"], - "project", - ); - } - - throw error; - } + projectStateGateway: { + readRememberedProjectId: async () => { + const remembered = await context.stateStore.readLastResolvedProject(); + return remembered?.id ?? null; + }, + rememberProjectId: async (projectId) => { + await context.stateStore.setRememberedProject({ + id: projectId, + name: projectId, + workspaceId: "unknown", + }); }, }, sessionGateway: { diff --git a/packages/cli/src/use-cases/project.ts b/packages/cli/src/use-cases/project.ts index d37e58b..45cf103 100644 --- a/packages/cli/src/use-cases/project.ts +++ b/packages/cli/src/use-cases/project.ts @@ -1,11 +1,10 @@ import { authRequiredError, CliError } from "../shell/errors"; import type { AuthStateResult } from "../types/auth"; -import type { ProjectListResult, ProjectShowResult, ProjectSummary } from "../types/project"; -import type { ProjectConfigGateway, ProjectGateway, ProjectUseCases } from "./contracts"; +import type { ProjectListResult, ProjectSummary } from "../types/project"; +import type { ProjectGateway, ProjectUseCases } from "./contracts"; interface ProjectUseCaseDependencies { projectGateway: ProjectGateway; - projectConfigGateway: ProjectConfigGateway; } export function createProjectUseCases(dependencies: ProjectUseCaseDependencies): ProjectUseCases { @@ -15,62 +14,9 @@ export function createProjectUseCases(dependencies: ProjectUseCaseDependencies): return { workspace, - linkedProjectId: authState.linkedProjectId, projects: listSortedWorkspaceProjects(dependencies.projectGateway, workspace.id).map(toProjectSummary), }; }, - show: async (authState: AuthStateResult): Promise => { - if (!authState.linkedProjectId) { - return { - linkedProjectId: null, - workspace: null, - project: null, - }; - } - - if (!authState.authenticated || !authState.workspace) { - return { - linkedProjectId: authState.linkedProjectId, - workspace: null, - project: null, - }; - } - - const project = dependencies.projectGateway.getProjectForWorkspace(authState.workspace.id, authState.linkedProjectId); - - if (!project) { - return { - linkedProjectId: authState.linkedProjectId, - workspace: null, - project: null, - }; - } - - return { - linkedProjectId: authState.linkedProjectId, - workspace: authState.workspace, - project: toProjectSummary(project), - }; - }, - link: async (authState: AuthStateResult, projectId: string): Promise => { - const workspace = requireWorkspace(authState); - const project = dependencies.projectGateway.getProjectForWorkspace(workspace.id, projectId); - - if (!project) { - throw projectNotFoundError( - `The project "${projectId}" does not exist in workspace "${workspace.name}".`, - "Run prisma-cli project list and choose a project id from the active workspace.", - ); - } - - await dependencies.projectConfigGateway.writeLinkedProjectId(project.id); - - return { - linkedProjectId: project.id, - workspace, - project: toProjectSummary(project), - }; - }, listProjectsForWorkspace: async (workspaceId: string): Promise => listSortedWorkspaceProjects(dependencies.projectGateway, workspaceId).map(toProjectSummary), }; From e04943b0c2d833b60c2833132801736b1dedcf46 Mon Sep 17 00:00:00 2001 From: Luan van der Westhuizen Date: Wed, 13 May 2026 15:49:06 +0200 Subject: [PATCH 2/4] test(project): cover resolution hierarchy --- packages/cli/tests/app-controller.test.ts | 505 ++++++----------- packages/cli/tests/app-env-vars.test.ts | 165 ++++-- packages/cli/tests/app-env.test.ts | 167 ++++-- packages/cli/tests/auth-ops.test.ts | 1 - packages/cli/tests/auth-real-mode.test.ts | 3 - packages/cli/tests/auth-usecases.test.ts | 6 +- packages/cli/tests/auth.test.ts | 4 +- packages/cli/tests/branch-usecases.test.ts | 18 +- packages/cli/tests/branch.test.ts | 65 ++- packages/cli/tests/config-adapter.test.ts | 21 - packages/cli/tests/helpers.ts | 56 +- packages/cli/tests/project-controller.test.ts | 15 +- packages/cli/tests/project-real-mode.test.ts | 516 ++---------------- packages/cli/tests/project-usecases.test.ts | 62 +-- packages/cli/tests/project.test.ts | 469 ++++------------ packages/cli/tests/shell.test.ts | 15 +- packages/cli/tests/use-case-helpers.ts | 16 +- 17 files changed, 691 insertions(+), 1413 deletions(-) delete mode 100644 packages/cli/tests/config-adapter.test.ts diff --git a/packages/cli/tests/app-controller.test.ts b/packages/cli/tests/app-controller.test.ts index a763f4f..1d15406 100644 --- a/packages/cli/tests/app-controller.test.ts +++ b/packages/cli/tests/app-controller.test.ts @@ -1,9 +1,35 @@ import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +beforeEach(() => { + process.env.PRISMA_CLI_TEST_REMEMBER_PROJECT_ID = "proj_123"; + process.env.PRISMA_CLI_TEST_REMEMBER_PROJECT_NAME = "Acme Dashboard"; + process.env.PRISMA_CLI_TEST_REMEMBER_WORKSPACE_ID = "ws_123"; + + 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(), + })); +}); afterEach(() => { - vi.doUnmock("../src/adapters/config"); + delete process.env.PRISMA_CLI_TEST_REMEMBER_PROJECT_ID; + delete process.env.PRISMA_CLI_TEST_REMEMBER_PROJECT_NAME; + delete process.env.PRISMA_CLI_TEST_REMEMBER_WORKSPACE_ID; + + vi.doUnmock("../src/lib/auth/auth-ops"); vi.doUnmock("../src/lib/auth/guard"); vi.doUnmock("../src/lib/app/preview-provider"); vi.doUnmock("open"); @@ -11,10 +37,36 @@ afterEach(() => { vi.restoreAllMocks(); }); +function createProjectClient(projectId = "proj_123") { + return { + token: "token", + GET: vi.fn().mockImplementation((pathName: string) => { + if (pathName === "/v1/projects") { + return { + data: { + data: [ + { + id: projectId, + name: projectId === "proj_456" ? "Billing API" : "Acme Dashboard", + slug: projectId === "proj_456" ? "billing-api" : "acme-dashboard", + workspace: { + id: "ws_123", + name: "Acme Inc", + }, + }, + ], + }, + }; + } + + throw new Error(`Unexpected path ${pathName}`); + }), + }; +} + describe("app controller", () => { it("deploy selects the correct existing app when --app is provided", async () => { - const readLinkedProjectId = vi.fn().mockResolvedValue("proj_123"); - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_2", name: "billing", region: "eu-west-3", liveDeploymentId: null }, { id: "app_1", name: "hello-world", region: "eu-west-3", liveDeploymentId: "dep_live" }, @@ -34,13 +86,6 @@ describe("app controller", () => { }, }); - vi.doMock("../src/adapters/config", async () => { - const actual = await vi.importActual("../src/adapters/config"); - return { - ...actual, - readLinkedProjectId, - }; - }); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth, })); @@ -74,8 +119,22 @@ describe("app controller", () => { appId: "app_1", }), ); - expect(result.result).toEqual({ - projectId: "proj_123", + expect(result.result).toMatchObject({ + workspace: { + id: "ws_123", + name: "Acme Inc", + }, + project: { + id: "proj_123", + name: "Acme Dashboard", + }, + branch: { + name: "preview", + kind: "preview", + }, + resolution: { + projectSource: "remembered-local", + }, app: { id: "app_1", name: "hello-world", @@ -93,8 +152,7 @@ describe("app controller", () => { }); it("forwards deploy build options and HTTP port overrides to the provider", async () => { - const readLinkedProjectId = vi.fn().mockResolvedValue("proj_123"); - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", name: "hello-world", region: "eu-central-1", liveDeploymentId: null, liveUrl: null }, ]); @@ -114,13 +172,6 @@ describe("app controller", () => { }, }); - vi.doMock("../src/adapters/config", async () => { - const actual = await vi.importActual("../src/adapters/config"); - return { - ...actual, - readLinkedProjectId, - }; - }); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth, })); @@ -220,8 +271,7 @@ describe("app controller", () => { }); it("interactive first deploy can create a new app when none is selected", async () => { - const readLinkedProjectId = vi.fn().mockResolvedValue("proj_123"); - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([]); const deployApp = vi.fn().mockResolvedValue({ projectId: "proj_123", @@ -238,13 +288,6 @@ describe("app controller", () => { }, }); - vi.doMock("../src/adapters/config", async () => { - const actual = await vi.importActual("../src/adapters/config"); - return { - ...actual, - readLinkedProjectId, - }; - }); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth, })); @@ -288,17 +331,9 @@ describe("app controller", () => { }); it("returns USAGE_ERROR for deploy without app selection in non-interactive mode", async () => { - const readLinkedProjectId = vi.fn().mockResolvedValue("proj_123"); - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([]); - vi.doMock("../src/adapters/config", async () => { - const actual = await vi.importActual("../src/adapters/config"); - return { - ...actual, - readLinkedProjectId, - }; - }); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth, })); @@ -333,8 +368,7 @@ describe("app controller", () => { }); it("creates a named new app with the default Frankfurt region in non-interactive mode", async () => { - const readLinkedProjectId = vi.fn().mockResolvedValue("proj_123"); - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([]); const deployApp = vi.fn().mockResolvedValue({ projectId: "proj_123", @@ -351,13 +385,6 @@ describe("app controller", () => { }, }); - vi.doMock("../src/adapters/config", async () => { - const actual = await vi.importActual("../src/adapters/config"); - return { - ...actual, - readLinkedProjectId, - }; - }); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth, })); @@ -397,8 +424,8 @@ describe("app controller", () => { ); }); - it("creates and links a project before first deploy when none is linked", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + it("creates a project before first deploy when none is resolved", async () => { + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const createProject = vi.fn().mockResolvedValue({ id: "proj_new", name: "next-smoke", @@ -442,6 +469,7 @@ describe("app controller", () => { isTTY: false, env: { ...process.env, + PRISMA_CLI_TEST_REMEMBER_PROJECT_ID: "", PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, }, }); @@ -457,17 +485,17 @@ describe("app controller", () => { appName: "hello-world", }), ); - await expect(readPrismaConfig(cwd)).resolves.toContain('project: "proj_new"'); - await expect(readPrismaConfig(cwd)).resolves.not.toContain("app:"); + await expect(readPrismaConfig(cwd)).rejects.toMatchObject({ code: "ENOENT" }); await expect(context.stateStore.readSelectedApp("proj_new")).resolves.toEqual({ id: "app_new", name: "hello-world", }); - expect(result.result.projectId).toBe("proj_new"); + expect(result.result.project.id).toBe("proj_new"); }); - it("reuses the linked project on second deploy instead of creating another one", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + it("reuses the created project on second deploy instead of creating another one", async () => { + const client = createProjectClient(); + const requireComputeAuth = vi.fn().mockResolvedValue(client); const createProject = vi.fn().mockResolvedValue({ id: "proj_new", name: "next-smoke", @@ -532,11 +560,33 @@ describe("app controller", () => { isTTY: false, env: { ...process.env, + PRISMA_CLI_TEST_REMEMBER_PROJECT_ID: "", PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, }, }); await runAppDeploy(context, "hello-world"); + client.GET.mockImplementation((pathName: string) => { + if (pathName === "/v1/projects") { + return { + data: { + data: [ + { + id: "proj_new", + name: "next-smoke", + slug: "next-smoke", + workspace: { + id: "ws_123", + name: "Acme Inc", + }, + }, + ], + }, + }; + } + + throw new Error(`Unexpected path ${pathName}`); + }); await runAppDeploy(context, "hello-world"); expect(createProject).toHaveBeenCalledTimes(1); @@ -549,9 +599,26 @@ describe("app controller", () => { ); }); - it("fails before remote project creation when first-deploy config preflight is unsafe", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); - const createProject = vi.fn(); + it("creates a missing project without depending on repo config preflight", async () => { + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const createProject = vi.fn().mockResolvedValue({ + id: "proj_new", + name: "next-smoke", + }); + 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, @@ -559,24 +626,14 @@ describe("app controller", () => { vi.doMock("../src/lib/app/preview-provider", () => ({ createPreviewAppProvider: vi.fn(() => ({ createProject, - listApps: vi.fn(), - deployApp: vi.fn(), + listApps: vi.fn().mockResolvedValue([]), + deployApp, listDeployments: vi.fn(), showDeployment: vi.fn(), })), })); - vi.doMock("../src/adapters/config", async () => { - const actual = await vi.importActual("../src/adapters/config"); - return { - ...actual, - readLinkedProjectId: vi.fn().mockResolvedValue(null), - assertLinkedProjectIdWritable: vi.fn().mockRejectedValue( - new actual.UnsafeConfigWriteError("The existing prisma.config.ts file could not be updated safely."), - ), - }; - }); - const { createTempCwd, createTestCommandContext } = await import("./helpers"); + const { createTempCwd, createTestCommandContext, readPrismaConfig } = await import("./helpers"); const { runAppDeploy } = await import("../src/controllers/app"); const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state"); @@ -586,21 +643,27 @@ describe("app controller", () => { isTTY: false, env: { ...process.env, + PRISMA_CLI_TEST_REMEMBER_PROJECT_ID: "", PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, }, }); - await expect(runAppDeploy(context, "hello-world")).rejects.toMatchObject({ - code: "USAGE_ERROR", - domain: "app", - summary: "Project bootstrap requires a writable Prisma config", + await expect(runAppDeploy(context, "hello-world")).resolves.toMatchObject({ + result: { + project: { + id: "proj_new", + }, + resolution: { + projectSource: "created", + }, + }, }); - expect(createProject).not.toHaveBeenCalled(); + expect(createProject).toHaveBeenCalledWith({ name: path.basename(cwd) }); + await expect(readPrismaConfig(cwd)).rejects.toMatchObject({ code: "ENOENT" }); }); it("reuses the saved app selection on a second deploy", async () => { - const readLinkedProjectId = vi.fn().mockResolvedValue("proj_123"); - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", name: "hello-world", region: "eu-west-3", liveDeploymentId: "dep_live" }, ]); @@ -619,13 +682,6 @@ describe("app controller", () => { }, }); - vi.doMock("../src/adapters/config", async () => { - const actual = await vi.importActual("../src/adapters/config"); - return { - ...actual, - readLinkedProjectId, - }; - }); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth, })); @@ -666,8 +722,7 @@ describe("app controller", () => { }); it("list-deploys sorts deployments newest first for the selected app", async () => { - const readLinkedProjectId = vi.fn().mockResolvedValue("proj_123"); - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", name: "hello-world", region: "eu-west-3", liveDeploymentId: "dep_2" }, ]); @@ -696,13 +751,6 @@ describe("app controller", () => { ], }); - vi.doMock("../src/adapters/config", async () => { - const actual = await vi.importActual("../src/adapters/config"); - return { - ...actual, - readLinkedProjectId, - }; - }); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth, })); @@ -733,18 +781,10 @@ describe("app controller", () => { expect(result.result.deployments.map((deployment) => deployment.id)).toEqual(["dep_2", "dep_1"]); }); - it("returns PROJECT_NOT_FOUND when the linked project is not accessible in real mode", async () => { - const readLinkedProjectId = vi.fn().mockResolvedValue("proj_123"); - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + it("returns PROJECT_NOT_FOUND when the resolved project is not accessible in real mode", async () => { + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockRejectedValue(new Error("Resource Not Found")); - vi.doMock("../src/adapters/config", async () => { - const actual = await vi.importActual("../src/adapters/config"); - return { - ...actual, - readLinkedProjectId, - }; - }); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth, })); @@ -778,8 +818,7 @@ describe("app controller", () => { }); it("list-deploys uses the local known live deployment when the provider cannot confirm it", async () => { - const readLinkedProjectId = vi.fn().mockResolvedValue("proj_123"); - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", name: "hello-world", region: "eu-west-3", liveDeploymentId: null }, ]); @@ -808,13 +847,6 @@ describe("app controller", () => { ], }); - vi.doMock("../src/adapters/config", async () => { - const actual = await vi.importActual("../src/adapters/config"); - return { - ...actual, - readLinkedProjectId, - }; - }); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth, })); @@ -854,18 +886,10 @@ describe("app controller", () => { ]); }); - it("show returns undeployed state when the linked project has no apps", async () => { - const readLinkedProjectId = vi.fn().mockResolvedValue("proj_123"); - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + it("show returns undeployed state when the resolved project has no apps", async () => { + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([]); - vi.doMock("../src/adapters/config", async () => { - const actual = await vi.importActual("../src/adapters/config"); - return { - ...actual, - readLinkedProjectId, - }; - }); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth, })); @@ -905,8 +929,7 @@ describe("app controller", () => { }); it("show returns selected app, live deployment, live URL, and recent deployments", async () => { - const readLinkedProjectId = vi.fn().mockResolvedValue("proj_123"); - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -942,13 +965,6 @@ describe("app controller", () => { ], }); - vi.doMock("../src/adapters/config", async () => { - const actual = await vi.importActual("../src/adapters/config"); - return { - ...actual, - readLinkedProjectId, - }; - }); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth, })); @@ -1012,8 +1028,7 @@ describe("app controller", () => { }); it("show uses the local known live hint when provider live state is incomplete", async () => { - const readLinkedProjectId = vi.fn().mockResolvedValue("proj_123"); - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -1049,13 +1064,6 @@ describe("app controller", () => { ], }); - vi.doMock("../src/adapters/config", async () => { - const actual = await vi.importActual("../src/adapters/config"); - return { - ...actual, - readLinkedProjectId, - }; - }); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth, })); @@ -1093,7 +1101,7 @@ describe("app controller", () => { }); it("show-deploy returns deployment detail without branch inference", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const showDeployment = vi.fn().mockResolvedValue({ app: { id: "app_1", @@ -1153,7 +1161,7 @@ describe("app controller", () => { }); it("show-deploy uses the local known live deployment when available", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const showDeployment = vi.fn().mockResolvedValue({ app: { id: "app_1", @@ -1182,10 +1190,9 @@ describe("app controller", () => { })), })); - const { createTempCwd, createTestCommandContext, writePrismaConfig } = await import("./helpers"); + const { createTempCwd, createTestCommandContext } = await import("./helpers"); const { runAppShowDeploy } = await import("../src/controllers/app"); const cwd = await createTempCwd(); - await writePrismaConfig(cwd, "proj_123"); const stateDir = path.join(cwd, ".state"); const { context } = await createTestCommandContext({ cwd, @@ -1204,7 +1211,7 @@ describe("app controller", () => { }); it("show-deploy surfaces provider failures instead of reporting not found", async () => { - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const showDeployment = vi.fn().mockRejectedValue(new Error("Missing or invalid authorization token")); vi.doMock("../src/lib/auth/guard", () => ({ @@ -1241,8 +1248,7 @@ describe("app controller", () => { it("open launches only in interactive human mode", async () => { const openUrl = vi.fn().mockResolvedValue(undefined); - const readLinkedProjectId = vi.fn().mockResolvedValue("proj_123"); - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -1274,13 +1280,6 @@ describe("app controller", () => { vi.doMock("open", () => ({ default: openUrl, })); - vi.doMock("../src/adapters/config", async () => { - const actual = await vi.importActual("../src/adapters/config"); - return { - ...actual, - readLinkedProjectId, - }; - }); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth, })); @@ -1317,8 +1316,7 @@ describe("app controller", () => { it("open returns the URL without launching the browser in json mode", async () => { const openUrl = vi.fn().mockResolvedValue(undefined); - const readLinkedProjectId = vi.fn().mockResolvedValue("proj_123"); - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -1350,13 +1348,6 @@ describe("app controller", () => { vi.doMock("open", () => ({ default: openUrl, })); - vi.doMock("../src/adapters/config", async () => { - const actual = await vi.importActual("../src/adapters/config"); - return { - ...actual, - readLinkedProjectId, - }; - }); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth, })); @@ -1402,8 +1393,7 @@ describe("app controller", () => { }); it("open returns NO_DEPLOYMENTS when the selected app has not been deployed yet", async () => { - const readLinkedProjectId = vi.fn().mockResolvedValue("proj_123"); - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -1424,13 +1414,6 @@ describe("app controller", () => { deployments: [], }); - vi.doMock("../src/adapters/config", async () => { - const actual = await vi.importActual("../src/adapters/config"); - return { - ...actual, - readLinkedProjectId, - }; - }); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth, })); @@ -1465,8 +1448,7 @@ describe("app controller", () => { }); it("open returns FEATURE_UNAVAILABLE when deployments exist but no live URL is exposed", async () => { - const readLinkedProjectId = vi.fn().mockResolvedValue("proj_123"); - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", @@ -1495,13 +1477,6 @@ describe("app controller", () => { ], }); - vi.doMock("../src/adapters/config", async () => { - const actual = await vi.importActual("../src/adapters/config"); - return { - ...actual, - readLinkedProjectId, - }; - }); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth, })); @@ -1537,8 +1512,7 @@ describe("app controller", () => { }); it("promote switches the selected app to the requested deployment", async () => { - const readLinkedProjectId = vi.fn().mockResolvedValue("proj_123"); - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", name: "hello-world", region: "eu-west-3", liveDeploymentId: "dep_2" }, ]); @@ -1568,13 +1542,6 @@ describe("app controller", () => { }); const promoteDeployment = vi.fn().mockResolvedValue(undefined); - vi.doMock("../src/adapters/config", async () => { - const actual = await vi.importActual("../src/adapters/config"); - return { - ...actual, - readLinkedProjectId, - }; - }); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth, })); @@ -1627,8 +1594,7 @@ describe("app controller", () => { }); it("promote returns a warning when the requested deployment is already live", async () => { - const readLinkedProjectId = vi.fn().mockResolvedValue("proj_123"); - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", name: "hello-world", region: "eu-west-3", liveDeploymentId: "dep_2" }, ]); @@ -1651,13 +1617,6 @@ describe("app controller", () => { }); const promoteDeployment = vi.fn(); - vi.doMock("../src/adapters/config", async () => { - const actual = await vi.importActual("../src/adapters/config"); - return { - ...actual, - readLinkedProjectId, - }; - }); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth, })); @@ -1692,8 +1651,7 @@ describe("app controller", () => { }); it("rollback chooses the previous deployment when no explicit target is provided", async () => { - const readLinkedProjectId = vi.fn().mockResolvedValue("proj_123"); - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", name: "hello-world", region: "eu-west-3", liveDeploymentId: "dep_2" }, ]); @@ -1723,13 +1681,6 @@ describe("app controller", () => { }); const promoteDeployment = vi.fn().mockResolvedValue(undefined); - vi.doMock("../src/adapters/config", async () => { - const actual = await vi.importActual("../src/adapters/config"); - return { - ...actual, - readLinkedProjectId, - }; - }); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth, })); @@ -1783,8 +1734,7 @@ describe("app controller", () => { }); it("rollback uses the local known live deployment when the provider cannot confirm it", async () => { - const readLinkedProjectId = vi.fn().mockResolvedValue("proj_123"); - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", name: "hello-world", region: "eu-west-3", liveDeploymentId: null }, ]); @@ -1814,13 +1764,6 @@ describe("app controller", () => { }); const promoteDeployment = vi.fn().mockResolvedValue(undefined); - vi.doMock("../src/adapters/config", async () => { - const actual = await vi.importActual("../src/adapters/config"); - return { - ...actual, - readLinkedProjectId, - }; - }); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth, })); @@ -1863,8 +1806,7 @@ describe("app controller", () => { }); it("rollback uses an explicit deployment target when provided", async () => { - const readLinkedProjectId = vi.fn().mockResolvedValue("proj_123"); - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", name: "hello-world", region: "eu-west-3", liveDeploymentId: "dep_3" }, ]); @@ -1901,13 +1843,6 @@ describe("app controller", () => { }); const promoteDeployment = vi.fn().mockResolvedValue(undefined); - vi.doMock("../src/adapters/config", async () => { - const actual = await vi.importActual("../src/adapters/config"); - return { - ...actual, - readLinkedProjectId, - }; - }); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth, })); @@ -1947,8 +1882,7 @@ describe("app controller", () => { }); it("rollback returns NO_PREVIOUS_DEPLOYMENT when only one deployment exists", async () => { - const readLinkedProjectId = vi.fn().mockResolvedValue("proj_123"); - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", name: "hello-world", region: "eu-west-3", liveDeploymentId: "dep_2" }, ]); @@ -1970,13 +1904,6 @@ describe("app controller", () => { ], }); - vi.doMock("../src/adapters/config", async () => { - const actual = await vi.importActual("../src/adapters/config"); - return { - ...actual, - readLinkedProjectId, - }; - }); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth, })); @@ -2010,18 +1937,10 @@ describe("app controller", () => { }); }); - it("does not reuse the wrong saved app when the linked project changes", async () => { - const readLinkedProjectId = vi.fn().mockResolvedValue("proj_456"); - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + it("does not reuse the wrong saved app when the resolved project changes", async () => { + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient("proj_456")); const listApps = vi.fn().mockResolvedValue([]); - vi.doMock("../src/adapters/config", async () => { - const actual = await vi.importActual("../src/adapters/config"); - return { - ...actual, - readLinkedProjectId, - }; - }); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth, })); @@ -2044,6 +1963,8 @@ describe("app controller", () => { isTTY: false, env: { ...process.env, + PRISMA_CLI_TEST_REMEMBER_PROJECT_ID: "proj_456", + PRISMA_CLI_TEST_REMEMBER_PROJECT_NAME: "Billing API", PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, }, }); @@ -2061,8 +1982,7 @@ describe("app controller", () => { }); it("logs streams the live deployment for the selected app", async () => { - const readLinkedProjectId = vi.fn().mockResolvedValue("proj_123"); - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", name: "hello-world", region: "eu-central-1", liveDeploymentId: "dep_live" }, ]); @@ -2077,13 +1997,6 @@ describe("app controller", () => { options.onRecord({ type: "log", text: "hello from live\n", byteStart: 0, byteEnd: 16 }); }); - vi.doMock("../src/adapters/config", async () => { - const actual = await vi.importActual("../src/adapters/config"); - return { - ...actual, - readLinkedProjectId, - }; - }); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth, @@ -2121,8 +2034,7 @@ describe("app controller", () => { }); it("logs streams an explicit deployment for the selected app", async () => { - const readLinkedProjectId = vi.fn().mockResolvedValue("proj_123"); - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", name: "hello-world", region: "eu-central-1", liveDeploymentId: "dep_live" }, ]); @@ -2137,13 +2049,6 @@ describe("app controller", () => { options.onRecord({ type: "log", text: "old log\n", byteStart: 0, byteEnd: 8 }); }); - vi.doMock("../src/adapters/config", async () => { - const actual = await vi.importActual("../src/adapters/config"); - return { - ...actual, - readLinkedProjectId, - }; - }); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth, })); @@ -2178,8 +2083,7 @@ describe("app controller", () => { }); it("logs rejects an explicit deployment that does not belong to the selected app", async () => { - const readLinkedProjectId = vi.fn().mockResolvedValue("proj_123"); - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", name: "hello-world", region: "eu-central-1", liveDeploymentId: "dep_live" }, ]); @@ -2191,13 +2095,6 @@ describe("app controller", () => { }); const streamDeploymentLogs = vi.fn(); - vi.doMock("../src/adapters/config", async () => { - const actual = await vi.importActual("../src/adapters/config"); - return { - ...actual, - readLinkedProjectId, - }; - }); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth, })); @@ -2231,8 +2128,7 @@ describe("app controller", () => { }); it("logs emits newline-delimited JSON events in --json mode", async () => { - const readLinkedProjectId = vi.fn().mockResolvedValue("proj_123"); - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", name: "hello-world", region: "eu-central-1", liveDeploymentId: "dep_live" }, ]); @@ -2247,13 +2143,6 @@ describe("app controller", () => { options.onRecord({ type: "terminal", kind: "end", code: "done", message: "done", retryable: false, cursor: null }); }); - vi.doMock("../src/adapters/config", async () => { - const actual = await vi.importActual("../src/adapters/config"); - return { - ...actual, - readLinkedProjectId, - }; - }); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth, })); @@ -2304,8 +2193,7 @@ describe("app controller", () => { }); it("remove deletes the selected app when --yes is passed", async () => { - const readLinkedProjectId = vi.fn().mockResolvedValue("proj_123"); - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", name: "hello-world", region: "eu-central-1", liveDeploymentId: "dep_2" }, ]); @@ -2314,13 +2202,6 @@ describe("app controller", () => { name: "hello-world", }); - vi.doMock("../src/adapters/config", async () => { - const actual = await vi.importActual("../src/adapters/config"); - return { - ...actual, - readLinkedProjectId, - }; - }); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth, })); @@ -2374,8 +2255,7 @@ describe("app controller", () => { }); it("remove prompts for confirmation in interactive mode", async () => { - const readLinkedProjectId = vi.fn().mockResolvedValue("proj_123"); - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", name: "hello-world", region: "eu-central-1", liveDeploymentId: "dep_2" }, ]); @@ -2385,13 +2265,6 @@ describe("app controller", () => { }); const textPrompt = vi.fn().mockResolvedValue("hello-world"); - vi.doMock("../src/adapters/config", async () => { - const actual = await vi.importActual("../src/adapters/config"); - return { - ...actual, - readLinkedProjectId, - }; - }); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth, })); @@ -2440,20 +2313,12 @@ describe("app controller", () => { }); it("remove returns CONFIRMATION_REQUIRED in non-interactive mode without --yes", async () => { - const readLinkedProjectId = vi.fn().mockResolvedValue("proj_123"); - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", name: "hello-world", region: "eu-central-1", liveDeploymentId: "dep_2" }, ]); const removeApp = vi.fn(); - vi.doMock("../src/adapters/config", async () => { - const actual = await vi.importActual("../src/adapters/config"); - return { - ...actual, - readLinkedProjectId, - }; - }); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth, })); @@ -2492,20 +2357,12 @@ describe("app controller", () => { }); it("remove returns REMOVE_FAILED when remote deletion fails", async () => { - const readLinkedProjectId = vi.fn().mockResolvedValue("proj_123"); - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", name: "hello-world", region: "eu-central-1", liveDeploymentId: "dep_2" }, ]); const removeApp = vi.fn().mockRejectedValue(new Error("Resource Not Found")); - vi.doMock("../src/adapters/config", async () => { - const actual = await vi.importActual("../src/adapters/config"); - return { - ...actual, - readLinkedProjectId, - }; - }); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth, })); @@ -2545,8 +2402,7 @@ describe("app controller", () => { }); it("remove returns a warning when local cleanup fails after remote deletion", async () => { - const readLinkedProjectId = vi.fn().mockResolvedValue("proj_123"); - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", name: "hello-world", region: "eu-central-1", liveDeploymentId: "dep_2" }, ]); @@ -2555,13 +2411,6 @@ describe("app controller", () => { name: "hello-world", }); - vi.doMock("../src/adapters/config", async () => { - const actual = await vi.importActual("../src/adapters/config"); - return { - ...actual, - readLinkedProjectId, - }; - }); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth, })); diff --git a/packages/cli/tests/app-env-vars.test.ts b/packages/cli/tests/app-env-vars.test.ts index fa44cc7..5f334d6 100644 --- a/packages/cli/tests/app-env-vars.test.ts +++ b/packages/cli/tests/app-env-vars.test.ts @@ -1,15 +1,68 @@ import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +beforeEach(() => { + process.env.PRISMA_CLI_TEST_REMEMBER_PROJECT_ID = "proj_123"; + process.env.PRISMA_CLI_TEST_REMEMBER_PROJECT_NAME = "Acme Dashboard"; + process.env.PRISMA_CLI_TEST_REMEMBER_WORKSPACE_ID = "ws_123"; + + 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(), + })); +}); afterEach(() => { - vi.doUnmock("../src/adapters/config"); + delete process.env.PRISMA_CLI_TEST_REMEMBER_PROJECT_ID; + delete process.env.PRISMA_CLI_TEST_REMEMBER_PROJECT_NAME; + delete process.env.PRISMA_CLI_TEST_REMEMBER_WORKSPACE_ID; + + vi.doUnmock("../src/lib/auth/auth-ops"); vi.doUnmock("../src/lib/auth/guard"); vi.doUnmock("../src/lib/app/preview-provider"); vi.resetModules(); vi.restoreAllMocks(); }); +function createProjectClient() { + return { + token: "token", + GET: vi.fn().mockImplementation((pathName: string) => { + if (pathName === "/v1/projects") { + return { + data: { + data: [ + { + id: "proj_123", + name: "Acme Dashboard", + slug: "acme-dashboard", + workspace: { + id: "ws_123", + name: "Acme Inc", + }, + }, + ], + }, + }; + } + + throw new Error(`Unexpected path ${pathName}`); + }), + }; +} + describe("app env vars", () => { it("parses repeated env assignments and allows empty values", async () => { const { parseEnvAssignments } = await import("../src/lib/app/env-vars"); @@ -76,8 +129,7 @@ describe("app env vars", () => { }); it("passes env vars to provider deploy without surfacing values", async () => { - const readLinkedProjectId = vi.fn().mockResolvedValue("proj_123"); - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", name: "hello-world", region: "eu-central-1", liveDeploymentId: null, liveUrl: null }, ]); @@ -97,13 +149,6 @@ describe("app env vars", () => { }, }); - vi.doMock("../src/adapters/config", async () => { - const actual = await vi.importActual("../src/adapters/config"); - return { - ...actual, - readLinkedProjectId, - }; - }); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth, })); @@ -158,8 +203,7 @@ describe("app env vars", () => { }); it("returns NO_DEPLOYMENTS when updating env vars for an app without deployments", async () => { - const readLinkedProjectId = vi.fn().mockResolvedValue("proj_123"); - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", name: "hello-world", region: "eu-central-1", liveDeploymentId: null, liveUrl: null }, ]); @@ -168,13 +212,6 @@ describe("app env vars", () => { deployments: [], }); - vi.doMock("../src/adapters/config", async () => { - const actual = await vi.importActual("../src/adapters/config"); - return { - ...actual, - readLinkedProjectId, - }; - }); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth, })); @@ -214,8 +251,7 @@ describe("app env vars", () => { }); it("updates env vars, stores the new live deployment, and returns variable names only", async () => { - const readLinkedProjectId = vi.fn().mockResolvedValue("proj_123"); - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", name: "hello-world", region: "eu-central-1", liveDeploymentId: "dep_old", liveUrl: "https://hello-world.prisma.app" }, ]); @@ -244,13 +280,6 @@ describe("app env vars", () => { variables: ["DATABASE_URL", "FEATURE_FLAG"], }); - vi.doMock("../src/adapters/config", async () => { - const actual = await vi.importActual("../src/adapters/config"); - return { - ...actual, - readLinkedProjectId, - }; - }); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth, })); @@ -317,8 +346,7 @@ describe("app env vars", () => { }); it("lists variable names for the resolved live deployment", async () => { - const readLinkedProjectId = vi.fn().mockResolvedValue("proj_123"); - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", name: "hello-world", region: "eu-central-1", liveDeploymentId: null, liveUrl: "https://hello-world.prisma.app" }, ]); @@ -348,13 +376,6 @@ describe("app env vars", () => { variables: ["DATABASE_URL", "FEATURE_FLAG"], }); - vi.doMock("../src/adapters/config", async () => { - const actual = await vi.importActual("../src/adapters/config"); - return { - ...actual, - readLinkedProjectId, - }; - }); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth, })); @@ -411,8 +432,7 @@ describe("app env vars", () => { }); it("uses the saved known-live deployment when provider version listing lags", async () => { - const readLinkedProjectId = vi.fn().mockResolvedValue("proj_123"); - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([ { id: "app_1", name: "hello-world", region: "eu-central-1", liveDeploymentId: null, liveUrl: "https://hello-world.prisma.app" }, ]); @@ -441,13 +461,6 @@ describe("app env vars", () => { variables: ["DATABASE_URL"], }); - vi.doMock("../src/adapters/config", async () => { - const actual = await vi.importActual("../src/adapters/config"); - return { - ...actual, - readLinkedProjectId, - }; - }); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth, })); @@ -491,17 +504,9 @@ describe("app env vars", () => { }); it("returns an empty success state when listing env vars before any app exists", async () => { - const readLinkedProjectId = vi.fn().mockResolvedValue("proj_123"); - const requireComputeAuth = vi.fn().mockResolvedValue({ token: "token" }); + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([]); - vi.doMock("../src/adapters/config", async () => { - const actual = await vi.importActual("../src/adapters/config"); - return { - ...actual, - readLinkedProjectId, - }; - }); vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth, })); @@ -594,14 +599,29 @@ describe("app env vars", () => { expect.anything(), "hello-world", ["DATABASE_URL=postgresql://example"], + undefined, ); }); - it("parses deploy build and port options through the CLI command layer", async () => { + it("parses deploy build, port, explicit project, and JSON output through the CLI command layer", async () => { const runAppDeploy = vi.fn().mockResolvedValue({ command: "app.deploy", result: { - projectId: "proj_123", + workspace: { + id: "ws_123", + name: "Acme Inc", + }, + project: { + id: "proj_123", + name: "Acme Dashboard", + }, + branch: { + name: "preview", + kind: "preview", + }, + resolution: { + projectSource: "explicit", + }, app: { id: "app_1", name: "hello-world", @@ -640,6 +660,9 @@ describe("app env vars", () => { "3000", "--env", "DATABASE_URL=postgresql://example", + "--project", + "proj_123", + "--json", ], cwd, stateDir, @@ -650,6 +673,31 @@ describe("app env vars", () => { }); expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: true, + command: "app.deploy", + result: { + workspace: { + id: "ws_123", + name: "Acme Inc", + }, + project: { + id: "proj_123", + name: "Acme Dashboard", + }, + branch: { + name: "preview", + kind: "preview", + }, + app: { + id: "app_1", + name: "hello-world", + }, + deployment: { + id: "dep_123", + }, + }, + }); expect(runAppDeploy).toHaveBeenCalledWith( expect.anything(), "hello-world", @@ -658,6 +706,7 @@ describe("app env vars", () => { buildType: "nextjs", httpPort: "3000", envAssignments: ["DATABASE_URL=postgresql://example"], + projectRef: "proj_123", }, ); }); diff --git a/packages/cli/tests/app-env.test.ts b/packages/cli/tests/app-env.test.ts index 3161f35..038dac5 100644 --- a/packages/cli/tests/app-env.test.ts +++ b/packages/cli/tests/app-env.test.ts @@ -1,9 +1,17 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { writePrismaConfig } from "./helpers"; +beforeEach(() => { + process.env.PRISMA_CLI_TEST_REMEMBER_PROJECT_ID = "proj_123"; + process.env.PRISMA_CLI_TEST_REMEMBER_PROJECT_NAME = "Acme Dashboard"; + process.env.PRISMA_CLI_TEST_REMEMBER_WORKSPACE_ID = "ws_123"; +}); afterEach(() => { - vi.doUnmock("../src/adapters/config"); + delete process.env.PRISMA_CLI_TEST_REMEMBER_PROJECT_ID; + delete process.env.PRISMA_CLI_TEST_REMEMBER_PROJECT_NAME; + delete process.env.PRISMA_CLI_TEST_REMEMBER_WORKSPACE_ID; + + vi.doUnmock("../src/lib/auth/auth-ops"); vi.doUnmock("../src/lib/auth/guard"); vi.doUnmock("../src/lib/app/preview-provider"); vi.resetModules(); @@ -12,14 +20,37 @@ afterEach(() => { interface MockClient { GET: ReturnType; + envGET: ReturnType; POST: ReturnType; PATCH: ReturnType; DELETE: ReturnType; } function createMockClient(): MockClient { + const envGET = vi.fn(); return { - GET: vi.fn(), + GET: vi.fn().mockImplementation((pathName: string, ...args: unknown[]) => { + if (pathName === "/v1/projects") { + return { + data: { + data: [ + { + id: "proj_123", + name: "Acme Dashboard", + slug: "acme-dashboard", + workspace: { + id: "ws_123", + name: "Acme Inc", + }, + }, + ], + }, + }; + } + + return envGET(pathName, ...args); + }), + envGET, POST: vi.fn(), PATCH: vi.fn(), DELETE: vi.fn(), @@ -35,17 +66,23 @@ function expectNoApiCalls(client: MockClient) { async function loadControllers(client: MockClient, projectId: string) { vi.resetModules(); - - vi.doMock("../src/adapters/config", async () => { - const actual = - await vi.importActual( - "../src/adapters/config", - ); - return { - ...actual, - readLinkedProjectId: vi.fn().mockResolvedValue(projectId), - }; - }); + void projectId; + + 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.fn().mockResolvedValue(client), })); @@ -82,7 +119,7 @@ function makeVariableRow(overrides: Partial<{ describe("env add", () => { it("creates a new variable on the production template via POST", async () => { const client = createMockClient(); - client.GET.mockResolvedValueOnce({ + client.envGET.mockResolvedValueOnce({ data: { data: [], pagination: { hasMore: false, nextCursor: null } }, response: { status: 200 }, }); @@ -94,7 +131,6 @@ describe("env add", () => { const { controllers, createTempCwd, createTestCommandContext } = await loadControllers(client, "proj_123"); const cwd = await createTempCwd(); - await writePrismaConfig(cwd, "proj_123"); const { context } = await createTestCommandContext({ cwd }); const result = await controllers.runEnvAdd( @@ -122,9 +158,49 @@ describe("env add", () => { expect(JSON.stringify(result)).not.toContain("sk_test_xxx"); }); + it("reads KEY-only assignments from the current environment with explicit --project", async () => { + const client = createMockClient(); + client.envGET.mockResolvedValueOnce({ + data: { data: [], pagination: { hasMore: false, nextCursor: null } }, + response: { status: 200 }, + }); + client.POST.mockResolvedValueOnce({ + data: { data: makeVariableRow({ key: "API_URL", class: "preview" }) }, + response: { status: 201 }, + }); + + const { controllers, createTempCwd, createTestCommandContext } = + await loadControllers(client, "proj_123"); + const cwd = await createTempCwd(); + const { context } = await createTestCommandContext({ + cwd, + env: { + ...process.env, + API_URL: "https://api.example", + }, + }); + + await controllers.runEnvAdd(context, "API_URL", { + roleName: "preview", + projectRef: "proj_123", + }); + + expect(client.POST).toHaveBeenCalledWith( + "/v1/environment-variables", + expect.objectContaining({ + body: { + projectId: "proj_123", + class: "preview", + key: "API_URL", + value: "https://api.example", + }, + }), + ); + }); + it("fails when the variable already exists", async () => { const client = createMockClient(); - client.GET.mockResolvedValueOnce({ + client.envGET.mockResolvedValueOnce({ data: { data: [makeVariableRow()], pagination: { hasMore: false, nextCursor: null }, @@ -135,7 +211,6 @@ describe("env add", () => { const { controllers, createTempCwd, createTestCommandContext } = await loadControllers(client, "proj_123"); const cwd = await createTempCwd(); - await writePrismaConfig(cwd, "proj_123"); const { context } = await createTestCommandContext({ cwd }); await expect( @@ -154,7 +229,6 @@ describe("env add", () => { const { controllers, createTempCwd, createTestCommandContext } = await loadControllers(client, "proj_123"); const cwd = await createTempCwd(); - await writePrismaConfig(cwd, "proj_123"); const { context } = await createTestCommandContext({ cwd }); await expect( @@ -170,7 +244,6 @@ describe("env add", () => { const { controllers, createTempCwd, createTestCommandContext } = await loadControllers(client, "proj_123"); const cwd = await createTempCwd(); - await writePrismaConfig(cwd, "proj_123"); const { context } = await createTestCommandContext({ cwd }); await expect( @@ -188,7 +261,6 @@ describe("env add", () => { const { controllers, createTempCwd, createTestCommandContext } = await loadControllers(client, "proj_123"); const cwd = await createTempCwd(); - await writePrismaConfig(cwd, "proj_123"); const { context } = await createTestCommandContext({ cwd }); await expect( @@ -205,7 +277,7 @@ describe("env add", () => { describe("env update", () => { it("replaces an existing variable's value via PATCH", async () => { const client = createMockClient(); - client.GET.mockResolvedValueOnce({ + client.envGET.mockResolvedValueOnce({ data: { data: [makeVariableRow()], pagination: { hasMore: false, nextCursor: null }, @@ -220,7 +292,6 @@ describe("env update", () => { const { controllers, createTempCwd, createTestCommandContext } = await loadControllers(client, "proj_123"); const cwd = await createTempCwd(); - await writePrismaConfig(cwd, "proj_123"); const { context } = await createTestCommandContext({ cwd }); const result = await controllers.runEnvUpdate( @@ -246,7 +317,7 @@ describe("env update", () => { it("fails when the variable does not exist", async () => { const client = createMockClient(); - client.GET.mockResolvedValueOnce({ + client.envGET.mockResolvedValueOnce({ data: { data: [], pagination: { hasMore: false, nextCursor: null } }, response: { status: 200 }, }); @@ -254,7 +325,6 @@ describe("env update", () => { const { controllers, createTempCwd, createTestCommandContext } = await loadControllers(client, "proj_123"); const cwd = await createTempCwd(); - await writePrismaConfig(cwd, "proj_123"); const { context } = await createTestCommandContext({ cwd }); await expect( @@ -273,7 +343,6 @@ describe("env update", () => { const { controllers, createTempCwd, createTestCommandContext } = await loadControllers(client, "proj_123"); const cwd = await createTempCwd(); - await writePrismaConfig(cwd, "proj_123"); const { context } = await createTestCommandContext({ cwd }); await expect( @@ -288,7 +357,7 @@ describe("env update", () => { describe("env list", () => { it("returns metadata for a role scope and never includes values", async () => { const client = createMockClient(); - client.GET.mockResolvedValueOnce({ + client.envGET.mockResolvedValueOnce({ data: { data: [ makeVariableRow({ id: "envvar_a", key: "STRIPE_KEY" }), @@ -302,7 +371,6 @@ describe("env list", () => { const { controllers, createTempCwd, createTestCommandContext } = await loadControllers(client, "proj_123"); const cwd = await createTempCwd(); - await writePrismaConfig(cwd, "proj_123"); const { context } = await createTestCommandContext({ cwd }); const result = await controllers.runEnvList(context, { @@ -331,7 +399,7 @@ describe("env list", () => { it("defaults to --role production when no scope flag is provided", async () => { const client = createMockClient(); - client.GET.mockResolvedValueOnce({ + client.envGET.mockResolvedValueOnce({ data: { data: [], pagination: { hasMore: false, nextCursor: null } }, response: { status: 200 }, }); @@ -339,7 +407,6 @@ describe("env list", () => { const { controllers, createTempCwd, createTestCommandContext } = await loadControllers(client, "proj_123"); const cwd = await createTempCwd(); - await writePrismaConfig(cwd, "proj_123"); const { context } = await createTestCommandContext({ cwd }); const result = await controllers.runEnvList(context, {}); @@ -362,7 +429,7 @@ describe("env list", () => { describe("env rm", () => { it("looks up the row and DELETEs it on the happy path", async () => { const client = createMockClient(); - client.GET.mockResolvedValueOnce({ + client.envGET.mockResolvedValueOnce({ data: { data: [makeVariableRow({ id: "envvar_target", key: "STRIPE_KEY" })], pagination: { hasMore: false, nextCursor: null }, @@ -377,7 +444,6 @@ describe("env rm", () => { const { controllers, createTempCwd, createTestCommandContext } = await loadControllers(client, "proj_123"); const cwd = await createTempCwd(); - await writePrismaConfig(cwd, "proj_123"); const { context } = await createTestCommandContext({ cwd }); const result = await controllers.runEnvRm(context, "STRIPE_KEY", { @@ -399,7 +465,7 @@ describe("env rm", () => { it("returns a focused not-found error when the row does not exist", async () => { const client = createMockClient(); - client.GET.mockResolvedValueOnce({ + client.envGET.mockResolvedValueOnce({ data: { data: [], pagination: { hasMore: false, nextCursor: null } }, response: { status: 200 }, }); @@ -407,7 +473,6 @@ describe("env rm", () => { const { controllers, createTempCwd, createTestCommandContext } = await loadControllers(client, "proj_123"); const cwd = await createTempCwd(); - await writePrismaConfig(cwd, "proj_123"); const { context } = await createTestCommandContext({ cwd }); await expect( @@ -425,7 +490,6 @@ describe("env rm", () => { const { controllers, createTempCwd, createTestCommandContext } = await loadControllers(client, "proj_123"); const cwd = await createTempCwd(); - await writePrismaConfig(cwd, "proj_123"); const { context } = await createTestCommandContext({ cwd }); await expect( @@ -439,8 +503,8 @@ describe("env rm", () => { /** * Shared scaffolding for the legacy `app update-env` / `app list-env` - * deprecation tests. The two flows share an auth gate, project-link - * lookup, and preview-provider seam; centralizing the mock keeps the + * deprecation tests. The two flows share an auth gate, project + * resolution, and preview-provider seam; centralizing the mock keeps the * tests focused on the deprecation banner contract instead of the * provider stub shape, and means a future change to either of those * underlying dependencies needs to be reflected in exactly one place. @@ -451,18 +515,23 @@ function mockLegacyEnvDependencies( listAppEnvNames?: ReturnType; } = {}, ): void { - vi.doMock("../src/adapters/config", async () => { - const actual = - await vi.importActual( - "../src/adapters/config", - ); - return { - ...actual, - readLinkedProjectId: vi.fn().mockResolvedValue("proj_123"), - }; - }); + 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.fn().mockResolvedValue({ token: "t" }), + requireComputeAuth: vi.fn().mockResolvedValue(createMockClient()), })); const appRecord = { diff --git a/packages/cli/tests/auth-ops.test.ts b/packages/cli/tests/auth-ops.test.ts index a0ebaf9..30c05cd 100644 --- a/packages/cli/tests/auth-ops.test.ts +++ b/packages/cli/tests/auth-ops.test.ts @@ -53,7 +53,6 @@ describe("readAuthState", () => { id: "wksp_cmmxlp7ae1251zyfs8mdpnavm", name: "Sandpit", }, - linkedProjectId: null, }); }); diff --git a/packages/cli/tests/auth-real-mode.test.ts b/packages/cli/tests/auth-real-mode.test.ts index 9867b7c..e651c32 100644 --- a/packages/cli/tests/auth-real-mode.test.ts +++ b/packages/cli/tests/auth-real-mode.test.ts @@ -28,7 +28,6 @@ describe("real auth mode", () => { id: "ws_real", name: "Real Workspace", }, - linkedProjectId: null, }); const performLogout = vi.fn().mockResolvedValue(undefined); @@ -142,7 +141,6 @@ describe("real auth mode", () => { email: "real@example.com", }, workspace: null, - linkedProjectId: null, }, ).join(""); @@ -175,7 +173,6 @@ describe("real auth mode", () => { id: "ws_real", name: "Real Workspace", }, - linkedProjectId: null, }, ).join(""); diff --git a/packages/cli/tests/auth-usecases.test.ts b/packages/cli/tests/auth-usecases.test.ts index 94f550b..a7f30d9 100644 --- a/packages/cli/tests/auth-usecases.test.ts +++ b/packages/cli/tests/auth-usecases.test.ts @@ -13,7 +13,6 @@ describe("auth use cases", () => { provider: null, user: null, workspace: null, - linkedProjectId: null, }); }); @@ -37,7 +36,6 @@ describe("auth use cases", () => { id: "ws_123", name: "Acme Inc", }, - linkedProjectId: null, }); expect(readState().authSession).toEqual({ @@ -47,14 +45,13 @@ describe("auth use cases", () => { }); }); - it("clears the session on logout and preserves linked project context", async () => { + it("clears the session on logout", async () => { const { gateways, readState } = await createUseCaseGateways({ authSession: { provider: "github", userId: "usr_456", workspaceId: "ws_123", }, - linkedProjectId: "proj_123", }); const useCases = createAuthUseCases(gateways); @@ -63,7 +60,6 @@ describe("auth use cases", () => { provider: null, user: null, workspace: null, - linkedProjectId: "proj_123", }); expect(readState().authSession).toBeNull(); diff --git a/packages/cli/tests/auth.test.ts b/packages/cli/tests/auth.test.ts index 10cccd2..f6dcb60 100644 --- a/packages/cli/tests/auth.test.ts +++ b/packages/cli/tests/auth.test.ts @@ -2,7 +2,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import stripAnsi from "strip-ansi"; -import { createTempCwd, executeCli, writePrismaConfig } from "./helpers"; +import { createTempCwd, executeCli } from "./helpers"; const fixturePath = path.resolve("fixtures/mock-api.json"); @@ -46,7 +46,6 @@ describe("auth commands", () => { it("returns the stable signed-in JSON shape for whoami", async () => { const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state"); - await writePrismaConfig(cwd, "proj_123"); await executeCli({ argv: ["auth", "login", "--provider", "github", "--user", "usr_456"], @@ -77,7 +76,6 @@ describe("auth commands", () => { id: "ws_123", name: "Acme Inc", }, - linkedProjectId: "proj_123", }, warnings: [], nextSteps: [], diff --git a/packages/cli/tests/branch-usecases.test.ts b/packages/cli/tests/branch-usecases.test.ts index 1f74029..128ea6b 100644 --- a/packages/cli/tests/branch-usecases.test.ts +++ b/packages/cli/tests/branch-usecases.test.ts @@ -4,15 +4,15 @@ import { createBranchUseCases } from "../src/use-cases/branch"; import { createUseCaseGateways } from "./use-case-helpers"; describe("branch use cases", () => { - it("lists all linked-project branches and keeps an active preview without remote state visible", async () => { + it("lists all resolved-project branches and keeps an active preview without remote state visible", async () => { const { gateways } = await createUseCaseGateways({ - linkedProjectId: "proj_123", + projectId: "proj_123", activeBranch: "feat-auth", }); const useCases = createBranchUseCases(gateways); await expect(useCases.list()).resolves.toEqual({ - linkedProjectId: "proj_123", + projectId: "proj_123", projectName: "Acme Dashboard", activeBranch: "feat-auth", branches: [ @@ -57,13 +57,13 @@ describe("branch use cases", () => { it("shows live deployment details when remote state exists", async () => { const { gateways } = await createUseCaseGateways({ - linkedProjectId: "proj_123", + projectId: "proj_123", activeBranch: "preview", }); const useCases = createBranchUseCases(gateways); await expect(useCases.show()).resolves.toEqual({ - linkedProjectId: "proj_123", + projectId: "proj_123", projectName: "Acme Dashboard", branch: { name: "preview", @@ -79,14 +79,14 @@ describe("branch use cases", () => { }); }); - it("updates the active branch without mutating linked project state", async () => { + it("updates the active branch without mutating resolved project state", async () => { const { gateways, readState } = await createUseCaseGateways({ - linkedProjectId: "proj_123", + projectId: "proj_123", }); const useCases = createBranchUseCases(gateways); await expect(useCases.use("production")).resolves.toEqual({ - linkedProjectId: "proj_123", + projectId: "proj_123", projectName: "Acme Dashboard", branch: { name: "production", @@ -101,7 +101,7 @@ describe("branch use cases", () => { }, }); - expect(readState().linkedProjectId).toBe("proj_123"); + expect(readState().projectId).toBe("proj_123"); expect(readState().activeBranch).toBe("production"); }); }); diff --git a/packages/cli/tests/branch.test.ts b/packages/cli/tests/branch.test.ts index a768c74..d067017 100644 --- a/packages/cli/tests/branch.test.ts +++ b/packages/cli/tests/branch.test.ts @@ -1,13 +1,47 @@ -import { readFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; import stripAnsi from "strip-ansi"; -import { createTempCwd, executeCli, readPrismaConfig, writePrismaConfig } from "./helpers"; +import { createTempCwd, executeCli } from "./helpers"; import { DEFAULT_STATE_DIR_NAME } from "../src/shell/runtime"; const fixturePath = path.resolve("fixtures/mock-api.json"); +async function rememberProject(stateDir: string, projectId = "proj_123") { + await mkdir(stateDir, { recursive: true }); + await writeFile( + path.join(stateDir, "state.json"), + `${JSON.stringify( + { + auth: null, + project: { + rememberedByWorkspace: { + ws_123: { + id: projectId, + name: projectId === "proj_123" ? "Acme Dashboard" : projectId, + workspaceId: "ws_123", + }, + }, + lastResolved: { + id: projectId, + name: projectId === "proj_123" ? "Acme Dashboard" : projectId, + workspaceId: "ws_123", + }, + }, + branch: { active: "preview" }, + app: { + selectedByProject: {}, + knownLiveDeploymentByProject: {}, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); +} + describe("branch commands", () => { it("returns FEATURE_UNAVAILABLE for branch list in preview mode instead of crashing", async () => { const cwd = await createTempCwd(); @@ -117,7 +151,7 @@ describe("branch commands", () => { it("renders the documented human output for branch list", async () => { const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state"); - await writePrismaConfig(cwd, "proj_123"); + await rememberProject(stateDir); const result = await executeCli({ argv: ["branch", "list"], @@ -130,7 +164,7 @@ describe("branch commands", () => { expect(result.exitCode).toBe(0); expect(result.stdout).toBe(""); expect(stripAnsi(result.stderr)).toBe( - "branch list → Listing branches for the linked project.\n\n│ project: Acme Dashboard\n│ ⚬ branch: production\n│ ⚬ branch: pr-123\n│ ⚬ branch: preview (active)\n│ ⚬ branch: staging\n", + "branch list → Listing branches for the resolved project.\n\n│ project: Acme Dashboard\n│ ⚬ branch: production\n│ ⚬ branch: pr-123\n│ ⚬ branch: preview (active)\n│ ⚬ branch: staging\n", ); }); @@ -149,14 +183,14 @@ describe("branch commands", () => { expect(result.exitCode).toBe(0); expect(result.stdout).toBe(""); expect(stripAnsi(result.stderr)).toBe( - "branch show → Showing the current active branch context.\n\n│ project: not linked\n│ branch: preview\n│ kind: preview\n│ remote state: not created yet\n", + "branch show → Showing the current active branch context.\n\n│ project: not resolved\n│ branch: preview\n│ kind: preview\n│ remote state: not created yet\n", ); }); it("shows remote branch status and url without leaking deployment ids in human output", async () => { const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state"); - await writePrismaConfig(cwd, "proj_123"); + await rememberProject(stateDir); await executeCli({ argv: ["branch", "use", "preview"], @@ -187,7 +221,7 @@ describe("branch commands", () => { it("returns the shared list JSON shape for branch list", async () => { const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state"); - await writePrismaConfig(cwd, "proj_123"); + await rememberProject(stateDir); const result = await executeCli({ argv: ["branch", "list", "--json"], @@ -237,7 +271,7 @@ describe("branch commands", () => { it("returns the documented JSON shape for branch show when remote state exists", async () => { const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state"); - await writePrismaConfig(cwd, "proj_123"); + await rememberProject(stateDir); await executeCli({ argv: ["branch", "use", "preview"], @@ -259,7 +293,7 @@ describe("branch commands", () => { ok: true, command: "branch.show", result: { - linkedProjectId: "proj_123", + projectId: "proj_123", projectName: "Acme Dashboard", branch: { name: "preview", @@ -281,7 +315,7 @@ describe("branch commands", () => { it("returns the documented JSON shape for branch use production", async () => { const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state"); - await writePrismaConfig(cwd, "proj_123"); + await rememberProject(stateDir); const result = await executeCli({ argv: ["branch", "use", "production", "--json"], @@ -296,7 +330,7 @@ describe("branch commands", () => { ok: true, command: "branch.use", result: { - linkedProjectId: "proj_123", + projectId: "proj_123", projectName: "Acme Dashboard", branch: { name: "production", @@ -318,7 +352,7 @@ describe("branch commands", () => { it("prompts for branch selection when no name is passed in interactive mode", async () => { const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state"); - await writePrismaConfig(cwd, "proj_123"); + await rememberProject(stateDir); const result = await executeCli({ argv: ["branch", "use"], @@ -487,7 +521,7 @@ describe("branch commands", () => { expect(branchHelp.stderr).toContain("$ prisma-cli branch show"); expect(listHelp.exitCode).toBe(0); - expect(listHelp.stderr).toContain("List active Platform branches linked to this project"); + expect(listHelp.stderr).toContain("List active Platform branches for the resolved project"); expect(listHelp.stderr).toContain("$ prisma-cli branch list"); expect(listHelp.stderr).toContain("$ prisma-cli branch list --json"); @@ -502,10 +536,10 @@ describe("branch commands", () => { expect(useHelp.stderr).toContain("$ prisma-cli branch use production"); }); - it("writes only local branch state and does not mutate config or fixture data", async () => { + it("writes only local branch state and does not mutate fixture data", async () => { const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state"); - await writePrismaConfig(cwd, "proj_123"); + await rememberProject(stateDir); const fixtureBefore = await readFile(fixturePath, "utf8"); const result = await executeCli({ @@ -521,7 +555,6 @@ describe("branch commands", () => { active: "feat-auth", }, }); - expect(await readPrismaConfig(cwd)).toContain('project: "proj_123"'); expect(await readFile(fixturePath, "utf8")).toBe(fixtureBefore); }); diff --git a/packages/cli/tests/config-adapter.test.ts b/packages/cli/tests/config-adapter.test.ts deleted file mode 100644 index 1dbac80..0000000 --- a/packages/cli/tests/config-adapter.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { assertLinkedProjectIdWritable, readLinkedProjectId, writeLinkedProjectId } from "../src/adapters/config"; -import { createTempCwd, readPrismaConfig } from "./helpers"; - -describe("config adapter", () => { - it("writes a plain-object prisma.config.ts when creating project config", async () => { - const cwd = await createTempCwd(); - - await writeLinkedProjectId(cwd, "proj_123"); - - await expect(readPrismaConfig(cwd)).resolves.toContain(`export default {\n project: "proj_123",\n};`); - await expect(readLinkedProjectId(cwd)).resolves.toBe("proj_123"); - }); - - it("preflights writable config for a missing prisma.config.ts", async () => { - const cwd = await createTempCwd(); - - await expect(assertLinkedProjectIdWritable(cwd)).resolves.toBeUndefined(); - }); -}); diff --git a/packages/cli/tests/helpers.ts b/packages/cli/tests/helpers.ts index 9373970..b3aebdf 100644 --- a/packages/cli/tests/helpers.ts +++ b/packages/cli/tests/helpers.ts @@ -1,10 +1,11 @@ -import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { PassThrough, Writable } from "node:stream"; import { runCli } from "../src/cli"; -import { createCommandContext, type CliRuntime, type CommandContext } from "../src/shell/runtime"; +import { LocalStateStore } from "../src/adapters/local-state"; +import { createCommandContext, resolveStateDir, type CliRuntime, type CommandContext } from "../src/shell/runtime"; import type { GlobalFlags } from "../src/shell/global-flags"; class CaptureStream extends Writable { @@ -30,15 +31,6 @@ export async function createTempCwd(): Promise { return mkdtemp(path.join(os.tmpdir(), "prisma-cli-")); } -export async function writePrismaConfig(cwd: string, projectId: string): Promise { - await mkdir(cwd, { recursive: true }); - await writeFile( - path.join(cwd, "prisma.config.ts"), - `export default {\n project: "${projectId}",\n};\n`, - "utf8", - ); -} - export async function readPrismaConfig(cwd: string): Promise { return readFile(path.join(cwd, "prisma.config.ts"), "utf8"); } @@ -64,16 +56,20 @@ export async function executeCli(options: { const stdin = new CaptureInput(); stdin.isTTY = options.isTTY ?? false; - const cliPromise = runCli({ + const env = createTestEnv(options.env, options.preserveCI); + const runtime: CliRuntime = { argv: options.argv, cwd: options.cwd, - env: createTestEnv(options.env, options.preserveCI), + env, fixturePath: options.fixturePath, stateDir: options.stateDir, stdin: stdin as unknown as NodeJS.ReadStream, stdout: stdout as unknown as NodeJS.WriteStream, stderr: stderr as unknown as NodeJS.WriteStream, - }); + }; + await seedRememberedProjectStateForTest(runtime); + + const cliPromise = runCli(runtime); void streamInput(stdin, options.stdinText); @@ -137,13 +133,43 @@ export async function createTestCommandContext(options: { }; return { - context: await createCommandContext(runtime, flags), + context: await seedRememberedProjectForTest(await createCommandContext(runtime, flags), runtime.env), runtime, stdout, stderr, }; } +async function seedRememberedProjectForTest( + context: CommandContext, + env: NodeJS.ProcessEnv, +): Promise { + const projectId = env.PRISMA_CLI_TEST_REMEMBER_PROJECT_ID; + if (!projectId) { + return context; + } + + await context.stateStore.setRememberedProject({ + id: projectId, + name: env.PRISMA_CLI_TEST_REMEMBER_PROJECT_NAME ?? "Acme Dashboard", + workspaceId: env.PRISMA_CLI_TEST_REMEMBER_WORKSPACE_ID ?? "ws_123", + }); + return context; +} + +async function seedRememberedProjectStateForTest(runtime: CliRuntime): Promise { + const projectId = runtime.env.PRISMA_CLI_TEST_REMEMBER_PROJECT_ID; + if (!projectId) { + return; + } + + await new LocalStateStore(resolveStateDir(runtime)).setRememberedProject({ + id: projectId, + name: runtime.env.PRISMA_CLI_TEST_REMEMBER_PROJECT_NAME ?? "Acme Dashboard", + workspaceId: runtime.env.PRISMA_CLI_TEST_REMEMBER_WORKSPACE_ID ?? "ws_123", + }); +} + function createTestEnv(env: NodeJS.ProcessEnv | undefined, preserveCI = false): NodeJS.ProcessEnv { const next = { ...process.env, ...env }; diff --git a/packages/cli/tests/project-controller.test.ts b/packages/cli/tests/project-controller.test.ts index 2b052af..762447a 100644 --- a/packages/cli/tests/project-controller.test.ts +++ b/packages/cli/tests/project-controller.test.ts @@ -1,13 +1,13 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; -import { runProjectLink } from "../src/controllers/project"; +import { runProjectShow } from "../src/controllers/project"; import { createTempCwd, createTestCommandContext } from "./helpers"; const fixturePath = path.resolve("fixtures/mock-api.json"); describe("project controller", () => { - it("returns a structured usage error when project link cannot prompt and no target is provided", async () => { + it("returns PROJECT_UNRESOLVED when automatic resolution cannot choose a project", async () => { const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state"); const { context } = await createTestCommandContext({ @@ -17,10 +17,15 @@ describe("project controller", () => { isTTY: false, }); - await expect(runProjectLink(context, undefined)).rejects.toMatchObject({ - code: "USAGE_ERROR", + await context.stateStore.setAuthSession({ + provider: "github", + userId: "usr_456", + workspaceId: "ws_123", + }); + + await expect(runProjectShow(context, undefined)).rejects.toMatchObject({ + code: "PROJECT_UNRESOLVED", domain: "project", - summary: "Project link requires a project target in non-interactive mode", }); }); }); diff --git a/packages/cli/tests/project-real-mode.test.ts b/packages/cli/tests/project-real-mode.test.ts index f1279d9..5e1df0d 100644 --- a/packages/cli/tests/project-real-mode.test.ts +++ b/packages/cli/tests/project-real-mode.test.ts @@ -1,49 +1,52 @@ import path from "node:path"; -import stripAnsi from "strip-ansi"; import { afterEach, describe, expect, it, vi } from "vitest"; afterEach(() => { vi.doUnmock("../src/lib/auth/auth-ops"); vi.doUnmock("../src/lib/auth/guard"); - vi.doUnmock("../src/adapters/config"); vi.resetModules(); vi.restoreAllMocks(); }); +function mockAuthState() { + return vi.fn().mockResolvedValue({ + authenticated: true, + provider: null, + user: { + email: "real@example.com", + }, + workspace: { + id: "ws_123", + name: "Acme Inc", + }, + }); +} + +function mockClient() { + return { + GET: vi.fn().mockImplementation((pathName: string) => { + if (pathName === "/v1/projects") { + return { + data: { + data: [ + { id: "proj_456", name: "Billing API", slug: "billing-api", workspace: { id: "ws_123", name: "Acme Inc" } }, + { id: "proj_999", name: "Alpha", slug: "alpha", workspace: { id: "ws_other", name: "Other" } }, + { id: "proj_123", name: "Acme Dashboard", slug: "acme-dashboard", workspace: { id: "ws_123", name: "Acme Inc" } }, + ], + }, + }; + } + + throw new Error(`Unexpected path ${pathName}`); + }), + }; +} + describe("real project mode", () => { it("uses the real API path for project list and sorts by name then id", async () => { - const readAuthState = vi.fn().mockResolvedValue({ - authenticated: true, - provider: null, - user: { - email: "real@example.com", - }, - workspace: { - id: "ws_123", - name: "Acme Inc", - }, - linkedProjectId: null, - }); - const requireComputeAuth = vi.fn().mockResolvedValue({ - GET: vi.fn().mockImplementation((pathName: string) => { - if (pathName === "/v1/projects") { - return { - data: { - data: [ - { id: "proj_456", name: "Billing API", workspace: { id: "ws_123", name: "Acme Inc" } }, - { id: "proj_999", name: "Alpha", workspace: { id: "ws_other", name: "Other" } }, - { id: "proj_123", name: "Acme Dashboard", workspace: { id: "ws_123", name: "Acme Inc" } }, - { id: "proj_321", name: "Billing API", workspace: { id: "ws_123", name: "Acme Inc" } }, - ], - }, - }; - } - - throw new Error(`Unexpected path ${pathName}`); - }), - }); - const readLinkedProjectId = vi.fn().mockResolvedValue("proj_123"); + const readAuthState = mockAuthState(); + const requireComputeAuth = vi.fn().mockResolvedValue(mockClient()); vi.doMock("../src/lib/auth/auth-ops", () => ({ readAuthState, @@ -53,14 +56,6 @@ describe("real project mode", () => { vi.doMock("../src/lib/auth/guard", () => ({ requireComputeAuth, })); - vi.doMock("../src/adapters/config", async () => { - const actual = await vi.importActual("../src/adapters/config"); - return { - ...actual, - readLinkedProjectId, - writeLinkedProjectId: vi.fn(), - }; - }); const { createTempCwd, createTestCommandContext } = await import("./helpers"); const { runProjectList } = await import("../src/controllers/project"); @@ -84,153 +79,22 @@ describe("real project mode", () => { id: "ws_123", name: "Acme Inc", }, - linkedProjectId: "proj_123", projects: [ { id: "proj_123", name: "Acme Dashboard" }, - { id: "proj_321", name: "Billing API" }, { id: "proj_456", name: "Billing API" }, ], }); }); - it("stays in fixture mode for project list when a fixture path is enabled", async () => { - const readAuthState = vi.fn(); - const requireComputeAuth = vi.fn(); - - vi.doMock("../src/lib/auth/auth-ops", () => ({ - readAuthState, - performLogin: vi.fn(), - performLogout: vi.fn(), - })); - vi.doMock("../src/lib/auth/guard", () => ({ - requireComputeAuth, - })); - - const fixturePath = path.resolve("fixtures/mock-api.json"); - const { createTempCwd, createTestCommandContext } = await import("./helpers"); - const { runProjectList } = await import("../src/controllers/project"); - const cwd = await createTempCwd(); - const stateDir = path.join(cwd, ".state"); - const { context } = await createTestCommandContext({ - cwd, - stateDir, - fixturePath, - }); - - await context.stateStore.setAuthSession({ - provider: "github", - userId: "usr_456", - workspaceId: "ws_123", - }); - - const result = await runProjectList(context); - - expect(readAuthState).not.toHaveBeenCalled(); - expect(requireComputeAuth).not.toHaveBeenCalled(); - expect(result.result.projects).toEqual([ - { id: "proj_123", name: "Acme Dashboard" }, - { id: "proj_456", name: "Billing API" }, - ]); - }); - - it("returns linked local-only state from project show when signed out", async () => { - const readAuthState = vi.fn().mockResolvedValue({ - authenticated: false, - provider: null, - user: null, - workspace: null, - linkedProjectId: null, - }); - const readLinkedProjectId = vi.fn().mockResolvedValue("proj_123"); - - vi.doMock("../src/lib/auth/auth-ops", () => ({ - readAuthState, - performLogin: vi.fn(), - performLogout: vi.fn(), - })); - vi.doMock("../src/adapters/config", async () => { - const actual = await vi.importActual("../src/adapters/config"); - return { - ...actual, - readLinkedProjectId, - writeLinkedProjectId: vi.fn(), - }; - }); - - const { createTempCwd, createTestCommandContext } = await import("./helpers"); - const { runProjectShow } = await import("../src/controllers/project"); - const cwd = await createTempCwd(); - const stateDir = path.join(cwd, ".state"); - const { context } = await createTestCommandContext({ - cwd, - stateDir, - env: { - ...process.env, - PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, - }, - }); - - await expect(runProjectShow(context)).resolves.toMatchObject({ - result: { - linkedProjectId: "proj_123", - workspace: null, - project: null, - }, - }); - expect(readAuthState).toHaveBeenCalledWith(context.runtime.env); - }); - - it("returns enriched remote details from project show when signed in", async () => { - const readAuthState = vi.fn().mockResolvedValue({ - authenticated: true, - provider: null, - user: { - email: "real@example.com", - }, - workspace: { - id: "ws_123", - name: "Acme Inc", - }, - linkedProjectId: null, - }); - const readLinkedProjectId = vi.fn().mockResolvedValue("proj_123"); - const requireComputeAuth = vi.fn().mockResolvedValue({ - GET: vi.fn().mockImplementation((pathName: string, request?: { params?: { path?: { id?: string } } }) => { - if (pathName === "/v1/projects/{id}" && request?.params?.path?.id === "proj_123") { - return { - data: { - data: { - id: "proj_123", - name: "Acme Dashboard", - workspace: { - id: "ws_123", - name: "Acme Inc", - }, - }, - }, - }; - } - - throw new Error(`Unexpected path ${pathName}`); - }), - }); - + it("resolves an explicit project in real mode", async () => { vi.doMock("../src/lib/auth/auth-ops", () => ({ - readAuthState, + readAuthState: mockAuthState(), performLogin: vi.fn(), performLogout: vi.fn(), })); vi.doMock("../src/lib/auth/guard", () => ({ - requireComputeAuth, + requireComputeAuth: vi.fn().mockResolvedValue(mockClient()), })); - vi.doMock("../src/adapters/config", async () => { - const actual = await vi.importActual("../src/adapters/config"); - return { - ...actual, - readLinkedProjectId, - writeLinkedProjectId: vi.fn(), - }; - }); const { createTempCwd, createTestCommandContext } = await import("./helpers"); const { runProjectShow } = await import("../src/controllers/project"); @@ -245,9 +109,8 @@ describe("real project mode", () => { }, }); - await expect(runProjectShow(context)).resolves.toMatchObject({ + await expect(runProjectShow(context, "proj_123")).resolves.toMatchObject({ result: { - linkedProjectId: "proj_123", workspace: { id: "ws_123", name: "Acme Inc", @@ -256,303 +119,10 @@ describe("real project mode", () => { id: "proj_123", name: "Acme Dashboard", }, + resolution: { + projectSource: "explicit", + }, }, }); }); - - it("validates explicit project ids against the active workspace and writes only the project id", async () => { - const readAuthState = vi.fn().mockResolvedValue({ - authenticated: true, - provider: null, - user: { - email: "real@example.com", - }, - workspace: { - id: "ws_123", - name: "Acme Inc", - }, - linkedProjectId: null, - }); - const writeLinkedProjectId = vi.fn().mockResolvedValue(undefined); - const requireComputeAuth = vi.fn().mockResolvedValue({ - GET: vi.fn().mockImplementation((pathName: string, request?: { params?: { path?: { id?: string } } }) => { - if (pathName === "/v1/projects/{id}" && request?.params?.path?.id === "proj_123") { - return { - data: { - data: { - id: "proj_123", - name: "Acme Dashboard", - workspace: { - id: "ws_123", - name: "Acme Inc", - }, - }, - }, - }; - } - - throw new Error(`Unexpected path ${pathName}`); - }), - }); - - vi.doMock("../src/lib/auth/auth-ops", () => ({ - readAuthState, - performLogin: vi.fn(), - performLogout: vi.fn(), - })); - vi.doMock("../src/lib/auth/guard", () => ({ - requireComputeAuth, - })); - vi.doMock("../src/adapters/config", async () => { - const actual = await vi.importActual("../src/adapters/config"); - return { - ...actual, - readLinkedProjectId: vi.fn().mockResolvedValue(null), - writeLinkedProjectId, - }; - }); - - const { createTempCwd, createTestCommandContext } = await import("./helpers"); - const { runProjectLink } = await import("../src/controllers/project"); - const cwd = await createTempCwd(); - const stateDir = path.join(cwd, ".state"); - const { context } = await createTestCommandContext({ - cwd, - stateDir, - env: { - ...process.env, - PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, - }, - }); - - const result = await runProjectLink(context, "proj_123"); - - expect(writeLinkedProjectId).toHaveBeenCalledWith(context.runtime.cwd, "proj_123"); - expect(result.result).toEqual({ - linkedProjectId: "proj_123", - workspace: { - id: "ws_123", - name: "Acme Inc", - }, - project: { - id: "proj_123", - name: "Acme Dashboard", - }, - }); - }); - - it("uses sorted interactive project labels with ids in real mode", async () => { - const readAuthState = vi.fn().mockResolvedValue({ - authenticated: true, - provider: null, - user: { - email: "real@example.com", - }, - workspace: { - id: "ws_123", - name: "Acme Inc", - }, - linkedProjectId: null, - }); - const writeLinkedProjectId = vi.fn().mockResolvedValue(undefined); - const requireComputeAuth = vi.fn().mockResolvedValue({ - GET: vi.fn().mockImplementation((pathName: string) => { - if (pathName === "/v1/projects") { - return { - data: { - data: [ - { id: "proj_456", name: "Billing API", workspace: { id: "ws_123", name: "Acme Inc" } }, - { id: "proj_123", name: "Acme Dashboard", workspace: { id: "ws_123", name: "Acme Inc" } }, - ], - }, - }; - } - - throw new Error(`Unexpected path ${pathName}`); - }), - }); - - vi.doMock("../src/lib/auth/auth-ops", () => ({ - readAuthState, - performLogin: vi.fn(), - performLogout: vi.fn(), - })); - vi.doMock("../src/lib/auth/guard", () => ({ - requireComputeAuth, - })); - vi.doMock("../src/adapters/config", async () => { - const actual = await vi.importActual("../src/adapters/config"); - return { - ...actual, - readLinkedProjectId: vi.fn().mockResolvedValue(null), - writeLinkedProjectId, - }; - }); - - const { createTempCwd, createTestCommandContext } = await import("./helpers"); - const { runProjectLink } = await import("../src/controllers/project"); - const cwd = await createTempCwd(); - const stateDir = path.join(cwd, ".state"); - const { context, stderr } = await createTestCommandContext({ - cwd, - stateDir, - env: { - ...process.env, - PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, - }, - isTTY: true, - stdinText: "\u001B[B\r", - }); - - const result = await runProjectLink(context, undefined); - const output = stripAnsi(stderr.buffer); - - expect(output).toContain("Select a project"); - expect(output).toContain("Acme Dashboard (proj_123)"); - expect(output).toContain("Billing API (proj_456)"); - expect(writeLinkedProjectId).toHaveBeenCalledWith(context.runtime.cwd, "proj_456"); - expect(result.result.linkedProjectId).toBe("proj_456"); - }); - - it("surfaces writable-config failures as the documented usage error", async () => { - const readAuthState = vi.fn().mockResolvedValue({ - authenticated: true, - provider: null, - user: { - email: "real@example.com", - }, - workspace: { - id: "ws_123", - name: "Acme Inc", - }, - linkedProjectId: null, - }); - - vi.doMock("../src/lib/auth/auth-ops", () => ({ - readAuthState, - performLogin: vi.fn(), - performLogout: vi.fn(), - })); - vi.doMock("../src/lib/auth/guard", () => ({ - requireComputeAuth: vi.fn().mockResolvedValue({ - GET: vi.fn().mockImplementation((pathName: string, request?: { params?: { path?: { id?: string } } }) => { - if (pathName === "/v1/projects/{id}" && request?.params?.path?.id === "proj_123") { - return { - data: { - data: { - id: "proj_123", - name: "Acme Dashboard", - workspace: { - id: "ws_123", - name: "Acme Inc", - }, - }, - }, - }; - } - - throw new Error(`Unexpected path ${pathName}`); - }), - }), - })); - vi.doMock("../src/adapters/config", async () => { - const actual = await vi.importActual("../src/adapters/config"); - return { - ...actual, - readLinkedProjectId: vi.fn().mockResolvedValue(null), - writeLinkedProjectId: vi.fn().mockRejectedValue( - new actual.UnsafeConfigWriteError("The existing prisma.config.ts file could not be updated safely."), - ), - }; - }); - - const { createTempCwd, createTestCommandContext } = await import("./helpers"); - const { runProjectLink } = await import("../src/controllers/project"); - const cwd = await createTempCwd(); - const stateDir = path.join(cwd, ".state"); - const { context } = await createTestCommandContext({ - cwd, - stateDir, - env: { - ...process.env, - PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, - }, - }); - - await expect(runProjectLink(context, "proj_123")).rejects.toMatchObject({ - code: "USAGE_ERROR", - domain: "project", - summary: "Project link requires a writable Prisma config", - }); - }); - - it("does not mutate local branch state when linking in real mode", async () => { - const readAuthState = vi.fn().mockResolvedValue({ - authenticated: true, - provider: null, - user: { - email: "real@example.com", - }, - workspace: { - id: "ws_123", - name: "Acme Inc", - }, - linkedProjectId: null, - }); - const writeLinkedProjectId = vi.fn().mockResolvedValue(undefined); - - vi.doMock("../src/lib/auth/auth-ops", () => ({ - readAuthState, - performLogin: vi.fn(), - performLogout: vi.fn(), - })); - vi.doMock("../src/lib/auth/guard", () => ({ - requireComputeAuth: vi.fn().mockResolvedValue({ - GET: vi.fn().mockImplementation((pathName: string, request?: { params?: { path?: { id?: string } } }) => { - if (pathName === "/v1/projects/{id}" && request?.params?.path?.id === "proj_123") { - return { - data: { - data: { - id: "proj_123", - name: "Acme Dashboard", - workspace: { - id: "ws_123", - name: "Acme Inc", - }, - }, - }, - }; - } - - throw new Error(`Unexpected path ${pathName}`); - }), - }), - })); - vi.doMock("../src/adapters/config", async () => { - const actual = await vi.importActual("../src/adapters/config"); - return { - ...actual, - readLinkedProjectId: vi.fn().mockResolvedValue(null), - writeLinkedProjectId, - }; - }); - - const { createTempCwd, createTestCommandContext } = await import("./helpers"); - const { runProjectLink } = await import("../src/controllers/project"); - const cwd = await createTempCwd(); - const stateDir = path.join(cwd, ".state"); - const { context } = await createTestCommandContext({ - cwd, - stateDir, - env: { - ...process.env, - PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, - }, - }); - - await runProjectLink(context, "proj_123"); - - const state = await context.stateStore.read(); - expect(state.branch.active).toBe("preview"); - }); }); diff --git a/packages/cli/tests/project-usecases.test.ts b/packages/cli/tests/project-usecases.test.ts index a2fd78a..dd5d306 100644 --- a/packages/cli/tests/project-usecases.test.ts +++ b/packages/cli/tests/project-usecases.test.ts @@ -5,9 +5,7 @@ import { createUseCaseGateways } from "./use-case-helpers"; describe("project use cases", () => { it("lists sorted projects for the authenticated workspace", async () => { - const { gateways } = await createUseCaseGateways({ - linkedProjectId: "proj_123", - }); + const { gateways } = await createUseCaseGateways(); const useCases = createProjectUseCases(gateways); await expect( @@ -21,14 +19,12 @@ describe("project use cases", () => { id: "ws_123", name: "Acme Inc", }, - linkedProjectId: "proj_123", }), ).resolves.toEqual({ workspace: { id: "ws_123", name: "Acme Inc", }, - linkedProjectId: "proj_123", projects: [ { id: "proj_123", @@ -41,60 +37,4 @@ describe("project use cases", () => { ], }); }); - - it("shows local-only linked state when auth is missing", async () => { - const { gateways } = await createUseCaseGateways({ - linkedProjectId: "proj_123", - }); - const useCases = createProjectUseCases(gateways); - - await expect( - useCases.show({ - authenticated: false, - provider: null, - user: null, - workspace: null, - linkedProjectId: "proj_123", - }), - ).resolves.toEqual({ - linkedProjectId: "proj_123", - workspace: null, - project: null, - }); - }); - - it("links a project and returns the enriched linked result", async () => { - const { gateways, readState } = await createUseCaseGateways(); - const useCases = createProjectUseCases(gateways); - - await expect( - useCases.link( - { - authenticated: true, - provider: "github", - user: { - email: "bob@example.com", - }, - workspace: { - id: "ws_123", - name: "Acme Inc", - }, - linkedProjectId: null, - }, - "proj_456", - ), - ).resolves.toEqual({ - linkedProjectId: "proj_456", - workspace: { - id: "ws_123", - name: "Acme Inc", - }, - project: { - id: "proj_456", - name: "Billing API", - }, - }); - - expect(readState().linkedProjectId).toBe("proj_456"); - }); }); diff --git a/packages/cli/tests/project.test.ts b/packages/cli/tests/project.test.ts index 6dc4013..560ad7f 100644 --- a/packages/cli/tests/project.test.ts +++ b/packages/cli/tests/project.test.ts @@ -1,188 +1,103 @@ -import { readFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; import stripAnsi from "strip-ansi"; -import { createTempCwd, executeCli, readPrismaConfig, writePrismaConfig } from "./helpers"; +import { createTempCwd, executeCli } from "./helpers"; const fixturePath = path.resolve("fixtures/mock-api.json"); -describe("project commands", () => { - it("lists projects in human mode", async () => { - const cwd = await createTempCwd(); - const stateDir = path.join(cwd, ".state"); - - await executeCli({ - argv: ["auth", "login", "--provider", "github", "--user", "usr_456"], - cwd, - stateDir, - fixturePath, - }); - await writePrismaConfig(cwd, "proj_123"); - - const result = await executeCli({ - argv: ["project", "list"], - cwd, - stateDir, - fixturePath, - }); - - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe( - "project list → Listing projects for the authenticated workspace.\n\n│ workspace: Acme Inc\n│ ⚬ project: Acme Dashboard (linked)\n│ ⚬ project: Billing API\n", - ); +async function login(cwd: string, stateDir: string, selectedFixturePath = fixturePath) { + await executeCli({ + argv: ["auth", "login", "--provider", "github", "--user", "usr_456"], + cwd, + stateDir, + fixturePath: selectedFixturePath, }); - - it("lists projects in JSON mode", async () => { - const cwd = await createTempCwd(); - const stateDir = path.join(cwd, ".state"); - - await executeCli({ - argv: ["auth", "login", "--provider", "github", "--user", "usr_456"], - cwd, - stateDir, - fixturePath, - }); - await writePrismaConfig(cwd, "proj_123"); - - const result = await executeCli({ - argv: ["project", "list", "--json"], - cwd, - stateDir, - fixturePath, - }); - - expect(result.exitCode).toBe(0); - expect(result.stderr).toBe(""); - expect(JSON.parse(result.stdout)).toEqual({ - ok: true, - command: "project.list", - result: { - context: { - workspace: "Acme Inc", +} + +async function writePackageJson(cwd: string, name: string) { + await writeFile(path.join(cwd, "package.json"), `${JSON.stringify({ name }, null, 2)}\n`, "utf8"); +} + +async function writeStaleProjectState(stateDir: string) { + await mkdir(stateDir, { recursive: true }); + await writeFile( + path.join(stateDir, "state.json"), + `${JSON.stringify( + { + auth: { + provider: "github", + userId: "usr_456", + workspaceId: "ws_123", }, - items: [ - { - id: "proj_123", - name: "Acme Dashboard", - status: "linked", + project: { + rememberedByWorkspace: { + ws_123: { + id: "proj_missing", + name: "Missing Project", + workspaceId: "ws_123", + }, }, - { - id: "proj_456", - name: "Billing API", - status: null, + lastResolved: { + id: "proj_missing", + name: "Missing Project", + workspaceId: "ws_123", }, - ], - count: 2, - }, - warnings: [], - nextSteps: ["prisma-cli project link"], - }); - }); - - it("returns AUTH_REQUIRED for project list in JSON mode when signed out", async () => { - const cwd = await createTempCwd(); - const stateDir = path.join(cwd, ".state"); - - const result = await executeCli({ - argv: ["project", "list", "--json"], - cwd, - stateDir, - fixturePath, - }); - - expect(result.exitCode).toBe(1); - expect(result.stderr).toBe(""); - expect(JSON.parse(result.stdout)).toEqual({ - ok: false, - command: "project.list", - error: { - code: "AUTH_REQUIRED", - domain: "auth", - severity: "error", - summary: "Authentication required", - why: "This command needs an authenticated session.", - fix: "Run prisma-cli auth login, or rerun the command in a TTY to sign in interactively.", - where: null, - meta: {}, - docsUrl: null, + }, + branch: { active: "preview" }, + app: { + selectedByProject: {}, + knownLiveDeploymentByProject: {}, + }, }, - warnings: [], - nextSteps: ["prisma-cli auth login"], - }); + null, + 2, + )}\n`, + "utf8", + ); +} + +async function createAmbiguousFixture(cwd: string): Promise { + const raw = JSON.parse(await readFile(fixturePath, "utf8")) as { + projects: Array<{ id: string; name: string; slug: string; workspaceId: string }>; + }; + raw.projects.push({ + id: "proj_321", + name: "Acme Dashboard", + slug: "acme-dashboard", + workspaceId: "ws_123", }); + const nextPath = path.join(cwd, "ambiguous-fixture.json"); + await writeFile(nextPath, `${JSON.stringify(raw, null, 2)}\n`, "utf8"); + return nextPath; +} - it("auto-starts login for project list in interactive TTY mode", async () => { +describe("project commands", () => { + it("lists projects without resolving the current directory", async () => { const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state"); + await login(cwd, stateDir); const result = await executeCli({ argv: ["project", "list"], cwd, stateDir, fixturePath, - isTTY: true, - stdinText: "\r\u001B[B\r", - }); - const stderr = stripAnsi(result.stderr); - - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(""); - expect(stderr).toContain("project list → Listing projects for the authenticated workspace."); - expect(stderr).toContain("Select a provider"); - expect(stderr).toContain("Select a user"); - expect(stderr).toContain("workspace: Acme Inc"); - expect(stderr).toContain("⚬ project: Acme Dashboard"); - }); - - it("shows the unlinked empty state", async () => { - const cwd = await createTempCwd(); - const stateDir = path.join(cwd, ".state"); - - const result = await executeCli({ - argv: ["project", "show"], - cwd, - stateDir, - fixturePath, - }); - - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe( - "project show → Showing the linked project for the current repo.\n\n│ project: not linked\n", - ); - }); - - it("shows linked local-only state when signed out", async () => { - const cwd = await createTempCwd(); - const stateDir = path.join(cwd, ".state"); - await writePrismaConfig(cwd, "proj_123"); - - const result = await executeCli({ - argv: ["project", "show"], - cwd, - stateDir, - fixturePath, }); expect(result.exitCode).toBe(0); expect(result.stdout).toBe(""); expect(result.stderr).toBe( - "project show → Showing the linked project for the current repo.\n\n│ project: linked\n│ remote details: unavailable until you sign in\n", + "project list → Listing projects for the authenticated workspace.\n\n│ workspace: Acme Inc\n│ ⚬ project: Acme Dashboard\n│ ⚬ project: Billing API\n", ); }); - it("shows linked enriched state in JSON mode when signed in", async () => { + it("shows the project resolved from package.json in JSON mode", async () => { const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state"); - await writePrismaConfig(cwd, "proj_123"); - await executeCli({ - argv: ["auth", "login", "--provider", "github", "--user", "usr_456"], - cwd, - stateDir, - fixturePath, - }); + await writePackageJson(cwd, "billing-api"); + await login(cwd, stateDir); const result = await executeCli({ argv: ["project", "show", "--json"], @@ -197,14 +112,16 @@ describe("project commands", () => { ok: true, command: "project.show", result: { - linkedProjectId: "proj_123", workspace: { id: "ws_123", name: "Acme Inc", }, project: { - id: "proj_123", - name: "Acme Dashboard", + id: "proj_456", + name: "Billing API", + }, + resolution: { + projectSource: "package-name", }, }, warnings: [], @@ -212,249 +129,116 @@ describe("project commands", () => { }); }); - it("links a project and writes prisma.config.ts", async () => { + it("shows an explicit project without mutating local state", async () => { const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state"); - - await executeCli({ - argv: ["auth", "login", "--provider", "github", "--user", "usr_456"], - cwd, - stateDir, - fixturePath, - }); + await login(cwd, stateDir); const result = await executeCli({ - argv: ["project", "link", "proj_123"], + argv: ["project", "show", "--project", "proj_123", "--json"], cwd, stateDir, fixturePath, }); + const state = JSON.parse(await readFile(path.join(stateDir, "state.json"), "utf8")); expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe( - "project link → Linking the current repo to an existing project.\n\n│ project: Acme Dashboard\n│ workspace: Acme Inc\n\n◇ Applying local project link...\n✔ Applied 1 operation(s)\n Project link written to local repo config.\n", - ); - expect(await readPrismaConfig(cwd)).toContain('project: "proj_123"'); + expect(JSON.parse(result.stdout).result.resolution.projectSource).toBe("explicit"); + expect(state.project?.lastResolved ?? null).toBe(null); }); - it("prompts for project selection when no project id is passed in interactive mode", async () => { + it("returns PROJECT_NOT_FOUND for an inaccessible explicit project", async () => { const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state"); - - await executeCli({ - argv: ["auth", "login", "--provider", "github", "--user", "usr_456"], - cwd, - stateDir, - fixturePath, - }); + await login(cwd, stateDir); const result = await executeCli({ - argv: ["project", "link"], + argv: ["project", "show", "--project", "proj_789", "--json"], cwd, stateDir, fixturePath, - isTTY: true, - stdinText: "\u001B[B\r", }); - const stderr = stripAnsi(result.stderr); - expect(result.exitCode).toBe(0); - expect(result.stdout).toBe(""); - expect(stderr).toContain("project link → Linking the current repo to an existing project."); - expect(stderr).toContain("Select a project"); - expect(stderr).toContain("Acme Dashboard (proj_123)"); - expect(stderr).toContain("Billing API (proj_456)"); - expect(stderr).toContain("✔ Applied 1 operation(s)"); - expect(await readPrismaConfig(cwd)).toContain('project: "proj_456"'); + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stdout).error.code).toBe("PROJECT_NOT_FOUND"); }); - it("links a project in JSON mode", async () => { + it("returns PROJECT_UNRESOLVED when automatic resolution has no safe source", async () => { const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state"); - - await executeCli({ - argv: ["auth", "login", "--provider", "github", "--user", "usr_456"], - cwd, - stateDir, - fixturePath, - }); + await login(cwd, stateDir); const result = await executeCli({ - argv: ["project", "link", "proj_123", "--json"], + argv: ["project", "show", "--json"], cwd, stateDir, fixturePath, }); - expect(result.exitCode).toBe(0); - expect(result.stderr).toBe(""); - expect(JSON.parse(result.stdout)).toEqual({ - ok: true, - command: "project.link", - result: { - linkedProjectId: "proj_123", - workspace: { - id: "ws_123", - name: "Acme Inc", - }, - project: { - id: "proj_123", - name: "Acme Dashboard", - }, - }, - warnings: [], - nextSteps: ["prisma-cli project show", "prisma-cli app deploy"], - }); + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stdout).error.code).toBe("PROJECT_UNRESOLVED"); }); - it("returns USAGE_ERROR for project link in JSON mode when no project id is passed", async () => { + it("does not prompt for project selection in interactive human mode", async () => { const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state"); + await login(cwd, stateDir); const result = await executeCli({ - argv: ["project", "link", "--json"], + argv: ["project", "show"], cwd, stateDir, fixturePath, + isTTY: true, }); + const stderr = stripAnsi(result.stderr); - expect(result.exitCode).toBe(2); - expect(result.stderr).toBe(""); - expect(JSON.parse(result.stdout)).toEqual({ - ok: false, - command: "project.link", - error: { - code: "USAGE_ERROR", - domain: "project", - severity: "error", - summary: "Project link requires a project target in non-interactive mode", - why: "This command cannot prompt for project selection in the current mode.", - fix: "Re-run prisma-cli project link in a TTY, or pass a project id explicitly.", - where: null, - meta: {}, - docsUrl: null, - }, - warnings: [], - nextSteps: ["prisma-cli project list"], - }); + expect(result.exitCode).toBe(1); + expect(stderr).toContain("No project is resolved for this directory [PROJECT_UNRESOLVED]"); + expect(stderr).toContain("prisma-cli project list"); + expect(stderr).not.toContain("Select a project"); }); - it("returns USAGE_ERROR for project link with --no-interactive when no project id is passed", async () => { + it("returns PROJECT_AMBIGUOUS when package inference matches multiple projects", async () => { const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state"); - - await executeCli({ - argv: ["auth", "login", "--provider", "github", "--user", "usr_456"], - cwd, - stateDir, - fixturePath, - }); + const ambiguousFixturePath = await createAmbiguousFixture(cwd); + await writePackageJson(cwd, "acme-dashboard"); + await login(cwd, stateDir, ambiguousFixturePath); const result = await executeCli({ - argv: ["project", "link", "--no-interactive", "--json"], + argv: ["project", "show", "--json"], cwd, stateDir, - fixturePath, + fixturePath: ambiguousFixturePath, }); - expect(result.exitCode).toBe(2); - expect(result.stderr).toBe(""); - expect(JSON.parse(result.stdout)).toEqual({ - ok: false, - command: "project.link", - error: { - code: "USAGE_ERROR", - domain: "project", - severity: "error", - summary: "Project link requires a project target in non-interactive mode", - why: "This command cannot prompt for project selection in the current mode.", - fix: "Re-run prisma-cli project link in a TTY, or pass a project id explicitly.", - where: null, - meta: {}, - docsUrl: null, - }, - warnings: [], - nextSteps: ["prisma-cli project list"], - }); + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stdout).error.code).toBe("PROJECT_AMBIGUOUS"); }); - it("returns PROJECT_NOT_FOUND for an inaccessible project id", async () => { + it("returns LOCAL_STATE_STALE when remembered context is invalid and continuing is ambiguous", async () => { const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state"); - - await executeCli({ - argv: ["auth", "login", "--provider", "github", "--user", "usr_456"], - cwd, - stateDir, - fixturePath, - }); + await writeStaleProjectState(stateDir); const result = await executeCli({ - argv: ["project", "link", "proj_789", "--json"], + argv: ["project", "show", "--json"], cwd, stateDir, fixturePath, }); expect(result.exitCode).toBe(1); - expect(result.stderr).toBe(""); - expect(JSON.parse(result.stdout)).toEqual({ - ok: false, - command: "project.link", - error: { - code: "PROJECT_NOT_FOUND", - domain: "project", - severity: "error", - summary: "Project not found", - why: 'The project "proj_789" does not exist in workspace "Acme Inc".', - fix: "Run prisma-cli project list and choose a project id from the active workspace.", - where: null, - meta: {}, - docsUrl: null, - }, - warnings: [], - nextSteps: ["prisma-cli project list"], - }); + expect(JSON.parse(result.stdout).error.code).toBe("LOCAL_STATE_STALE"); }); - it("does not mutate local branch state when linking a project", async () => { + it("shows Public Beta project help without project link", async () => { const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state"); - await executeCli({ - argv: ["auth", "login", "--provider", "github", "--user", "usr_456"], - cwd, - stateDir, - fixturePath, - }); - - await executeCli({ - argv: ["project", "link", "proj_123"], - cwd, - stateDir, - fixturePath, - }); - - const whoami = await executeCli({ - argv: ["auth", "whoami", "--json"], - cwd, - stateDir, - fixturePath, - }); - const state = JSON.parse(await readFile(path.join(stateDir, "state.json"), "utf8")); - - expect(JSON.parse(whoami.stdout).result.linkedProjectId).toBe("proj_123"); - expect(JSON.parse(whoami.stdout).result.workspace.id).toBe("ws_123"); - expect(state.branch.active).toBe("preview"); - }); - - it("shows the documented help text for project commands", async () => { - const cwd = await createTempCwd(); - const stateDir = path.join(cwd, ".state"); - - const listHelp = await executeCli({ - argv: ["project", "list", "--help"], + const projectHelp = await executeCli({ + argv: ["project", "--help"], cwd, stateDir, fixturePath, @@ -465,27 +249,12 @@ describe("project commands", () => { stateDir, fixturePath, }); - const linkHelp = await executeCli({ - argv: ["project", "link", "--help"], - cwd, - stateDir, - fixturePath, - }); + const stderr = stripAnsi(`${projectHelp.stderr}\n${showHelp.stderr}`); - expect(listHelp.exitCode).toBe(0); - expect(listHelp.stderr).toContain("List all projects in your workspace"); - expect(listHelp.stderr).toContain("│ Examples:"); - expect(listHelp.stderr).toContain("$ prisma-cli project list"); - expect(listHelp.stderr).toContain("$ prisma-cli project list --json"); - - expect(showHelp.exitCode).toBe(0); - expect(showHelp.stderr).toContain("Show the Prisma project linked to this directory"); - expect(showHelp.stderr).toContain("$ prisma-cli project show"); - expect(showHelp.stderr).toContain("$ prisma-cli project show --json"); - - expect(linkHelp.exitCode).toBe(0); - expect(linkHelp.stderr).toContain("Link this directory to a Prisma project"); - expect(linkHelp.stderr).toContain("$ prisma-cli project link"); - expect(linkHelp.stderr).toContain("$ prisma-cli project link proj_123"); + expect(projectHelp.exitCode).toBe(0); + expect(stderr).toContain("project → Manage and inspect your Prisma projects"); + expect(stderr).toContain("Show which project is active for this directory"); + expect(stderr).not.toContain("project link"); + expect(stderr).not.toContain("linked project"); }); }); diff --git a/packages/cli/tests/shell.test.ts b/packages/cli/tests/shell.test.ts index 6b7067d..13454d2 100644 --- a/packages/cli/tests/shell.test.ts +++ b/packages/cli/tests/shell.test.ts @@ -82,7 +82,7 @@ describe("shell behavior", () => { expect(authResult.stderr).not.toContain("--color"); expect(projectResult.exitCode).toBe(0); - expect(projectResult.stderr).toContain("project → Manage the link between this directory and a Prisma project"); + expect(projectResult.stderr).toContain("project → Manage and inspect your Prisma projects"); expect(projectResult.stderr).toContain("Global options:"); expect(branchResult.exitCode).toBe(0); @@ -133,15 +133,14 @@ describe("shell behavior", () => { const stateDir = path.join(cwd, ".state"); const result = await executeCli({ - argv: ["--no-interactive", "project", "link"], + argv: ["--no-interactive", "project", "show"], cwd, stateDir, fixturePath, }); - expect(result.exitCode).toBe(2); - expect(result.stderr).toContain("[USAGE_ERROR]"); - expect(result.stderr).toContain("Project link requires a project target in non-interactive mode"); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("[AUTH_REQUIRED]"); }); it("shows a did-you-mean suggestion for mistyped subcommands", async () => { @@ -181,14 +180,14 @@ describe("shell behavior", () => { const stateDir = path.join(cwd, ".state"); const result = await executeCli({ - argv: ["project", "link", "--no-interactive", "--trace"], + argv: ["project", "show", "--no-interactive", "--trace"], cwd, stateDir, fixturePath, }); - expect(result.exitCode).toBe(2); - expect(result.stderr).toContain("[USAGE_ERROR]"); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("[AUTH_REQUIRED]"); expect(result.stderr).not.toContain("More: Re-run with --trace"); }); diff --git a/packages/cli/tests/use-case-helpers.ts b/packages/cli/tests/use-case-helpers.ts index 89dfbfe..f4b2e80 100644 --- a/packages/cli/tests/use-case-helpers.ts +++ b/packages/cli/tests/use-case-helpers.ts @@ -8,19 +8,19 @@ const fixturePath = path.resolve("fixtures/mock-api.json"); export async function createUseCaseGateways(options?: { authSession?: AuthSessionRecord | null; - linkedProjectId?: string | null; + projectId?: string | null; activeBranch?: string; }): Promise<{ gateways: CliUseCaseGateways; readState: () => { authSession: AuthSessionRecord | null; - linkedProjectId: string | null; + projectId: string | null; activeBranch: string; }; }> { const api = await MockApi.load(fixturePath); let authSession = options?.authSession ?? null; - let linkedProjectId = options?.linkedProjectId ?? null; + let projectId = options?.projectId ?? null; let activeBranch = options?.activeBranch ?? "preview"; return { @@ -70,10 +70,10 @@ export async function createUseCaseGateways(options?: { }, getDeployment: (deploymentId) => api.getDeployment(deploymentId), }, - projectConfigGateway: { - readLinkedProjectId: async () => linkedProjectId, - writeLinkedProjectId: async (projectId) => { - linkedProjectId = projectId; + projectStateGateway: { + readRememberedProjectId: async () => projectId, + rememberProjectId: async (nextProjectId) => { + projectId = nextProjectId; }, }, sessionGateway: { @@ -94,7 +94,7 @@ export async function createUseCaseGateways(options?: { }, readState: () => ({ authSession, - linkedProjectId, + projectId, activeBranch, }), }; From 25bc33915de0a418bd99d6221ac43efc61528559 Mon Sep 17 00:00:00 2001 From: Luan van der Westhuizen Date: Wed, 13 May 2026 15:49:32 +0200 Subject: [PATCH 3/4] docs(project): document automatic resolution --- docs/product/cli-style-guide.md | 5 +- docs/product/command-principles.md | 11 +---- docs/product/command-spec.md | 76 +++++++++++++----------------- docs/product/error-conventions.md | 8 +++- docs/product/output-conventions.md | 11 ++--- docs/product/resource-model.md | 35 +++++++------- 6 files changed, 65 insertions(+), 81 deletions(-) diff --git a/docs/product/cli-style-guide.md b/docs/product/cli-style-guide.md index 77ab6b7..3d5aea4 100644 --- a/docs/product/cli-style-guide.md +++ b/docs/product/cli-style-guide.md @@ -64,12 +64,13 @@ Recommended symbols: Human-oriented command output in TTY mode should usually start with a compact header: ```text -project link → Linking the current repo to an existing project. +project show → Showing the project resolved for this directory. │ project: Acme Dashboard │ workspace: Acme Inc +│ source: package-name │ -│ Read more docs/product/command-spec.md#prisma-cli-project-link-project +│ Read more docs/product/command-spec.md#prisma-cli-project-show ``` Rules: diff --git a/docs/product/command-principles.md b/docs/product/command-principles.md index 288becb..bb31bc1 100644 --- a/docs/product/command-principles.md +++ b/docs/product/command-principles.md @@ -83,23 +83,14 @@ Change local CLI context only. `use` must never mutate a remote resource. -`use` is intentionally different from `link`: +`use` changes local active context only: - `use` changes local active context only -- `link` binds local repo context to an existing remote resource Example: - `branch use production` -### `link` - -Connect local repo context to an existing remote resource. - -Example: - -- `project link` - ### `deploy` Build and release an app into a target branch. diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index aac1b85..8afd394 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -39,7 +39,7 @@ Out of scope for the current preview: - Long flags use kebab-case. - Boolean negation uses `--no-`. - `--json` and non-interactive mode must not block on prompts. -- `prisma.config.ts` stores only the linked project id. +- Public Beta does not read or write `prisma.config.ts`, `.prisma/settings.json`, or any repo config file for Project -> Branch -> App resolution. - Remote commands do not silently change local context. ## Context Resolution @@ -48,18 +48,23 @@ Out of scope for the current preview: Commands resolve project context in this order: -1. linked project id in `prisma.config.ts` -2. explicit `project link` -3. implicit creation by `app deploy` when no project is linked +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 -Only `app deploy` may create project context implicitly. +`--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. ### App Selection Preview app commands that need an app resolve it in this order: 1. `--app ` -2. locally selected app for the linked project +2. locally selected app for the resolved project 3. interactive select-or-create flow in TTY mode 4. `USAGE_ERROR` in non-interactive or `--json` mode when unresolved @@ -107,8 +112,7 @@ In `--json`, `result` uses this shape: "workspace": { "id": "ws_123", "name": "Acme Inc" - }, - "linkedProjectId": "proj_123" + } } ``` @@ -118,7 +122,6 @@ Rules: - `provider` is `github`, `google`, or `null` - `user` contains the current user email or is `null` - `workspace` is the active workspace or `null` -- `linkedProjectId` is the linked project id for the current repo or `null` - signed-out state is an empty auth state, not an error ## `prisma-cli auth login` @@ -189,7 +192,8 @@ Behavior: - requires auth - lists projects visible to the active workspace -- marks the locally linked project when one is present +- does not resolve the current directory +- does not mutate local state Examples: @@ -202,50 +206,35 @@ prisma-cli project list --json Purpose: -- show the Prisma project linked to this directory +- show the Prisma project resolved for this directory Behavior: -- reads the linked project id from `prisma.config.ts` -- requires auth when resolving remote project details -- fails with `PROJECT_NOT_LINKED` when no project is linked +- requires auth +- resolves project context without creating projects +- does not prompt for project selection +- does not mutate local state +- `--project ` resolves only the explicit project +- returns Workspace, Project, and `resolution.projectSource` +- fails with `PROJECT_UNRESOLVED`, `PROJECT_NOT_FOUND`, `PROJECT_AMBIGUOUS`, or `LOCAL_STATE_STALE` when resolution cannot continue safely Examples: ```bash prisma-cli project show prisma-cli project show --json -``` - -## `prisma-cli project link [project]` - -Purpose: - -- link this directory to a Prisma project - -Behavior: - -- writes only the project id to `prisma.config.ts` -- prompts for a project when no project id is passed and prompting is allowed -- fails with `USAGE_ERROR` when no project can be selected non-interactively -- does not change active branch context - -Examples: - -```bash -prisma-cli project link -prisma-cli project link proj_123 +prisma-cli project show --project proj_123 --json ``` ## `prisma-cli branch list` Purpose: -- list active Platform branches linked to this project +- list active Platform branches for the resolved project Behavior: -- shows known remote branches for the linked project +- shows known remote branches for the resolved project - marks active context - does not create remote state - does not expose branch `role` or `durability` fields yet @@ -266,7 +255,7 @@ Purpose: Behavior: - reads local branch context -- shows linked project context when known +- shows resolved project context when known - does not mutate local or remote state - does not expose branch `role` or `durability` fields yet @@ -347,7 +336,7 @@ prisma-cli app deploy --app hello-world --build-type tanstack-start ## `prisma-cli project env` -Manage durable, platform-stored environment variables for the linked +Manage durable, platform-stored environment variables for the resolved project. Replaces the legacy `prisma app update-env` / `prisma app list-env` workflow, which mutated env vars on a single Foundry version and is now deprecated. The `env` namespace operates on the @@ -373,9 +362,10 @@ Purpose: Behavior: -- requires auth and a linked project +- requires auth and a resolved project; accepts `--project ` as an explicit fallback - KEY=VALUE is parsed from a single positional; KEY must match `[A-Z_][A-Z0-9_]*` +- KEY without `=VALUE` reads the value from the current process environment - if a variable with the same key already exists in the scope, the command fails with a clear error directing to `env update` - the response carries metadata only — the value is never echoed back @@ -385,6 +375,7 @@ Examples: ```bash prisma-cli project env add STRIPE_KEY=sk_test_xxx --role production prisma-cli project env add STRIPE_KEY=sk_test_xxx --role preview +API_URL=https://api.example prisma-cli project env add API_URL --project proj_123 --role preview ``` ### `prisma-cli project env update KEY=VALUE --role ` @@ -396,9 +387,10 @@ Purpose: Behavior: -- requires auth and a linked project +- requires auth and a resolved project; accepts `--project ` as an explicit fallback - KEY=VALUE is parsed from a single positional; KEY must match `[A-Z_][A-Z0-9_]*` +- KEY without `=VALUE` reads the value from the current process environment - if no variable with the key exists in the scope, the command fails with a clear error directing to `env add` - the response carries metadata only — the value is never echoed back @@ -418,7 +410,7 @@ Purpose: Behavior: -- requires auth and a linked project +- requires auth and a resolved project; accepts `--project ` as an explicit fallback - defaults to `--role production` when `--role` is not supplied - never prints values (never-reveal) - emits `key`, `id`, `last updated`, and a `scope` annotation per row @@ -438,7 +430,7 @@ Purpose: Behavior: -- requires auth and a linked project +- requires auth and a resolved project; accepts `--project ` as an explicit fallback - looks the variable up by natural key in the scope and `DELETE`s it - returns a focused error when no matching variable exists diff --git a/docs/product/error-conventions.md b/docs/product/error-conventions.md index b05d558..9080795 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -147,8 +147,10 @@ These codes are the minimum stable set for the MVP: - `USAGE_ERROR` - `AUTH_REQUIRED` -- `PROJECT_NOT_LINKED` +- `PROJECT_UNRESOLVED` - `PROJECT_NOT_FOUND` +- `PROJECT_AMBIGUOUS` +- `LOCAL_STATE_STALE` - `BRANCH_NOT_DEPLOYABLE` - `DEPLOYMENT_NOT_FOUND` - `NO_DEPLOYMENTS` @@ -166,8 +168,10 @@ Recommended meanings: - `USAGE_ERROR`: invalid arguments or invalid command combination - `AUTH_REQUIRED`: command needs an authenticated session -- `PROJECT_NOT_LINKED`: command needs project context and none is linked +- `PROJECT_UNRESOLVED`: command needs project context and none could be resolved - `PROJECT_NOT_FOUND`: requested project does not exist or is not accessible +- `PROJECT_AMBIGUOUS`: multiple safe project candidates matched +- `LOCAL_STATE_STALE`: remembered local project context no longer matches platform data and continuing would be ambiguous - `BRANCH_NOT_DEPLOYABLE`: command tried to deploy to a non-deployable branch context - `DEPLOYMENT_NOT_FOUND`: requested deployment id does not exist - `NO_DEPLOYMENTS`: command resolved a branch or app but found no deployments diff --git a/docs/product/output-conventions.md b/docs/product/output-conventions.md index 6e5d8b1..bff8620 100644 --- a/docs/product/output-conventions.md +++ b/docs/product/output-conventions.md @@ -67,7 +67,6 @@ Current MVP commands map to patterns like this: | `auth whoami` | `show` | | `project list` | `list` | | `project show` | `show` | -| `project link` | `mutate` | | `branch list` | `list` | | `branch show` | `show` | | `branch use` | `mutate` | @@ -99,7 +98,6 @@ Rules: - each list row uses `⚬` and repeats the same item noun - annotations are limited to one per row and use: - `(active)` for current context - - `(linked)` for the current local repo binding - `(default)` when the command defines a default item - human output prefers display labels over opaque ids - empty lists render a single dim sentence in the item area such as `No projects found.` @@ -122,7 +120,7 @@ In `--json`, all list commands use this result shape: } ``` -`status` may be `"active"`, `"linked"`, or `null`. +`status` may be `"active"`, `"default"`, or `null`. #### `show` @@ -219,12 +217,13 @@ Human output should: Recommended header shape: ```text -project link → Linking the current repo to an existing project. +project show → Showing the project resolved for this directory. │ project: Acme Dashboard │ workspace: Acme Inc +│ source: package-name │ -│ Read more docs/product/command-spec.md#prisma-cli-project-link-project +│ Read more docs/product/command-spec.md#prisma-cli-project-show ``` Rules: @@ -238,7 +237,7 @@ Rules: Recommended summary lines: ```text -✔ Project linked +✔ Project resolved ✘ Authentication required [AUTH_REQUIRED] ``` diff --git a/docs/product/resource-model.md b/docs/product/resource-model.md index 6a124aa..eccd1a5 100644 --- a/docs/product/resource-model.md +++ b/docs/product/resource-model.md @@ -31,24 +31,16 @@ Preview relevance: ### Project -`project` is the remote Prisma resource linked to a local repo. +`project` is the remote Prisma resource resolved for local work. Rules: - `project` is not the same thing as `app` -- `prisma.config.ts` stores only the linked project id -- `app deploy` may create project context when none is linked +- Public Beta does not read or write `prisma.config.ts`, `.prisma/settings.json`, or any repo config file for project resolution +- `app deploy` may create missing project context only when resolution is unambiguous - other commands must not create project context implicitly - everything under a project happens in a branch -Example config: - -```ts -export default { - project: "proj_123", -}; -``` - ### Branch `branch` is the named project-scoped isolation boundary for app and database @@ -107,7 +99,7 @@ accidental destructive actions. Rules: - `app` is not the same thing as `project` -- the app belongs to the linked project +- the app belongs to the resolved project - app work is scoped by branch in the platform model - the app may be selected or created as part of app deployment workflows - app selection is local CLI state when needed for the preview package @@ -187,7 +179,7 @@ Long-term, branch is where app and database relationships meet. - `local` is CLI context, not a branch or deploy target - `production` is protected and durable - every other named branch is preview by default -- `prisma.config.ts` stores only the linked project id +- Public Beta does not use repo config files for Project -> Branch -> App resolution ## Resolution Rules @@ -195,18 +187,23 @@ Long-term, branch is where app and database relationships meet. Commands resolve project context in this order: -1. linked project id in `prisma.config.ts` -2. explicit `project link` -3. implicit creation by `app deploy` when no project is linked +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 -Only `app deploy` may create projects implicitly. +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. ### App Selection Resolution Preview app commands that need an app resolve it in this order: 1. explicit `--app ` -2. locally selected app for the linked project +2. locally selected app for the resolved project 3. interactive selection or creation in a TTY 4. structured usage error when no app can be resolved non-interactively @@ -229,7 +226,7 @@ Consequences: Commands that inspect deployments resolve in this order: 1. exact deployment id if the command accepts one -2. selected app for the linked project +2. selected app for the resolved project 3. latest known live deployment for that app ### Promote Resolution From badae6455dd76cfb37bd757e391089fb012fa014 Mon Sep 17 00:00:00 2001 From: Luan van der Westhuizen Date: Wed, 13 May 2026 16:16:59 +0200 Subject: [PATCH 4/4] fix(project): address resolution review feedback --- docs/product/command-principles.md | 4 +- packages/cli/src/controllers/app-env.ts | 9 ++- packages/cli/src/controllers/app.ts | 24 ++++++-- packages/cli/src/controllers/project.ts | 6 +- packages/cli/src/lib/project/resolution.ts | 8 ++- packages/cli/src/shell/errors.ts | 10 ++++ packages/cli/src/use-cases/contracts.ts | 1 - .../cli/src/use-cases/create-cli-gateways.ts | 7 --- packages/cli/tests/app-controller.test.ts | 55 +++++++++++++++++++ packages/cli/tests/project.test.ts | 17 ++++++ packages/cli/tests/use-case-helpers.ts | 3 - 11 files changed, 116 insertions(+), 28 deletions(-) diff --git a/docs/product/command-principles.md b/docs/product/command-principles.md index bb31bc1..6697bab 100644 --- a/docs/product/command-principles.md +++ b/docs/product/command-principles.md @@ -83,9 +83,7 @@ Change local CLI context only. `use` must never mutate a remote resource. -`use` changes local active context only: - -- `use` changes local active context only +`use` changes local active context only. Example: diff --git a/packages/cli/src/controllers/app-env.ts b/packages/cli/src/controllers/app-env.ts index 04b7384..06d1cf8 100644 --- a/packages/cli/src/controllers/app-env.ts +++ b/packages/cli/src/controllers/app-env.ts @@ -8,7 +8,7 @@ import { type EnvVarRole, } from "../lib/app/env-config"; import { requireComputeAuth } from "../lib/auth/guard"; -import { authRequiredError, CliError, usageError } from "../shell/errors"; +import { authRequiredError, CliError, usageError, workspaceRequiredError } from "../shell/errors"; import type { CommandSuccess } from "../shell/output"; import type { CommandContext } from "../shell/runtime"; import { resolveProjectTarget } from "../lib/project/resolution"; @@ -261,8 +261,11 @@ async function requireClientAndProject( ): Promise<{ client: ManagementApiClient; projectId: string }> { const authState = await requireAuthenticatedAuthState(context); const client = await requireComputeAuth(context.runtime.env); - if (!client || !authState.workspace) { - throw authRequiredError(["prisma auth login"]); + if (!client) { + throw authRequiredError(["prisma-cli auth login"]); + } + if (!authState.workspace) { + throw workspaceRequiredError(); } const target = await resolveProjectTarget({ diff --git a/packages/cli/src/controllers/app.ts b/packages/cli/src/controllers/app.ts index 3dbecd0..1da981d 100644 --- a/packages/cli/src/controllers/app.ts +++ b/packages/cli/src/controllers/app.ts @@ -3,7 +3,7 @@ import type { PortMapping, StreamRecord } from "@prisma/compute-sdk"; import type { ManagementApiClient } from "@prisma/management-api-sdk"; import { FileTokenStorage } from "../adapters/token-storage"; -import { authRequiredError, CliError, featureUnavailableError, usageError } from "../shell/errors"; +import { authRequiredError, CliError, featureUnavailableError, usageError, workspaceRequiredError } from "../shell/errors"; import { writeJsonEvent, type CommandSuccess } from "../shell/output"; import { canPrompt, type CommandContext } from "../shell/runtime"; import { textPrompt } from "../shell/prompt"; @@ -27,6 +27,7 @@ import type { AuthWorkspace } from "../types/auth"; import type { BranchKind } from "../types/branch"; import type { ProjectResolution, ProjectSummary } from "../types/project"; import { requireComputeAuth } from "../lib/auth/guard"; +import { readAuthState } from "../lib/auth/auth-ops"; import { getApiBaseUrl, SERVICE_TOKEN_ENV_VAR } from "../lib/auth/client"; import { parseEnvAssignments } from "../lib/app/env-vars"; import { @@ -177,11 +178,9 @@ export async function runAppDeploy( commandName: "deploy", }), ); - const { client, provider } = await requirePreviewAppProviderWithClient(context); - const target = await resolveProjectContext(context, client, provider, options?.projectRef, { + const { provider, target, projectId } = await requireProviderAndProjectContext(context, options?.projectRef, { allowCreate: true, }); - const projectId = target.project.id; const apps = await listApps(context, provider, projectId); const selectedApp = await resolveDeploySelection(context, projectId, apps, appName); @@ -556,7 +555,8 @@ export async function runAppShowDeploy( }); } - const rememberedProject = deployment?.app ? await context.stateStore.readLastResolvedProject() : null; + const workspaceId = deployment?.app ? await readCurrentWorkspaceId(context) : null; + const rememberedProject = workspaceId ? await context.stateStore.readRememberedProject(workspaceId) : null; const knownLiveDeploymentId = deployment?.app && rememberedProject ? await context.stateStore.readKnownLiveDeployment(rememberedProject.id, deployment.app.id) : null; @@ -1407,6 +1407,7 @@ async function requireProviderAndProjectContext( explicitProject: string | undefined, options?: { allowCreate?: boolean }, ): Promise<{ + client: ManagementApiClient; provider: ReturnType; target: ResolvedAppProjectContext; projectId: string; @@ -1414,6 +1415,7 @@ async function requireProviderAndProjectContext( const { client, provider } = await requirePreviewAppProviderWithClient(context); const target = await resolveProjectContext(context, client, provider, explicitProject, options); return { + client, provider, target, projectId: target.project.id, @@ -1429,7 +1431,7 @@ async function resolveProjectContext( ): Promise { const authState = await requireAuthenticatedAuthState(context); if (!authState.workspace) { - throw authRequiredError(["prisma-cli auth login"]); + throw workspaceRequiredError(); } const resolved = await resolveProjectTarget({ @@ -1468,6 +1470,16 @@ function toBranchKind(name: string): BranchKind { return name === "production" ? "production" : "preview"; } +async function readCurrentWorkspaceId(context: CommandContext): Promise { + const state = await context.stateStore.read(); + if (state.auth?.workspaceId) { + return state.auth.workspaceId; + } + + const authState = await readAuthState(context.runtime.env); + return authState.workspace?.id ?? null; +} + function normalizeBuildType(requestedBuildType: string | undefined): PreviewBuildType { if (!requestedBuildType) { return "auto"; diff --git a/packages/cli/src/controllers/project.ts b/packages/cli/src/controllers/project.ts index 47f68e7..260fc8e 100644 --- a/packages/cli/src/controllers/project.ts +++ b/packages/cli/src/controllers/project.ts @@ -1,6 +1,6 @@ import type { ManagementApiClient } from "@prisma/management-api-sdk"; -import { authRequiredError } from "../shell/errors"; +import { authRequiredError, workspaceRequiredError } from "../shell/errors"; import type { CommandSuccess } from "../shell/output"; import type { CommandContext } from "../shell/runtime"; import type { AuthWorkspace } from "../types/auth"; @@ -23,7 +23,7 @@ export async function runProjectList(context: CommandContext): Promise { try { const raw = await readFile(path.join(cwd, "package.json"), "utf8"); - const parsed = JSON.parse(raw) as { name?: unknown }; - return typeof parsed.name === "string" && parsed.name.trim().length > 0 ? parsed.name.trim() : null; + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object") { + return null; + } + const packageName = "name" in parsed ? parsed.name : null; + return typeof packageName === "string" && packageName.trim().length > 0 ? packageName.trim() : null; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { return null; diff --git a/packages/cli/src/shell/errors.ts b/packages/cli/src/shell/errors.ts index 1029fdf..f0e277b 100644 --- a/packages/cli/src/shell/errors.ts +++ b/packages/cli/src/shell/errors.ts @@ -77,6 +77,16 @@ export function authRequiredError(nextSteps: string[] = ["prisma-cli auth login" }); } +export function workspaceRequiredError(): CliError { + return usageError( + "Workspace required", + "This command needs an active workspace, but the authenticated session does not have one.", + "Run prisma-cli auth login and choose a workspace.", + ["prisma-cli auth login"], + "auth", + ); +} + export function featureUnavailableError( summary: string, why: string, diff --git a/packages/cli/src/use-cases/contracts.ts b/packages/cli/src/use-cases/contracts.ts index ac5583b..ede97ad 100644 --- a/packages/cli/src/use-cases/contracts.ts +++ b/packages/cli/src/use-cases/contracts.ts @@ -71,7 +71,6 @@ export interface BranchStateGateway { export interface ProjectStateGateway { readRememberedProjectId(): Promise; - rememberProjectId(projectId: string): Promise; } export interface LoginSelection { diff --git a/packages/cli/src/use-cases/create-cli-gateways.ts b/packages/cli/src/use-cases/create-cli-gateways.ts index 37f8d10..5c5ff55 100644 --- a/packages/cli/src/use-cases/create-cli-gateways.ts +++ b/packages/cli/src/use-cases/create-cli-gateways.ts @@ -68,13 +68,6 @@ export function createCliUseCaseGateways(context: CommandContext): CliUseCaseGat const remembered = await context.stateStore.readLastResolvedProject(); return remembered?.id ?? null; }, - rememberProjectId: async (projectId) => { - await context.stateStore.setRememberedProject({ - id: projectId, - name: projectId, - workspaceId: "unknown", - }); - }, }, sessionGateway: { readAuthSession: async () => { diff --git a/packages/cli/tests/app-controller.test.ts b/packages/cli/tests/app-controller.test.ts index 1d15406..404c721 100644 --- a/packages/cli/tests/app-controller.test.ts +++ b/packages/cli/tests/app-controller.test.ts @@ -1210,6 +1210,61 @@ describe("app controller", () => { expect(result.result.deployment.live).toBe(true); }); + it("show-deploy ignores known live deployments from another workspace", async () => { + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const showDeployment = vi.fn().mockResolvedValue({ + app: { + id: "app_1", + name: "hello-world", + region: "eu-west-3", + liveDeploymentId: null, + }, + deployment: { + id: "dep_123", + status: "running", + url: "https://preview.prisma.app", + createdAt: "2026-04-11T12:00:00.000Z", + live: null, + }, + }); + + 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, + })), + })); + + const { createTempCwd, createTestCommandContext } = await import("./helpers"); + const { runAppShowDeploy } = await import("../src/controllers/app"); + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + const { context } = await createTestCommandContext({ + cwd, + stateDir, + env: { + ...process.env, + PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, + }, + }); + + await context.stateStore.setRememberedProject({ + id: "proj_other", + name: "Other Project", + workspaceId: "ws_other", + }); + await context.stateStore.setKnownLiveDeployment("proj_other", "app_1", "dep_123"); + + const result = await runAppShowDeploy(context, "dep_123"); + + expect(result.result.deployment.live).toBe(null); + }); + it("show-deploy surfaces provider failures instead of reporting not found", async () => { const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const showDeployment = vi.fn().mockRejectedValue(new Error("Missing or invalid authorization token")); diff --git a/packages/cli/tests/project.test.ts b/packages/cli/tests/project.test.ts index 560ad7f..640119c 100644 --- a/packages/cli/tests/project.test.ts +++ b/packages/cli/tests/project.test.ts @@ -179,6 +179,23 @@ describe("project commands", () => { expect(JSON.parse(result.stdout).error.code).toBe("PROJECT_UNRESOLVED"); }); + it("treats a primitive package.json root as missing package metadata", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + await writeFile(path.join(cwd, "package.json"), "null\n", "utf8"); + await login(cwd, stateDir); + + const result = await executeCli({ + argv: ["project", "show", "--json"], + cwd, + stateDir, + fixturePath, + }); + + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stdout).error.code).toBe("PROJECT_UNRESOLVED"); + }); + it("does not prompt for project selection in interactive human mode", async () => { const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state"); diff --git a/packages/cli/tests/use-case-helpers.ts b/packages/cli/tests/use-case-helpers.ts index f4b2e80..6f94971 100644 --- a/packages/cli/tests/use-case-helpers.ts +++ b/packages/cli/tests/use-case-helpers.ts @@ -72,9 +72,6 @@ export async function createUseCaseGateways(options?: { }, projectStateGateway: { readRememberedProjectId: async () => projectId, - rememberProjectId: async (nextProjectId) => { - projectId = nextProjectId; - }, }, sessionGateway: { readAuthSession: async () => authSession,