From 9f920013f26e7b60b04babfc8d18b8e8a5c45536 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Mon, 28 Sep 2026 14:40:17 +0530 Subject: [PATCH] fix(auth): preserve Prisma login result in interactive deployments --- src/services/command-runner.ts | 2 +- src/tasks/composer/auth.ts | 45 ++++++++++----------- src/tasks/prisma-cli.ts | 4 +- tests/deploy-with-composer.test.ts | 65 ++++++++++++++---------------- 4 files changed, 57 insertions(+), 59 deletions(-) diff --git a/src/services/command-runner.ts b/src/services/command-runner.ts index 611547c..741ca97 100644 --- a/src/services/command-runner.ts +++ b/src/services/command-runner.ts @@ -14,7 +14,7 @@ export type CommandSpec = { args: readonly string[]; cwd: string; env?: NodeJS.ProcessEnv; - stdio?: "pipe" | "inherit"; + stdio?: "pipe" | "inherit" | ["inherit", "pipe", "inherit"]; onStderrLine?: (line: string) => void; }; diff --git a/src/tasks/composer/auth.ts b/src/tasks/composer/auth.ts index 9a967b6..5b9c95b 100644 --- a/src/tasks/composer/auth.ts +++ b/src/tasks/composer/auth.ts @@ -4,14 +4,13 @@ import type { Writable } from "node:stream"; import { CreateCancellationError, CreateFailure } from "../../create-outcome"; import { PrismaWorkspaceSchema } from "../../result"; -import { CommandRunner } from "../../services/command-runner"; import type { PackageManager } from "../../types"; +import { getLocalPackageBinaryCommand, getRunScriptCommand } from "../../utils/package-manager"; import { - getLocalPackageBinaryArgs, - getLocalPackageBinaryCommand, - getRunScriptCommand, -} from "../../utils/package-manager"; -import { decodePrismaCommandResult, runPrismaJsonCommandEffect } from "../prisma-cli"; + decodePrismaCommandResult, + PrismaCliCommandError, + runPrismaJsonCommandEffect, +} from "../prisma-cli"; import { getWorkspaceLabel } from "./workspace"; const WhoamiResultSchema = Schema.Struct({ @@ -68,23 +67,23 @@ export const ensureAuthentication = Effect.fn("Deployment.ensureAuthentication") options.beforeInteractiveLogin?.(); log.info("Sign in to Prisma to deploy.", { output: options.output }); }); - const runner = yield* CommandRunner; - const login = getLocalPackageBinaryArgs(options.packageManager, "prisma", ["auth", "login"]); - yield* runner - .runChecked({ - command: login.command, - args: login.args, - cwd: options.projectDir, - env: process.env, - stdio: "inherit", - }) - .pipe( - Effect.mapError((error) => - error.childProcessFailure === "interrupted" || error.childProcessFailure === "cancelled" - ? new CreateCancellationError({ stage: "authenticate" }) - : error, - ), - ); + yield* runPrismaJsonCommandEffect({ + packageManager: options.packageManager, + projectDir: options.projectDir, + args: ["auth", "login"], + interactive: true, + }).pipe( + Effect.mapError((error) => + (error instanceof PrismaCliCommandError && + (error.code === "AUTH.LOGIN_DENIED" || + error.code === "CLI.PROMPT_CANCELLED" || + error.code === "CLI.ABORTED")) || + error.childProcessFailure === "interrupted" || + error.childProcessFailure === "cancelled" + ? new CreateCancellationError({ stage: "authenticate" }) + : error, + ), + ); const authenticatedState = yield* whoami(); if (!authenticatedState.authenticated) { diff --git a/src/tasks/prisma-cli.ts b/src/tasks/prisma-cli.ts index 1c69801..bc930c9 100644 --- a/src/tasks/prisma-cli.ts +++ b/src/tasks/prisma-cli.ts @@ -49,18 +49,20 @@ export const runPrismaJsonCommandEffect = Effect.fn("PrismaCli.runJson")(functio args: string[]; env?: NodeJS.ProcessEnv; onStderrLine?: (line: string) => void; + interactive?: boolean; }) { const runner = yield* CommandRunner; const invocation = getLocalPackageBinaryArgs(options.packageManager, "prisma", [ ...options.args, "--json", - "--no-interactive", + ...(options.interactive ? [] : ["--no-interactive"]), ]); const result = yield* runner.run({ command: invocation.command, args: invocation.args, cwd: options.projectDir, env: options.env ?? process.env, + ...(options.interactive ? { stdio: ["inherit", "pipe", "inherit"] as const } : {}), ...(options.onStderrLine ? { onStderrLine: options.onStderrLine } : {}), }); diff --git a/tests/deploy-with-composer.test.ts b/tests/deploy-with-composer.test.ts index 8f7a86a..e768294 100644 --- a/tests/deploy-with-composer.test.ts +++ b/tests/deploy-with-composer.test.ts @@ -4,7 +4,7 @@ import { PassThrough } from "node:stream"; import { CreateCancellationError, CreateFailure } from "../src/create-outcome"; import { applicationRuntime } from "../src/runtime"; -import { CommandExecutionError, CommandRunner } from "../src/services/command-runner"; +import { CommandRunner } from "../src/services/command-runner"; import { deployNewProjectWithComposer, deployNewProjectWithComposerEffect, @@ -193,11 +193,11 @@ describe("parseComposerDeployResult", () => { }); describe("deployNewProjectWithComposer", () => { - test("reports interrupted sign-in as cancellation without hiding real login failures", async () => { + test("classifies structured sign-in refusals as cancellation and preserves real failures", async () => { const originalTTY = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true }); try { - for (const exitCode of [130, 0xc000013a, 1]) { + for (const code of ["AUTH.LOGIN_DENIED", "CLI.PROMPT_CANCELLED", "AUTH.NETWORK_ERROR"]) { const output = new PassThrough(); let text = ""; output.on("data", (chunk) => { @@ -205,7 +205,6 @@ describe("deployNewProjectWithComposer", () => { }); const result = await applicationRuntime.runPromiseExit( Effect.gen(function* () { - const realRunner = yield* CommandRunner; return yield* deployNewProjectWithComposerEffect({ appName: "test-app", packageManager: "npm", @@ -215,37 +214,32 @@ describe("deployNewProjectWithComposer", () => { output, }).pipe( Effect.provideService(CommandRunner, { - run: (spec) => { - expect(spec.args).toContain("whoami"); - return Effect.succeed({ - exitCode: 0, - stdout: JSON.stringify({ - ok: true, - result: { authenticated: false, workspace: null, source: null }, - }), - stderr: "", - }); - }, - runChecked: (spec) => { - expect(spec.args).toContain("login"); - if (exitCode === 0xc000013a && process.platform !== "win32") { - return Effect.fail( - new CommandExecutionError({ - command: spec.command, - args: [...spec.args], - exitCode, - stdout: "", + run: (spec) => + Effect.sync(() => { + if (spec.args.includes("whoami")) { + return { + exitCode: 0, + stdout: JSON.stringify({ + ok: true, + result: { authenticated: false, workspace: null, source: null }, + }), stderr: "", - childProcessFailure: "interrupted", + }; + } + expect(spec.args).toContain("login"); + expect(spec.args).toContain("--json"); + expect(spec.args).not.toContain("--no-interactive"); + expect(spec.stdio).toEqual(["inherit", "pipe", "inherit"]); + return { + exitCode: code === "CLI.PROMPT_CANCELLED" ? 130 : 2, + stdout: JSON.stringify({ + ok: false, + error: { code, summary: "Sign-in did not complete." }, }), - ); - } - return realRunner.runChecked({ - ...spec, - command: process.execPath, - args: ["-e", `process.exit(${exitCode})`], - }); - }, + stderr: "", + }; + }), + runChecked: () => Effect.die("Not used"), }), ); }), @@ -253,12 +247,15 @@ describe("deployNewProjectWithComposer", () => { expect(Exit.isFailure(result)).toBe(true); if (Exit.isSuccess(result)) throw new Error("Expected sign-in to stop deployment"); const error = Cause.squash(result.cause); - if (exitCode === 1) { + if (code === "AUTH.NETWORK_ERROR") { expect(error).toBeInstanceOf(CreateFailure); expect(error).toMatchObject({ stage: "authenticate", reason: "prisma_auth_command_failed", }); + expect((error as CreateFailure).cause).toMatchObject({ + prismaCliErrorCode: code, + }); expect(text).toContain("Deploy failed:"); } else { expect(error).toBeInstanceOf(CreateCancellationError);