From f2278b830085f397eae0a2a96ff527ef6519d18e Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 18 Aug 2026 12:40:46 +0200 Subject: [PATCH 1/2] fix(cli): answer outputStreamsShareDevice from the fds so split terminals keep the stdout mirror The engine suppresses the stdout payload when both output streams are TTYs unless the host says they are separate devices. The bin never answered, so stdout and stderr routed to two different terminals were still treated as one screen and the mirror dropped. runtimeFromProcess now compares the device and inode behind fds 1 and 2 and sets the field; where a stream exposes no fd or cannot be stat-ed the answer stays absent and the engine keeps its one-screen default. Upstream prisma/prisma-cli#198 makes the field required, so this also pre-empts the next engine adoption. Signed-off-by: willbot Signed-off-by: Will Madden --- .../1-framework/3-tooling/cli/src/orm/cli.ts | 24 ++++++ .../3-tooling/cli/test/orm/cli.test.ts | 81 ++++++++++++++++++- 2 files changed, 104 insertions(+), 1 deletion(-) diff --git a/packages/1-framework/3-tooling/cli/src/orm/cli.ts b/packages/1-framework/3-tooling/cli/src/orm/cli.ts index dd307b12a1c6..30462b9f5343 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/cli.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/cli.ts @@ -1,3 +1,4 @@ +import { fstatSync } from 'node:fs'; import { ifDefined } from '@internal/utils/defined'; import type { Cli, HostProcess, MountedTree, Runtime } from '@prisma/cli-engine'; import { createCli, telemetryCommandGroup } from '@prisma/cli-engine'; @@ -117,11 +118,33 @@ export function createOrmCli(): Cli { }); } +/** + * The file identity behind a stream's fd, or undefined where the host exposes + * no fd (a harness stream) or the fd cannot be stat-ed. + */ +function streamFileIdentity( + stream: HostProcess['stdout'] | HostProcess['stderr'], +): string | undefined { + if (!('fd' in stream) || typeof stream.fd !== 'number') { + return undefined; + } + try { + const stat = fstatSync(stream.fd); + return `${stat.dev}:${stat.ino}`; + } catch { + return undefined; + } +} + /** * Everything environmental the engine is given, adapted from the host process * once. The engine owns signal policy; the bin is dumb wiring. */ export function runtimeFromProcess(proc: HostProcess): Runtime { + const stdoutFile = streamFileIdentity(proc.stdout); + const stderrFile = streamFileIdentity(proc.stderr); + const outputStreamsShareDevice = + stdoutFile !== undefined && stderrFile !== undefined ? stdoutFile === stderrFile : undefined; return { stdout: { write: (text) => void proc.stdout.write(text) }, // The terminal width the drawings get to use, read once with everything @@ -139,6 +162,7 @@ export function runtimeFromProcess(proc: HostProcess): Runtime { stdout: proc.stdout.isTTY === true, stderr: proc.stderr.isTTY === true, }, + ...ifDefined('outputStreamsShareDevice', outputStreamsShareDevice), host: { runtime: { name: 'node', version: proc.version }, platform: proc.platform, diff --git a/packages/1-framework/3-tooling/cli/test/orm/cli.test.ts b/packages/1-framework/3-tooling/cli/test/orm/cli.test.ts index 73efb29e0ea2..ecfd276df76e 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/cli.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/cli.test.ts @@ -1,8 +1,15 @@ +import { closeSync, openSync } from 'node:fs'; import type { HostProcess, LoadedConfig } from '@prisma/cli-engine'; import { createTestCli } from '@prisma/cli-engine/testing'; import { join } from 'pathe'; import { describe, expect, it } from 'vitest'; -import { BIN_COMMANDS, BIN_GROUPS, createOrmCli, runOrmCli } from '../../src/orm/cli'; +import { + BIN_COMMANDS, + BIN_GROUPS, + createOrmCli, + runOrmCli, + runtimeFromProcess, +} from '../../src/orm/cli'; import { ormCommandFamily } from '../../src/orm/family'; import { createTestProjectDir } from '../utils/test-project-dir'; @@ -205,6 +212,78 @@ function processWithUnreadableCwd(stderr: string[]): HostProcess { }; } +function processWithOutputStreams( + stdout: HostProcess['stdout'] & { readonly fd?: number }, + stderr: HostProcess['stderr'] & { readonly fd?: number }, +): HostProcess { + return { + argv: ['node', 'prisma-next'], + env: {}, + version: process.version, + versions: process.versions, + platform: process.platform, + arch: process.arch, + cwd: () => '/', + stdout, + stderr, + stdin: { [Symbol.asyncIterator]: () => [][Symbol.iterator]() as never }, + on: () => undefined, + off: () => undefined, + exit: () => { + throw new Error('exit must not be called'); + }, + }; +} + +describe('runtimeFromProcess', () => { + it('reports that stdout and stderr share a device when their fds name the same file', () => { + const dir = createTestProjectDir('orm-share-device-same'); + const first = openSync(join(dir, 'screen.log'), 'w'); + const second = openSync(join(dir, 'screen.log'), 'w'); + try { + const runtime = runtimeFromProcess( + processWithOutputStreams( + { write: () => true, fd: first }, + { write: () => true, fd: second }, + ), + ); + expect(runtime.outputStreamsShareDevice).toBe(true); + } finally { + closeSync(first); + closeSync(second); + } + }); + + it('reports separate devices when the fds name different files', () => { + const dir = createTestProjectDir('orm-share-device-split'); + const out = openSync(join(dir, 'out.log'), 'w'); + const err = openSync(join(dir, 'err.log'), 'w'); + try { + const runtime = runtimeFromProcess( + processWithOutputStreams({ write: () => true, fd: out }, { write: () => true, fd: err }), + ); + expect(runtime.outputStreamsShareDevice).toBe(false); + } finally { + closeSync(out); + closeSync(err); + } + }); + + it('leaves the answer absent when a stream exposes no fd', () => { + const runtime = runtimeFromProcess( + processWithOutputStreams({ write: () => true }, { write: () => true }), + ); + expect(runtime.outputStreamsShareDevice).toBeUndefined(); + }); + + it('leaves the answer absent when an fd cannot be stat-ed', () => { + const runtime = runtimeFromProcess( + processWithOutputStreams({ write: () => true, fd: -1 }, { write: () => true, fd: -1 }), + ); + expect(runtime.outputStreamsShareDevice).toBeUndefined(); + }); +}); + describe('runOrmCli', () => { it('reports a startup failure as a structured line instead of a raw stack trace', async () => { const stderr: string[] = []; From d83c6ae3c263ea4a7ae7dfdc9b69e5c2b8c21094 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 18 Aug 2026 14:01:29 +0200 Subject: [PATCH 2/2] fix(config-loader): import c12 by the realpath of its entry so config loading survives symlink-preserving resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under pnpm, resolving the bare c12 specifier can pin c12 at its symlinked node_modules path: Node's synchronous ESM linker (and the resolver state the CLI process ends up in) skips the realpath step. From the symlink path c12's own dependencies (dotenv) do not resolve, and every config load dies with CONFIG.EVALUATION_FAILED before evaluating anything. Which linker serves the import depends on the bundler's chunk graph, so any unrelated source change in the cli package could flip it — the previous commit did, turning five CI checks red while main stayed green by luck. Resolving the entry with the CommonJS resolver, realpathing it, and importing that file URL anchors c12 and all its transitive dependencies at their real on-disk locations in every resolver state. Signed-off-by: willbot Signed-off-by: Will Madden --- .../3-tooling/config-loader/src/load.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/1-framework/3-tooling/config-loader/src/load.ts b/packages/1-framework/3-tooling/config-loader/src/load.ts index 7c2fbbdefcae..02ff8c31d584 100644 --- a/packages/1-framework/3-tooling/config-loader/src/load.ts +++ b/packages/1-framework/3-tooling/config-loader/src/load.ts @@ -1,4 +1,7 @@ +import { realpathSync } from 'node:fs'; import { access } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; import { hasCurrentConfigFormatVersion, type PrismaNextConfig, @@ -190,6 +193,19 @@ function toConfigLoadFailure(error: unknown, configPath?: string): CliStructured * fail the load: they are returned as section-tagged diagnostics so commands * fail only on the sections they read (via {@link requireConfigSections}). */ +/** + * Imports c12 by the realpath of its entry file. Under pnpm, resolving the + * bare specifier can pin c12 at its symlinked node_modules path — Node's + * synchronous ESM linker and some resolver states skip the realpath step — + * and from that path c12's own dependencies (`dotenv`) do not resolve, which + * fails every config load with CONFIG.EVALUATION_FAILED. Anchoring the import + * at the real on-disk location keeps every transitive resolution working. + */ +async function importC12(): Promise { + const entry = realpathSync(createRequire(import.meta.url).resolve('c12')); + return await import(pathToFileURL(entry).href); +} + export async function loadConfig( configPath?: string, options?: { readonly cwd?: string }, @@ -214,7 +230,7 @@ export async function loadConfig( let result: Awaited>>>; try { - const c12 = await import('c12'); + const c12 = await importC12(); result = await c12.loadConfig>({ name: discoveryName, ...ifDefined('configFile', resolvedConfigPath),