Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/product/command-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id-or-name>` 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:

Expand All @@ -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:

Expand Down
2 changes: 2 additions & 0 deletions docs/product/error-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/product/output-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
69 changes: 64 additions & 5 deletions packages/cli/src/lib/project/resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -41,8 +45,9 @@ export interface ResolveProjectOptions {
}

export async function resolveProjectTarget(options: ResolveProjectOptions): Promise<ResolvedProjectTarget> {
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;
Expand All @@ -57,8 +62,9 @@ export async function resolveProjectTarget(options: ResolveProjectOptions): Prom
}

export async function inspectProjectBinding(options: ResolveProjectOptions): Promise<ProjectShowResult> {
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;
Expand Down Expand Up @@ -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 <id-or-name>"],
});
}

export async function buildProjectSetupSuggestion(options: {
cwd: string;
projects: ProjectCandidate[];
Expand Down Expand Up @@ -305,6 +334,7 @@ async function resolveBoundProjectTarget(
projects: ProjectCandidate[],
settings: {
allowEnvProjectId: boolean;
localPin: LocalResolutionPinReadResult | null;
},
): Promise<BoundProjectShowResult | null> {
if (options.explicitProject) {
Expand All @@ -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);
Expand All @@ -356,6 +393,28 @@ async function resolveBoundProjectTarget(
return null;
}

async function readImplicitLocalPin(
options: ResolveProjectOptions,
settings: {
allowEnvProjectId: boolean;
},
): Promise<LocalResolutionPinReadResult | null> {
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,
Expand Down
49 changes: 29 additions & 20 deletions packages/cli/src/presenters/project.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -11,32 +13,39 @@ 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(
context: CommandContext,
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 <id-or-name>",
"Create a new Project: prisma-cli project create <name>",
]));
}
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([
Expand Down
109 changes: 109 additions & 0 deletions packages/cli/tests/project-resolution.test.ts
Original file line number Diff line number Diff line change
@@ -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<ProjectCandidate[]>>();

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<ProjectCandidate[]> => [{
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<ProjectCandidate[]> => [{
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);
});
});
Loading
Loading