From cfce0bc24190b12ead9a9360c88e5449dd692113 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Tue, 8 Sep 2026 19:39:00 +0530 Subject: [PATCH 1/2] test(cli): reproduce Windows command shim execution failure Signed-off-by: Aman Varshney --- packages/cli/tests/spawn-adapter.test.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/cli/tests/spawn-adapter.test.ts b/packages/cli/tests/spawn-adapter.test.ts index a1273d93..1d7f43ec 100644 --- a/packages/cli/tests/spawn-adapter.test.ts +++ b/packages/cli/tests/spawn-adapter.test.ts @@ -9,7 +9,7 @@ */ import { existsSync, mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { beforeEach, describe, expect, test, vi } from "vitest"; const spawnOptionsSeen = vi.hoisted(() => [] as Array>); @@ -55,6 +55,23 @@ async function waitForFile(path: string, timeoutMs = 10_000): Promise { } describe("the shipped spawn adapter", () => { + test.skipIf(process.platform !== "win32")( + "runs the installed npm.cmd through the shipped spawn adapter", + async () => { + const command = join(dirname(process.execPath), "npm.cmd"); + expect(existsSync(command)).toBe(true); + const child = spawnChild({ + command, + args: ["--version"], + cwd: process.cwd(), + env: process.env, + output: "diagnostic", + }); + await expect(child.ended).resolves.toEqual({ exitCode: 0, signal: null }); + expect(diagnosticText.trim()).toMatch(/^\d+\.\d+\.\d+$/); + }, + ); + test("passes the spawn options the design rests on: inherited stdio, no detached, no new console", async () => { const child = spawnChild({ command: NODE, From 4d842129487bf5bfcac198e3acc12da0e228a9e4 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Tue, 8 Sep 2026 19:44:36 +0530 Subject: [PATCH 2/2] fix(cli): launch delegated Windows shims with cross-spawn Signed-off-by: Aman Varshney --- docs/product/output-conventions.md | 6 ++++++ packages/cli/package.json | 2 ++ packages/cli/src/spawn.ts | 4 ++-- packages/cli/tests/spawn-adapter.test.ts | 13 ++++++++----- packages/prisma/package.json | 1 + pnpm-lock.yaml | 16 ++++++++++++++++ 6 files changed, 35 insertions(+), 7 deletions(-) diff --git a/docs/product/output-conventions.md b/docs/product/output-conventions.md index a3614ea2..27db0172 100644 --- a/docs/product/output-conventions.md +++ b/docs/product/output-conventions.md @@ -593,6 +593,12 @@ context, status, decoration, and errors stay on stderr. ## Design Rule +Delegated commands must support installed Windows `.cmd` shims as well as +native executables and POSIX shebang scripts. The host uses cross-spawn for +platform-specific resolution and argument escaping; callers still pass a +command and argument array, not a shell command string. Human stdio inheritance, +structured diagnostic forwarding, and child exit status remain unchanged. + Human output and JSON output should describe the same underlying model. The CLI should never require users or agents to learn different meanings for the same command. diff --git a/packages/cli/package.json b/packages/cli/package.json index 99079121..7ef50342 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -56,6 +56,7 @@ "@prisma/orm-toolchain": "8.0.0-rc.8", "@vercel/detect-agent": "^1.2.3", "better-result": "^2.9.2", + "cross-spawn": "^7.0.6", "dotenv": "^17.4.2", "execa": "^9.6.1", "open": "^11.0.0" @@ -67,6 +68,7 @@ "@repo/cli-telemetry": "workspace:8.0.0-rc.13", "@repo/tsconfig": "workspace:8.0.0-rc.13", "@types/node": "^22.19.19", + "@types/cross-spawn": "^6.0.6", "tsdown": "^0.21.10", "tsx": "^4.22.4", "typescript": "^6.0.3", diff --git a/packages/cli/src/spawn.ts b/packages/cli/src/spawn.ts index 095f17f7..15b8a132 100644 --- a/packages/cli/src/spawn.ts +++ b/packages/cli/src/spawn.ts @@ -1,7 +1,7 @@ -import { spawn } from "node:child_process"; import { type Readable, Writable } from "node:stream"; import { pipeline } from "node:stream/promises"; import type { ChildResult, SpawnChild } from "@prisma/cli-engine"; +import spawn from "cross-spawn"; interface DiagnosticStream { write(text: string): unknown; @@ -25,7 +25,7 @@ export interface SpawnChildOptions { } /** - * The engine's spawn seam, adapted to node:child_process. Human mode + * The engine's spawn seam, adapted through cross-spawn. Human mode * inherits stdio; structured mode pipes both child output streams to * diagnostics. Neither mode detaches or opens a new console, so the child * stays in this process's group (POSIX) or console (Windows). diff --git a/packages/cli/tests/spawn-adapter.test.ts b/packages/cli/tests/spawn-adapter.test.ts index 1d7f43ec..05a09a1c 100644 --- a/packages/cli/tests/spawn-adapter.test.ts +++ b/packages/cli/tests/spawn-adapter.test.ts @@ -14,17 +14,19 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; const spawnOptionsSeen = vi.hoisted(() => [] as Array>); -vi.mock("node:child_process", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("cross-spawn", async (importOriginal) => { + const actual = await importOriginal<{ + default: typeof import("cross-spawn"); + }>(); return { ...actual, - spawn: ( + default: ( command: string, args: readonly string[], options: Record, ) => { spawnOptionsSeen.push(options); - return actual.spawn(command, [...args], options as never); + return actual.default(command, [...args], options as never); }, }; }); @@ -32,6 +34,7 @@ vi.mock("node:child_process", async (importOriginal) => { import { makeSpawnChild } from "../src/spawn"; const NODE = process.execPath; +const VERSION = /^\d+\.\d+\.\d+$/; let diagnosticText = ""; const spawnChild = makeSpawnChild({ write: (text) => { @@ -68,7 +71,7 @@ describe("the shipped spawn adapter", () => { output: "diagnostic", }); await expect(child.ended).resolves.toEqual({ exitCode: 0, signal: null }); - expect(diagnosticText.trim()).toMatch(/^\d+\.\d+\.\d+$/); + expect(diagnosticText.trim()).toMatch(VERSION); }, ); diff --git a/packages/prisma/package.json b/packages/prisma/package.json index 16197a5c..a144d06a 100644 --- a/packages/prisma/package.json +++ b/packages/prisma/package.json @@ -56,6 +56,7 @@ "@prisma/orm-toolchain": "8.0.0-rc.8", "@vercel/detect-agent": "^1.2.3", "better-result": "^2.9.2", + "cross-spawn": "^7.0.6", "dotenv": "^17.4.2", "execa": "^9.6.1", "open": "^11.0.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 570f79c2..5d620e90 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: better-result: specifier: ^2.9.2 version: 2.9.2 + cross-spawn: + specifier: ^7.0.6 + version: 7.0.6 dotenv: specifier: ^17.4.2 version: 17.4.2 @@ -72,6 +75,9 @@ importers: '@repo/tsconfig': specifier: workspace:8.0.0-rc.13 version: link:../tsconfig + '@types/cross-spawn': + specifier: ^6.0.6 + version: 6.0.6 '@types/node': specifier: ^22.19.19 version: 22.19.19 @@ -221,6 +227,9 @@ importers: better-result: specifier: ^2.9.2 version: 2.9.2 + cross-spawn: + specifier: ^7.0.6 + version: 7.0.6 dotenv: specifier: ^17.4.2 version: 17.4.2 @@ -1843,6 +1852,9 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/cross-spawn@6.0.6': + resolution: {integrity: sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -4839,6 +4851,10 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/cross-spawn@6.0.6': + dependencies: + '@types/node': 22.19.19 + '@types/deep-eql@4.0.2': {} '@types/estree@1.0.9': {}