diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index fc1410a..6896258 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -367,12 +367,14 @@ Behavior: - requires auth - lists projects visible to the active workspace +- human output shows each Project's name and id - does not resolve the current directory - does not mutate local state - when the current directory is not linked, human output adds setup hints after the list - in JSON, unlinked directories include a `user-choice` `nextActions` entry for Project setup - listed Projects are not marked selected unless durable local binding actually selects one - listed Projects are candidates only; the user must choose one before `project link ` runs +- `branches` is intentionally deferred until `/v1/projects` exposes a branch count in this same response; the CLI must not make per-project branch-list requests to render `project list` Examples: @@ -399,7 +401,7 @@ Behavior: - when unbound, human output says `project: Not linked` and shows link/create next steps - when unbound, JSON exits successfully with `project: null`, `localBinding.status: "not-linked"`, `resolution.projectSource: "unbound"`, a suggested Project name, matching Project candidates, recovery commands, and `user-choice` `nextActions` - package names and directory names only power unbound suggestions -- fails with `PROJECT_NOT_FOUND`, `PROJECT_AMBIGUOUS`, or `LOCAL_STATE_STALE` when explicit or durable binding validation cannot continue safely +- fails with `PROJECT_NOT_FOUND`, `PROJECT_AMBIGUOUS`, `LOCAL_PROJECT_WORKSPACE_MISMATCH`, or `LOCAL_STATE_STALE` when explicit or durable binding validation cannot continue safely Examples: diff --git a/docs/product/error-conventions.md b/docs/product/error-conventions.md index 0d56877..e473876 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -166,6 +166,7 @@ These codes are the minimum stable set for the MVP: - `PROJECT_NOT_FOUND` - `PROJECT_AMBIGUOUS` - `APP_AMBIGUOUS` +- `LOCAL_PROJECT_WORKSPACE_MISMATCH` - `LOCAL_STATE_STALE` - `BRANCH_NOT_DEPLOYABLE` - `FRAMEWORK_NOT_DETECTED` @@ -210,6 +211,7 @@ Recommended meanings: - `PROJECT_NOT_FOUND`: requested project does not exist or is not accessible - `PROJECT_AMBIGUOUS`: multiple safe project candidates matched - `APP_AMBIGUOUS`: multiple apps matched the inferred or explicit app target +- `LOCAL_PROJECT_WORKSPACE_MISMATCH`: local Project pin points at a different workspace than the active authenticated workspace; callers should sign in to the linked workspace or relink the directory - `LOCAL_STATE_STALE`: local Project pin no longer matches platform data and continuing would be ambiguous - `BRANCH_NOT_DEPLOYABLE`: command tried to deploy to a non-deployable branch context - `FRAMEWORK_NOT_DETECTED`: app deploy could not detect a supported Beta framework and no explicit framework/build type was provided diff --git a/docs/product/output-conventions.md b/docs/product/output-conventions.md index 505d35f..1dce0df 100644 --- a/docs/product/output-conventions.md +++ b/docs/product/output-conventions.md @@ -130,6 +130,7 @@ Rules: - the title uses a present participle such as `Listing` - the first row in the card is the parent scope - each list row uses `⚬` and repeats the same item noun +- `project list` may render a compact name/id table instead of bullet rows because project errors and recovery commands use ids as stable handles - annotations are limited to one per row and use: - `(active)` for current context - `(default)` when the command defines a default item diff --git a/packages/cli/src/lib/project/resolution.ts b/packages/cli/src/lib/project/resolution.ts index 183567d..679da63 100644 --- a/packages/cli/src/lib/project/resolution.ts +++ b/packages/cli/src/lib/project/resolution.ts @@ -14,7 +14,11 @@ import type { ProjectSummary, ProjectShowResult, } from "../../types/project"; -import { LOCAL_RESOLUTION_PIN_RELATIVE_PATH, readLocalResolutionPin } from "./local-pin"; +import { + LOCAL_RESOLUTION_PIN_RELATIVE_PATH, + type LocalResolutionPinReadResult, + readLocalResolutionPin, +} from "./local-pin"; export interface ProjectCandidate extends ProjectSummary { slug?: string | null; @@ -41,8 +45,9 @@ export interface ResolveProjectOptions { } export async function resolveProjectTarget(options: ResolveProjectOptions): Promise { + const localPin = await readImplicitLocalPin(options, { allowEnvProjectId: true }); const projects = await options.listProjects(); - const target = await resolveBoundProjectTarget(options, projects, { allowEnvProjectId: true }); + const target = await resolveBoundProjectTarget(options, projects, { allowEnvProjectId: true, localPin }); if (target) { return target; @@ -57,8 +62,9 @@ export async function resolveProjectTarget(options: ResolveProjectOptions): Prom } export async function inspectProjectBinding(options: ResolveProjectOptions): Promise { + const localPin = await readImplicitLocalPin(options, { allowEnvProjectId: false }); const projects = await options.listProjects(); - const target = await resolveBoundProjectTarget(options, projects, { allowEnvProjectId: false }); + const target = await resolveBoundProjectTarget(options, projects, { allowEnvProjectId: false, localPin }); if (target) { return target; @@ -134,6 +140,29 @@ export function localStateStaleError(): CliError { }); } +export function localProjectWorkspaceMismatchError(options: { + pinnedWorkspaceId: string; + pinnedProjectId: string; + activeWorkspace: AuthWorkspace; +}): CliError { + return new CliError({ + code: "LOCAL_PROJECT_WORKSPACE_MISMATCH", + domain: "project", + summary: "Project link uses another workspace", + why: `${LOCAL_RESOLUTION_PIN_RELATIVE_PATH} links this directory to project ${options.pinnedProjectId} in workspace ${options.pinnedWorkspaceId}, but your current CLI session is workspace "${options.activeWorkspace.name}" (${options.activeWorkspace.id}).`, + fix: "Sign in to the linked workspace, or relink this directory to a project in the current workspace.", + meta: { + pinPath: LOCAL_RESOLUTION_PIN_RELATIVE_PATH, + pinnedWorkspaceId: options.pinnedWorkspaceId, + pinnedProjectId: options.pinnedProjectId, + activeWorkspaceId: options.activeWorkspace.id, + activeWorkspaceName: options.activeWorkspace.name, + }, + exitCode: 1, + nextSteps: ["prisma-cli auth login", "prisma-cli project list", "prisma-cli project link "], + }); +} + export async function buildProjectSetupSuggestion(options: { cwd: string; projects: ProjectCandidate[]; @@ -305,6 +334,7 @@ async function resolveBoundProjectTarget( projects: ProjectCandidate[], settings: { allowEnvProjectId: boolean; + localPin: LocalResolutionPinReadResult | null; }, ): Promise { if (options.explicitProject) { @@ -325,13 +355,20 @@ async function resolveBoundProjectTarget( }); } - const localPin = await readLocalResolutionPin(options.context.runtime.cwd, options.context.runtime.signal); + const localPin = settings.localPin; + if (!localPin) { + return null; + } if (localPin.kind === "invalid") { throw localStateStaleError(); } if (localPin.kind === "present") { if (localPin.pin.workspaceId !== options.workspace.id) { - throw localStateStaleError(); + throw localProjectWorkspaceMismatchError({ + pinnedWorkspaceId: localPin.pin.workspaceId, + pinnedProjectId: localPin.pin.projectId, + activeWorkspace: options.workspace, + }); } const project = projects.find((candidate) => candidate.id === localPin.pin.projectId); @@ -356,6 +393,28 @@ async function resolveBoundProjectTarget( return null; } +async function readImplicitLocalPin( + options: ResolveProjectOptions, + settings: { + allowEnvProjectId: boolean; + }, +): Promise { + if (options.explicitProject || (settings.allowEnvProjectId && options.envProjectId)) { + return null; + } + + const localPin = await readLocalResolutionPin(options.context.runtime.cwd, options.context.runtime.signal); + if (localPin.kind === "present" && localPin.pin.workspaceId !== options.workspace.id) { + throw localProjectWorkspaceMismatchError({ + pinnedWorkspaceId: localPin.pin.workspaceId, + pinnedProjectId: localPin.pin.projectId, + activeWorkspace: options.workspace, + }); + } + + return localPin; +} + function resolvedTarget( workspace: AuthWorkspace, project: ProjectCandidate, diff --git a/packages/cli/src/presenters/project.ts b/packages/cli/src/presenters/project.ts index f3da508..c721db7 100644 --- a/packages/cli/src/presenters/project.ts +++ b/packages/cli/src/presenters/project.ts @@ -1,8 +1,10 @@ import path from "node:path"; +import stringWidth from "string-width"; + +import { formatCommandArgument } from "../shell/command-arguments"; import type { CommandDescriptor } from "../shell/command-meta"; import { formatDescriptorLabel } from "../shell/command-meta"; -import { formatCommandArgument } from "../shell/command-arguments"; import type { CommandContext } from "../shell/runtime"; import type { GitRepositoryConnection, @@ -11,7 +13,7 @@ import type { ProjectSetupResult, ProjectShowResult, } from "../types/project"; -import { renderList, renderMutate, renderShow, serializeList } from "../output/patterns"; +import { renderMutate, renderShow, serializeList } from "../output/patterns"; import { padDisplay, renderNextSteps, renderSummaryLine } from "../shell/ui"; export function renderProjectList( @@ -19,24 +21,31 @@ export function renderProjectList( descriptor: CommandDescriptor, result: ProjectListResult, ): string[] { - const lines = renderList( - { - title: "Listing projects for the authenticated workspace.", - descriptor, - parentContext: { - key: "workspace", - value: result.workspace.name, - }, - items: result.projects.map((project) => ({ - noun: "project", - label: project.name, - id: project.id, - status: null, - })), - emptyMessage: "No projects found.", - }, - context.ui, - ); + const ui = context.ui; + const rail = ui.dim("│"); + const lines = [ + `${ui.strong(formatDescriptorLabel(descriptor))} ${ui.dim("→")} ${ui.dim("Listing projects for the authenticated workspace.")}`, + "", + ]; + lines.push(`${rail} ${ui.accent("workspace:")} ${result.workspace.name}`); + + if (result.projects.length === 0) { + lines.push(`${rail} ${ui.dim("No projects found.")}`); + if (result.localBinding?.status === "not-linked" || result.localBinding?.status === "invalid") { + lines.push(...renderNextSteps([ + "Link an existing Project you choose: prisma-cli project link ", + "Create a new Project: prisma-cli project create ", + ])); + } + return lines; + } + + const nameWidth = Math.max("name".length, ...result.projects.map((project) => stringWidth(project.name))); + lines.push(rail); + lines.push(`${rail} ${ui.accent(padDisplay("name", nameWidth))} ${ui.accent("id")}`); + for (const project of result.projects) { + lines.push(`${rail} ${padDisplay(project.name, nameWidth)} ${project.id}`); + } if (result.localBinding?.status === "not-linked" || result.localBinding?.status === "invalid") { lines.push(...renderNextSteps([ diff --git a/packages/cli/tests/project-resolution.test.ts b/packages/cli/tests/project-resolution.test.ts new file mode 100644 index 0000000..8b5c968 --- /dev/null +++ b/packages/cli/tests/project-resolution.test.ts @@ -0,0 +1,109 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; + +import { createTempCwd, createTestCommandContext } from "./helpers"; +import { resolveProjectTarget } from "../src/lib/project/resolution"; +import type { ProjectCandidate } from "../src/lib/project/resolution"; + +async function writeLocalPin(cwd: string, pin: unknown) { + await mkdir(path.join(cwd, ".prisma"), { recursive: true }); + await writeFile(path.join(cwd, ".prisma/local.json"), `${JSON.stringify(pin, null, 2)}\n`, "utf8"); +} + +describe("project resolution", () => { + it("returns LOCAL_PROJECT_WORKSPACE_MISMATCH before listing projects", async () => { + const cwd = await createTempCwd(); + await writeLocalPin(cwd, { + workspaceId: "ws_other", + projectId: "proj_123", + }); + const { context } = await createTestCommandContext({ cwd }); + const listProjects = vi.fn<() => Promise>(); + + await expect(resolveProjectTarget({ + context, + workspace: { + id: "ws_123", + name: "Acme Inc", + }, + listProjects, + commandName: "app deploy", + })).rejects.toMatchObject({ + code: "LOCAL_PROJECT_WORKSPACE_MISMATCH", + domain: "project", + meta: { + pinPath: ".prisma/local.json", + pinnedWorkspaceId: "ws_other", + pinnedProjectId: "proj_123", + activeWorkspaceId: "ws_123", + activeWorkspaceName: "Acme Inc", + }, + }); + expect(listProjects).not.toHaveBeenCalled(); + }); + + it("lets explicit project targeting bypass a mismatched local pin", async () => { + const cwd = await createTempCwd(); + await writeLocalPin(cwd, { + workspaceId: "ws_other", + projectId: "proj_123", + }); + const { context } = await createTestCommandContext({ cwd }); + const listProjects = vi.fn(async (): Promise => [{ + id: "proj_active", + name: "Active Project", + workspace: { + id: "ws_123", + name: "Acme Inc", + }, + }]); + + const result = await resolveProjectTarget({ + context, + workspace: { + id: "ws_123", + name: "Acme Inc", + }, + explicitProject: "proj_active", + listProjects, + commandName: "app deploy", + }); + + expect(result.resolution.projectSource).toBe("explicit"); + expect(result.project.id).toBe("proj_active"); + expect(listProjects).toHaveBeenCalledTimes(1); + }); + + it("lets PRISMA_PROJECT_ID bypass a mismatched local pin", async () => { + const cwd = await createTempCwd(); + await writeLocalPin(cwd, { + workspaceId: "ws_other", + projectId: "proj_123", + }); + const { context } = await createTestCommandContext({ cwd }); + const listProjects = vi.fn(async (): Promise => [{ + id: "proj_env", + name: "Env Project", + workspace: { + id: "ws_123", + name: "Acme Inc", + }, + }]); + + const result = await resolveProjectTarget({ + context, + workspace: { + id: "ws_123", + name: "Acme Inc", + }, + envProjectId: "proj_env", + listProjects, + commandName: "app deploy", + }); + + expect(result.resolution.projectSource).toBe("env"); + expect(result.project.id).toBe("proj_env"); + expect(listProjects).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/cli/tests/project.test.ts b/packages/cli/tests/project.test.ts index f2b9795..d344067 100644 --- a/packages/cli/tests/project.test.ts +++ b/packages/cli/tests/project.test.ts @@ -111,7 +111,7 @@ describe("project commands", () => { expect(result.exitCode).toBe(0); expect(result.stdout).toBe(""); expect(result.stderr).toBe( - "project list → Listing projects for the authenticated workspace.\n\n│ workspace: Acme Inc\n│ ⚬ project: Acme Dashboard\n│ ⚬ project: Billing API\n\nNext steps:\n- Link an existing Project you choose: prisma-cli project link \n- Create a new Project: prisma-cli project create \n", + "project list → Listing projects for the authenticated workspace.\n\n│ workspace: Acme Inc\n│\n│ name id\n│ Acme Dashboard proj_123\n│ Billing API proj_456\n\nNext steps:\n- Link an existing Project you choose: prisma-cli project link \n- Create a new Project: prisma-cli project create \n", ); }); @@ -135,7 +135,7 @@ describe("project commands", () => { status: "not-linked", }, items: expect.arrayContaining([ - expect.objectContaining({ name: "Acme Dashboard", status: null }), + expect.objectContaining({ name: "Acme Dashboard", id: "proj_123", status: null }), ]), }); expect(payload.nextActions).toEqual([ @@ -443,6 +443,46 @@ describe("project commands", () => { ); }); + it("returns LOCAL_PROJECT_WORKSPACE_MISMATCH when the local pin belongs to another workspace", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + await writeLocalPin(cwd, { + workspaceId: "ws_other", + projectId: "proj_123", + }); + await login(cwd, stateDir); + + const result = await executeCli({ + argv: ["project", "show", "--json"], + cwd, + stateDir, + fixturePath, + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toBe(""); + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: false, + command: "project.show", + error: { + code: "LOCAL_PROJECT_WORKSPACE_MISMATCH", + domain: "project", + meta: { + pinPath: ".prisma/local.json", + pinnedWorkspaceId: "ws_other", + pinnedProjectId: "proj_123", + activeWorkspaceId: "ws_123", + activeWorkspaceName: "Acme Inc", + }, + }, + nextSteps: [ + "prisma-cli auth login", + "prisma-cli project list", + "prisma-cli project link ", + ], + }); + }); + it("returns PROJECT_NOT_FOUND for an inaccessible explicit project", async () => { const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state");