From 84b6ca70113d17f3139ca14e406bf74d7ed0b302 Mon Sep 17 00:00:00 2001 From: Luan van der Westhuizen Date: Mon, 1 Jun 2026 10:44:16 +0200 Subject: [PATCH] Polish project show output --- docs/product/cli-style-guide.md | 17 ++++- docs/product/command-spec.md | 1 + docs/product/output-conventions.md | 15 +++- packages/cli/fixtures/mock-api.json | 3 + packages/cli/src/adapters/mock-api.ts | 1 + packages/cli/src/controllers/project.ts | 2 + packages/cli/src/lib/project/resolution.ts | 3 +- packages/cli/src/lib/project/setup.ts | 3 +- packages/cli/src/presenters/project.ts | 67 ++++++++++-------- packages/cli/src/types/project.ts | 1 + packages/cli/src/use-cases/project.ts | 3 +- packages/cli/tests/project-real-mode.test.ts | 9 +-- packages/cli/tests/project-usecases.test.ts | 2 + packages/cli/tests/project.test.ts | 74 +++++++++++++++++++- 14 files changed, 160 insertions(+), 41 deletions(-) diff --git a/docs/product/cli-style-guide.md b/docs/product/cli-style-guide.md index efdefe4..28422ec 100644 --- a/docs/product/cli-style-guide.md +++ b/docs/product/cli-style-guide.md @@ -61,7 +61,20 @@ Recommended symbols: - Banners are reserved for `init` and similar first-run experiences. - Outside those flows, focus on status, context, result, and next steps. -Human-oriented command output in TTY mode should usually start with a compact header: +Human-oriented command output in TTY mode should usually start with a compact header. + +Bound state: + +```text +project show → This directory is linked to the following platform project. + +│ local repo ~/code/apple +│ platform Edith / orange +│ +│ → https://prisma.build/edith/orange +``` + +Recovery state: ```text project show → This directory is not linked to a Prisma Project. @@ -84,6 +97,8 @@ Rules: - mask sensitive values rather than omitting their presence entirely when the value matters to the flow - include only rows that are actually known for the current command - use human labels such as `Not linked` instead of internal resolution terms such as `unbound` +- hide internal resolution terms such as `local pin` from default human output when the visible binding is clearer +- document distinct success and recovery states when a command's terminal output materially differs - include a `Read more` row that points to the source-of-truth repo doc or anchor until a stable public docs URL exists - leave one blank line between the header block and the body diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index 96aea2e..5faac42 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -391,6 +391,7 @@ Behavior: - does not mutate local state - `--project ` resolves only the explicit project - when bound, returns Workspace, Project, and `resolution.projectSource` +- when bound, human output shows the local repo path, the ` / ` platform label, and the Project URL; it does not show the internal resolution source - 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 diff --git a/docs/product/output-conventions.md b/docs/product/output-conventions.md index ed9010e..78d2718 100644 --- a/docs/product/output-conventions.md +++ b/docs/product/output-conventions.md @@ -217,7 +217,18 @@ Human output should: - keep header metadata compact and aligned - avoid placeholder rows for unknown values -Recommended header shape: +Recommended header shape for a bound Project: + +```text +project show → This directory is linked to the following platform project. + +│ local repo ~/code/apple +│ platform Edith / orange +│ +│ → https://prisma.build/edith/orange +``` + +Recommended recovery shape for an unbound Project: ```text project show → This directory is not linked to a Prisma Project. @@ -238,6 +249,8 @@ Rules: - include `Read more` when a stable repo doc reference exists - prefer display labels in default human output and keep opaque ids in JSON unless a later verbose mode explicitly asks for them - do not expose agent-only reasoning in human output when a clear status and next step is enough +- for bound `project show`, show the local repo, platform project label, and Project URL instead of the internal resolution source +- keep explicit recovery examples when a command has a distinct not-linked or setup-required state Recommended summary lines: diff --git a/packages/cli/fixtures/mock-api.json b/packages/cli/fixtures/mock-api.json index 0c3168c..6becec8 100644 --- a/packages/cli/fixtures/mock-api.json +++ b/packages/cli/fixtures/mock-api.json @@ -48,18 +48,21 @@ "id": "proj_123", "name": "Acme Dashboard", "slug": "acme-dashboard", + "url": "https://prisma.build/acme/acme-dashboard", "workspaceId": "ws_123" }, { "id": "proj_456", "name": "Billing API", "slug": "billing-api", + "url": "https://prisma.build/acme/billing-api", "workspaceId": "ws_123" }, { "id": "proj_789", "name": "Website", "slug": "website", + "url": "https://prisma.build/prisma/website", "workspaceId": "ws_456" } ], diff --git a/packages/cli/src/adapters/mock-api.ts b/packages/cli/src/adapters/mock-api.ts index 23387dd..3bb5ae5 100644 --- a/packages/cli/src/adapters/mock-api.ts +++ b/packages/cli/src/adapters/mock-api.ts @@ -29,6 +29,7 @@ interface ProjectRecord { id: string; name: string; slug: string; + url?: string; workspaceId: string; } diff --git a/packages/cli/src/controllers/project.ts b/packages/cli/src/controllers/project.ts index f6f1c77..d9fd1c3 100644 --- a/packages/cli/src/controllers/project.ts +++ b/packages/cli/src/controllers/project.ts @@ -591,6 +591,7 @@ export async function listRealWorkspaceProjects( .map((project) => ({ id: project.id, name: project.name, + ...("url" in project && typeof project.url === "string" ? { url: project.url } : {}), slug: "slug" in project && typeof project.slug === "string" ? project.slug : null, workspace: { id: project.workspace.id, @@ -608,6 +609,7 @@ export function listFixtureWorkspaceProjects( context.api.listProjectsForWorkspace(workspace.id).map((project) => ({ id: project.id, name: project.name, + ...(project.url ? { url: project.url } : {}), slug: project.slug, workspace, })), diff --git a/packages/cli/src/lib/project/resolution.ts b/packages/cli/src/lib/project/resolution.ts index 8e1e90c..380dca7 100644 --- a/packages/cli/src/lib/project/resolution.ts +++ b/packages/cli/src/lib/project/resolution.ts @@ -375,9 +375,10 @@ function buildProjectRecoveryCommands(commandName: string | undefined): string[] return commands; } -function toProjectSummary(project: Pick): ProjectSummary { +function toProjectSummary(project: Pick): ProjectSummary { return { id: project.id, name: project.name, + ...(project.url ? { url: project.url } : {}), }; } diff --git a/packages/cli/src/lib/project/setup.ts b/packages/cli/src/lib/project/setup.ts index a8aaa85..9594aaf 100644 --- a/packages/cli/src/lib/project/setup.ts +++ b/packages/cli/src/lib/project/setup.ts @@ -65,10 +65,11 @@ export async function bindProjectToDirectory( }; } -export function toProjectSummary(project: Pick): ProjectSummary { +export function toProjectSummary(project: Pick): ProjectSummary { return { id: project.id, name: project.name, + ...(project.url ? { url: project.url } : {}), }; } diff --git a/packages/cli/src/presenters/project.ts b/packages/cli/src/presenters/project.ts index 56f3530..f3da508 100644 --- a/packages/cli/src/presenters/project.ts +++ b/packages/cli/src/presenters/project.ts @@ -1,4 +1,7 @@ +import path from "node:path"; + 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 { @@ -9,7 +12,7 @@ import type { ProjectShowResult, } from "../types/project"; import { renderList, renderMutate, renderShow, serializeList } from "../output/patterns"; -import { renderNextSteps, renderSummaryLine } from "../shell/ui"; +import { padDisplay, renderNextSteps, renderSummaryLine } from "../shell/ui"; export function renderProjectList( context: CommandContext, @@ -88,18 +91,7 @@ export function renderProjectShow( return lines; } - return renderShow( - { - title: "Showing this directory's Project binding.", - descriptor, - fields: [ - { key: "workspace", value: result.workspace.name }, - { key: "project", value: result.project.name }, - { key: "resolution", value: formatProjectSource(result.resolution.projectSource) }, - ], - }, - context.ui, - ); + return renderBoundProjectShow(context, descriptor, result); } export function serializeProjectShow(result: ProjectShowResult) { @@ -173,23 +165,40 @@ export function renderGitDisconnect( ); } -function formatProjectSource(source: ProjectShowResult["resolution"]["projectSource"]): string { - switch (source) { - case "explicit": - return "explicit"; - case "env": - return "environment"; - case "local-pin": - return "local pin"; - case "platform-mapping": - return "platform mapping"; - case "created": - return "created"; - case "prompt": - return "prompt"; - case "unbound": - return "unbound"; +function renderBoundProjectShow( + context: CommandContext, + descriptor: CommandDescriptor, + result: Exclude, +): string[] { + const { ui } = context; + const rail = ui.dim("│"); + const keyWidth = "local repo".length; + const platform = `${result.workspace.name} / ${result.project.name}`; + const lines = [ + `${ui.strong(formatDescriptorLabel(descriptor))} ${ui.dim("→")} ${ui.dim("This directory is linked to the following platform project.")}`, + "", + `${rail} ${ui.accent(padDisplay("local repo", keyWidth))} ${formatLocalRepoPath(context.runtime.cwd, context.runtime.env)}`, + `${rail} ${ui.accent(padDisplay("platform", keyWidth))} ${ui.strong(platform)}`, + ]; + + if (result.project.url) { + lines.push(rail); + lines.push(`${rail} ${ui.dim("→")} ${ui.link(result.project.url)}`); } + + return lines; +} + +function formatLocalRepoPath(cwd: string, env: NodeJS.ProcessEnv): string { + const resolved = path.resolve(cwd); + const home = env.HOME ? path.resolve(env.HOME) : null; + + if (home && (resolved === home || resolved.startsWith(`${home}${path.sep}`))) { + const relative = path.relative(home, resolved); + return relative ? `~/${relative}` : "~"; + } + + return resolved; } function formatGitConnectionDetail(status: GitRepositoryConnection["status"]): string { diff --git a/packages/cli/src/types/project.ts b/packages/cli/src/types/project.ts index e8ded52..35288a4 100644 --- a/packages/cli/src/types/project.ts +++ b/packages/cli/src/types/project.ts @@ -3,6 +3,7 @@ import type { AuthWorkspace } from "./auth"; export interface ProjectSummary { id: string; name: string; + url?: string; } export type ProjectSource = diff --git a/packages/cli/src/use-cases/project.ts b/packages/cli/src/use-cases/project.ts index 45cf103..e0d4bd8 100644 --- a/packages/cli/src/use-cases/project.ts +++ b/packages/cli/src/use-cases/project.ts @@ -37,10 +37,11 @@ function listSortedWorkspaceProjects(projectGateway: ProjectGateway, workspaceId .sort((left, right) => left.name.localeCompare(right.name) || left.id.localeCompare(right.id)); } -function toProjectSummary(project: { id: string; name: string }): ProjectSummary { +function toProjectSummary(project: { id: string; name: string; url?: string }): ProjectSummary { return { id: project.id, name: project.name, + ...(project.url ? { url: project.url } : {}), }; } diff --git a/packages/cli/tests/project-real-mode.test.ts b/packages/cli/tests/project-real-mode.test.ts index e4b20e2..60b677d 100644 --- a/packages/cli/tests/project-real-mode.test.ts +++ b/packages/cli/tests/project-real-mode.test.ts @@ -35,9 +35,9 @@ function mockClient(extra: Partial<{ return { data: { data: [ - { id: "proj_456", name: "Billing API", slug: "billing-api", workspace: { id: "ws_123", name: "Acme Inc" } }, + { id: "proj_456", name: "Billing API", slug: "billing-api", url: "https://prisma.build/acme/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" } }, + { id: "proj_123", name: "Acme Dashboard", slug: "acme-dashboard", url: "https://prisma.build/acme/acme-dashboard", workspace: { id: "ws_123", name: "Acme Inc" } }, ], }, }; @@ -144,8 +144,8 @@ describe("real project mode", () => { name: "Acme Inc", }, projects: [ - { id: "proj_123", name: "Acme Dashboard" }, - { id: "proj_456", name: "Billing API" }, + { id: "proj_123", name: "Acme Dashboard", url: "https://prisma.build/acme/acme-dashboard" }, + { id: "proj_456", name: "Billing API", url: "https://prisma.build/acme/billing-api" }, ], localBinding: { status: "not-linked", @@ -191,6 +191,7 @@ describe("real project mode", () => { project: { id: "proj_123", name: "Acme Dashboard", + url: "https://prisma.build/acme/acme-dashboard", }, resolution: { projectSource: "explicit", diff --git a/packages/cli/tests/project-usecases.test.ts b/packages/cli/tests/project-usecases.test.ts index 9cb09c8..d62f5ba 100644 --- a/packages/cli/tests/project-usecases.test.ts +++ b/packages/cli/tests/project-usecases.test.ts @@ -30,10 +30,12 @@ describe("project use cases", () => { { id: "proj_123", name: "Acme Dashboard", + url: "https://prisma.build/acme/acme-dashboard", }, { id: "proj_456", name: "Billing API", + url: "https://prisma.build/acme/billing-api", }, ], }); diff --git a/packages/cli/tests/project.test.ts b/packages/cli/tests/project.test.ts index 2765326..f2b9795 100644 --- a/packages/cli/tests/project.test.ts +++ b/packages/cli/tests/project.test.ts @@ -7,10 +7,16 @@ import { createTempCwd, executeCli } from "./helpers"; const fixturePath = path.resolve("fixtures/mock-api.json"); -async function login(cwd: string, stateDir: string, selectedFixturePath = fixturePath) { +async function login( + cwd: string, + stateDir: string, + selectedFixturePath = fixturePath, + env?: NodeJS.ProcessEnv, +) { await executeCli({ argv: ["auth", "login", "--provider", "github", "--user", "usr_456"], cwd, + env, stateDir, fixturePath: selectedFixturePath, }); @@ -31,12 +37,13 @@ async function writeLocalPin(cwd: string, pin: unknown | string) { 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 }>; + projects: Array<{ id: string; name: string; slug: string; url?: string; workspaceId: string }>; }; raw.projects.push({ id: "proj_321", name: "Acme Dashboard", slug: "acme-dashboard", + url: "https://prisma.build/acme/acme-dashboard-2", workspaceId: "ws_123", }); const nextPath = path.join(cwd, "ambiguous-fixture.json"); @@ -46,13 +53,14 @@ async function createAmbiguousFixture(cwd: string): Promise { async function createAppleFixture(cwd: string): Promise { const raw = JSON.parse(await readFile(fixturePath, "utf8")) as { - projects: Array<{ id: string; name: string; slug: string; workspaceId: string }>; + projects: Array<{ id: string; name: string; slug: string; url?: string; workspaceId: string }>; }; raw.projects = [ { id: "proj_apple", name: "apple", slug: "apple", + url: "https://prisma.build/acme/apple", workspaceId: "ws_123", }, ]; @@ -61,6 +69,32 @@ async function createAppleFixture(cwd: string): Promise { return nextPath; } +async function createEdithOrangeFixture(cwd: string): Promise { + const raw = JSON.parse(await readFile(fixturePath, "utf8")) as { + workspaces: Array<{ id: string; name: string; slug: string }>; + projects: Array<{ id: string; name: string; slug: string; url?: string; workspaceId: string }>; + }; + raw.workspaces = [ + { + id: "ws_123", + name: "Edith", + slug: "edith", + }, + ]; + raw.projects = [ + { + id: "proj_orange", + name: "orange", + slug: "orange", + url: "https://prisma.build/edith/orange", + workspaceId: "ws_123", + }, + ]; + const nextPath = path.join(cwd, "edith-orange-fixture.json"); + await writeFile(nextPath, `${JSON.stringify(raw, null, 2)}\n`, "utf8"); + return nextPath; +} + describe("project commands", () => { it("lists projects without resolving the current directory", async () => { const cwd = await createTempCwd(); @@ -366,6 +400,7 @@ describe("project commands", () => { project: { id: "proj_123", name: "Acme Dashboard", + url: "https://prisma.build/acme/acme-dashboard", }, resolution: { projectSource: "local-pin", @@ -375,6 +410,39 @@ describe("project commands", () => { }); }); + it("shows a polished bound project block in human mode", async () => { + const home = await createTempCwd(); + const cwd = path.join(home, "code", "apple"); + await mkdir(cwd, { recursive: true }); + const stateDir = path.join(cwd, ".state"); + const edithFixturePath = await createEdithOrangeFixture(cwd); + const env = { + ...process.env, + HOME: home, + }; + await writeLocalPin(cwd, { + workspaceId: "ws_123", + projectId: "proj_orange", + }); + await login(cwd, stateDir, edithFixturePath, env); + + const result = await executeCli({ + argv: ["project", "show"], + cwd, + env, + stateDir, + fixturePath: edithFixturePath, + isTTY: true, + }); + const stderr = stripAnsi(result.stderr); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(""); + expect(stderr).toBe( + "project show → This directory is linked to the following platform project.\n\n│ local repo ~/code/apple\n│ platform Edith / orange\n│\n│ → https://prisma.build/edith/orange\n", + ); + }); + it("returns PROJECT_NOT_FOUND for an inaccessible explicit project", async () => { const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state");