From dee25a5af45cadb8549f340184c70a5d19af70de Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Tue, 23 Jun 2026 15:37:10 +0530 Subject: [PATCH] feat(auth): add workspace env override --- docs/product/command-spec.md | 9 +- packages/cli/src/adapters/token-storage.ts | 179 ++++++++++--- packages/cli/src/controllers/auth.ts | 192 ++++++++++++-- packages/cli/src/lib/auth/client.ts | 19 ++ packages/cli/src/lib/auth/guard.ts | 3 +- packages/cli/src/presenters/auth.ts | 6 +- packages/cli/src/shell/command-runner.ts | 36 ++- packages/cli/src/shell/errors.ts | 41 ++- packages/cli/tests/auth-real-mode.test.ts | 294 +++++++++++++++++++++ packages/cli/tests/auth.test.ts | 258 ++++++++++++++++++ packages/cli/tests/token-storage.test.ts | 290 ++++++++++++++++++++ 11 files changed, 1257 insertions(+), 70 deletions(-) diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index fe573c93..5d87c390 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -63,16 +63,19 @@ Out of scope for the current beta: ## Authentication -The CLI accepts two authentication sources, in this fixed precedence: +The CLI accepts these authentication inputs, in this fixed precedence: 1. `PRISMA_SERVICE_TOKEN` environment variable — long-lived service token, intended for CI and other headless contexts. -2. Stored OAuth session — created by `prisma-cli auth login`, kept in the OS-appropriate credentials store, refreshed automatically. +2. `PRISMA_CLI_WORKSPACE_ID` environment variable — process-local selector for one locally authenticated OAuth workspace. +3. Stored active OAuth workspace — created by `prisma-cli auth login`, kept in the OS-appropriate credentials store, refreshed automatically. Stored OAuth sessions include a short-lived access token and a refresh token. The local credentials store may contain OAuth grants for multiple workspaces. One local active workspace pointer selects which grant authenticated commands use. Commands refresh the selected access token automatically when the API rejects it, coordinate refreshes across concurrent CLI processes, and tolerate short refresh-token rotation races. If the selected session cannot be refreshed, commands fail with a structured `AUTH_REQUIRED` error instead of surfacing SDK stack traces or silently falling through to another workspace. When `PRISMA_SERVICE_TOKEN` is set and non-empty, the token is fully sufficient for authenticated commands. If `PRISMA_SERVICE_TOKEN` is set but empty or only whitespace, commands fail with an auth configuration error instead of falling back to stored OAuth. The CLI does not read any locally stored OAuth session when a non-empty service token is present, so behavior is identical on a fresh runner and a developer machine that happens to be signed in. The active workspace is derived from the token's `sub` claim; no additional flag or environment variable is required for the common case where the token is scoped to a single workspace. -`auth login`, `auth logout`, and `auth workspace` operate on stored OAuth sessions. They do not affect the `PRISMA_SERVICE_TOKEN` environment variable. `auth login` stores the authorized workspace and makes it active. `auth logout` clears all local OAuth workspace sessions. `auth workspace logout` and `auth logout --workspace` clear one local OAuth workspace session, including while `PRISMA_SERVICE_TOKEN` is set, because they only clean local OAuth state. If that workspace was active, the CLI does not silently fall through to another cached workspace; the user must explicitly choose the next workspace with `auth workspace use`. `auth workspace use` changes only local CLI context and never mutates a remote resource. When `PRISMA_SERVICE_TOKEN` is set, workspace switching is unavailable because the token is the active auth source. +When `PRISMA_CLI_WORKSPACE_ID` is set and `PRISMA_SERVICE_TOKEN` is not set, authenticated commands use the matching locally stored OAuth workspace for that process only. The value matches a workspace id or canonical workspace id from `auth workspace list`, including the same id with or without a `wksp_` prefix; it does not match workspace names. This environment variable does not mutate the stored active workspace pointer, which makes it suitable for parallel agents or scripts that should not fight over shared CLI state. If it is empty, ambiguous, or does not match a locally authenticated workspace, commands fail with a structured auth error instead of falling back to another workspace. + +`auth login`, `auth logout`, and `auth workspace` operate on stored OAuth sessions. They do not affect the `PRISMA_SERVICE_TOKEN` environment variable. `auth login` stores the authorized workspace and makes it active. `auth logout` clears all local OAuth workspace sessions. `auth workspace logout` and `auth logout --workspace` clear one local OAuth workspace session, including while `PRISMA_SERVICE_TOKEN` is set, because they only clean local OAuth state. If that workspace was active, the CLI does not silently fall through to another cached workspace; the user must explicitly choose the next workspace with `auth workspace use`. `auth workspace use` changes only local CLI context and never mutates a remote resource; its output also includes a `PRISMA_CLI_WORKSPACE_ID= prisma-cli project list` example for process-local use. When `PRISMA_SERVICE_TOKEN` is set, workspace switching is unavailable because the token is the active auth source. ## Context Resolution diff --git a/packages/cli/src/adapters/token-storage.ts b/packages/cli/src/adapters/token-storage.ts index 2619e745..e16aca7a 100644 --- a/packages/cli/src/adapters/token-storage.ts +++ b/packages/cli/src/adapters/token-storage.ts @@ -4,7 +4,11 @@ import fs from "node:fs/promises"; import path from "node:path"; import { CredentialsStore } from "@prisma/credentials-store"; import type { TokenStorage, Tokens } from "@prisma/management-api-sdk"; -import { getAuthFilePath } from "../lib/auth/client"; +import { + getAuthFilePath, + getWorkspaceIdOverride, + WORKSPACE_ID_ENV_VAR, +} from "../lib/auth/client"; interface StoredCredential { workspaceId?: unknown; @@ -26,6 +30,10 @@ export interface StoredAuthWorkspaceLogout { activeWorkspace: StoredAuthWorkspace | null; } +interface ListWorkspacesOptions { + migrateAuthContext?: boolean; +} + interface AuthContextWorkspace { id?: unknown; name?: unknown; @@ -162,7 +170,7 @@ export class FileTokenStorage implements TokenStorage { private readonly lockFilePath: string; constructor( - env: NodeJS.ProcessEnv = process.env, + private readonly env: NodeJS.ProcessEnv = process.env, private readonly signal?: AbortSignal, private readonly options: FileTokenStorageOptions = {}, ) { @@ -176,8 +184,18 @@ export class FileTokenStorage implements TokenStorage { async getTokens(): Promise { this.signal?.throwIfAborted(); try { + const workspaceIdOverride = getWorkspaceIdOverride(this.env); // CredentialsStore does not accept AbortSignal; check immediately before and after the boundary. const credentials = await this.readCredentialsFromDisk(); + + if (workspaceIdOverride) { + const context = await this.readAuthContext(); + return this.selectWorkspaceTokens(credentials, context, { + ref: workspaceIdOverride, + matcher: workspaceMatchesIdRef, + }); + } + const context = await this.readAuthContext(); if (context.state.activeWorkspaceId) { @@ -199,8 +217,9 @@ export class FileTokenStorage implements TokenStorage { // state, usually after logging out the active workspace. Do not fall back // to another cached workspace without an explicit `auth workspace use`. return null; - } catch (_error) { + } catch (error) { if (this.signal?.aborted) throw this.signal.reason; + if (isWorkspaceOverrideError(error)) throw error; return null; } } @@ -268,38 +287,17 @@ export class FileTokenStorage implements TokenStorage { this.signal?.throwIfAborted(); } - async listWorkspaces(): Promise { + async listWorkspaces( + options: ListWorkspacesOptions = {}, + ): Promise { this.signal?.throwIfAborted(); const credentials = await this.readCredentialsFromDisk(); - const context = await this.ensureMigratedAuthContext(credentials); + const context = + options.migrateAuthContext === false + ? await this.readAuthContext() + : await this.ensureMigratedAuthContext(credentials); - return credentials - .map((credential) => storedCredentialToTokens(credential)) - .filter((tokens): tokens is Tokens => tokens !== null) - .map((tokens) => { - const cached = context.state.workspaces[tokens.workspaceId]; - const id = - typeof cached?.id === "string" && cached.id.trim().length > 0 - ? cached.id.trim() - : tokens.workspaceId; - const name = - typeof cached?.name === "string" && cached.name.trim().length > 0 - ? workspaceDisplayName(cached.name.trim(), tokens.workspaceId) - : UNKNOWN_WORKSPACE_NAME; - const lastSeenAt = - typeof cached?.lastSeenAt === "string" && - cached.lastSeenAt.trim().length > 0 - ? cached.lastSeenAt.trim() - : null; - - return { - id, - name, - credentialWorkspaceId: tokens.workspaceId, - active: context.state.activeWorkspaceId === tokens.workspaceId, - lastSeenAt, - }; - }); + return this.buildStoredAuthWorkspaces(credentials, context); } async listWorkspaceTokens(): Promise { @@ -310,6 +308,21 @@ export class FileTokenStorage implements TokenStorage { .filter((tokens): tokens is Tokens => tokens !== null); } + async getTokensForWorkspaceId(workspaceId: string): Promise { + this.signal?.throwIfAborted(); + const ref = workspaceId.trim(); + if (!ref) { + throw new WorkspaceSelectionError("missing"); + } + + const credentials = await this.readCredentialsFromDisk(); + const context = await this.readAuthContext(); + return this.selectWorkspaceTokens(credentials, context, { + ref, + matcher: workspaceMatchesIdRef, + }); + } + async useWorkspace(workspaceRef: string): Promise<{ previous: StoredAuthWorkspace | null; selected: StoredAuthWorkspace; @@ -431,6 +444,14 @@ export class FileTokenStorage implements TokenStorage { workspace: { id: string; name: string }, ): Promise { const context = await this.readAuthContext(); + if ( + !context.exists && + this.env[WORKSPACE_ID_ENV_VAR] !== undefined && + this.options.activateOnSetTokens !== true + ) { + return; + } + context.state.workspaces[credentialWorkspaceId] = { id: workspace.id, name: workspace.name, @@ -546,6 +567,66 @@ export class FileTokenStorage implements TokenStorage { return (await this.credentialsStore.getCredentials()) as StoredCredential[]; } + private selectWorkspaceTokens( + credentials: StoredCredential[], + context: AuthContextReadResult, + options: { + ref: string; + matcher: (workspace: StoredAuthWorkspace, ref: string) => boolean; + }, + ): Tokens | null { + const workspaces = this.buildStoredAuthWorkspaces(credentials, context); + const matches = workspaces.filter((workspace) => + options.matcher(workspace, options.ref), + ); + + if (matches.length === 0) { + throw new WorkspaceSelectionError("not-found", options.ref); + } + + if (matches.length > 1) { + throw new WorkspaceSelectionError("ambiguous", options.ref, matches); + } + + return findTokensForWorkspace( + credentials, + matches[0].credentialWorkspaceId, + ); + } + + private buildStoredAuthWorkspaces( + credentials: StoredCredential[], + context: AuthContextReadResult, + ): StoredAuthWorkspace[] { + return credentials + .map((credential) => storedCredentialToTokens(credential)) + .filter((tokens): tokens is Tokens => tokens !== null) + .map((tokens) => { + const cached = context.state.workspaces[tokens.workspaceId]; + const id = + typeof cached?.id === "string" && cached.id.trim().length > 0 + ? cached.id.trim() + : tokens.workspaceId; + const name = + typeof cached?.name === "string" && cached.name.trim().length > 0 + ? workspaceDisplayName(cached.name.trim(), tokens.workspaceId) + : UNKNOWN_WORKSPACE_NAME; + const lastSeenAt = + typeof cached?.lastSeenAt === "string" && + cached.lastSeenAt.trim().length > 0 + ? cached.lastSeenAt.trim() + : null; + + return { + id, + name, + credentialWorkspaceId: tokens.workspaceId, + active: context.state.activeWorkspaceId === tokens.workspaceId, + lastSeenAt, + }; + }); + } + private async ensureMigratedAuthContext( credentials: StoredCredential[], ): Promise { @@ -584,8 +665,6 @@ export class FileTokenStorage implements TokenStorage { const context = await this.readAuthContext(); if ( this.options.activateOnSetTokens === false && - context.exists && - context.state.activeWorkspaceId && context.state.activeWorkspaceId !== workspaceId ) { return; @@ -605,6 +684,14 @@ export class FileTokenStorage implements TokenStorage { options: { preserveActivePointer: boolean }, ): Promise { const context = await this.readAuthContext(); + if ( + !context.exists && + this.env[WORKSPACE_ID_ENV_VAR] !== undefined && + options.preserveActivePointer + ) { + return; + } + delete context.state.workspaces[workspaceId]; if ( !options.preserveActivePointer && @@ -711,6 +798,30 @@ function workspaceMatchesRef( ); } +export function workspaceMatchesIdRef( + workspace: StoredAuthWorkspace, + ref: string, +): boolean { + return ( + workspace.credentialWorkspaceId === ref || + workspace.id === ref || + stripWorkspacePrefix(workspace.credentialWorkspaceId) === + stripWorkspacePrefix(ref) || + stripWorkspacePrefix(workspace.id) === stripWorkspacePrefix(ref) + ); +} + +function isWorkspaceOverrideError(error: unknown): boolean { + if (error instanceof WorkspaceSelectionError) { + return true; + } + + return ( + error instanceof Error && + error.message.startsWith(`${WORKSPACE_ID_ENV_VAR} is set but empty`) + ); +} + function stripWorkspacePrefix(value: string): string { return value.startsWith("wksp_") ? value.slice("wksp_".length) : value; } diff --git a/packages/cli/src/controllers/auth.ts b/packages/cli/src/controllers/auth.ts index 45702d11..60298a8a 100644 --- a/packages/cli/src/controllers/auth.ts +++ b/packages/cli/src/controllers/auth.ts @@ -7,6 +7,7 @@ import { FileTokenStorage, type StoredAuthWorkspace, WorkspaceSelectionError, + workspaceMatchesIdRef, } from "../adapters/token-storage"; import { performLogin, @@ -16,7 +17,9 @@ import { import { CLIENT_ID, getApiBaseUrl, + getWorkspaceIdOverride, SERVICE_TOKEN_ENV_VAR, + WORKSPACE_ID_ENV_VAR, } from "../lib/auth/client"; import { authRequiredError, @@ -64,16 +67,20 @@ export async function runAuthLogin( if (isRealMode(context)) { await performLogin(context.runtime.env, context.runtime.signal); - result = await readAuthState(context.runtime.env, context.runtime.signal); + result = await readAuthState( + envWithoutWorkspaceIdOverride(context.runtime.env), + context.runtime.signal, + ); } else { const useCases = createAuthUseCases(createCliUseCaseGateways(context)); result = await loginWithSelectionFlow(context, useCases, options); } - return createAuthSuccess("auth.login", result, [ - "prisma-cli auth whoami", - "prisma-cli project list", - ]); + return createAuthSuccess( + "auth.login", + result, + buildAuthLoginNextSteps(context.runtime.env, result), + ); } export async function runAuthLogout( @@ -83,13 +90,20 @@ export async function runAuthLogout( if (isRealMode(context)) { await performLogout(context.runtime.env, context.runtime.signal); - result = await readAuthState(context.runtime.env, context.runtime.signal); + result = await readAuthState( + envWithoutWorkspaceIdOverride(context.runtime.env), + context.runtime.signal, + ); } else { const useCases = createAuthUseCases(createCliUseCaseGateways(context)); result = await useCases.logout(); } - return createAuthSuccess("auth.logout", result, ["prisma-cli auth login"]); + return createAuthSuccess( + "auth.logout", + result, + buildAuthLogoutNextSteps(context.runtime.env), + ); } export async function runAuthWhoAmI( @@ -147,7 +161,10 @@ export async function runAuthWorkspaceUse( command: "auth.workspace.use", result, warnings: [], - nextSteps: ["prisma-cli auth whoami", "prisma-cli project list"], + nextSteps: buildWorkspaceUseNextSteps( + context.runtime.env, + result.workspace.id, + ), }; } @@ -175,12 +192,7 @@ export async function runAuthWorkspaceLogout( command: "auth.workspace.logout", result, warnings: [], - nextSteps: result.activeWorkspace - ? ["prisma-cli auth workspace list"] - : [ - "prisma-cli auth workspace list", - "prisma-cli auth workspace use ", - ], + nextSteps: buildWorkspaceLogoutNextSteps(context.runtime.env, result), }; } @@ -201,7 +213,10 @@ export async function requireAuthenticatedAuthState( } await performLogin(context.runtime.env, context.runtime.signal); - return readAuthState(context.runtime.env, context.runtime.signal); + return readAuthState( + envWithoutWorkspaceIdOverride(context.runtime.env), + context.runtime.signal, + ); } const useCases = createAuthUseCases(createCliUseCaseGateways(context)); @@ -220,16 +235,24 @@ export async function requireAuthenticatedAuthState( async function listRealAuthWorkspaces( context: CommandContext, + options: { ignoreWorkspaceIdOverride?: boolean } = {}, ): Promise { const rawServiceToken = context.runtime.env[SERVICE_TOKEN_ENV_VAR]; - const storage = new FileTokenStorage( - context.runtime.env, - context.runtime.signal, - ); + const shouldUseWorkspaceIdOverride = + rawServiceToken === undefined && !options.ignoreWorkspaceIdOverride; + const workspaceIdOverride = shouldUseWorkspaceIdOverride + ? getWorkspaceIdOverride(context.runtime.env) + : null; + const storageEnv = shouldUseWorkspaceIdOverride + ? context.runtime.env + : envWithoutWorkspaceIdOverride(context.runtime.env); + const storage = new FileTokenStorage(storageEnv, context.runtime.signal); const localWorkspaces = await hydrateLocalAuthWorkspaces( context, storage, - await storage.listWorkspaces(), + await storage.listWorkspaces({ + migrateAuthContext: workspaceIdOverride === null, + }), ); if (rawServiceToken !== undefined) { @@ -265,14 +288,16 @@ async function listRealAuthWorkspaces( }; } - const active = localWorkspaces.find((workspace) => workspace.active) ?? null; + const active = workspaceIdOverride + ? resolveLocalWorkspaceIdOverride(localWorkspaces, workspaceIdOverride) + : (localWorkspaces.find((workspace) => workspace.active) ?? null); return { authSource: localWorkspaces.length > 0 ? "oauth" : "none", activeWorkspace: active ? toAuthWorkspace(active) : null, workspaces: localWorkspaces.map((workspace) => ({ ...toAuthWorkspace(workspace), credentialWorkspaceId: workspace.credentialWorkspaceId, - active: workspace.active, + active: active?.credentialWorkspaceId === workspace.credentialWorkspaceId, source: "oauth" as const, switchable: true, lastSeenAt: workspace.lastSeenAt, @@ -280,6 +305,125 @@ async function listRealAuthWorkspaces( }; } +function resolveLocalWorkspaceIdOverride( + workspaces: StoredAuthWorkspace[], + workspaceIdOverride: string, +): StoredAuthWorkspace { + const matches = workspaces.filter((workspace) => + workspaceMatchesIdRef(workspace, workspaceIdOverride), + ); + + if (matches.length === 0) { + throw workspaceNotAuthenticatedError(workspaceIdOverride, { + workspaceIdOverride: true, + }); + } + + if (matches.length > 1) { + throw workspaceAmbiguousError( + workspaceIdOverride, + matches.map((match) => ({ + id: match.id, + name: match.name, + credentialWorkspaceId: match.credentialWorkspaceId, + })), + { workspaceIdOverride: true }, + ); + } + + return matches[0]; +} + +function workspaceEnvNextStep(workspaceId: string): string { + return workspaceEnvCommand(workspaceId, "prisma-cli project list"); +} + +function workspaceEnvCommand(workspaceId: string, command: string): string { + return `${WORKSPACE_ID_ENV_VAR}=${workspaceId} ${command}`; +} + +function hasEffectiveWorkspaceIdOverride(env: NodeJS.ProcessEnv): boolean { + return ( + env[WORKSPACE_ID_ENV_VAR] !== undefined && + env[SERVICE_TOKEN_ENV_VAR] === undefined + ); +} + +function buildAuthLoginNextSteps( + env: NodeJS.ProcessEnv, + result: AuthStateResult, +): string[] { + if (hasEffectiveWorkspaceIdOverride(env) && result.workspace) { + return [ + workspaceEnvCommand(result.workspace.id, "prisma-cli auth whoami"), + workspaceEnvNextStep(result.workspace.id), + `unset ${WORKSPACE_ID_ENV_VAR}`, + ]; + } + + return ["prisma-cli auth whoami", "prisma-cli project list"]; +} + +function buildAuthLogoutNextSteps(env: NodeJS.ProcessEnv): string[] { + return hasEffectiveWorkspaceIdOverride(env) + ? [`unset ${WORKSPACE_ID_ENV_VAR}`, "prisma-cli auth login"] + : ["prisma-cli auth login"]; +} + +function buildWorkspaceUseNextSteps( + env: NodeJS.ProcessEnv, + workspaceId: string, +): string[] { + if (hasEffectiveWorkspaceIdOverride(env)) { + return [ + workspaceEnvCommand(workspaceId, "prisma-cli auth whoami"), + workspaceEnvNextStep(workspaceId), + `unset ${WORKSPACE_ID_ENV_VAR}`, + ]; + } + + return [ + "prisma-cli auth whoami", + "prisma-cli project list", + workspaceEnvNextStep(workspaceId), + ]; +} + +function buildWorkspaceLogoutNextSteps( + env: NodeJS.ProcessEnv, + result: AuthWorkspaceLogoutResult, +): string[] { + if (hasEffectiveWorkspaceIdOverride(env)) { + return result.activeWorkspace + ? [ + `unset ${WORKSPACE_ID_ENV_VAR}`, + "prisma-cli auth workspace list", + workspaceEnvNextStep(result.activeWorkspace.id), + ] + : [ + `unset ${WORKSPACE_ID_ENV_VAR}`, + "prisma-cli auth workspace list", + "prisma-cli auth workspace use ", + ]; + } + + return result.activeWorkspace + ? ["prisma-cli auth workspace list"] + : ["prisma-cli auth workspace list", "prisma-cli auth workspace use "]; +} + +function envWithoutWorkspaceIdOverride( + env: NodeJS.ProcessEnv, +): NodeJS.ProcessEnv { + if (env[WORKSPACE_ID_ENV_VAR] === undefined) { + return env; + } + + const next = { ...env }; + delete next[WORKSPACE_ID_ENV_VAR]; + return next; +} + async function useRealAuthWorkspace( context: CommandContext, workspaceRef: string, @@ -378,7 +522,9 @@ async function selectWorkspaceSession( } const result = realMode - ? await listRealAuthWorkspaces(context) + ? await listRealAuthWorkspaces(context, { + ignoreWorkspaceIdOverride: true, + }) : await createAuthUseCases( createCliUseCaseGateways(context), ).listWorkspaces(); diff --git a/packages/cli/src/lib/auth/client.ts b/packages/cli/src/lib/auth/client.ts index c6f66137..ed6a90e8 100644 --- a/packages/cli/src/lib/auth/client.ts +++ b/packages/cli/src/lib/auth/client.ts @@ -5,6 +5,25 @@ export const CLIENT_ID = "cmm3lndn701oo0uefvxzo0ivw"; export const DEFAULT_API_BASE_URL = "https://api.prisma.io"; export const SERVICE_TOKEN_ENV_VAR = "PRISMA_SERVICE_TOKEN"; export const AUTH_FILE_ENV_VAR = "PRISMA_COMPUTE_AUTH_FILE"; +export const WORKSPACE_ID_ENV_VAR = "PRISMA_CLI_WORKSPACE_ID"; + +export function getWorkspaceIdOverride( + env: NodeJS.ProcessEnv = process.env, +): string | null { + const configured = env[WORKSPACE_ID_ENV_VAR]; + if (configured === undefined) { + return null; + } + + const workspaceId = configured.trim(); + if (!workspaceId) { + throw new Error( + `${WORKSPACE_ID_ENV_VAR} is set but empty. Provide a workspace id from prisma-cli auth workspace list, or unset the variable.`, + ); + } + + return workspaceId; +} export function getApiBaseUrl(env: NodeJS.ProcessEnv = process.env): string { return env.PRISMA_MANAGEMENT_API_URL?.trim() || DEFAULT_API_BASE_URL; diff --git a/packages/cli/src/lib/auth/guard.ts b/packages/cli/src/lib/auth/guard.ts index 7259966c..491329d8 100644 --- a/packages/cli/src/lib/auth/guard.ts +++ b/packages/cli/src/lib/auth/guard.ts @@ -12,7 +12,8 @@ import { CLIENT_ID, getApiBaseUrl, SERVICE_TOKEN_ENV_VAR } from "./client"; * * Priority: * 1. PRISMA_SERVICE_TOKEN env var → service token (CI / headless) - * 2. Stored OAuth tokens → SDK with auto-refresh + * 2. PRISMA_CLI_WORKSPACE_ID env var → stored OAuth workspace override + * 3. Stored active OAuth workspace → SDK with auto-refresh * * Returns null if not authenticated. */ diff --git a/packages/cli/src/presenters/auth.ts b/packages/cli/src/presenters/auth.ts index 0ac94724..9a675c1b 100644 --- a/packages/cli/src/presenters/auth.ts +++ b/packages/cli/src/presenters/auth.ts @@ -1,4 +1,5 @@ import stringWidth from "string-width"; +import { WORKSPACE_ID_ENV_VAR } from "../lib/auth/client"; import { renderMutate, renderShow } from "../output/patterns"; import type { CommandDescriptor } from "../shell/command-meta"; import { formatDescriptorLabel } from "../shell/command-meta"; @@ -176,7 +177,10 @@ export function renderAuthWorkspaceUse( ], operationDescription: "Applying workspace selection", operationCount: 1, - details: ["Local OAuth workspace selection updated."], + details: [ + "Local OAuth workspace selection updated.", + `For parallel agents: ${WORKSPACE_ID_ENV_VAR}=${result.workspace.id} prisma-cli project list`, + ], }, context.ui, ); diff --git a/packages/cli/src/shell/command-runner.ts b/packages/cli/src/shell/command-runner.ts index 14dc358c..cd03665f 100644 --- a/packages/cli/src/shell/command-runner.ts +++ b/packages/cli/src/shell/command-runner.ts @@ -1,4 +1,6 @@ import { AuthError as SDKAuthError } from "@prisma/management-api-sdk"; +import { WorkspaceSelectionError } from "../adapters/token-storage"; +import { WORKSPACE_ID_ENV_VAR } from "../lib/auth/client"; import { collectCommandDiagnostics } from "../lib/diagnostics"; import type { CommandDescriptor } from "./command-meta"; import { getCommandDescriptor } from "./command-meta"; @@ -8,6 +10,8 @@ import { authRequiredError, CliError, commandCanceledError, + workspaceAmbiguousError, + workspaceNotAuthenticatedError, } from "./errors"; import { resolveGlobalFlags } from "./global-flags"; import type { CommandSuccess } from "./output"; @@ -48,9 +52,39 @@ function toCliError(error: unknown, runtime: CliRuntime): CliError | null { }); } + if (error instanceof WorkspaceSelectionError) { + const workspaceRef = + error.workspaceRef ?? runtime.env[WORKSPACE_ID_ENV_VAR] ?? ""; + const fromWorkspaceIdOverride = + runtime.env[WORKSPACE_ID_ENV_VAR] !== undefined && + workspaceRef === runtime.env[WORKSPACE_ID_ENV_VAR]?.trim(); + if (error.reason === "ambiguous") { + return workspaceAmbiguousError( + workspaceRef, + error.matches.map((match) => ({ + id: match.id, + name: match.name, + credentialWorkspaceId: match.credentialWorkspaceId, + })), + { workspaceIdOverride: fromWorkspaceIdOverride }, + ); + } + + if (error.reason === "missing") { + return workspaceNotAuthenticatedError(workspaceRef, { + workspaceIdOverride: fromWorkspaceIdOverride, + }); + } + + return workspaceNotAuthenticatedError(workspaceRef, { + workspaceIdOverride: fromWorkspaceIdOverride, + }); + } + if ( error instanceof Error && - error.message.startsWith("PRISMA_SERVICE_TOKEN is set but empty") + (error.message.startsWith("PRISMA_SERVICE_TOKEN is set but empty") || + error.message.startsWith(`${WORKSPACE_ID_ENV_VAR} is set but empty`)) ) { return authConfigInvalidError(error.message); } diff --git a/packages/cli/src/shell/errors.ts b/packages/cli/src/shell/errors.ts index 3f18561a..5267ad39 100644 --- a/packages/cli/src/shell/errors.ts +++ b/packages/cli/src/shell/errors.ts @@ -1,3 +1,4 @@ +import { WORKSPACE_ID_ENV_VAR } from "../lib/auth/client"; import type { NextAction } from "./next-actions"; export type ErrorDomain = @@ -99,14 +100,22 @@ export function authRequiredError( } export function authConfigInvalidError(message: string): CliError { + const isWorkspaceOverrideError = message.startsWith( + `${WORKSPACE_ID_ENV_VAR} is set but empty`, + ); + return new CliError({ code: "AUTH_CONFIG_INVALID", domain: "auth", summary: "Authentication configuration is invalid", why: message, - fix: "Provide a valid PRISMA_SERVICE_TOKEN value, or unset the variable to use local OAuth login.", + fix: isWorkspaceOverrideError + ? "Set PRISMA_CLI_WORKSPACE_ID to a workspace id from prisma-cli auth workspace list, or unset the variable to use the active local OAuth workspace." + : "Provide a valid PRISMA_SERVICE_TOKEN value, or unset the variable to use local OAuth login.", exitCode: 1, - nextSteps: ["prisma-cli auth login"], + nextSteps: isWorkspaceOverrideError + ? ["prisma-cli auth workspace list", "unset PRISMA_CLI_WORKSPACE_ID"] + : ["prisma-cli auth login"], }); } @@ -144,37 +153,55 @@ export function workspaceSwitchUnavailableError(): CliError { }); } -export function workspaceNotAuthenticatedError(workspaceRef: string): CliError { +export function workspaceNotAuthenticatedError( + workspaceRef: string, + options: { workspaceIdOverride?: boolean } = {}, +): CliError { return new CliError({ code: "WORKSPACE_NOT_AUTHENTICATED", domain: "auth", summary: "Workspace is not authenticated", why: `No stored OAuth session matched "${workspaceRef}".`, - fix: "Run prisma-cli auth login and authorize that workspace, then switch to it.", + fix: options.workspaceIdOverride + ? "Set PRISMA_CLI_WORKSPACE_ID to an authenticated workspace id from prisma-cli auth workspace list, unset it to use the active local OAuth workspace, or run prisma-cli auth login." + : "Run prisma-cli auth login and authorize that workspace, then switch to it.", meta: { workspaceRef, + ...(options.workspaceIdOverride ? { envVar: WORKSPACE_ID_ENV_VAR } : {}), }, exitCode: 1, - nextSteps: ["prisma-cli auth workspace list", "prisma-cli auth login"], + nextSteps: options.workspaceIdOverride + ? [ + "prisma-cli auth workspace list", + `unset ${WORKSPACE_ID_ENV_VAR}`, + "prisma-cli auth login", + ] + : ["prisma-cli auth workspace list", "prisma-cli auth login"], }); } export function workspaceAmbiguousError( workspaceRef: string, matches: Array<{ id: string; name: string; credentialWorkspaceId: string }>, + options: { workspaceIdOverride?: boolean } = {}, ): CliError { return new CliError({ code: "WORKSPACE_AMBIGUOUS", domain: "auth", summary: "Workspace name is ambiguous", why: `Multiple authenticated workspaces matched "${workspaceRef}".`, - fix: "Run prisma-cli auth workspace list and switch by workspace id.", + fix: options.workspaceIdOverride + ? "Set PRISMA_CLI_WORKSPACE_ID to one full workspace id from prisma-cli auth workspace list, or unset it to use the active local OAuth workspace." + : "Run prisma-cli auth workspace list and switch by workspace id.", meta: { workspaceRef, matches, + ...(options.workspaceIdOverride ? { envVar: WORKSPACE_ID_ENV_VAR } : {}), }, exitCode: 2, - nextSteps: ["prisma-cli auth workspace list"], + nextSteps: options.workspaceIdOverride + ? ["prisma-cli auth workspace list", `unset ${WORKSPACE_ID_ENV_VAR}`] + : ["prisma-cli auth workspace list"], }); } diff --git a/packages/cli/tests/auth-real-mode.test.ts b/packages/cli/tests/auth-real-mode.test.ts index e100a297..9176d853 100644 --- a/packages/cli/tests/auth-real-mode.test.ts +++ b/packages/cli/tests/auth-real-mode.test.ts @@ -75,6 +75,142 @@ describe("real auth mode", () => { }); }); + it("does not let PRISMA_CLI_WORKSPACE_ID shadow the real auth login result", async () => { + const performLogin = vi.fn().mockResolvedValue(undefined); + const readAuthState = vi.fn().mockResolvedValue({ + authenticated: true, + provider: null, + user: { + email: "real@example.com", + }, + workspace: { + id: "wksp_real", + name: "Real Workspace", + }, + }); + + vi.doMock("../src/lib/auth/auth-ops", () => ({ + performLogin, + readAuthState, + performLogout: vi.fn(), + })); + + const { createTempCwd, createTestCommandContext } = await import( + "./helpers" + ); + const { runAuthLogin } = await import("../src/controllers/auth"); + 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, + PRISMA_CLI_WORKSPACE_ID: "wksp_old", + }, + }); + + const result = await runAuthLogin(context, {}); + + expect(result.result).toMatchObject({ + authenticated: true, + workspace: { + id: "wksp_real", + name: "Real Workspace", + }, + }); + expect(result.nextSteps).toEqual([ + "PRISMA_CLI_WORKSPACE_ID=wksp_real prisma-cli auth whoami", + "PRISMA_CLI_WORKSPACE_ID=wksp_real prisma-cli project list", + "unset PRISMA_CLI_WORKSPACE_ID", + ]); + expect(performLogin).toHaveBeenCalledWith( + context.runtime.env, + context.runtime.signal, + ); + const loginReadEnv = readAuthState.mock.calls[0]?.[0] as NodeJS.ProcessEnv; + expect(loginReadEnv).not.toHaveProperty("PRISMA_CLI_WORKSPACE_ID"); + expect(readAuthState).toHaveBeenCalledWith( + loginReadEnv, + context.runtime.signal, + ); + expect(context.runtime.env.PRISMA_CLI_WORKSPACE_ID).toBe("wksp_old"); + }); + + it("does not let PRISMA_CLI_WORKSPACE_ID shadow the real auto-login result", async () => { + const performLogin = vi.fn().mockResolvedValue(undefined); + const readAuthState = vi + .fn() + .mockResolvedValueOnce({ + authenticated: false, + provider: null, + user: null, + workspace: null, + credential: null, + }) + .mockResolvedValueOnce({ + authenticated: true, + provider: null, + user: { + email: "real@example.com", + }, + workspace: { + id: "wksp_real", + name: "Real Workspace", + }, + credential: null, + }); + + vi.doMock("../src/lib/auth/auth-ops", () => ({ + performLogin, + readAuthState, + performLogout: vi.fn(), + })); + + const { createTempCwd, createTestCommandContext } = await import( + "./helpers" + ); + const { requireAuthenticatedAuthState } = await import( + "../src/controllers/auth" + ); + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + const { context } = await createTestCommandContext({ + cwd, + stateDir, + isTTY: true, + env: { + ...process.env, + PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, + PRISMA_CLI_WORKSPACE_ID: "wksp_old", + }, + }); + + const result = await requireAuthenticatedAuthState(context); + + expect(result).toMatchObject({ + authenticated: true, + workspace: { + id: "wksp_real", + name: "Real Workspace", + }, + }); + expect(readAuthState).toHaveBeenNthCalledWith( + 1, + context.runtime.env, + context.runtime.signal, + ); + const reloginReadEnv = readAuthState.mock + .calls[1]?.[0] as NodeJS.ProcessEnv; + expect(reloginReadEnv).not.toHaveProperty("PRISMA_CLI_WORKSPACE_ID"); + expect(readAuthState).toHaveBeenNthCalledWith( + 2, + reloginReadEnv, + context.runtime.signal, + ); + }); + it("stays in mock mode when fixture mode is enabled", async () => { const performLogin = vi.fn().mockResolvedValue(undefined); const readAuthState = vi.fn().mockResolvedValue(null); @@ -232,6 +368,7 @@ describe("real auth mode", () => { PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, PRISMA_COMPUTE_AUTH_FILE: authFilePath, PRISMA_SERVICE_TOKEN: "service-token", + PRISMA_CLI_WORKSPACE_ID: " ", }; const storage = new FileTokenStorage(env); await storage.setTokens({ @@ -277,6 +414,163 @@ describe("real auth mode", () => { }); }); + it("uses PRISMA_CLI_WORKSPACE_ID for real OAuth whoami without changing the stored active workspace", async () => { + const get = vi.fn().mockImplementation((pathName: string) => { + if (pathName === "/v1/me") { + return { + data: { + data: { + user: { + id: "usr_123", + email: "luan@example.com", + name: "Luan", + }, + workspace: { + id: "wksp_cmmxworkspace2", + name: "Prisma Labs", + }, + credential: { + type: "oauth", + id: null, + name: null, + }, + }, + }, + response: { status: 200 }, + }; + } + + throw new Error(`Unexpected path ${pathName}`); + }); + + vi.doMock("@prisma/management-api-sdk", () => ({ + AuthError: class SDKAuthError extends Error {}, + createManagementApiSdk: vi.fn().mockReturnValue({ + client: { GET: get }, + }), + })); + + const { FileTokenStorage } = await import("../src/adapters/token-storage"); + const { createTempCwd, executeCli } = await import("./helpers"); + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + const authFilePath = path.join(cwd, "auth.json"); + const baseEnv = { + ...process.env, + PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, + PRISMA_COMPUTE_AUTH_FILE: authFilePath, + PRISMA_SERVICE_TOKEN: undefined, + }; + const storage = new FileTokenStorage(baseEnv); + await storage.setTokens({ + workspaceId: "cmmxworkspace1", + accessToken: "access-token-1", + refreshToken: "refresh-token-1", + }); + await storage.setTokens({ + workspaceId: "cmmxworkspace2", + accessToken: "access-token-2", + refreshToken: "refresh-token-2", + }); + await storage.useWorkspace("cmmxworkspace1"); + + const result = await executeCli({ + argv: ["auth", "whoami", "--json"], + cwd, + stateDir, + env: { + ...baseEnv, + PRISMA_CLI_WORKSPACE_ID: "wksp_cmmxworkspace2", + }, + }); + + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: true, + command: "auth.whoami", + result: { + authenticated: true, + workspace: { + id: "wksp_cmmxworkspace2", + name: "Prisma Labs", + }, + }, + }); + await expect(storage.getTokens()).resolves.toMatchObject({ + workspaceId: "cmmxworkspace1", + }); + }); + + it("marks PRISMA_CLI_WORKSPACE_ID as active in the real OAuth workspace list without changing local context", async () => { + const { FileTokenStorage } = await import("../src/adapters/token-storage"); + const { createTempCwd, executeCli } = await import("./helpers"); + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + const authFilePath = path.join(cwd, "auth.json"); + const baseEnv = { + ...process.env, + PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, + PRISMA_COMPUTE_AUTH_FILE: authFilePath, + PRISMA_SERVICE_TOKEN: undefined, + }; + const storage = new FileTokenStorage(baseEnv); + await storage.setTokens({ + workspaceId: "cmmxworkspace1", + accessToken: "access-token-1", + refreshToken: "refresh-token-1", + }); + await storage.setTokens({ + workspaceId: "cmmxworkspace2", + accessToken: "access-token-2", + refreshToken: "refresh-token-2", + }); + await storage.rememberWorkspace("cmmxworkspace1", { + id: "wksp_cmmxworkspace1", + name: "Acme Inc", + }); + await storage.rememberWorkspace("cmmxworkspace2", { + id: "wksp_cmmxworkspace2", + name: "Prisma Labs", + }); + await storage.useWorkspace("wksp_cmmxworkspace1"); + + const result = await executeCli({ + argv: ["auth", "workspace", "list", "--json"], + cwd, + stateDir, + env: { + ...baseEnv, + PRISMA_CLI_WORKSPACE_ID: "wksp_cmmxworkspace2", + }, + }); + + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: true, + command: "auth.workspace.list", + result: { + context: { + authSource: "oauth", + activeWorkspaceId: "wksp_cmmxworkspace2", + activeWorkspaceName: "Prisma Labs", + }, + items: [ + expect.objectContaining({ + id: "wksp_cmmxworkspace1", + status: null, + }), + expect.objectContaining({ + id: "wksp_cmmxworkspace2", + status: "active", + }), + ], + }, + }); + await expect(storage.getTokens()).resolves.toMatchObject({ + workspaceId: "cmmxworkspace1", + }); + }); + it("hydrates placeholder OAuth workspace metadata before rendering the workspace list", async () => { const getWorkspace = vi.fn().mockResolvedValue({ data: { diff --git a/packages/cli/tests/auth.test.ts b/packages/cli/tests/auth.test.ts index 2cee976a..b43ed888 100644 --- a/packages/cli/tests/auth.test.ts +++ b/packages/cli/tests/auth.test.ts @@ -436,12 +436,77 @@ describe("auth commands", () => { name: "Prisma Labs", }, }, + nextSteps: [ + "prisma-cli auth whoami", + "prisma-cli project list", + "PRISMA_CLI_WORKSPACE_ID=wksp_cmmxworkspace2 prisma-cli project list", + ], }); await expect(storage.getTokens()).resolves.toMatchObject({ workspaceId: "cmmxworkspace2", }); }); + it("returns env-aware next steps when workspace use runs under PRISMA_CLI_WORKSPACE_ID", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + const authFilePath = path.join(cwd, "auth.json"); + await writeAuthFile(authFilePath, [ + { + workspaceId: "cmmxworkspace1", + token: "access-token-1", + refreshToken: "refresh-token-1", + }, + { + workspaceId: "cmmxworkspace2", + token: "access-token-2", + refreshToken: "refresh-token-2", + }, + ]); + const storage = new FileTokenStorage({ + PRISMA_COMPUTE_AUTH_FILE: authFilePath, + } as NodeJS.ProcessEnv); + await storage.rememberWorkspace("cmmxworkspace1", { + id: "wksp_cmmxworkspace1", + name: "Acme Inc", + }); + await storage.rememberWorkspace("cmmxworkspace2", { + id: "wksp_cmmxworkspace2", + name: "Prisma Labs", + }); + await storage.useWorkspace("wksp_cmmxworkspace1"); + + const result = await executeCli({ + argv: ["auth", "workspace", "use", "wksp_cmmxworkspace2", "--json"], + cwd, + stateDir, + env: { + ...process.env, + PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, + PRISMA_COMPUTE_AUTH_FILE: authFilePath, + PRISMA_SERVICE_TOKEN: undefined, + PRISMA_CLI_WORKSPACE_ID: "wksp_cmmxworkspace1", + }, + }); + + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: true, + command: "auth.workspace.use", + result: { + workspace: { + id: "wksp_cmmxworkspace2", + name: "Prisma Labs", + }, + }, + nextSteps: [ + "PRISMA_CLI_WORKSPACE_ID=wksp_cmmxworkspace2 prisma-cli auth whoami", + "PRISMA_CLI_WORKSPACE_ID=wksp_cmmxworkspace2 prisma-cli project list", + "unset PRISMA_CLI_WORKSPACE_ID", + ], + }); + }); + it("selects the only real OAuth workspace without prompting when no workspace argument is provided", async () => { const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state"); @@ -491,6 +556,61 @@ describe("auth commands", () => { }); }); + it("selects the only real OAuth workspace when no workspace argument is provided and PRISMA_CLI_WORKSPACE_ID is stale", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + const authFilePath = path.join(cwd, "auth.json"); + await writeAuthFile(authFilePath, [ + { + workspaceId: "cmmxworkspace1", + token: "access-token-1", + refreshToken: "refresh-token-1", + }, + ]); + const storage = new FileTokenStorage({ + PRISMA_COMPUTE_AUTH_FILE: authFilePath, + } as NodeJS.ProcessEnv); + await storage.rememberWorkspace("cmmxworkspace1", { + id: "wksp_cmmxworkspace1", + name: "Acme Inc", + }); + + const result = await executeCli({ + argv: ["auth", "workspace", "use", "--json"], + cwd, + stateDir, + env: { + ...process.env, + PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, + PRISMA_COMPUTE_AUTH_FILE: authFilePath, + PRISMA_SERVICE_TOKEN: undefined, + PRISMA_CLI_WORKSPACE_ID: "wksp_missing", + }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: true, + command: "auth.workspace.use", + result: { + previousWorkspace: null, + workspace: { + id: "wksp_cmmxworkspace1", + name: "Acme Inc", + }, + }, + nextSteps: [ + "PRISMA_CLI_WORKSPACE_ID=wksp_cmmxworkspace1 prisma-cli auth whoami", + "PRISMA_CLI_WORKSPACE_ID=wksp_cmmxworkspace1 prisma-cli project list", + "unset PRISMA_CLI_WORKSPACE_ID", + ], + }); + await expect(storage.getTokens()).resolves.toMatchObject({ + workspaceId: "cmmxworkspace1", + }); + }); + it("logs out one real OAuth workspace by canonical workspace id", async () => { const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state"); @@ -529,6 +649,7 @@ describe("auth commands", () => { PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, PRISMA_COMPUTE_AUTH_FILE: authFilePath, PRISMA_SERVICE_TOKEN: undefined, + PRISMA_CLI_WORKSPACE_ID: "wksp_cmmxworkspace2", }, }); @@ -544,6 +665,11 @@ describe("auth commands", () => { wasActive: true, activeWorkspace: null, }, + nextSteps: [ + "unset PRISMA_CLI_WORKSPACE_ID", + "prisma-cli auth workspace list", + "prisma-cli auth workspace use ", + ], }); await expect(storage.getTokens()).resolves.toBeNull(); await expect(storage.listWorkspaces()).resolves.toEqual([ @@ -592,6 +718,7 @@ describe("auth commands", () => { PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, PRISMA_COMPUTE_AUTH_FILE: authFilePath, PRISMA_SERVICE_TOKEN: "service-token", + PRISMA_CLI_WORKSPACE_ID: "wksp_cmmxworkspace2", }, }); @@ -610,6 +737,7 @@ describe("auth commands", () => { name: "Acme Inc", }, }, + nextSteps: ["prisma-cli auth workspace list"], }); await expect(storage.getTokens()).resolves.toMatchObject({ workspaceId: "cmmxworkspace1", @@ -721,6 +849,58 @@ describe("auth commands", () => { ]); }); + it("clears all real OAuth workspaces when PRISMA_CLI_WORKSPACE_ID is set", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + const authFilePath = path.join(cwd, "auth.json"); + await writeAuthFile(authFilePath, [ + { + workspaceId: "cmmxworkspace1", + token: "access-token-1", + refreshToken: "refresh-token-1", + }, + { + workspaceId: "cmmxworkspace2", + token: "access-token-2", + refreshToken: "refresh-token-2", + }, + ]); + const baseEnv = { + PRISMA_COMPUTE_AUTH_FILE: authFilePath, + } as NodeJS.ProcessEnv; + const storage = new FileTokenStorage(baseEnv); + await storage.useWorkspace("cmmxworkspace1"); + + const result = await executeCli({ + argv: ["auth", "logout", "--json"], + cwd, + stateDir, + env: { + ...process.env, + PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, + PRISMA_COMPUTE_AUTH_FILE: authFilePath, + PRISMA_SERVICE_TOKEN: undefined, + PRISMA_CLI_WORKSPACE_ID: "cmmxworkspace2", + }, + }); + + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: true, + command: "auth.logout", + result: { + authenticated: false, + provider: null, + user: null, + workspace: null, + credential: null, + }, + nextSteps: ["unset PRISMA_CLI_WORKSPACE_ID", "prisma-cli auth login"], + }); + await expect(storage.getTokens()).resolves.toBeNull(); + await expect(storage.listWorkspaceTokens()).resolves.toEqual([]); + }); + it("returns WORKSPACE_NOT_AUTHENTICATED when no cached workspace matches", async () => { const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state"); @@ -752,6 +932,51 @@ describe("auth commands", () => { }); }); + it("returns WORKSPACE_NOT_AUTHENTICATED when PRISMA_CLI_WORKSPACE_ID has no cached match", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + const authFilePath = path.join(cwd, "auth.json"); + await writeAuthFile(authFilePath, [ + { + workspaceId: "workspace-1", + token: "access-token-1", + refreshToken: "refresh-token-1", + }, + ]); + + const result = await executeCli({ + argv: ["auth", "whoami", "--json"], + cwd, + stateDir, + env: { + ...process.env, + PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, + PRISMA_COMPUTE_AUTH_FILE: authFilePath, + PRISMA_SERVICE_TOKEN: undefined, + PRISMA_CLI_WORKSPACE_ID: "wksp_missing", + }, + }); + + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: false, + command: "auth.whoami", + error: { + code: "WORKSPACE_NOT_AUTHENTICATED", + domain: "auth", + meta: { + envVar: "PRISMA_CLI_WORKSPACE_ID", + workspaceRef: "wksp_missing", + }, + }, + nextSteps: [ + "prisma-cli auth workspace list", + "unset PRISMA_CLI_WORKSPACE_ID", + "prisma-cli auth login", + ], + }); + }); + it("returns WORKSPACE_AMBIGUOUS when a cached workspace name is duplicated", async () => { const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state"); @@ -913,6 +1138,39 @@ describe("auth commands", () => { }); }); + it("returns a structured auth config error when PRISMA_CLI_WORKSPACE_ID is empty", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + + const result = await executeCli({ + argv: ["auth", "whoami", "--json"], + cwd, + stateDir, + env: { + ...process.env, + PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, + PRISMA_SERVICE_TOKEN: undefined, + PRISMA_CLI_WORKSPACE_ID: " ", + }, + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toBe(""); + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: false, + command: "auth.whoami", + error: { + code: "AUTH_CONFIG_INVALID", + domain: "auth", + why: "PRISMA_CLI_WORKSPACE_ID is set but empty. Provide a workspace id from prisma-cli auth workspace list, or unset the variable.", + }, + nextSteps: [ + "prisma-cli auth workspace list", + "unset PRISMA_CLI_WORKSPACE_ID", + ], + }); + }); + it("returns a structured usage error for non-interactive login without selectors", async () => { const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state"); diff --git a/packages/cli/tests/token-storage.test.ts b/packages/cli/tests/token-storage.test.ts index 90c87863..32310d57 100644 --- a/packages/cli/tests/token-storage.test.ts +++ b/packages/cli/tests/token-storage.test.ts @@ -78,6 +78,260 @@ describe("FileTokenStorage", () => { }); }); + it("uses PRISMA_CLI_WORKSPACE_ID without changing the active workspace", async () => { + const cwd = await createTempCwd(); + const authFilePath = path.join(cwd, "auth.json"); + await writeAuthFile(authFilePath, [ + { + workspaceId: "cmmxworkspace1", + token: "access-token-1", + refreshToken: "refresh-token-1", + }, + { + workspaceId: "cmmxworkspace2", + token: "access-token-2", + refreshToken: "refresh-token-2", + }, + ]); + + const baseEnv = { + PRISMA_COMPUTE_AUTH_FILE: authFilePath, + } as NodeJS.ProcessEnv; + const storage = new FileTokenStorage(baseEnv); + await storage.rememberWorkspace("cmmxworkspace2", { + id: "wksp_cmmxworkspace2", + name: "Prisma Labs", + }); + await storage.useWorkspace("cmmxworkspace1"); + + const overrideStorage = new FileTokenStorage({ + ...baseEnv, + PRISMA_CLI_WORKSPACE_ID: "wksp_cmmxworkspace2", + } as NodeJS.ProcessEnv); + + await expect(overrideStorage.getTokens()).resolves.toEqual({ + workspaceId: "cmmxworkspace2", + accessToken: "access-token-2", + refreshToken: "refresh-token-2", + }); + await expect(storage.getTokens()).resolves.toEqual({ + workspaceId: "cmmxworkspace1", + accessToken: "access-token-1", + refreshToken: "refresh-token-1", + }); + await expect(storage.listWorkspaces()).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + credentialWorkspaceId: "cmmxworkspace1", + active: true, + }), + expect.objectContaining({ + credentialWorkspaceId: "cmmxworkspace2", + active: false, + }), + ]), + ); + }); + + it("does not migrate or write active workspace context when PRISMA_CLI_WORKSPACE_ID is used", async () => { + const cwd = await createTempCwd(); + const authFilePath = path.join(cwd, "auth.json"); + await writeAuthFile(authFilePath, [ + { + workspaceId: "workspace-1", + token: "access-token-1", + refreshToken: "refresh-token-1", + }, + { + workspaceId: "workspace-2", + token: "access-token-2", + refreshToken: "refresh-token-2", + }, + ]); + + const storage = new FileTokenStorage({ + PRISMA_COMPUTE_AUTH_FILE: authFilePath, + PRISMA_CLI_WORKSPACE_ID: "workspace-1", + } as NodeJS.ProcessEnv); + + await expect(storage.getTokens()).resolves.toEqual({ + workspaceId: "workspace-1", + accessToken: "access-token-1", + refreshToken: "refresh-token-1", + }); + await expect( + fs.stat(getAuthContextFilePath(authFilePath)), + ).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("can list workspaces without migrating or writing active workspace context", async () => { + const cwd = await createTempCwd(); + const authFilePath = path.join(cwd, "auth.json"); + await writeAuthFile(authFilePath, [ + { + workspaceId: "workspace-1", + token: "access-token-1", + refreshToken: "refresh-token-1", + }, + { + workspaceId: "workspace-2", + token: "access-token-2", + refreshToken: "refresh-token-2", + }, + ]); + + const storage = new FileTokenStorage({ + PRISMA_COMPUTE_AUTH_FILE: authFilePath, + } as NodeJS.ProcessEnv); + + await expect( + storage.listWorkspaces({ migrateAuthContext: false }), + ).resolves.toEqual([ + expect.objectContaining({ + credentialWorkspaceId: "workspace-1", + active: false, + }), + expect.objectContaining({ + credentialWorkspaceId: "workspace-2", + active: false, + }), + ]); + await expect( + fs.stat(getAuthContextFilePath(authFilePath)), + ).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("does not create a signed-out context when remembering metadata under PRISMA_CLI_WORKSPACE_ID", async () => { + const cwd = await createTempCwd(); + const authFilePath = path.join(cwd, "auth.json"); + await writeAuthFile(authFilePath, [ + { + workspaceId: "workspace-1", + token: "access-token-1", + refreshToken: "refresh-token-1", + }, + ]); + + const storage = new FileTokenStorage({ + PRISMA_COMPUTE_AUTH_FILE: authFilePath, + PRISMA_CLI_WORKSPACE_ID: "workspace-1", + } as NodeJS.ProcessEnv); + + await storage.rememberWorkspace("workspace-1", { + id: "wksp_workspace1", + name: "Acme Inc", + }); + + await expect( + fs.stat(getAuthContextFilePath(authFilePath)), + ).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("does not create a signed-out context when clearing current PRISMA_CLI_WORKSPACE_ID tokens", async () => { + const cwd = await createTempCwd(); + const authFilePath = path.join(cwd, "auth.json"); + await writeAuthFile(authFilePath, [ + { + workspaceId: "workspace-1", + token: "access-token-1", + refreshToken: "refresh-token-1", + }, + { + workspaceId: "workspace-2", + token: "access-token-2", + refreshToken: "refresh-token-2", + }, + ]); + + const storage = new FileTokenStorage({ + PRISMA_COMPUTE_AUTH_FILE: authFilePath, + PRISMA_CLI_WORKSPACE_ID: "workspace-1", + } as NodeJS.ProcessEnv); + + await storage.clearTokensIfCurrent({ + workspaceId: "workspace-1", + accessToken: "access-token-1", + refreshToken: "refresh-token-1", + }); + + await expect(storage.listWorkspaceTokens()).resolves.toEqual([ + { + workspaceId: "workspace-2", + accessToken: "access-token-2", + refreshToken: "refresh-token-2", + }, + ]); + await expect( + fs.stat(getAuthContextFilePath(authFilePath)), + ).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("does not match PRISMA_CLI_WORKSPACE_ID by workspace name", async () => { + const cwd = await createTempCwd(); + const authFilePath = path.join(cwd, "auth.json"); + await writeAuthFile(authFilePath, [ + { + workspaceId: "cmmxworkspace2", + token: "access-token-2", + refreshToken: "refresh-token-2", + }, + ]); + + const storage = new FileTokenStorage({ + PRISMA_COMPUTE_AUTH_FILE: authFilePath, + } as NodeJS.ProcessEnv); + await storage.rememberWorkspace("cmmxworkspace2", { + id: "wksp_cmmxworkspace2", + name: "Prisma Labs", + }); + + const overrideStorage = new FileTokenStorage({ + PRISMA_COMPUTE_AUTH_FILE: authFilePath, + PRISMA_CLI_WORKSPACE_ID: "Prisma Labs", + } as NodeJS.ProcessEnv); + + await expect(overrideStorage.getTokens()).rejects.toMatchObject({ + reason: "not-found", + workspaceRef: "Prisma Labs", + }); + }); + + it("treats an empty PRISMA_CLI_WORKSPACE_ID as invalid", async () => { + const cwd = await createTempCwd(); + const authFilePath = path.join(cwd, "auth.json"); + await writeAuthFile(authFilePath, [ + { + workspaceId: "workspace-1", + token: "access-token-1", + refreshToken: "refresh-token-1", + }, + ]); + + const storage = new FileTokenStorage({ + PRISMA_COMPUTE_AUTH_FILE: authFilePath, + PRISMA_CLI_WORKSPACE_ID: " ", + } as NodeJS.ProcessEnv); + + await expect(storage.getTokens()).rejects.toThrow( + "PRISMA_CLI_WORKSPACE_ID is set but empty. Provide a workspace id from prisma-cli auth workspace list, or unset the variable.", + ); + }); + + it("reports an empty PRISMA_CLI_WORKSPACE_ID before reading credentials", async () => { + const cwd = await createTempCwd(); + const authFilePath = path.join(cwd, "auth.json"); + await fs.mkdir(authFilePath, { recursive: true }); + + const storage = new FileTokenStorage({ + PRISMA_COMPUTE_AUTH_FILE: authFilePath, + PRISMA_CLI_WORKSPACE_ID: " ", + } as NodeJS.ProcessEnv); + + await expect(storage.getTokens()).rejects.toThrow( + "PRISMA_CLI_WORKSPACE_ID is set but empty. Provide a workspace id from prisma-cli auth workspace list, or unset the variable.", + ); + }); + it("recovers from a malformed auth context file by migrating to the latest valid credential", async () => { const cwd = await createTempCwd(); const authFilePath = path.join(cwd, "auth.json"); @@ -178,6 +432,42 @@ describe("FileTokenStorage", () => { ); }); + it("does not activate a workspace from refresh storage when the active pointer is intentionally empty", async () => { + const cwd = await createTempCwd(); + const authFilePath = path.join(cwd, "auth.json"); + const env = { + PRISMA_COMPUTE_AUTH_FILE: authFilePath, + } as NodeJS.ProcessEnv; + await writeAuthFile(authFilePath, [ + { + workspaceId: "workspace-1", + token: "access-token-1", + refreshToken: "refresh-token-1", + }, + ]); + + const storage = new FileTokenStorage(env); + await storage.useWorkspace("workspace-1"); + await storage.logoutWorkspace("workspace-1"); + + const refreshStorage = new FileTokenStorage(env, undefined, { + activateOnSetTokens: false, + }); + await refreshStorage.setTokens({ + workspaceId: "workspace-1", + accessToken: "new-access-token-1", + refreshToken: "new-refresh-token-1", + }); + + await expect(storage.getTokens()).resolves.toBeNull(); + await expect(storage.listWorkspaces()).resolves.toEqual([ + expect.objectContaining({ + credentialWorkspaceId: "workspace-1", + active: false, + }), + ]); + }); + it("keeps a workspace switch ordered after an in-flight refresh write", async () => { const cwd = await createTempCwd(); const authFilePath = path.join(cwd, "auth.json");