From b50b587a4bacbb70f1de0a99ce6c8314cb6d4555 Mon Sep 17 00:00:00 2001 From: Luan van der Westhuizen Date: Thu, 24 Sep 2026 04:50:44 +0200 Subject: [PATCH] feat(auth): add plugin-specific login completion guidance --- docs/product/output-conventions.md | 10 ++++ packages/cli/src/auth/login.ts | 38 +++++++++---- packages/cli/src/auth/operations.ts | 5 +- packages/cli/src/commands/auth/login.ts | 13 ++++- packages/cli/tests/auth-login.test.ts | 72 +++++++++++++++++++++++-- packages/cli/tests/auth-ops.test.ts | 45 ++++++++++++++++ packages/cli/tests/auth.test.ts | 44 +++++++++++++++ 7 files changed, 212 insertions(+), 15 deletions(-) diff --git a/docs/product/output-conventions.md b/docs/product/output-conventions.md index 8d9c17e4..aab53d9f 100644 --- a/docs/product/output-conventions.md +++ b/docs/product/output-conventions.md @@ -197,6 +197,16 @@ No current MVP command uses `verify` or `inspect`, but new commands must still c ### Workspace session identity +`auth login --ui-context prisma-plugin` changes only the browser completion +guidance for an invocation started by the Prisma plugin. Its success page says +“You’re connected to Prisma. Return to your ChatGPT conversation.” Its failure +page says “Sign-in couldn’t be completed. Return to your ChatGPT conversation to +try again.” The plugin completion page omits the skills-install command. +Omitting the flag preserves the terminal guidance and skills-install prompt. +The only accepted explicit value is `prisma-plugin`; other values fail argument +validation before login starts. The context is never inferred or stored and +does not change OAuth, consent, scopes, credential storage, or terminal output. + Each `auth login` authorizes one workspace and stores one local session. Two sessions can belong to different Prisma users. `auth workspace list` shows the sessions authorized on this machine. It is not the full list of workspaces the diff --git a/packages/cli/src/auth/login.ts b/packages/cli/src/auth/login.ts index d2f90b90..46a8fbb2 100644 --- a/packages/cli/src/auth/login.ts +++ b/packages/cli/src/auth/login.ts @@ -25,6 +25,8 @@ export class AuthError extends Error { } export interface LoginOptions { + /** Presentation only, scoped to this invocation; never stored with tokens. */ + uiContext?: "prisma-plugin"; tokenStorage?: TokenStorage; clientId?: string; apiBaseUrl?: string; @@ -115,7 +117,7 @@ export async function login(options: LoginOptions = {}): Promise { // success page anyway so a late browser callback isn't left dangling. const workspaceName = await state.resolveWorkspaceName(); res.setHeader("Content-Type", "text/html; charset=utf-8"); - res.end(renderSuccessPage(workspaceName)); + res.end(renderSuccessPage(workspaceName, options.uiContext)); return; } @@ -123,7 +125,7 @@ export async function login(options: LoginOptions = {}): Promise { await completeOnce(url); const workspaceName = await state.resolveWorkspaceName(); res.setHeader("Content-Type", "text/html; charset=utf-8"); - res.end(renderSuccessPage(workspaceName)); + res.end(renderSuccessPage(workspaceName, options.uiContext)); settle(resolve); } catch (error) { res.statusCode = 400; @@ -133,7 +135,11 @@ export async function login(options: LoginOptions = {}): Promise { // process does not control. The operator still sees the real // error: it is what this promise rejects with. res.setHeader("Content-Type", "text/plain; charset=utf-8"); - res.end("Sign-in could not be completed. Return to your terminal."); + res.end( + options.uiContext === "prisma-plugin" + ? "Sign-in couldn’t be completed. Return to your ChatGPT conversation to try again." + : "Sign-in could not be completed. Return to your terminal.", + ); settle(() => reject(error)); return; } @@ -401,10 +407,16 @@ class LoginState { } } -function renderSuccessPage(workspaceName: string | null): string { - const body = workspaceName +function renderSuccessPage( + workspaceName: string | null, + uiContext?: LoginOptions["uiContext"], +): string { + let body = workspaceName ? `Your terminal is now connected to your ${escapeHtml(workspaceName)} workspace. Head back to your terminal to continue.` : "Your terminal is now connected to your Prisma workspace. Head back to your terminal to continue."; + if (uiContext === "prisma-plugin") { + body = "You’re connected to Prisma. Return to your ChatGPT conversation."; + } return ` @@ -580,7 +592,10 @@ function renderSuccessPage(workspaceName: string | null): string {

You're all set.

${body}

-
+ ${ + uiContext === "prisma-plugin" + ? "" + : `
Using an AI coding agent? Add the Prisma skills: @@ -593,9 +608,13 @@ function renderSuccessPage(workspaceName: string | null): string {
-
+
` + }
- + ` + } `; } diff --git a/packages/cli/src/auth/operations.ts b/packages/cli/src/auth/operations.ts index db3383d8..bccbd912 100644 --- a/packages/cli/src/auth/operations.ts +++ b/packages/cli/src/auth/operations.ts @@ -11,7 +11,7 @@ import type { } from "@prisma/management-api-sdk"; import type { AuthStateResult } from "../types/auth"; import { authenticatedManagementApiClient } from "./guard"; -import { AuthError, login } from "./login"; +import { AuthError, type LoginOptions, login } from "./login"; import { FileTokenStorage } from "./token-storage"; const WORKSPACE_SUB_PREFIX = "workspace:"; @@ -91,7 +91,7 @@ class ThrowawayTokenStorage implements TokenStorage { export async function performLogin( env: NodeJS.ProcessEnv, signal?: AbortSignal, - options?: { onVerificationUrl?: (url: string) => void }, + options?: Pick, ): Promise { const tokenStorage = new ThrowawayTokenStorage(); await login({ @@ -99,6 +99,7 @@ export async function performLogin( env, signal, onVerificationUrl: options?.onVerificationUrl, + uiContext: options?.uiContext, }); const tokens = tokenStorage.tokens; diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index e649d3e6..b7c2423b 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -1,6 +1,7 @@ import { credentialWorkspaceId, defineCommand, + flag, type Presentations, type Session, } from "@prisma/cli-engine"; @@ -120,13 +121,22 @@ function presentationsFor( export const authLoginCommand = defineCommand({ managesCredentials: true, + args: { + flags: { + uiContext: flag.enum({ + values: ["prisma-plugin"], + brief: + "Show browser completion guidance for the Prisma plugin in ChatGPT (default: terminal guidance)", + }), + }, + }, help: { summary: "Log in to your Prisma platform account", description: "Opens a browser sign-in and stores a session for one workspace, the account-level container that holds your Projects. Run it again to add a session for another workspace; 'auth workspace use' switches between stored sessions. In CI or other non-interactive environments, skip login and set PRISMA_SERVICE_TOKEN instead.", examples: ["auth login"], }, - handler: async (_args, ctx) => { + handler: async (args, ctx) => { // A blank service token is the single blank-token error, raised // before the browser opens rather than after a credential is minted. const environmentSession = environmentCredentialInForce(ctx.env); @@ -134,6 +144,7 @@ export const authLoginCommand = defineCommand({ let session: Session; try { const credential = await performLogin(ctx.env, ctx.signal, { + uiContext: args.flags.uiContext, onVerificationUrl: (url) => ctx.report({ kind: "endpoint", name: "verification", url }), }); diff --git a/packages/cli/tests/auth-login.test.ts b/packages/cli/tests/auth-login.test.ts index 667a4a96..0bfcd637 100644 --- a/packages/cli/tests/auth-login.test.ts +++ b/packages/cli/tests/auth-login.test.ts @@ -9,7 +9,10 @@ afterEach(() => { }); describe("auth login callback", () => { - it("reports OAuth denial as an expected refusal without persisting credentials or reflecting callback text", async () => { + it.each([ + undefined, + "prisma-plugin", + ] as const)("reports OAuth denial without persisting credentials or reflecting callback text (UI context: %s)", async (uiContext) => { const tokenStorage: TokenStorage = { getTokens: vi.fn().mockResolvedValue(null), setTokens: vi.fn(), @@ -18,6 +21,7 @@ describe("auth login callback", () => { const { login } = await import("../src/auth/login"); await expect( login({ + uiContext, hostname: "127.0.0.1", tokenStorage, openUrl: async (authorizationUrl) => { @@ -33,8 +37,10 @@ describe("auth login callback", () => { ); const response = await fetch(callback); expect(response.status).toBe(400); - expect(await response.text()).not.toContain( - "private-callback-detail", + expect(await response.text()).toBe( + uiContext === "prisma-plugin" + ? "Sign-in couldn’t be completed. Return to your ChatGPT conversation to try again." + : "Sign-in could not be completed. Return to your terminal.", ); }, }), @@ -53,6 +59,27 @@ describe("auth login callback", () => { expect(result.body).toContain(''); }); + it.each([ + 'Acme & "Team"', + undefined, + ])("renders plugin guidance without terminal or installation instructions (workspace: %s)", async (workspaceName) => { + const result = await requestSuccessPage({ + uiContext: "prisma-plugin", + workspaceName, + ...(workspaceName ? {} : { workspaceLookupError: new Error("offline") }), + }); + + expect(result.body).toContain( + "You’re connected to Prisma. Return to your ChatGPT conversation.", + ); + expect(result.body).not.toContain("terminal"); + expect(result.body).not.toContain("npx skills"); + expect(result.body).not.toContain("