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[] = []; 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),