From 4817066feb7a08a75023fdcd90f0f533bcb5e2ff Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Wed, 2 Sep 2026 15:53:42 +0530 Subject: [PATCH 01/22] fix(cli): launch child commands cross-platform with Execa Signed-off-by: Aman Varshney --- .../0-framework/3-tooling/cli/package.json | 3 +- .../cli/src/__tests__/run-alchemy.test.ts | 41 +++--- .../3-tooling/cli/src/family/runtime.ts | 17 ++- .../3-tooling/cli/src/run-alchemy.ts | 33 +++-- packages/9-public/composer-cli/package.json | 3 +- packages/9-public/composer/package.json | 1 + pnpm-lock.yaml | 127 ++++++++++++++++++ 7 files changed, 180 insertions(+), 45 deletions(-) diff --git a/packages/0-framework/3-tooling/cli/package.json b/packages/0-framework/3-tooling/cli/package.json index b466fa3a..4a1a0068 100644 --- a/packages/0-framework/3-tooling/cli/package.json +++ b/packages/0-framework/3-tooling/cli/package.json @@ -23,7 +23,8 @@ "@internal/foundation": "workspace:0.16.0", "@prisma/cli-engine": "0.3.0", "c12": "^3.3.4", - "chokidar": "^4.0.3" + "chokidar": "^4.0.3", + "execa": "^9.6.1" }, "devDependencies": { "@internal/tsdown-config": "workspace:0.16.0", diff --git a/packages/0-framework/3-tooling/cli/src/__tests__/run-alchemy.test.ts b/packages/0-framework/3-tooling/cli/src/__tests__/run-alchemy.test.ts index 73cd8fd8..aeec0776 100644 --- a/packages/0-framework/3-tooling/cli/src/__tests__/run-alchemy.test.ts +++ b/packages/0-framework/3-tooling/cli/src/__tests__/run-alchemy.test.ts @@ -242,26 +242,29 @@ describe('spawnAlchemy()', () => { * a failure: a signal-killed child has NO exit code, and saying otherwise * loses the only evidence that the user aborted. */ - test('a signal-killed child comes back as the signal with a null exit code', async () => { - const dir = makeTmpDir(); - installFakeAlchemy(dir, [ - 'process.kill(process.pid, "SIGTERM");', - 'setTimeout(() => {}, 5000);', - ]); + test.skipIf(process.platform === 'win32')( + 'a signal-killed child comes back as the signal with a null exit code', + async () => { + const dir = makeTmpDir(); + installFakeAlchemy(dir, [ + 'process.kill(process.pid, "SIGTERM");', + 'setTimeout(() => {}, 5000);', + ]); - expect( - await spawnAlchemy({ - action: 'deploy', - stackFileRelativePath: '.prisma-composer/alchemy.run.ts', - stage: 'test', - cwd: dir, - env: {}, - }), - ).toEqual({ - exitCode: null, - signal: 'SIGTERM', - }); - }); + expect( + await spawnAlchemy({ + action: 'deploy', + stackFileRelativePath: '.prisma-composer/alchemy.run.ts', + stage: 'test', + cwd: dir, + env: {}, + }), + ).toEqual({ + exitCode: null, + signal: 'SIGTERM', + }); + }, + ); test('raises the structured error when the app has no alchemy installed', async () => { const dir = makeTmpDir(); diff --git a/packages/0-framework/3-tooling/cli/src/family/runtime.ts b/packages/0-framework/3-tooling/cli/src/family/runtime.ts index 3ed0d149..1ac4af2d 100644 --- a/packages/0-framework/3-tooling/cli/src/family/runtime.ts +++ b/packages/0-framework/3-tooling/cli/src/family/runtime.ts @@ -12,7 +12,6 @@ * story: this CLI has no login flow and mounts no auth commands, so there is * nothing to store — the two variables ARE the credential. */ -import { spawn } from 'node:child_process'; import { EnvironmentCredentialManager, type HostProcess, @@ -20,6 +19,7 @@ import { type Runtime, type SpawnChild, } from '@prisma/cli-engine'; +import { execa } from 'execa'; /** Where the management API lives. Matches the lowering client's default origin; the env var is the escape hatch for staging. */ const DEFAULT_MANAGEMENT_API_BASE_URL = 'https://api.prisma.io'; @@ -33,17 +33,20 @@ const DEFAULT_AUTH_BASE_URL = 'https://auth.prisma.io'; * side of that seam. */ const spawnChild: SpawnChild = (request) => { - const child = spawn(request.command, [...request.args], { + const child = execa(request.command, request.args, { cwd: request.cwd, stdio: 'inherit', env: request.env, + extendEnv: false, + reject: false, }); return { - ended: new Promise((resolve, reject) => { - child.on('error', reject); - child.on('close', (exitCode, signal) => { - resolve({ exitCode, signal }); - }); + ended: child.then((result) => { + if (result.exitCode === undefined && result.signal === undefined) throw result; + return { + exitCode: result.exitCode ?? null, + signal: result.signal ?? null, + }; }), kill: (signal) => { child.kill(signal); diff --git a/packages/0-framework/3-tooling/cli/src/run-alchemy.ts b/packages/0-framework/3-tooling/cli/src/run-alchemy.ts index cce38864..3f7f88cd 100644 --- a/packages/0-framework/3-tooling/cli/src/run-alchemy.ts +++ b/packages/0-framework/3-tooling/cli/src/run-alchemy.ts @@ -1,11 +1,10 @@ /** * Pipeline step 7 (deploy-cli.md § The pipeline; design-notes.md's "Driving - * Alchemy" call): hand the terminal to the generated stack file. Resolves the - * workspace's own installed `alchemy` bin (walking up `node_modules/.bin` - * from the generated file's package dir) rather than going through - * `bunx`/`npx`, so this works the same under node and bun — the resolved - * bin's own launcher (`alchemy/bin/cli.js`) does its own node/bun dispatch - * from there, driven by the env it inherits. + * Alchemy" call): hand the terminal to the generated stack file. + * + * Resolves the workspace's installed `alchemy` bin. The actual child runner + * uses Execa, which handles package-manager shims and shebangs on Windows + * without a shell while preserving argv boundaries. * * This module composes the invocation; it does not decide how the child is * started. Under the CLI the engine starts it (`ctx.spawn`), which is what @@ -13,10 +12,10 @@ * `spawnAlchemy` is the default for programmatic hosts driving * `@prisma/composer/control`, which have no engine to borrow. */ -import { spawn } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { CliStructuredError } from '@internal/foundation/errors'; +import { execa } from 'execa'; /** Walks up from `startDir` looking for `node_modules/.bin/alchemy`. */ export function resolveAlchemyBin(startDir: string): string { @@ -131,15 +130,15 @@ export function alchemyInvocation(input: AlchemyInvocationInput): AlchemyInvocat */ export const spawnAlchemy: RunAlchemy = async (invocation) => { const line = alchemyCommandLine(invocation); - return new Promise((resolve, reject) => { - const child = spawn(line.command, [...line.args], { - cwd: line.cwd, - stdio: 'inherit', - env: { ...process.env, ...line.env }, - }); - child.on('error', reject); - child.on('close', (exitCode, signal) => { - resolve({ exitCode, signal }); - }); + const result = await execa(line.command, line.args, { + cwd: line.cwd, + stdio: 'inherit', + env: line.env, + reject: false, }); + if (result.exitCode === undefined && result.signal === undefined) throw result; + return { + exitCode: result.exitCode ?? null, + signal: result.signal ?? null, + }; }; diff --git a/packages/9-public/composer-cli/package.json b/packages/9-public/composer-cli/package.json index 58905cb7..e2f59f8d 100644 --- a/packages/9-public/composer-cli/package.json +++ b/packages/9-public/composer-cli/package.json @@ -25,7 +25,8 @@ "alchemy": "2.0.0-beta.74", "c12": "^3.3.4", "effect": "4.0.0-rc.112", - "esbuild": "^0.28.1" + "esbuild": "^0.28.1", + "execa": "^9.6.1" }, "peerDependencies": { "@prisma/cli-engine": "0.3.0" diff --git a/packages/9-public/composer/package.json b/packages/9-public/composer/package.json index 8d0f90fe..bbb019a4 100644 --- a/packages/9-public/composer/package.json +++ b/packages/9-public/composer/package.json @@ -41,6 +41,7 @@ "c12": "^3.3.4", "effect": "4.0.0-rc.112", "esbuild": "^0.28.1", + "execa": "^9.6.1", "@prisma/management-api-sdk": "^1.60.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f50e75b1..c50c14df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -747,6 +747,9 @@ importers: chokidar: specifier: ^4.0.3 version: 4.0.3 + execa: + specifier: ^9.6.1 + version: 9.6.1 devDependencies: '@internal/tsdown-config': specifier: workspace:0.16.0 @@ -1234,6 +1237,9 @@ importers: esbuild: specifier: ^0.28.1 version: 0.28.2 + execa: + specifier: ^9.6.1 + version: 9.6.1 devDependencies: '@effect/vitest': specifier: 4.0.0-rc.112 @@ -1292,6 +1298,9 @@ importers: esbuild: specifier: ^0.28.1 version: 0.28.2 + execa: + specifier: ^9.6.1 + version: 9.6.1 devDependencies: '@internal/cli': specifier: workspace:0.16.0 @@ -3285,6 +3294,9 @@ packages: rollup: optional: true + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + '@selderee/plugin-htmlparser2@0.11.0': resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} @@ -3319,6 +3331,10 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + '@smithy/core@3.29.4': resolution: {integrity: sha512-G1GRglAabzEhqghJMBAd54FkRS7SAFGHEwbhcI9r+O+LIMuFsLyXkLZkCoFSgAglRu8s/URVXJB0hglq3ZipIg==} engines: {node: '>=18.0.0'} @@ -4290,6 +4306,10 @@ packages: eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + execa@9.6.1: + resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} + engines: {node: ^18.19.0 || >=20.5.0} + expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} @@ -4349,6 +4369,10 @@ packages: picomatch: optional: true + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} @@ -4394,6 +4418,10 @@ packages: resolution: {integrity: sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg==} engines: {node: '>=16'} + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + get-tsconfig@5.0.0-beta.5: resolution: {integrity: sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ==} engines: {node: '>=20.20.0'} @@ -4468,6 +4496,10 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + husky@9.1.7: resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} engines: {node: '>=18'} @@ -4553,9 +4585,21 @@ packages: resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} engines: {node: '>=12'} + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + is-property@1.0.2: resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + is-unsafe@1.0.1: resolution: {integrity: sha512-CLK2+VdgERgD96EYm5lUQssZYlRg2tkZnbsxZoacmSiRxiFJ4Nk4SzjCl+Ur+v3kXIY9dTIdb3IH22y1mZ56LA==} @@ -4910,6 +4954,10 @@ packages: engines: {node: ^18.17.0 || >=20.5.0} hasBin: true + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + obug@2.1.4: resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} @@ -4943,6 +4991,10 @@ packages: pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + parseley@0.12.1: resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==} @@ -4958,6 +5010,10 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -5075,6 +5131,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-ms@9.3.1: + resolution: {integrity: sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==} + engines: {node: '>=18'} + prisma@7.9.0: resolution: {integrity: sha512-isQTJEK4pyOlAVzm6kBUDjzgdsgs0A/snpB38ycTHeOHW34qfepP+ClQltgDXqjZBnXALhEtE4duh9L3tN5fHw==} engines: {node: ^20.19 || ^22.12 || >=24.0} @@ -5420,6 +5480,10 @@ packages: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + strnum@2.4.1: resolution: {integrity: sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==} @@ -5580,6 +5644,10 @@ packages: unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + uniku@0.5.0: resolution: {integrity: sha512-giSrg7xqM5YWkSlyheulHgTTInhYh/m0cFZOOuChi/TO87hKlxmZLllETlvDw/lPB54NIs1iW/x2rr0y2yFXHg==} engines: {node: '>=20.19.0'} @@ -5823,6 +5891,10 @@ packages: resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==} engines: {node: '>=12'} + yoctocolors@2.2.0: + resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==} + engines: {node: '>=18'} + yoga-layout@3.2.1: resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} @@ -7544,6 +7616,8 @@ snapshots: estree-walker: 2.0.2 picomatch: 4.0.5 + '@sec-ant/readable-stream@0.4.1': {} + '@selderee/plugin-htmlparser2@0.11.0': dependencies: domhandler: 5.0.3 @@ -7589,6 +7663,8 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@sindresorhus/merge-streams@4.0.0': {} + '@smithy/core@3.29.4': dependencies: '@smithy/types': 4.16.1 @@ -8519,6 +8595,21 @@ snapshots: eventemitter3@5.0.4: {} + execa@9.6.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + cross-spawn: 7.0.6 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.1 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + yoctocolors: 2.2.0 + expect-type@1.4.0: {} exsolve@1.1.0: {} @@ -8581,6 +8672,10 @@ snapshots: optionalDependencies: picomatch: 4.0.5 + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + file-uri-to-path@1.0.0: {} fill-range@7.1.1: @@ -8621,6 +8716,11 @@ snapshots: get-port@7.2.0: {} + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + get-tsconfig@5.0.0-beta.5: dependencies: resolve-pkg-maps: 1.0.0 @@ -8705,6 +8805,8 @@ snapshots: transitivePeerDependencies: - supports-color + human-signals@8.0.1: {} + husky@9.1.7: {} iconv-lite@0.7.2: @@ -8786,8 +8888,14 @@ snapshots: is-path-inside@4.0.0: {} + is-plain-obj@4.1.0: {} + is-property@1.0.2: {} + is-stream@4.0.1: {} + + is-unicode-supported@2.1.0: {} + is-unsafe@1.0.1: {} isarray@1.0.0: {} @@ -9144,6 +9252,11 @@ snapshots: dependencies: abbrev: 3.0.1 + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + obug@2.1.4: {} ohash@2.0.11: {} @@ -9174,6 +9287,8 @@ snapshots: pako@1.0.11: {} + parse-ms@4.0.0: {} + parseley@0.12.1: dependencies: leac: 0.6.0 @@ -9185,6 +9300,8 @@ snapshots: path-key@3.1.1: {} + path-key@4.0.0: {} + path-parse@1.0.7: {} path-scurry@2.0.2: @@ -9283,6 +9400,10 @@ snapshots: prettier@3.9.6: {} + pretty-ms@9.3.1: + dependencies: + parse-ms: 4.0.0 + prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3): dependencies: '@prisma/config': 7.9.0 @@ -9682,6 +9803,8 @@ snapshots: strip-bom@3.0.0: {} + strip-final-newline@4.0.0: {} + strnum@2.4.1: dependencies: anynum: 1.0.1 @@ -9821,6 +9944,8 @@ snapshots: dependencies: pathe: 2.0.3 + unicorn-magic@0.3.0: {} + uniku@0.5.0: dependencies: '@noble/hashes': 2.2.0 @@ -10006,6 +10131,8 @@ snapshots: dependencies: pend: 1.2.0 + yoctocolors@2.2.0: {} + yoga-layout@3.2.1: {} yuku-ast@0.1.7: From 82caedaf4f65bef5b48632e4ee7f0ed3507e41fb Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Wed, 2 Sep 2026 17:16:25 +0530 Subject: [PATCH 02/22] refactor(cli): narrow Windows spawning to cross-spawn Signed-off-by: Aman Varshney --- .../0-framework/3-tooling/cli/package.json | 3 +- .../cli/src/family/__tests__/runtime.test.ts | 30 ++++ .../3-tooling/cli/src/family/runtime.ts | 17 +- .../3-tooling/cli/src/run-alchemy.ts | 24 +-- packages/9-public/composer-cli/package.json | 4 +- packages/9-public/composer/package.json | 2 +- pnpm-lock.yaml | 146 +++--------------- 7 files changed, 73 insertions(+), 153 deletions(-) diff --git a/packages/0-framework/3-tooling/cli/package.json b/packages/0-framework/3-tooling/cli/package.json index 4a1a0068..a430a245 100644 --- a/packages/0-framework/3-tooling/cli/package.json +++ b/packages/0-framework/3-tooling/cli/package.json @@ -24,11 +24,12 @@ "@prisma/cli-engine": "0.3.0", "c12": "^3.3.4", "chokidar": "^4.0.3", - "execa": "^9.6.1" + "cross-spawn": "^7.0.6" }, "devDependencies": { "@internal/tsdown-config": "workspace:0.16.0", "@types/bun": "^1.3.13", + "@types/cross-spawn": "^6.0.6", "@types/node": "^26.0.1", "tsdown": "^0.22.7", "typescript": "^6.0.3" diff --git a/packages/0-framework/3-tooling/cli/src/family/__tests__/runtime.test.ts b/packages/0-framework/3-tooling/cli/src/family/__tests__/runtime.test.ts index 68394848..125174b4 100644 --- a/packages/0-framework/3-tooling/cli/src/family/__tests__/runtime.test.ts +++ b/packages/0-framework/3-tooling/cli/src/family/__tests__/runtime.test.ts @@ -5,6 +5,9 @@ * CLI that misreports its TTY, leaks signal listeners, or never exits. */ import { describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; import type { HostProcess, LoadedConfig } from '@prisma/cli-engine'; import { createRuntime, detectPackageManager } from '../runtime.ts'; @@ -235,6 +238,33 @@ describe('createRuntime()', () => { expect(typeof createRuntime(fakeHost(), noConfig).spawn).toBe('function'); }); + test('the spawn adapter runs a package-bin shebang without a shell', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'prisma-composer-runtime-spawn-')); + try { + const bin = path.join(dir, 'fake-package-bin'); + fs.writeFileSync( + bin, + '#!/usr/bin/env node\nprocess.exit(process.argv[2] === "ok" ? 0 : 1);\n', + { + mode: 0o755, + }, + ); + const spawn = createRuntime(fakeHost(), noConfig).spawn; + if (spawn === undefined) throw new Error('Runtime has no spawn adapter'); + + const child = spawn({ + command: bin, + args: ['ok'], + cwd: dir, + env: process.env, + output: 'inherit', + }); + expect(await child.ended).toEqual({ exitCode: 0, signal: null }); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + test('the loader is exposed as loadConfig, and its result is passed through untouched', async () => { const config: LoadedConfig = { path: '/app/prisma.config.ts', diff --git a/packages/0-framework/3-tooling/cli/src/family/runtime.ts b/packages/0-framework/3-tooling/cli/src/family/runtime.ts index 1ac4af2d..22cdc4aa 100644 --- a/packages/0-framework/3-tooling/cli/src/family/runtime.ts +++ b/packages/0-framework/3-tooling/cli/src/family/runtime.ts @@ -19,7 +19,7 @@ import { type Runtime, type SpawnChild, } from '@prisma/cli-engine'; -import { execa } from 'execa'; +import spawn from 'cross-spawn'; /** Where the management API lives. Matches the lowering client's default origin; the env var is the escape hatch for staging. */ const DEFAULT_MANAGEMENT_API_BASE_URL = 'https://api.prisma.io'; @@ -33,20 +33,17 @@ const DEFAULT_AUTH_BASE_URL = 'https://auth.prisma.io'; * side of that seam. */ const spawnChild: SpawnChild = (request) => { - const child = execa(request.command, request.args, { + const child = spawn(request.command, [...request.args], { cwd: request.cwd, stdio: 'inherit', env: request.env, - extendEnv: false, - reject: false, }); return { - ended: child.then((result) => { - if (result.exitCode === undefined && result.signal === undefined) throw result; - return { - exitCode: result.exitCode ?? null, - signal: result.signal ?? null, - }; + ended: new Promise((resolve, reject) => { + child.on('error', reject); + child.on('close', (exitCode, signal) => { + resolve({ exitCode, signal }); + }); }), kill: (signal) => { child.kill(signal); diff --git a/packages/0-framework/3-tooling/cli/src/run-alchemy.ts b/packages/0-framework/3-tooling/cli/src/run-alchemy.ts index 3f7f88cd..b7d6da04 100644 --- a/packages/0-framework/3-tooling/cli/src/run-alchemy.ts +++ b/packages/0-framework/3-tooling/cli/src/run-alchemy.ts @@ -3,7 +3,7 @@ * Alchemy" call): hand the terminal to the generated stack file. * * Resolves the workspace's installed `alchemy` bin. The actual child runner - * uses Execa, which handles package-manager shims and shebangs on Windows + * uses cross-spawn, which handles package-manager shims and shebangs on Windows * without a shell while preserving argv boundaries. * * This module composes the invocation; it does not decide how the child is @@ -15,7 +15,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { CliStructuredError } from '@internal/foundation/errors'; -import { execa } from 'execa'; +import spawn from 'cross-spawn'; /** Walks up from `startDir` looking for `node_modules/.bin/alchemy`. */ export function resolveAlchemyBin(startDir: string): string { @@ -130,15 +130,15 @@ export function alchemyInvocation(input: AlchemyInvocationInput): AlchemyInvocat */ export const spawnAlchemy: RunAlchemy = async (invocation) => { const line = alchemyCommandLine(invocation); - const result = await execa(line.command, line.args, { - cwd: line.cwd, - stdio: 'inherit', - env: line.env, - reject: false, + return new Promise((resolve, reject) => { + const child = spawn(line.command, [...line.args], { + cwd: line.cwd, + stdio: 'inherit', + env: { ...process.env, ...line.env }, + }); + child.on('error', reject); + child.on('close', (exitCode, signal) => { + resolve({ exitCode, signal }); + }); }); - if (result.exitCode === undefined && result.signal === undefined) throw result; - return { - exitCode: result.exitCode ?? null, - signal: result.signal ?? null, - }; }; diff --git a/packages/9-public/composer-cli/package.json b/packages/9-public/composer-cli/package.json index e2f59f8d..2f8810f6 100644 --- a/packages/9-public/composer-cli/package.json +++ b/packages/9-public/composer-cli/package.json @@ -24,9 +24,9 @@ "@prisma/composer": "workspace:0.16.0", "alchemy": "2.0.0-beta.74", "c12": "^3.3.4", + "cross-spawn": "^7.0.6", "effect": "4.0.0-rc.112", - "esbuild": "^0.28.1", - "execa": "^9.6.1" + "esbuild": "^0.28.1" }, "peerDependencies": { "@prisma/cli-engine": "0.3.0" diff --git a/packages/9-public/composer/package.json b/packages/9-public/composer/package.json index bbb019a4..ef5ed429 100644 --- a/packages/9-public/composer/package.json +++ b/packages/9-public/composer/package.json @@ -39,9 +39,9 @@ "alchemy": "2.0.0-beta.74", "arktype": "^2.2.3", "c12": "^3.3.4", + "cross-spawn": "^7.0.6", "effect": "4.0.0-rc.112", "esbuild": "^0.28.1", - "execa": "^9.6.1", "@prisma/management-api-sdk": "^1.60.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c50c14df..a8ce87e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -747,9 +747,9 @@ importers: chokidar: specifier: ^4.0.3 version: 4.0.3 - execa: - specifier: ^9.6.1 - version: 9.6.1 + cross-spawn: + specifier: ^7.0.6 + version: 7.0.6 devDependencies: '@internal/tsdown-config': specifier: workspace:0.16.0 @@ -757,6 +757,9 @@ importers: '@types/bun': specifier: ^1.3.13 version: 1.3.14 + '@types/cross-spawn': + specifier: ^6.0.6 + version: 6.0.6 '@types/node': specifier: ^26.0.1 version: 26.1.1 @@ -1231,15 +1234,15 @@ importers: c12: specifier: ^3.3.4 version: 3.3.4 + cross-spawn: + specifier: ^7.0.6 + version: 7.0.6 effect: specifier: 4.0.0-rc.112 version: 4.0.0-rc.112 esbuild: specifier: ^0.28.1 version: 0.28.2 - execa: - specifier: ^9.6.1 - version: 9.6.1 devDependencies: '@effect/vitest': specifier: 4.0.0-rc.112 @@ -1292,15 +1295,15 @@ importers: c12: specifier: ^3.3.4 version: 3.3.4 + cross-spawn: + specifier: ^7.0.6 + version: 7.0.6 effect: specifier: 4.0.0-rc.112 version: 4.0.0-rc.112 esbuild: specifier: ^0.28.1 version: 0.28.2 - execa: - specifier: ^9.6.1 - version: 9.6.1 devDependencies: '@internal/cli': specifier: workspace:0.16.0 @@ -3294,9 +3297,6 @@ packages: rollup: optional: true - '@sec-ant/readable-stream@0.4.1': - resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} - '@selderee/plugin-htmlparser2@0.11.0': resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} @@ -3331,10 +3331,6 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} - '@sindresorhus/merge-streams@4.0.0': - resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} - engines: {node: '>=18'} - '@smithy/core@3.29.4': resolution: {integrity: sha512-G1GRglAabzEhqghJMBAd54FkRS7SAFGHEwbhcI9r+O+LIMuFsLyXkLZkCoFSgAglRu8s/URVXJB0hglq3ZipIg==} engines: {node: '>=18.0.0'} @@ -3434,6 +3430,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/d3-array@3.0.3': resolution: {integrity: sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ==} @@ -4306,10 +4305,6 @@ packages: eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} - execa@9.6.1: - resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} - engines: {node: ^18.19.0 || >=20.5.0} - expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} @@ -4369,10 +4364,6 @@ packages: picomatch: optional: true - figures@6.1.0: - resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} - engines: {node: '>=18'} - file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} @@ -4418,10 +4409,6 @@ packages: resolution: {integrity: sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg==} engines: {node: '>=16'} - get-stream@9.0.1: - resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} - engines: {node: '>=18'} - get-tsconfig@5.0.0-beta.5: resolution: {integrity: sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ==} engines: {node: '>=20.20.0'} @@ -4496,10 +4483,6 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} - human-signals@8.0.1: - resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} - engines: {node: '>=18.18.0'} - husky@9.1.7: resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} engines: {node: '>=18'} @@ -4585,21 +4568,9 @@ packages: resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} engines: {node: '>=12'} - is-plain-obj@4.1.0: - resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} - engines: {node: '>=12'} - is-property@1.0.2: resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} - is-stream@4.0.1: - resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} - engines: {node: '>=18'} - - is-unicode-supported@2.1.0: - resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} - engines: {node: '>=18'} - is-unsafe@1.0.1: resolution: {integrity: sha512-CLK2+VdgERgD96EYm5lUQssZYlRg2tkZnbsxZoacmSiRxiFJ4Nk4SzjCl+Ur+v3kXIY9dTIdb3IH22y1mZ56LA==} @@ -4954,10 +4925,6 @@ packages: engines: {node: ^18.17.0 || >=20.5.0} hasBin: true - npm-run-path@6.0.0: - resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} - engines: {node: '>=18'} - obug@2.1.4: resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} @@ -4991,10 +4958,6 @@ packages: pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} - parse-ms@4.0.0: - resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} - engines: {node: '>=18'} - parseley@0.12.1: resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==} @@ -5010,10 +4973,6 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} - path-key@4.0.0: - resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} - engines: {node: '>=12'} - path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -5131,10 +5090,6 @@ packages: engines: {node: '>=14'} hasBin: true - pretty-ms@9.3.1: - resolution: {integrity: sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==} - engines: {node: '>=18'} - prisma@7.9.0: resolution: {integrity: sha512-isQTJEK4pyOlAVzm6kBUDjzgdsgs0A/snpB38ycTHeOHW34qfepP+ClQltgDXqjZBnXALhEtE4duh9L3tN5fHw==} engines: {node: ^20.19 || ^22.12 || >=24.0} @@ -5480,10 +5435,6 @@ packages: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} - strip-final-newline@4.0.0: - resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} - engines: {node: '>=18'} - strnum@2.4.1: resolution: {integrity: sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==} @@ -5644,10 +5595,6 @@ packages: unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} - unicorn-magic@0.3.0: - resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} - engines: {node: '>=18'} - uniku@0.5.0: resolution: {integrity: sha512-giSrg7xqM5YWkSlyheulHgTTInhYh/m0cFZOOuChi/TO87hKlxmZLllETlvDw/lPB54NIs1iW/x2rr0y2yFXHg==} engines: {node: '>=20.19.0'} @@ -5891,10 +5838,6 @@ packages: resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==} engines: {node: '>=12'} - yoctocolors@2.2.0: - resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==} - engines: {node: '>=18'} - yoga-layout@3.2.1: resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} @@ -7616,8 +7559,6 @@ snapshots: estree-walker: 2.0.2 picomatch: 4.0.5 - '@sec-ant/readable-stream@0.4.1': {} - '@selderee/plugin-htmlparser2@0.11.0': dependencies: domhandler: 5.0.3 @@ -7663,8 +7604,6 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} - '@sindresorhus/merge-streams@4.0.0': {} - '@smithy/core@3.29.4': dependencies: '@smithy/types': 4.16.1 @@ -7769,6 +7708,10 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/cross-spawn@6.0.6': + dependencies: + '@types/node': 26.1.1 + '@types/d3-array@3.0.3': {} '@types/d3-color@3.1.0': {} @@ -8595,21 +8538,6 @@ snapshots: eventemitter3@5.0.4: {} - execa@9.6.1: - dependencies: - '@sindresorhus/merge-streams': 4.0.0 - cross-spawn: 7.0.6 - figures: 6.1.0 - get-stream: 9.0.1 - human-signals: 8.0.1 - is-plain-obj: 4.1.0 - is-stream: 4.0.1 - npm-run-path: 6.0.0 - pretty-ms: 9.3.1 - signal-exit: 4.1.0 - strip-final-newline: 4.0.0 - yoctocolors: 2.2.0 - expect-type@1.4.0: {} exsolve@1.1.0: {} @@ -8672,10 +8600,6 @@ snapshots: optionalDependencies: picomatch: 4.0.5 - figures@6.1.0: - dependencies: - is-unicode-supported: 2.1.0 - file-uri-to-path@1.0.0: {} fill-range@7.1.1: @@ -8716,11 +8640,6 @@ snapshots: get-port@7.2.0: {} - get-stream@9.0.1: - dependencies: - '@sec-ant/readable-stream': 0.4.1 - is-stream: 4.0.1 - get-tsconfig@5.0.0-beta.5: dependencies: resolve-pkg-maps: 1.0.0 @@ -8805,8 +8724,6 @@ snapshots: transitivePeerDependencies: - supports-color - human-signals@8.0.1: {} - husky@9.1.7: {} iconv-lite@0.7.2: @@ -8888,14 +8805,8 @@ snapshots: is-path-inside@4.0.0: {} - is-plain-obj@4.1.0: {} - is-property@1.0.2: {} - is-stream@4.0.1: {} - - is-unicode-supported@2.1.0: {} - is-unsafe@1.0.1: {} isarray@1.0.0: {} @@ -9252,11 +9163,6 @@ snapshots: dependencies: abbrev: 3.0.1 - npm-run-path@6.0.0: - dependencies: - path-key: 4.0.0 - unicorn-magic: 0.3.0 - obug@2.1.4: {} ohash@2.0.11: {} @@ -9287,8 +9193,6 @@ snapshots: pako@1.0.11: {} - parse-ms@4.0.0: {} - parseley@0.12.1: dependencies: leac: 0.6.0 @@ -9300,8 +9204,6 @@ snapshots: path-key@3.1.1: {} - path-key@4.0.0: {} - path-parse@1.0.7: {} path-scurry@2.0.2: @@ -9400,10 +9302,6 @@ snapshots: prettier@3.9.6: {} - pretty-ms@9.3.1: - dependencies: - parse-ms: 4.0.0 - prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3): dependencies: '@prisma/config': 7.9.0 @@ -9803,8 +9701,6 @@ snapshots: strip-bom@3.0.0: {} - strip-final-newline@4.0.0: {} - strnum@2.4.1: dependencies: anynum: 1.0.1 @@ -9944,8 +9840,6 @@ snapshots: dependencies: pathe: 2.0.3 - unicorn-magic@0.3.0: {} - uniku@0.5.0: dependencies: '@noble/hashes': 2.2.0 @@ -10131,8 +10025,6 @@ snapshots: dependencies: pend: 1.2.0 - yoctocolors@2.2.0: {} - yoga-layout@3.2.1: {} yuku-ast@0.1.7: From 242839582fb6bda97f8541c18e49984263009156 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Wed, 2 Sep 2026 17:16:46 +0530 Subject: [PATCH 03/22] ci: verify package-bin spawning on Windows Signed-off-by: Aman Varshney --- .../workflows/windows-spawn-diagnostic.yml | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .github/workflows/windows-spawn-diagnostic.yml diff --git a/.github/workflows/windows-spawn-diagnostic.yml b/.github/workflows/windows-spawn-diagnostic.yml new file mode 100644 index 00000000..884f5bf5 --- /dev/null +++ b/.github/workflows/windows-spawn-diagnostic.yml @@ -0,0 +1,26 @@ +name: Windows spawn diagnostic + +on: + pull_request: + +permissions: + contents: read + +jobs: + spawn: + runs-on: windows-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + persist-credentials: false + + - uses: ./.github/actions/setup + + - run: pnpm install --frozen-lockfile --ignore-scripts + + - name: Verify package-bin spawning + run: >- + pnpm exec bun test + packages/0-framework/3-tooling/cli/src/family/__tests__/runtime.test.ts + packages/0-framework/3-tooling/cli/src/__tests__/run-alchemy.test.ts From cd00ba68ce46d1d6a02311ba29930602fe0d3a5c Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Wed, 2 Sep 2026 17:18:59 +0530 Subject: [PATCH 04/22] ci: build dependencies before Windows diagnostic Signed-off-by: Aman Varshney --- .github/workflows/windows-spawn-diagnostic.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/windows-spawn-diagnostic.yml b/.github/workflows/windows-spawn-diagnostic.yml index 884f5bf5..1cb8c3db 100644 --- a/.github/workflows/windows-spawn-diagnostic.yml +++ b/.github/workflows/windows-spawn-diagnostic.yml @@ -19,6 +19,8 @@ jobs: - run: pnpm install --frozen-lockfile --ignore-scripts + - run: pnpm turbo run build --filter=@internal/cli... + - name: Verify package-bin spawning run: >- pnpm exec bun test From b1b49501e7719f5a40b9ce82fb4844844544e0b9 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Wed, 2 Sep 2026 17:20:57 +0530 Subject: [PATCH 05/22] ci: remove Windows spawn diagnostic Signed-off-by: Aman Varshney --- .../workflows/windows-spawn-diagnostic.yml | 28 ------------------- 1 file changed, 28 deletions(-) delete mode 100644 .github/workflows/windows-spawn-diagnostic.yml diff --git a/.github/workflows/windows-spawn-diagnostic.yml b/.github/workflows/windows-spawn-diagnostic.yml deleted file mode 100644 index 1cb8c3db..00000000 --- a/.github/workflows/windows-spawn-diagnostic.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Windows spawn diagnostic - -on: - pull_request: - -permissions: - contents: read - -jobs: - spawn: - runs-on: windows-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - with: - persist-credentials: false - - - uses: ./.github/actions/setup - - - run: pnpm install --frozen-lockfile --ignore-scripts - - - run: pnpm turbo run build --filter=@internal/cli... - - - name: Verify package-bin spawning - run: >- - pnpm exec bun test - packages/0-framework/3-tooling/cli/src/family/__tests__/runtime.test.ts - packages/0-framework/3-tooling/cli/src/__tests__/run-alchemy.test.ts From ccb6955e7d00fcaf7f4a2138ea887ed86d581b28 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Wed, 2 Sep 2026 18:54:09 +0530 Subject: [PATCH 06/22] test(cli): verify spawned argument boundaries Signed-off-by: Aman Varshney --- .../3-tooling/cli/src/family/__tests__/runtime.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/0-framework/3-tooling/cli/src/family/__tests__/runtime.test.ts b/packages/0-framework/3-tooling/cli/src/family/__tests__/runtime.test.ts index 125174b4..c60545fc 100644 --- a/packages/0-framework/3-tooling/cli/src/family/__tests__/runtime.test.ts +++ b/packages/0-framework/3-tooling/cli/src/family/__tests__/runtime.test.ts @@ -244,7 +244,7 @@ describe('createRuntime()', () => { const bin = path.join(dir, 'fake-package-bin'); fs.writeFileSync( bin, - '#!/usr/bin/env node\nprocess.exit(process.argv[2] === "ok" ? 0 : 1);\n', + '#!/usr/bin/env node\nprocess.exit(process.argv[2] === "ok value" ? 0 : 1);\n', { mode: 0o755, }, @@ -254,7 +254,7 @@ describe('createRuntime()', () => { const child = spawn({ command: bin, - args: ['ok'], + args: ['ok value'], cwd: dir, env: process.env, output: 'inherit', From aec590fe0ef4f2b5b352e87fc0e688d1312dabbf Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Thu, 3 Sep 2026 17:19:57 +0530 Subject: [PATCH 07/22] ci: test spawn adapters on Windows and macOS Signed-off-by: Aman Varshney --- .github/workflows/ci.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a52285be..ec6526f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,6 +101,29 @@ jobs: - name: Test scripts (cast-ratchet unit tests) run: pnpm test:scripts + spawn-platforms: + name: Spawn adapters (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest] + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: ./.github/actions/setup + - name: Install dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + - name: Build CLI dependencies + run: pnpm turbo run build --filter=@internal/cli... + - name: Test child-process adapters + run: >- + pnpm exec bun test + packages/0-framework/3-tooling/cli/src/family/__tests__/runtime.test.ts + packages/0-framework/3-tooling/cli/src/__tests__/run-alchemy.test.ts + node-floor: name: Node 22.18 floor # The published packages declare `engines.node: >=22.18.0`, but every suite From 817e5268985914bc5c2b6164ab709ee9fe643b71 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Thu, 3 Sep 2026 17:44:12 +0530 Subject: [PATCH 08/22] ci: run full suite on Windows and macOS Signed-off-by: Aman Varshney --- .github/workflows/ci.yml | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec6526f7..ec9e6fac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,10 +101,10 @@ jobs: - name: Test scripts (cast-ratchet unit tests) run: pnpm test:scripts - spawn-platforms: - name: Spawn adapters (${{ matrix.os }}) + platform-tests: + name: Test (${{ matrix.os }}) runs-on: ${{ matrix.os }} - timeout-minutes: 15 + timeout-minutes: 30 strategy: fail-fast: false matrix: @@ -114,15 +114,21 @@ jobs: with: persist-credentials: false - uses: ./.github/actions/setup + - name: Install PostgreSQL tools (macOS) + if: runner.os == 'macOS' + run: | + brew install postgresql@17 + echo "$(brew --prefix postgresql@17)/bin" >> "$GITHUB_PATH" + - name: Add preinstalled PostgreSQL tools to PATH (Windows) + if: runner.os == 'Windows' + shell: bash + run: echo "$PGBIN" >> "$GITHUB_PATH" - name: Install dependencies - run: pnpm install --frozen-lockfile --ignore-scripts - - name: Build CLI dependencies - run: pnpm turbo run build --filter=@internal/cli... - - name: Test child-process adapters - run: >- - pnpm exec bun test - packages/0-framework/3-tooling/cli/src/family/__tests__/runtime.test.ts - packages/0-framework/3-tooling/cli/src/__tests__/run-alchemy.test.ts + run: pnpm install --frozen-lockfile + - name: Build packages + run: pnpm build + - name: Test + run: pnpm test node-floor: name: Node 22.18 floor From 879634901f5e48a49f9b40be363e30697e6ddd73 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Thu, 3 Sep 2026 17:57:07 +0530 Subject: [PATCH 09/22] fix(scripts): quote skill glob cross-platform Signed-off-by: Aman Varshney --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e665bffe..85d5943b 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "test:conformance:local": "pnpm --filter @internal/streams test:conformance:local", "typecheck": "turbo run typecheck", "clean": "turbo run clean", - "prepare": "husky && skills add prisma/skills --skill '*' --agent universal claude-code -y && skills add ./skills-contrib --skill '*' --agent universal claude-code -y && node scripts/sync-agent-rules.mjs", + "prepare": "husky && skills add prisma/skills --skill \"*\" --agent universal claude-code -y && skills add ./skills-contrib --skill \"*\" --agent universal claude-code -y && node scripts/sync-agent-rules.mjs", "lint:deps": "depcruise --config dependency-cruiser.config.mjs packages examples test website && node scripts/lint-architecture-coverage.mjs && node scripts/lint-publishable-location.mjs && node scripts/lint-framework-vocabulary.mjs && node scripts/lint-orm-pins.mjs && node scripts/lint-contract-snapshots.mjs" }, "devDependencies": { From 1ec10c4e2cab735428f2ef449e802f29740cb59e Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Thu, 3 Sep 2026 18:00:32 +0530 Subject: [PATCH 10/22] ci: use PostgreSQL 16 across platform tests Signed-off-by: Aman Varshney --- .github/workflows/ci.yml | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec9e6fac..0852df19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,20 +114,21 @@ jobs: with: persist-credentials: false - uses: ./.github/actions/setup - - name: Install PostgreSQL tools (macOS) - if: runner.os == 'macOS' - run: | - brew install postgresql@17 - echo "$(brew --prefix postgresql@17)/bin" >> "$GITHUB_PATH" - - name: Add preinstalled PostgreSQL tools to PATH (Windows) - if: runner.os == 'Windows' - shell: bash - run: echo "$PGBIN" >> "$GITHUB_PATH" + - name: Start PostgreSQL 16 + id: postgres + uses: ikalnytskyi/action-setup-postgres@c4dda34aae1c821e3a771b68b73b13af3198a7ee # v8 + with: + username: postgres + password: postgres + database: postgres + postgres-version: '16' - name: Install dependencies run: pnpm install --frozen-lockfile - name: Build packages run: pnpm build - name: Test + env: + STATE_TEST_DATABASE_URL: ${{ steps.postgres.outputs.connection-uri }} run: pnpm test node-floor: From 4504365d0ac1c6c1560c775dd26b76fffd42e8ff Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Thu, 3 Sep 2026 18:12:40 +0530 Subject: [PATCH 11/22] fix(build): handle Windows paths and symlinks Signed-off-by: Aman Varshney --- .../core/src/__tests__/invariants.test.ts | 5 +++- .../nextjs/src/__tests__/assemble.test.ts | 14 +++++---- .../node/src/__tests__/assemble.test.ts | 30 ++++++++++++++----- .../2-authoring/node/src/control/build.ts | 3 +- 4 files changed, 37 insertions(+), 15 deletions(-) diff --git a/packages/0-framework/1-core/core/src/__tests__/invariants.test.ts b/packages/0-framework/1-core/core/src/__tests__/invariants.test.ts index b92e82d3..e99a76f1 100644 --- a/packages/0-framework/1-core/core/src/__tests__/invariants.test.ts +++ b/packages/0-framework/1-core/core/src/__tests__/invariants.test.ts @@ -14,7 +14,10 @@ function shippedSources(): { file: string; text: string }[] { if (entry.isDirectory()) { if (entry.name !== '__tests__') walk(full); } else if (entry.name.endsWith('.ts')) { - out.push({ file: path.relative(srcDir, full), text: fs.readFileSync(full, 'utf8') }); + out.push({ + file: path.relative(srcDir, full).split(path.sep).join('/'), + text: fs.readFileSync(full, 'utf8'), + }); } } }; diff --git a/packages/0-framework/2-authoring/nextjs/src/__tests__/assemble.test.ts b/packages/0-framework/2-authoring/nextjs/src/__tests__/assemble.test.ts index ea8245ea..b17fadbf 100644 --- a/packages/0-framework/2-authoring/nextjs/src/__tests__/assemble.test.ts +++ b/packages/0-framework/2-authoring/nextjs/src/__tests__/assemble.test.ts @@ -34,7 +34,7 @@ function writeNextBuild(root: string): { appRel: string } { fs.writeFileSync(path.join(appOut, 'server.js'), '// standalone server\n'); fs.mkdirSync(path.join(standalone, 'node_modules', 'next'), { recursive: true }); fs.writeFileSync(path.join(standalone, 'node_modules', 'next', 'marker.txt'), 'next\n'); - fs.symlinkSync('next', path.join(standalone, 'node_modules', 'next-linked')); + fs.symlinkSync('next', path.join(standalone, 'node_modules', 'next-linked'), 'dir'); // Client assets — omitted from standalone by Next, at the app root. fs.mkdirSync(path.join(root, '.next', 'static'), { recursive: true }); fs.writeFileSync(path.join(root, '.next', 'static', 'chunk.js'), '// static asset\n'); @@ -148,7 +148,7 @@ describe('assemble()', () => { fs.writeFileSync(path.join(source, 'index.js'), 'module.exports = "6.3.1";\n'); const linkDir = path.join(standalone, 'node_modules', '.pnpm', 'node_modules'); fs.mkdirSync(linkDir, { recursive: true }); - fs.symlinkSync('../semver@6.3.1/node_modules/semver', path.join(linkDir, 'semver')); + fs.symlinkSync('../semver@6.3.1/node_modules/semver', path.join(linkDir, 'semver'), 'dir'); const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'prisma-composer-nextjs-cwd-')); tmpDirs.push(cwd); @@ -200,7 +200,7 @@ describe('assemble()', () => { const standalone = path.join(root, '.next', 'standalone'); const linkDir = path.join(standalone, 'node_modules', '.pnpm', 'node_modules'); fs.mkdirSync(linkDir, { recursive: true }); - fs.symlinkSync('../semver@6.3.1/node_modules/semver', path.join(linkDir, 'semver')); + fs.symlinkSync('../semver@6.3.1/node_modules/semver', path.join(linkDir, 'semver'), 'dir'); const manifestPath = path.join(root, '.next', 'required-server-files.json'); fs.writeFileSync(manifestPath, JSON.stringify({ relativeAppDir: 'apps/web', config: {} })); @@ -229,10 +229,14 @@ describe('assemble()', () => { fs.writeFileSync(path.join(source, 'index.js'), 'module.exports = "6.3.1";\n'); fs.writeFileSync(path.join(root, 'outside-the-bundle.txt'), 'must not ship'); // Copied verbatim into the bundle by staging, where it points outside. - fs.symlinkSync(path.join(root, 'outside-the-bundle.txt'), path.join(source, 'escaped.txt')); + fs.symlinkSync( + path.join(root, 'outside-the-bundle.txt'), + path.join(source, 'escaped.txt'), + 'file', + ); const linkDir = path.join(standalone, 'node_modules', '.pnpm', 'node_modules'); fs.mkdirSync(linkDir, { recursive: true }); - fs.symlinkSync('../semver@6.3.1/node_modules/semver', path.join(linkDir, 'semver')); + fs.symlinkSync('../semver@6.3.1/node_modules/semver', path.join(linkDir, 'semver'), 'dir'); const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'prisma-composer-nextjs-cwd-')); tmpDirs.push(cwd); diff --git a/packages/0-framework/2-authoring/node/src/__tests__/assemble.test.ts b/packages/0-framework/2-authoring/node/src/__tests__/assemble.test.ts index 99a0959c..b694e131 100644 --- a/packages/0-framework/2-authoring/node/src/__tests__/assemble.test.ts +++ b/packages/0-framework/2-authoring/node/src/__tests__/assemble.test.ts @@ -115,7 +115,7 @@ describe('assemble()', () => { address: 'svc', cwd: makeCwd(), }), - ).rejects.toThrow(/no built entry at .*dist\/server\.js/); + ).rejects.toThrow(/no built entry at .*dist[\\/]server\.js/); }); test('rejects an entry that resolves inside the deploy-owned working dir', async () => { @@ -403,7 +403,7 @@ describe('assemble() — the directory form', () => { address: 'svc', cwd: makeCwd(), }), - ).rejects.toThrow(/no built directory at .*dist\/server/); + ).rejects.toThrow(/no built directory at .*dist[\\/]server/); }); test('rejects a dir that is a file — that is the single-file form, without dir', async () => { @@ -431,7 +431,7 @@ describe('assemble() — the directory form', () => { address: 'svc', cwd: makeCwd(), }), - ).rejects.toThrow(/no built entry at .*server\/start\.js.*resolves inside dir/s); + ).rejects.toThrow(/no built entry at .*server[\\/]start\.js.*resolves inside dir/s); }); test('rejects an entry that escapes dir with ../ — the file it names exists, so only the escape can reject it', async () => { @@ -523,6 +523,7 @@ describe('assemble() — the directory form', () => { fs.symlinkSync( path.join(serviceDir, 'dist', 'shared', 'util.js'), path.join(serviceDir, 'dist', 'server', 'util.js'), + 'file', ); writeServiceModule(serviceDir); @@ -532,7 +533,7 @@ describe('assemble() — the directory form', () => { address: 'svc', cwd: makeCwd(), }), - ).rejects.toThrow(/symlink whose target escapes the bundle.*bundle\/util\.js/s); + ).rejects.toThrow(/symlink whose target escapes the bundle.*bundle[\\/]util\.js/s); }); test('rejects an escaping directory symlink without descending into it', async () => { @@ -544,6 +545,7 @@ describe('assemble() — the directory form', () => { fs.symlinkSync( path.join(serviceDir, 'dist', 'shared'), path.join(serviceDir, 'dist', 'server', 'vendor'), + 'dir', ); writeServiceModule(serviceDir); @@ -553,7 +555,7 @@ describe('assemble() — the directory form', () => { address: 'svc', cwd: makeCwd(), }), - ).rejects.toThrow(/symlink whose target escapes the bundle.*bundle\/vendor/s); + ).rejects.toThrow(/symlink whose target escapes the bundle.*bundle[\\/]vendor/s); }); test('preserves a relative directory symlink whose target stays inside the built tree', async () => { @@ -562,7 +564,11 @@ describe('assemble() — the directory form', () => { 'start.js': 'export default "app-entry";\n', 'node_modules/real/index.js': 'export const value = 1;\n', }); - fs.symlinkSync('real', path.join(serviceDir, 'dist', 'server', 'node_modules', 'linked')); + fs.symlinkSync( + 'real', + path.join(serviceDir, 'dist', 'server', 'node_modules', 'linked'), + 'dir', + ); writeServiceModule(serviceDir); const result = await assemble({ @@ -586,7 +592,11 @@ describe('assemble() — the directory form', () => { writeTree(path.join(serviceDir, 'dist', 'real'), { 'start.js': 'export default "app-entry";\n', }); - fs.symlinkSync(path.join(serviceDir, 'dist', 'real'), path.join(serviceDir, 'dist', 'server')); + fs.symlinkSync( + path.join(serviceDir, 'dist', 'real'), + path.join(serviceDir, 'dist', 'server'), + 'dir', + ); writeServiceModule(serviceDir); await expect( @@ -609,6 +619,7 @@ describe('assemble() — the directory form', () => { fs.symlinkSync( path.join(serviceDir, 'dist-real-file.js'), path.join(serviceDir, 'dist', 'server'), + 'file', ); writeServiceModule(serviceDir); @@ -715,6 +726,7 @@ describe('assemble() — the directory form', () => { fs.symlinkSync( path.relative(serviceNodeModules, workspacePackage), path.join(serviceNodeModules, 'runtime-fixture'), + 'dir', ); writeServiceModule(serviceDir); @@ -779,6 +791,7 @@ describe('assemble() — the directory form', () => { fs.symlinkSync( path.relative(serviceNodeModules, storePackage), path.join(serviceNodeModules, 'dep'), + 'dir', ); writeServiceModule(serviceDir); @@ -842,6 +855,7 @@ describe('assemble() — the directory form', () => { fs.symlinkSync( path.relative(path.join(serviceDir, 'node_modules'), nestedLib), path.join(serviceDir, 'node_modules', 'lib'), + 'dir', ); writeServiceModule(serviceDir); @@ -851,6 +865,6 @@ describe('assemble() — the directory form', () => { address: 'svc', cwd: makeCwd(), }), - ).rejects.toThrow(/stage to the same bundle path.*node_modules\/dup.*packages\/lib/s); + ).rejects.toThrow(/stage to the same bundle path.*node_modules[\\/]dup.*packages[\\/]lib/s); }, 20_000); }); diff --git a/packages/0-framework/2-authoring/node/src/control/build.ts b/packages/0-framework/2-authoring/node/src/control/build.ts index e63e9cbc..4b677b69 100644 --- a/packages/0-framework/2-authoring/node/src/control/build.ts +++ b/packages/0-framework/2-authoring/node/src/control/build.ts @@ -214,7 +214,8 @@ async function copyTracedEntry( ? path.join(bundleDir, path.relative(dirPath, realTarget)) : stagedRuntimePath(realTarget, stagingRoot, bundleDir); const linkTarget = path.relative(path.dirname(destination), stagedTarget); - await fs.promises.symlink(linkTarget, destination); + const linkType = (await fs.promises.stat(realTarget)).isDirectory() ? 'dir' : 'file'; + await fs.promises.symlink(linkTarget, destination, linkType); return; } if (stat.isDirectory()) { From 8b2ef047e4364d702e5f213e09bd8b4e4feddbfb Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Thu, 3 Sep 2026 18:40:06 +0530 Subject: [PATCH 12/22] fix(build): preserve directory links on Windows Signed-off-by: Aman Varshney --- .../bundle-paths/src/bundle-paths.ts | 34 +++++++++++++++++++ .../bundle-paths/src/exports/index.ts | 2 +- .../nextjs/src/__tests__/assemble.test.ts | 9 +++-- .../2-authoring/nextjs/src/control/build.ts | 19 +++-------- .../node/src/__tests__/assemble.test.ts | 4 ++- .../2-authoring/node/src/control/build.ts | 5 ++- .../src/__tests__/artifact-extract.test.ts | 13 +++++-- .../lowering/src/__tests__/artifact.test.ts | 12 ++++--- 8 files changed, 68 insertions(+), 30 deletions(-) diff --git a/packages/0-framework/2-authoring/bundle-paths/src/bundle-paths.ts b/packages/0-framework/2-authoring/bundle-paths/src/bundle-paths.ts index 332c8fef..f75c4b65 100644 --- a/packages/0-framework/2-authoring/bundle-paths/src/bundle-paths.ts +++ b/packages/0-framework/2-authoring/bundle-paths/src/bundle-paths.ts @@ -8,6 +8,40 @@ import fs from 'node:fs'; import path from 'node:path'; +/** Repairs the one piece of link metadata `fs.cp` loses on Windows: whether a + * relative symlink targets a directory. Without the explicit type, Node + * recreates it as a file link and the copied tree contains a dangling link. */ +async function repairWindowsDirectorySymlinks(source: string, destination: string): Promise { + const sourceStat = await fs.promises.lstat(source); + if (sourceStat.isSymbolicLink()) { + try { + if (!(await fs.promises.stat(source)).isDirectory()) return; + } catch { + // Keep a dangling source link dangling so bundle validation reports it. + return; + } + const target = await fs.promises.readlink(source); + await fs.promises.rm(destination, { recursive: true, force: true }); + await fs.promises.symlink(target, destination, 'dir'); + return; + } + if (!sourceStat.isDirectory()) return; + await Promise.all( + (await fs.promises.readdir(source)).map((entry) => + repairWindowsDirectorySymlinks(path.join(source, entry), path.join(destination, entry)), + ), + ); +} + +/** Copies a file tree without dereferencing links, retaining the native + * implementation's performance and metadata behavior. */ +export async function copyTreeVerbatim(source: string, destination: string): Promise { + await fs.promises.cp(source, destination, { recursive: true, verbatimSymlinks: true }); + if (process.platform === 'win32') { + await repairWindowsDirectorySymlinks(source, destination); + } +} + /** Lexical containment: `candidate` is `root` itself or below it. Both paths * must already be absolute or share a resolution base; no filesystem access. */ export function isWithin(root: string, candidate: string): boolean { diff --git a/packages/0-framework/2-authoring/bundle-paths/src/exports/index.ts b/packages/0-framework/2-authoring/bundle-paths/src/exports/index.ts index de844f72..eb7658d7 100644 --- a/packages/0-framework/2-authoring/bundle-paths/src/exports/index.ts +++ b/packages/0-framework/2-authoring/bundle-paths/src/exports/index.ts @@ -1,2 +1,2 @@ /** Public surface. Implementation lives in `../bundle-paths.ts`. */ -export { assertBundleSymlinksStayInside, isWithin } from '../bundle-paths.ts'; +export { assertBundleSymlinksStayInside, copyTreeVerbatim, isWithin } from '../bundle-paths.ts'; diff --git a/packages/0-framework/2-authoring/nextjs/src/__tests__/assemble.test.ts b/packages/0-framework/2-authoring/nextjs/src/__tests__/assemble.test.ts index b17fadbf..c7348893 100644 --- a/packages/0-framework/2-authoring/nextjs/src/__tests__/assemble.test.ts +++ b/packages/0-framework/2-authoring/nextjs/src/__tests__/assemble.test.ts @@ -167,9 +167,12 @@ describe('assemble()', () => { 'node_modules', '.pnpm', ); - expect(fs.readlinkSync(path.join(bundleStore, 'node_modules', 'semver'))).toBe( - '../semver@6.3.1/node_modules/semver', - ); + expect( + fs + .readlinkSync(path.join(bundleStore, 'node_modules', 'semver')) + .split(path.sep) + .join('/'), + ).toBe('../semver@6.3.1/node_modules/semver'); expect( fs.readFileSync( path.join(bundleStore, 'semver@6.3.1', 'node_modules', 'semver', 'index.js'), diff --git a/packages/0-framework/2-authoring/nextjs/src/control/build.ts b/packages/0-framework/2-authoring/nextjs/src/control/build.ts index 85dde5e7..d6759453 100644 --- a/packages/0-framework/2-authoring/nextjs/src/control/build.ts +++ b/packages/0-framework/2-authoring/nextjs/src/control/build.ts @@ -28,7 +28,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { assertBundleSymlinksStayInside, isWithin } from '@internal/bundle-paths'; +import { assertBundleSymlinksStayInside, copyTreeVerbatim, isWithin } from '@internal/bundle-paths'; import type { BuildAdapter } from '@internal/core'; import type { ExtensionDescriptor } from '@internal/core/config'; import type { AssembleInput, Bundle } from '@internal/core/deploy'; @@ -184,7 +184,7 @@ async function stageMissingStandaloneLinkTargets( if (!isWithin(tracedRootReal, sourceReal)) continue; await fs.promises.mkdir(path.dirname(target), { recursive: true }); - await fs.promises.cp(source, target, { recursive: true, verbatimSymlinks: true }); + await copyTreeVerbatim(source, target); stagedSources.add(source); staged = true; } @@ -230,10 +230,7 @@ export async function assemble(input: AssembleInput): Promise { // Ship the standalone tree as `next build` produced it. Framework-emitted // links stay links; the packager validates that every target remains inside // the assembled bundle before emitting it into the archive. - await fs.promises.cp(standaloneRoot, bundleDir, { - recursive: true, - verbatimSymlinks: true, - }); + await copyTreeVerbatim(standaloneRoot, bundleDir); const stagedLinkTargets = await stageMissingStandaloneLinkTargets(bundleDir, manifest); // The documented copy: Next omits the client assets from standalone; place @@ -242,17 +239,11 @@ export async function assemble(input: AssembleInput): Promise { const appOut = path.join(bundleDir, appRel); const staticSrc = path.join(appDir, '.next', 'static'); if (fs.existsSync(staticSrc)) { - await fs.promises.cp(staticSrc, path.join(appOut, '.next', 'static'), { - recursive: true, - verbatimSymlinks: true, - }); + await copyTreeVerbatim(staticSrc, path.join(appOut, '.next', 'static')); } const publicSrc = path.join(appDir, 'public'); if (fs.existsSync(publicSrc)) { - await fs.promises.cp(publicSrc, path.join(appOut, 'public'), { - recursive: true, - verbatimSymlinks: true, - }); + await copyTreeVerbatim(publicSrc, path.join(appOut, 'public')); } // Fail here, at the cause, rather than in the packager: a dangling or diff --git a/packages/0-framework/2-authoring/node/src/__tests__/assemble.test.ts b/packages/0-framework/2-authoring/node/src/__tests__/assemble.test.ts index b694e131..9fe8ae8b 100644 --- a/packages/0-framework/2-authoring/node/src/__tests__/assemble.test.ts +++ b/packages/0-framework/2-authoring/node/src/__tests__/assemble.test.ts @@ -817,7 +817,9 @@ describe('assemble() — the directory form', () => { // own resolution finds the dependency the same way it did before assembly. const linked = path.join(first.dir, 'bundle', 'node_modules', 'dep'); expect(fs.lstatSync(linked).isSymbolicLink()).toBe(true); - expect(fs.readlinkSync(linked)).toBe('.pnpm/dep@1.0.0/node_modules/dep'); + expect(fs.readlinkSync(linked).split(path.sep).join('/')).toBe( + '.pnpm/dep@1.0.0/node_modules/dep', + ); const loaded = await import(pathToFileURL(path.join(first.dir, first.entry)).href); expect(loaded.default).toBe(marker); diff --git a/packages/0-framework/2-authoring/node/src/control/build.ts b/packages/0-framework/2-authoring/node/src/control/build.ts index 4b677b69..f30bc3c3 100644 --- a/packages/0-framework/2-authoring/node/src/control/build.ts +++ b/packages/0-framework/2-authoring/node/src/control/build.ts @@ -30,7 +30,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { assertBundleSymlinksStayInside, isWithin } from '@internal/bundle-paths'; +import { assertBundleSymlinksStayInside, copyTreeVerbatim, isWithin } from '@internal/bundle-paths'; import type { BuildAdapter } from '@internal/core'; import type { ExtensionDescriptor } from '@internal/core/config'; import type { AssembleInput, Bundle } from '@internal/core/deploy'; @@ -143,8 +143,7 @@ async function resolveDir( source: dirPath, sourceField: 'dir', entry: path.relative(dirPath, entryPath).split(path.sep).join('/'), - copyInto: (bundleDir) => - fs.promises.cp(dirPath, bundleDir, { recursive: true, verbatimSymlinks: true }), + copyInto: (bundleDir) => copyTreeVerbatim(dirPath, bundleDir), }; } diff --git a/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/artifact-extract.test.ts b/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/artifact-extract.test.ts index 4331df77..bb58ac6b 100644 --- a/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/artifact-extract.test.ts +++ b/packages/1-prisma-cloud/0-lowering/local-target/src/__tests__/artifact-extract.test.ts @@ -42,7 +42,9 @@ describe('extractComputeArtifact', () => { 'nested/asset.txt': 'hello world', 'nested/run.sh': '#!/bin/sh\nexit 0\n', }); - fs.chmodSync(path.join(bundleDir, 'nested', 'run.sh'), 0o755); + const executable = path.join(bundleDir, 'nested', 'run.sh'); + fs.chmodSync(executable, 0o755); + const sourceExecutable = fs.statSync(executable).mode & 0o100; fs.symlinkSync('asset.txt', path.join(bundleDir, 'nested', 'asset-link.txt')); const artifact = packageComputeArtifact({ id: 'auth', @@ -60,7 +62,7 @@ describe('extractComputeArtifact', () => { expect(extracted['nested/asset.txt']).toBe('hello world'); expect(extracted['nested/asset-link.txt']).toBe('hello world'); expect(fs.readlinkSync(path.join(destDir, 'nested', 'asset-link.txt'))).toBe('asset.txt'); - expect(fs.statSync(path.join(destDir, 'nested', 'run.sh')).mode & 0o100).toBe(0o100); + expect(fs.statSync(path.join(destDir, 'nested', 'run.sh')).mode & 0o100).toBe(sourceExecutable); expect(extracted['bootstrap.js']).toContain( 'await main.run(boot.address, () => import(boot.appEntrypoint));', ); @@ -161,7 +163,12 @@ describe('extractComputeArtifact', () => { extractComputeArtifact(artifact.path, destDir); - expect(fs.readlinkSync(path.join(destDir, 'node_modules', 'next'))).toBe(longTarget); + expect( + fs + .readlinkSync(path.join(destDir, 'node_modules', 'next')) + .split(path.sep) + .join('/'), + ).toBe(longTarget); expect(fs.readFileSync(path.join(destDir, 'node_modules', 'next', 'index.js'), 'utf8')).toBe( '// real', ); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/artifact.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/artifact.test.ts index bb168104..0a66bd75 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/artifact.test.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/artifact.test.ts @@ -134,9 +134,9 @@ describe('packageComputeArtifact', () => { test('keeps caller-provided entry and address strings out of executable JavaScript', () => { const marker = 'globalThis.COMPROMISED = true'; - const bundleEntry = `main"; ${marker}; ".js`; - const appEntry = `server"; ${marker}; ".js`; - const address = `auth"); ${marker}; ("`; + const bundleEntry = `main\`; ${marker}; \`.js`; + const appEntry = `server\`; ${marker}; \`.js`; + const address = `auth\`); ${marker}; (\``; const bundleDir = makeBundle({ [bundleEntry]: 'export default {};', [appEntry]: 'export default {};', @@ -371,7 +371,9 @@ describe('packageComputeArtifact', () => { 'main.js': 'export default {};', 'node_modules/tool/bin/run': '#!/bin/sh\nexit 0\n', }); - fs.chmodSync(path.join(bundleDir, 'node_modules', 'tool', 'bin', 'run'), 0o755); + const executable = path.join(bundleDir, 'node_modules', 'tool', 'bin', 'run'); + fs.chmodSync(executable, 0o755); + const sourceMode = (fs.statSync(executable).mode & 0o100) !== 0 ? 0o755 : 0o644; const artifact = packageComputeArtifact({ id: 'auth', @@ -381,7 +383,7 @@ describe('packageComputeArtifact', () => { }); const archive = readTar(fs.readFileSync(artifact.path)); - expect(archive.mode('node_modules/tool/bin/run')).toBe(0o755); + expect(archive.mode('node_modules/tool/bin/run')).toBe(sourceMode); expect(archive.mode('main.js')).toBe(0o644); }); From bb728d934ab9a5235e44d49bc094749c3edf3326 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Thu, 3 Sep 2026 18:49:53 +0530 Subject: [PATCH 13/22] fix(nextjs): repair staged directory links on Windows Signed-off-by: Aman Varshney --- .../bundle-paths/src/bundle-paths.ts | 51 ++++++++++--------- .../bundle-paths/src/exports/index.ts | 7 ++- .../2-authoring/nextjs/src/control/build.ts | 11 +++- 3 files changed, 44 insertions(+), 25 deletions(-) diff --git a/packages/0-framework/2-authoring/bundle-paths/src/bundle-paths.ts b/packages/0-framework/2-authoring/bundle-paths/src/bundle-paths.ts index f75c4b65..70178c83 100644 --- a/packages/0-framework/2-authoring/bundle-paths/src/bundle-paths.ts +++ b/packages/0-framework/2-authoring/bundle-paths/src/bundle-paths.ts @@ -10,36 +10,41 @@ import path from 'node:path'; /** Repairs the one piece of link metadata `fs.cp` loses on Windows: whether a * relative symlink targets a directory. Without the explicit type, Node - * recreates it as a file link and the copied tree contains a dangling link. */ -async function repairWindowsDirectorySymlinks(source: string, destination: string): Promise { - const sourceStat = await fs.promises.lstat(source); - if (sourceStat.isSymbolicLink()) { - try { - if (!(await fs.promises.stat(source)).isDirectory()) return; - } catch { - // Keep a dangling source link dangling so bundle validation reports it. - return; + * recreates it as a file link and the copied tree contains a dangling link. + * + * This is also callable after a framework stages an initially missing target: + * only then can we know that the link is a directory link. */ +export async function repairWindowsDirectorySymlinks(root: string): Promise { + if (process.platform !== 'win32') return; + + const visit = async (directory: string): Promise => { + for (const entry of await fs.promises.readdir(directory, { withFileTypes: true })) { + const full = path.join(directory, entry.name); + if (entry.isSymbolicLink()) { + const target = await fs.promises.readlink(full); + const resolvedTarget = path.resolve(path.dirname(full), target); + try { + if (!(await fs.promises.stat(resolvedTarget)).isDirectory()) continue; + } catch { + // Keep a dangling link dangling so bundle validation reports it. + continue; + } + await fs.promises.unlink(full); + await fs.promises.symlink(target, full, 'dir'); + } else if (entry.isDirectory()) { + await visit(full); + } } - const target = await fs.promises.readlink(source); - await fs.promises.rm(destination, { recursive: true, force: true }); - await fs.promises.symlink(target, destination, 'dir'); - return; - } - if (!sourceStat.isDirectory()) return; - await Promise.all( - (await fs.promises.readdir(source)).map((entry) => - repairWindowsDirectorySymlinks(path.join(source, entry), path.join(destination, entry)), - ), - ); + }; + + await visit(root); } /** Copies a file tree without dereferencing links, retaining the native * implementation's performance and metadata behavior. */ export async function copyTreeVerbatim(source: string, destination: string): Promise { await fs.promises.cp(source, destination, { recursive: true, verbatimSymlinks: true }); - if (process.platform === 'win32') { - await repairWindowsDirectorySymlinks(source, destination); - } + await repairWindowsDirectorySymlinks(destination); } /** Lexical containment: `candidate` is `root` itself or below it. Both paths diff --git a/packages/0-framework/2-authoring/bundle-paths/src/exports/index.ts b/packages/0-framework/2-authoring/bundle-paths/src/exports/index.ts index eb7658d7..7ca20571 100644 --- a/packages/0-framework/2-authoring/bundle-paths/src/exports/index.ts +++ b/packages/0-framework/2-authoring/bundle-paths/src/exports/index.ts @@ -1,2 +1,7 @@ /** Public surface. Implementation lives in `../bundle-paths.ts`. */ -export { assertBundleSymlinksStayInside, copyTreeVerbatim, isWithin } from '../bundle-paths.ts'; +export { + assertBundleSymlinksStayInside, + copyTreeVerbatim, + isWithin, + repairWindowsDirectorySymlinks, +} from '../bundle-paths.ts'; diff --git a/packages/0-framework/2-authoring/nextjs/src/control/build.ts b/packages/0-framework/2-authoring/nextjs/src/control/build.ts index d6759453..57481b60 100644 --- a/packages/0-framework/2-authoring/nextjs/src/control/build.ts +++ b/packages/0-framework/2-authoring/nextjs/src/control/build.ts @@ -28,7 +28,12 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { assertBundleSymlinksStayInside, copyTreeVerbatim, isWithin } from '@internal/bundle-paths'; +import { + assertBundleSymlinksStayInside, + copyTreeVerbatim, + isWithin, + repairWindowsDirectorySymlinks, +} from '@internal/bundle-paths'; import type { BuildAdapter } from '@internal/core'; import type { ExtensionDescriptor } from '@internal/core/config'; import type { AssembleInput, Bundle } from '@internal/core/deploy'; @@ -232,6 +237,10 @@ export async function assemble(input: AssembleInput): Promise { // the assembled bundle before emitting it into the archive. await copyTreeVerbatim(standaloneRoot, bundleDir); const stagedLinkTargets = await stageMissingStandaloneLinkTargets(bundleDir, manifest); + // A pnpm link can be dangling when Next emits standalone, then become valid + // only after its omitted virtual-store target is staged above. On Windows, + // now is the first point where Node can recover that it is a directory link. + await repairWindowsDirectorySymlinks(bundleDir); // The documented copy: Next omits the client assets from standalone; place // them beside the app's server.js so it serves them (docs: `cp -r public From cbf364e76006ae9aa20283c67f0663228a0587b9 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Thu, 3 Sep 2026 19:05:41 +0530 Subject: [PATCH 14/22] test: make platform suite portable Signed-off-by: Aman Varshney --- .../cli/src/__tests__/run-report.test.ts | 15 +++--- .../src/family/__tests__/fake-child.test.ts | 54 ++++++++++--------- .../src/__tests__/control-lowering.test.ts | 4 ++ .../target/src/__tests__/invariants.test.ts | 5 +- .../target/src/__tests__/orm-config.test.ts | 4 +- 5 files changed, 49 insertions(+), 33 deletions(-) diff --git a/packages/0-framework/3-tooling/cli/src/__tests__/run-report.test.ts b/packages/0-framework/3-tooling/cli/src/__tests__/run-report.test.ts index eda86aa4..337469a5 100644 --- a/packages/0-framework/3-tooling/cli/src/__tests__/run-report.test.ts +++ b/packages/0-framework/3-tooling/cli/src/__tests__/run-report.test.ts @@ -80,23 +80,24 @@ describe('toRunReport', () => { }); describe('resolveRunReportPath', () => { + const cwd = path.resolve(path.sep, 'work'); + test('the flag wins over the environment variable', () => { - expect(resolveRunReportPath('flag.json', 'env.json', '/work')).toBe('/work/flag.json'); + expect(resolveRunReportPath('flag.json', 'env.json', cwd)).toBe(path.join(cwd, 'flag.json')); }); test('the environment variable applies when no flag was passed', () => { - expect(resolveRunReportPath(undefined, 'env.json', '/work')).toBe('/work/env.json'); + expect(resolveRunReportPath(undefined, 'env.json', cwd)).toBe(path.join(cwd, 'env.json')); }); test('an absolute path is left alone', () => { - expect(resolveRunReportPath('/elsewhere/out.json', undefined, '/work')).toBe( - '/elsewhere/out.json', - ); + const absolute = path.resolve(path.sep, 'elsewhere', 'out.json'); + expect(resolveRunReportPath(absolute, undefined, cwd)).toBe(absolute); }); test('neither asked for means no report is written', () => { - expect(resolveRunReportPath(undefined, undefined, '/work')).toBeUndefined(); - expect(resolveRunReportPath('', '', '/work')).toBeUndefined(); + expect(resolveRunReportPath(undefined, undefined, cwd)).toBeUndefined(); + expect(resolveRunReportPath('', '', cwd)).toBeUndefined(); }); }); diff --git a/packages/0-framework/3-tooling/cli/src/family/__tests__/fake-child.test.ts b/packages/0-framework/3-tooling/cli/src/family/__tests__/fake-child.test.ts index 8b51f544..e535079a 100644 --- a/packages/0-framework/3-tooling/cli/src/family/__tests__/fake-child.test.ts +++ b/packages/0-framework/3-tooling/cli/src/family/__tests__/fake-child.test.ts @@ -52,29 +52,35 @@ describe('the fake child', () => { expect(signal).toBe('SIGTERM'); }); - test('a lingering child scripted to report a signal names it and exits 0', async () => { - const child = spawn(process.execPath, [FIXTURE, '--linger', '--on-signal', 'report']); - // Listening before the kill, and waiting for `close` rather than `exit`: - // `exit` fires when the child terminates, `close` only once its stdio has - // ended, so `close` is what says the report has actually been read. - const chunks: string[] = []; - child.stdout.on('data', (chunk: Buffer) => chunks.push(chunk.toString())); - await new Promise((resolve) => setTimeout(resolve, 150)); - child.kill('SIGINT'); - const code = await new Promise((resolve) => { - child.on('close', (exitCode) => resolve(exitCode)); - }); - expect(code).toBe(0); - expect(chunks.join('')).toBe('signal:SIGINT\n'); - }); + test.skipIf(process.platform === 'win32')( + 'a lingering child scripted to report a signal names it and exits 0', + async () => { + const child = spawn(process.execPath, [FIXTURE, '--linger', '--on-signal', 'report']); + // Listening before the kill, and waiting for `close` rather than `exit`: + // `exit` fires when the child terminates, `close` only once its stdio has + // ended, so `close` is what says the report has actually been read. + const chunks: string[] = []; + child.stdout.on('data', (chunk: Buffer) => chunks.push(chunk.toString())); + await new Promise((resolve) => setTimeout(resolve, 150)); + child.kill('SIGINT'); + const code = await new Promise((resolve) => { + child.on('close', (exitCode) => resolve(exitCode)); + }); + expect(code).toBe(0); + expect(chunks.join('')).toBe('signal:SIGINT\n'); + }, + ); - test('a lingering child scripted to ignore signals survives them — the escalation-ladder case', async () => { - const child = spawn(process.execPath, [FIXTURE, '--linger', '--on-signal', 'ignore']); - await new Promise((resolve) => setTimeout(resolve, 150)); - child.kill('SIGTERM'); - await new Promise((resolve) => setTimeout(resolve, 150)); - expect(child.exitCode).toBeNull(); - child.kill('SIGKILL'); - await new Promise((resolve) => child.on('exit', resolve)); - }); + test.skipIf(process.platform === 'win32')( + 'a lingering child scripted to ignore signals survives them — the escalation-ladder case', + async () => { + const child = spawn(process.execPath, [FIXTURE, '--linger', '--on-signal', 'ignore']); + await new Promise((resolve) => setTimeout(resolve, 150)); + child.kill('SIGTERM'); + await new Promise((resolve) => setTimeout(resolve, 150)); + expect(child.exitCode).toBeNull(); + child.kill('SIGKILL'); + await new Promise((resolve) => child.on('exit', resolve)); + }, + ); }); diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts index 624d125f..e20d62ef 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts @@ -16,6 +16,10 @@ import { secretString } from '@internal/foundation/arktype'; import * as RealPrismaAlchemy from '@internal/lowering'; import * as RealOutput from 'alchemy/Output'; import * as RealAlchemyPrisma from 'alchemy/Prisma'; +// `database-branch-convergence.test.ts` imports this subpath directly. Cache it +// before mocking the parent barrel so Bun cannot replace the subpath module on +// platforms whose test-file order loads this file first. +import 'alchemy/Prisma/Database'; import { type } from 'arktype'; import * as Effect from 'effect/Effect'; import * as Redacted from 'effect/Redacted'; diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts index 207454c6..331e8db7 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/invariants.test.ts @@ -14,7 +14,10 @@ function shippedSources(): { file: string; text: string }[] { if (entry.isDirectory()) { if (entry.name !== '__tests__') walk(full); } else if (entry.name.endsWith('.ts')) { - out.push({ file: path.relative(srcDir, full), text: fs.readFileSync(full, 'utf8') }); + out.push({ + file: path.relative(srcDir, full).split(path.sep).join('/'), + text: fs.readFileSync(full, 'utf8'), + }); } } }; diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/orm-config.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/orm-config.test.ts index 94e33e3e..7c3d1acb 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/orm-config.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/orm-config.test.ts @@ -41,7 +41,9 @@ describe('resolveOrmConfig', () => { const project = await resolveOrmConfig(widgetConfig); // The widget config sets no `migrations.dir`, so PN's default `migrations/` // resolves next to the config file (its `source/` directory). - expect(project.migrationsDir).toBe(path.join(path.dirname(widgetConfig), 'migrations')); + expect(path.normalize(project.migrationsDir)).toBe( + path.join(path.dirname(widgetConfig), 'migrations'), + ); expect(path.isAbsolute(project.migrationsDir)).toBe(true); }); From 5563fdae8a153f95512ee34b19fab047941315d7 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Thu, 3 Sep 2026 19:22:56 +0530 Subject: [PATCH 15/22] fix: make cross-platform validation reliable Signed-off-by: Aman Varshney --- .../cli/src/family/__tests__/host-adapter.test.ts | 10 ++++++++-- .../0-lowering/dev-emulators/src/buckets-main.ts | 12 ++++-------- .../1-prisma-cloud/1-extensions/target/package.json | 2 +- .../target/src/__tests__/control-lowering.test.ts | 4 ---- 4 files changed, 13 insertions(+), 15 deletions(-) diff --git a/packages/0-framework/3-tooling/cli/src/family/__tests__/host-adapter.test.ts b/packages/0-framework/3-tooling/cli/src/family/__tests__/host-adapter.test.ts index 3e4f8c55..8b067a19 100644 --- a/packages/0-framework/3-tooling/cli/src/family/__tests__/host-adapter.test.ts +++ b/packages/0-framework/3-tooling/cli/src/family/__tests__/host-adapter.test.ts @@ -114,8 +114,14 @@ describe('runComposerCli() — the real Runtime, on a command that needs config' ); expect(exitCode).toBe(2); - expect(host.out.join('')).toContain('CLI.CONFIG_NOT_FOUND'); - expect(host.out.join('')).toContain(path.join(dir, 'not-here.config.ts')); + expect(JSON.parse(host.out.join(''))).toMatchObject({ + envelope: { + error: { + code: 'CLI.CONFIG_NOT_FOUND', + where: { path: path.join(dir, 'not-here.config.ts') }, + }, + }, + }); expect(double.calls.dev).toEqual([]); }); diff --git a/packages/1-prisma-cloud/0-lowering/dev-emulators/src/buckets-main.ts b/packages/1-prisma-cloud/0-lowering/dev-emulators/src/buckets-main.ts index 26610aad..bcfcfed7 100644 --- a/packages/1-prisma-cloud/0-lowering/dev-emulators/src/buckets-main.ts +++ b/packages/1-prisma-cloud/0-lowering/dev-emulators/src/buckets-main.ts @@ -161,10 +161,6 @@ function main(): void { let state: BucketsState = { buckets: {}, credentials: {} }; - function schedulePersist(): void { - void stateFile.write(state); - } - const store = fsStore((physicalName) => state.buckets[physicalName]?.dir); function json(res: http.ServerResponse, status: number, body: unknown): void { @@ -211,7 +207,7 @@ function main(): void { await fs.promises.mkdir(parsed.dir, { recursive: true }); state.buckets[physicalName] = { app, name, dir: parsed.dir }; - schedulePersist(); + await stateFile.write(state); res.writeHead(204); res.end(); } @@ -244,19 +240,19 @@ function main(): void { ); } state.credentials[parsed.accessKeyId] = { app, secretAccessKey: parsed.secretAccessKey }; - schedulePersist(); + await stateFile.write(state); res.writeHead(204); res.end(); } - function handleDeleteApp(res: http.ServerResponse, app: string): void { + async function handleDeleteApp(res: http.ServerResponse, app: string): Promise { for (const [key, reg] of Object.entries(state.buckets)) { if (reg.app === app) delete state.buckets[key]; } for (const [key, cred] of Object.entries(state.credentials)) { if (cred.app === app) delete state.credentials[key]; } - schedulePersist(); + await stateFile.write(state); res.writeHead(204); res.end(); } diff --git a/packages/1-prisma-cloud/1-extensions/target/package.json b/packages/1-prisma-cloud/1-extensions/target/package.json index 073f7ede..ab11dcd0 100644 --- a/packages/1-prisma-cloud/1-extensions/target/package.json +++ b/packages/1-prisma-cloud/1-extensions/target/package.json @@ -14,7 +14,7 @@ }, "scripts": { "typecheck": "tsc --noEmit", - "test": "bun test", + "test": "bun test --isolate", "test:types": "vitest --typecheck --run", "build": "tsdown", "clean": "rm -rf dist" diff --git a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts index e20d62ef..624d125f 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/__tests__/control-lowering.test.ts @@ -16,10 +16,6 @@ import { secretString } from '@internal/foundation/arktype'; import * as RealPrismaAlchemy from '@internal/lowering'; import * as RealOutput from 'alchemy/Output'; import * as RealAlchemyPrisma from 'alchemy/Prisma'; -// `database-branch-convergence.test.ts` imports this subpath directly. Cache it -// before mocking the parent barrel so Bun cannot replace the subpath module on -// platforms whose test-file order loads this file first. -import 'alchemy/Prisma/Database'; import { type } from 'arktype'; import * as Effect from 'effect/Effect'; import * as Redacted from 'effect/Redacted'; From 3f49bbf4ebc111a7e7929014924316e77ef7a604 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Thu, 3 Sep 2026 19:33:27 +0530 Subject: [PATCH 16/22] test: align platform suites with supported surfaces Signed-off-by: Aman Varshney --- .github/workflows/ci.yml | 11 ++++ .../family/__tests__/signal-listeners.test.ts | 58 ++++++++++--------- .../operations/__tests__/operations.test.ts | 19 +++++- .../__tests__/entrypoint.integration.test.ts | 5 +- .../test/cli.extension-config.test.ts | 4 +- 5 files changed, 65 insertions(+), 32 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0852df19..b4dffc9e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -127,9 +127,20 @@ jobs: - name: Build packages run: pnpm build - name: Test + if: runner.os != 'Windows' env: STATE_TEST_DATABASE_URL: ${{ steps.postgres.outputs.connection-uri }} run: pnpm test + # Windows local dev/log are explicitly unsupported today, so their + # standalone acceptance scripts are not valid Windows tests. Run every + # package suite plus the supported deploy integration tests instead. + - name: Test (Windows-supported surface) + if: runner.os == 'Windows' + env: + STATE_TEST_DATABASE_URL: ${{ steps.postgres.outputs.connection-uri }} + run: | + pnpm turbo run test --filter="!@prisma/integration-tests" + pnpm --dir test/integration exec bun test node-floor: name: Node 22.18 floor diff --git a/packages/0-framework/3-tooling/cli/src/family/__tests__/signal-listeners.test.ts b/packages/0-framework/3-tooling/cli/src/family/__tests__/signal-listeners.test.ts index 64ce9f9b..50ee7c5e 100644 --- a/packages/0-framework/3-tooling/cli/src/family/__tests__/signal-listeners.test.ts +++ b/packages/0-framework/3-tooling/cli/src/family/__tests__/signal-listeners.test.ts @@ -28,6 +28,7 @@ import { spawnSync } from 'node:child_process'; import * as path from 'node:path'; const FIXTURE = path.join(import.meta.dir, 'fixtures', 'signal-listeners.mjs'); +const SUBPROCESS_TEST_TIMEOUT_MS = 30_000; interface Counts { readonly SIGINT: number; @@ -40,7 +41,10 @@ function listenerCounts(what: 'alchemy' | 'local-target'): { afterConfigEvaluation: Counts; afterLocalTargets: Counts; } { - const result = spawnSync(process.execPath, [FIXTURE, what], { encoding: 'utf-8' }); + const result = spawnSync(process.execPath, [FIXTURE, what], { + encoding: 'utf-8', + timeout: SUBPROCESS_TEST_TIMEOUT_MS, + }); if (result.status !== 0) { throw new Error(`the listener fixture failed (${String(result.status)}): ${result.stderr}`); } @@ -48,33 +52,35 @@ function listenerCounts(what: 'alchemy' | 'local-target'): { } describe('the engine is the sole signal listener', () => { - test('config evaluation registers no SIGINT or SIGTERM listener', () => { - const { before, afterConfigEvaluation } = listenerCounts('alchemy'); + test( + 'config evaluation registers no SIGINT, SIGTERM, or exit listener', + () => { + const { before, afterConfigEvaluation } = listenerCounts('alchemy'); - expect(before.SIGINT).toBe(0); - expect(before.SIGTERM).toBe(0); + expect(before.SIGINT).toBe(0); + expect(before.SIGTERM).toBe(0); - // The whole point: importing the provider tree must leave the signal - // surface exactly as it found it, so the engine's handler is the only one. - expect(afterConfigEvaluation.SIGINT).toBe(0); - expect(afterConfigEvaluation.SIGTERM).toBe(0); - }); + // The whole point: importing the provider tree must leave the signal + // surface exactly as it found it, so the engine's handler is the only one. + expect(afterConfigEvaluation.SIGINT).toBe(0); + expect(afterConfigEvaluation.SIGTERM).toBe(0); + expect(afterConfigEvaluation.exit).toBe(0); + }, + SUBPROCESS_TEST_TIMEOUT_MS, + ); - test("dev and log's local-target resolution registers none either", () => { - const { afterLocalTargets } = listenerCounts('local-target'); + test( + "dev and log's local-target resolution registers none either", + () => { + const { afterLocalTargets } = listenerCounts('local-target'); - expect(afterLocalTargets.SIGINT).toBe(0); - expect(afterLocalTargets.SIGTERM).toBe(0); - // The exit hook too: it is the single registration that installed all - // three upstream, so a local-target import that armed only it would slip - // past a check that looked at the two signals alone. - expect(afterLocalTargets.exit).toBe(0); - }); - - test('no exit hook is armed either, which is what the upstream fix changed', () => { - // Not a signal, but the same registration: the module-scope exitHook that - // installed all three. Asserting it separately says WHICH upstream - // behavior regressed if this suite ever goes red. - expect(listenerCounts('alchemy').afterConfigEvaluation.exit).toBe(0); - }); + expect(afterLocalTargets.SIGINT).toBe(0); + expect(afterLocalTargets.SIGTERM).toBe(0); + // The exit hook too: it is the single registration that installed all + // three upstream, so a local-target import that armed only it would slip + // past a check that looked at the two signals alone. + expect(afterLocalTargets.exit).toBe(0); + }, + SUBPROCESS_TEST_TIMEOUT_MS, + ); }); diff --git a/packages/0-framework/3-tooling/cli/src/operations/__tests__/operations.test.ts b/packages/0-framework/3-tooling/cli/src/operations/__tests__/operations.test.ts index 4e193109..a7d2a85e 100644 --- a/packages/0-framework/3-tooling/cli/src/operations/__tests__/operations.test.ts +++ b/packages/0-framework/3-tooling/cli/src/operations/__tests__/operations.test.ts @@ -859,7 +859,22 @@ function devConfigWith(attachment: LocalTargetAttachment): PrismaAppConfig { }; } -describe('dev()', () => { +describe.skipIf(process.platform !== 'win32')('local operations on Windows', () => { + test('dev and log return their platform refusal before touching the pipeline', async () => { + const devResult = await silently(() => devWithDeps({ entry: 'service.ts' }, {})); + const logResult = await silently(() => logWithDeps({ entry: 'service.ts' }, {})); + + expect(devResult.ok).toBe(false); + if (devResult.ok) throw new Error('unreachable'); + expect(devResult.failure.code).toBe('DEV.PLATFORM_UNSUPPORTED'); + + expect(logResult.ok).toBe(false); + if (logResult.ok) throw new Error('unreachable'); + expect(logResult.failure.code).toBe('LOG.PLATFORM_UNSUPPORTED'); + }); +}); + +describe.skipIf(process.platform === 'win32')('dev()', () => { test('a throw after services start (endpoint merge) is a pipeline failure, and the started services are stopped again', async () => { const app = makeAppDir('hello-dev'); let stops = 0; @@ -1081,7 +1096,7 @@ describe('dev()', () => { }, 15_000); }); -describe('log()', () => { +describe.skipIf(process.platform === 'win32')('log()', () => { test('merges every attachment into one stream and reports the running services', async () => { const attachments = [ linesAttachment([{ address: 'a', url: 'http://a' }], [{ service: 'a', line: 'from-a' }]), diff --git a/packages/1-prisma-cloud/2-shared-modules/streams/src/__tests__/entrypoint.integration.test.ts b/packages/1-prisma-cloud/2-shared-modules/streams/src/__tests__/entrypoint.integration.test.ts index 6663efb8..58fc81c4 100644 --- a/packages/1-prisma-cloud/2-shared-modules/streams/src/__tests__/entrypoint.integration.test.ts +++ b/packages/1-prisma-cloud/2-shared-modules/streams/src/__tests__/entrypoint.integration.test.ts @@ -11,13 +11,14 @@ import { type ChildProcess, spawn } from 'node:child_process'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { createPgStore, startStorageServer } from '@internal/storage/testing'; import { createTestDatabase, startTestPostgres, type TestDatabase } from './pg-harness.ts'; const postgres = startTestPostgres(); const API_KEY = 'streams-integration-key'; -const PACKAGE_ROOT = new URL('../..', import.meta.url).pathname; +const PACKAGE_ROOT = fileURLToPath(new URL('../..', import.meta.url)); let db: TestDatabase; let storageServer: { url: string; stop: () => void }; @@ -58,7 +59,7 @@ function childEnv(): NodeJS.ProcessEnv { } function startServer(): ChildProcess { - const proc = spawn('bun', ['src/exports/streams-entrypoint.ts'], { + const proc = spawn(process.execPath, ['src/exports/streams-entrypoint.ts'], { cwd: PACKAGE_ROOT, env: childEnv(), stdio: ['ignore', 'pipe', 'pipe'], diff --git a/test/integration/test/cli.extension-config.test.ts b/test/integration/test/cli.extension-config.test.ts index a7f24e64..0a5812d0 100644 --- a/test/integration/test/cli.extension-config.test.ts +++ b/test/integration/test/cli.extension-config.test.ts @@ -48,7 +48,7 @@ describe('prisma-composer deploy — real extension-config resolution of prisma- // Spawns the real CLI, which resolves /control entries and evaluates a config — // inherently slower than bun test's default 5000ms, so give it real headroom. test('resolves both /control entries for real and fails at the missing built entry, not at resolution', () => { - const result = spawnSync('bun', [prismaAppBin, 'deploy', fixtureEntry], { + const result = spawnSync(process.execPath, [prismaAppBin, 'deploy', fixtureEntry], { cwd: integrationDir, encoding: 'utf8', env: { @@ -82,7 +82,7 @@ describe('prisma-composer deploy — real extension-config resolution of prisma- const env: NodeJS.ProcessEnv = { ...process.env, PRISMA_SERVICE_TOKEN: serviceToken({}) }; delete env['PRISMA_WORKSPACE_ID']; - const result = spawnSync('bun', [prismaAppBin, 'deploy', fixtureEntry], { + const result = spawnSync(process.execPath, [prismaAppBin, 'deploy', fixtureEntry], { cwd: integrationDir, encoding: 'utf8', env, From 4b50ccf795fd6d373daa90040feb75c0386f6e37 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Thu, 3 Sep 2026 19:46:08 +0530 Subject: [PATCH 17/22] test: run supported Windows integration surface Signed-off-by: Aman Varshney --- .github/workflows/ci.yml | 7 ++++--- pnpm-lock.yaml | 6 ++++++ test/integration/package.json | 2 ++ test/integration/test/cli.engine-shell.test.ts | 4 ++-- test/integration/test/cli.extension-config.test.ts | 6 +++--- 5 files changed, 17 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4dffc9e..93f7d545 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -132,14 +132,15 @@ jobs: STATE_TEST_DATABASE_URL: ${{ steps.postgres.outputs.connection-uri }} run: pnpm test # Windows local dev/log are explicitly unsupported today, so their - # standalone acceptance scripts are not valid Windows tests. Run every - # package suite plus the supported deploy integration tests instead. + # daemon package and standalone acceptance scripts are not valid Windows + # tests. Run every supported package suite plus the deploy integration + # tests instead. - name: Test (Windows-supported surface) if: runner.os == 'Windows' env: STATE_TEST_DATABASE_URL: ${{ steps.postgres.outputs.connection-uri }} run: | - pnpm turbo run test --filter="!@prisma/integration-tests" + pnpm turbo run test --filter="!@prisma/integration-tests" --filter="!@internal/dev-emulators" pnpm --dir test/integration exec bun test node-floor: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a8ce87e3..a361d943 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1447,12 +1447,18 @@ importers: '@types/bun': specifier: ^1.3.13 version: 1.3.14 + '@types/cross-spawn': + specifier: ^6.0.6 + version: 6.0.6 '@types/node': specifier: ^26.0.1 version: 26.1.1 alchemy: specifier: 2.0.0-beta.74 version: 2.0.0-beta.74(@effect/platform-bun@4.0.0-rc.112(effect@4.0.0-rc.112))(@effect/platform-node@4.0.0-rc.112(effect@4.0.0-rc.112)(redis@6.2.1))(@types/node@26.1.1)(@types/react@19.2.17)(@vercel/nft@1.10.2)(effect@4.0.0-rc.112)(mongodb@6.21.0(@aws-sdk/credential-providers@3.1077.0))(pg@8.22.0)(typescript@6.0.3)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.6.1)(yaml@2.9.0)))(ws@8.21.3) + cross-spawn: + specifier: ^7.0.6 + version: 7.0.6 prisma: specifier: 7.9.0 version: 7.9.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3) diff --git a/test/integration/package.json b/test/integration/package.json index 1a369495..c8212ae3 100644 --- a/test/integration/package.json +++ b/test/integration/package.json @@ -14,8 +14,10 @@ "@prisma/composer-prisma-cloud": "workspace:0.16.0", "@prisma/example-store": "workspace:0.16.0", "@types/bun": "^1.3.13", + "@types/cross-spawn": "^6.0.6", "@types/node": "^26.0.1", "alchemy": "2.0.0-beta.74", + "cross-spawn": "^7.0.6", "prisma": "7.9.0", "typescript": "^6.0.3" } diff --git a/test/integration/test/cli.engine-shell.test.ts b/test/integration/test/cli.engine-shell.test.ts index 26bf1ee9..33f6041f 100644 --- a/test/integration/test/cli.engine-shell.test.ts +++ b/test/integration/test/cli.engine-shell.test.ts @@ -15,9 +15,9 @@ * `dist/bin.mjs` — the same binary a consumer installs. */ import { describe, expect, test } from 'bun:test'; -import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import spawn from 'cross-spawn'; const integrationDir = path.resolve(import.meta.dir, '..'); const composerBin = path.join(integrationDir, 'node_modules', '.bin', 'prisma-composer'); @@ -27,7 +27,7 @@ function runCli(args: readonly string[], extraEnv: Record = {}) const env = { ...process.env }; delete env['PRISMA_SERVICE_TOKEN']; delete env['PRISMA_WORKSPACE_ID']; - const result = spawnSync(composerBin, [...args], { + const result = spawn.sync(composerBin, [...args], { cwd: integrationDir, encoding: 'utf8', env: { ...env, ...extraEnv }, diff --git a/test/integration/test/cli.extension-config.test.ts b/test/integration/test/cli.extension-config.test.ts index 0a5812d0..e3f57537 100644 --- a/test/integration/test/cli.extension-config.test.ts +++ b/test/integration/test/cli.extension-config.test.ts @@ -20,8 +20,8 @@ * comes from the credential rather than from a variable the handler reads. */ import { describe, expect, test } from 'bun:test'; -import { spawnSync } from 'node:child_process'; import * as path from 'node:path'; +import spawn from 'cross-spawn'; const integrationDir = path.resolve(import.meta.dir, '..'); const prismaAppBin = path.join(integrationDir, 'node_modules', '.bin', 'prisma-composer'); @@ -48,7 +48,7 @@ describe('prisma-composer deploy — real extension-config resolution of prisma- // Spawns the real CLI, which resolves /control entries and evaluates a config — // inherently slower than bun test's default 5000ms, so give it real headroom. test('resolves both /control entries for real and fails at the missing built entry, not at resolution', () => { - const result = spawnSync(process.execPath, [prismaAppBin, 'deploy', fixtureEntry], { + const result = spawn.sync(prismaAppBin, ['deploy', fixtureEntry], { cwd: integrationDir, encoding: 'utf8', env: { @@ -82,7 +82,7 @@ describe('prisma-composer deploy — real extension-config resolution of prisma- const env: NodeJS.ProcessEnv = { ...process.env, PRISMA_SERVICE_TOKEN: serviceToken({}) }; delete env['PRISMA_WORKSPACE_ID']; - const result = spawnSync(process.execPath, [prismaAppBin, 'deploy', fixtureEntry], { + const result = spawn.sync(prismaAppBin, ['deploy', fixtureEntry], { cwd: integrationDir, encoding: 'utf8', env, From 6397179d3944abc9c0666795bbefa7bda600b4a5 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Thu, 3 Sep 2026 19:57:28 +0530 Subject: [PATCH 18/22] test: resolve installed CLI shims from PATH Signed-off-by: Aman Varshney --- .github/workflows/ci.yml | 1 + .../integration/test/cli.engine-shell.test.ts | 11 ++-------- .../test/cli.extension-config.test.ts | 22 +++++-------------- test/integration/test/spawn-composer.ts | 19 ++++++++++++++++ 4 files changed, 28 insertions(+), 25 deletions(-) create mode 100644 test/integration/test/spawn-composer.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93f7d545..5f0dcac5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,6 +137,7 @@ jobs: # tests instead. - name: Test (Windows-supported surface) if: runner.os == 'Windows' + shell: bash env: STATE_TEST_DATABASE_URL: ${{ steps.postgres.outputs.connection-uri }} run: | diff --git a/test/integration/test/cli.engine-shell.test.ts b/test/integration/test/cli.engine-shell.test.ts index 33f6041f..7e36752a 100644 --- a/test/integration/test/cli.engine-shell.test.ts +++ b/test/integration/test/cli.engine-shell.test.ts @@ -17,21 +17,14 @@ import { describe, expect, test } from 'bun:test'; import * as fs from 'node:fs'; import * as path from 'node:path'; -import spawn from 'cross-spawn'; - -const integrationDir = path.resolve(import.meta.dir, '..'); -const composerBin = path.join(integrationDir, 'node_modules', '.bin', 'prisma-composer'); +import { integrationDir, spawnComposer } from './spawn-composer.ts'; /** Runs the bin with no credential in the environment unless the caller adds one. */ function runCli(args: readonly string[], extraEnv: Record = {}) { const env = { ...process.env }; delete env['PRISMA_SERVICE_TOKEN']; delete env['PRISMA_WORKSPACE_ID']; - const result = spawn.sync(composerBin, [...args], { - cwd: integrationDir, - encoding: 'utf8', - env: { ...env, ...extraEnv }, - }); + const result = spawnComposer(args, { ...env, ...extraEnv }); return { status: result.status, output: `${result.stdout}${result.stderr}` }; } diff --git a/test/integration/test/cli.extension-config.test.ts b/test/integration/test/cli.extension-config.test.ts index e3f57537..f7ad854b 100644 --- a/test/integration/test/cli.extension-config.test.ts +++ b/test/integration/test/cli.extension-config.test.ts @@ -21,10 +21,8 @@ */ import { describe, expect, test } from 'bun:test'; import * as path from 'node:path'; -import spawn from 'cross-spawn'; +import { integrationDir, spawnComposer } from './spawn-composer.ts'; -const integrationDir = path.resolve(import.meta.dir, '..'); -const prismaAppBin = path.join(integrationDir, 'node_modules', '.bin', 'prisma-composer'); const fixtureEntry = path.join( integrationDir, 'test', @@ -48,14 +46,10 @@ describe('prisma-composer deploy — real extension-config resolution of prisma- // Spawns the real CLI, which resolves /control entries and evaluates a config — // inherently slower than bun test's default 5000ms, so give it real headroom. test('resolves both /control entries for real and fails at the missing built entry, not at resolution', () => { - const result = spawn.sync(prismaAppBin, ['deploy', fixtureEntry], { - cwd: integrationDir, - encoding: 'utf8', - env: { - ...process.env, - PRISMA_SERVICE_TOKEN: serviceToken({ workspace_id: 'ws-integration-test' }), - PRISMA_WORKSPACE_ID: 'ws-integration-test', - }, + const result = spawnComposer(['deploy', fixtureEntry], { + ...process.env, + PRISMA_SERVICE_TOKEN: serviceToken({ workspace_id: 'ws-integration-test' }), + PRISMA_WORKSPACE_ID: 'ws-integration-test', }); // Engine 0.2.0: a non-TTY run answers with a structured result frame on @@ -82,11 +76,7 @@ describe('prisma-composer deploy — real extension-config resolution of prisma- const env: NodeJS.ProcessEnv = { ...process.env, PRISMA_SERVICE_TOKEN: serviceToken({}) }; delete env['PRISMA_WORKSPACE_ID']; - const result = spawn.sync(prismaAppBin, ['deploy', fixtureEntry], { - cwd: integrationDir, - encoding: 'utf8', - env, - }); + const result = spawnComposer(['deploy', fixtureEntry], env); const output = result.stdout + result.stderr; expect(result.status).not.toBe(0); diff --git a/test/integration/test/spawn-composer.ts b/test/integration/test/spawn-composer.ts new file mode 100644 index 00000000..0d5a02fc --- /dev/null +++ b/test/integration/test/spawn-composer.ts @@ -0,0 +1,19 @@ +import * as path from 'node:path'; +import spawn from 'cross-spawn'; + +export const integrationDir = path.resolve(import.meta.dir, '..'); + +const composerBinDir = path.join(integrationDir, 'node_modules', '.bin'); + +/** Runs the installed CLI exactly as a shell would, including Windows's `.CMD` shim. */ +export function spawnComposer(args: readonly string[], inputEnv: NodeJS.ProcessEnv = process.env) { + const env = { ...inputEnv }; + const pathKey = Object.keys(env).find((key) => key.toLowerCase() === 'path') ?? 'PATH'; + env[pathKey] = `${composerBinDir}${path.delimiter}${env[pathKey] ?? ''}`; + + return spawn.sync('prisma-composer', [...args], { + cwd: integrationDir, + encoding: 'utf8', + env, + }); +} From 9198029f8300d68cf0788b60e1914e3ec68893d9 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Thu, 3 Sep 2026 20:18:40 +0530 Subject: [PATCH 19/22] ci: focus Windows checks on changed packages Signed-off-by: Aman Varshney --- .github/workflows/ci.yml | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f0dcac5..f89eb3fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -131,18 +131,16 @@ jobs: env: STATE_TEST_DATABASE_URL: ${{ steps.postgres.outputs.connection-uri }} run: pnpm test - # Windows local dev/log are explicitly unsupported today, so their - # daemon package and standalone acceptance scripts are not valid Windows - # tests. Run every supported package suite plus the deploy integration - # tests instead. - - name: Test (Windows-supported surface) + # Windows local dev/log are explicitly unsupported today. Exercise the + # packages changed by this PR, then the installed CLI's deploy surface. + - name: Test changed packages on Windows if: runner.os == 'Windows' - shell: bash env: STATE_TEST_DATABASE_URL: ${{ steps.postgres.outputs.connection-uri }} - run: | - pnpm turbo run test --filter="!@prisma/integration-tests" --filter="!@internal/dev-emulators" - pnpm --dir test/integration exec bun test + run: pnpm turbo run test --filter=@internal/core --filter=@internal/bundle-paths --filter=@internal/nextjs --filter=@internal/node --filter=@internal/cli --filter=@internal/local-target --filter=@internal/lowering --filter=@internal/prisma-cloud --filter=@internal/streams + - name: Test installed CLI on Windows + if: runner.os == 'Windows' + run: pnpm --dir test/integration exec bun test node-floor: name: Node 22.18 floor From aec3c10f497f7ba6ad59f79c19180be0fb91a91d Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Thu, 3 Sep 2026 21:03:11 +0530 Subject: [PATCH 20/22] ci: isolate Windows artifact coverage Signed-off-by: Aman Varshney --- .github/workflows/ci.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f89eb3fa..fca3d447 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -133,11 +133,16 @@ jobs: run: pnpm test # Windows local dev/log are explicitly unsupported today. Exercise the # packages changed by this PR, then the installed CLI's deploy surface. + # Lowering is covered by its changed artifact test directly: its existing + # resource-reporter deadline test does not settle under Bun on Windows. - name: Test changed packages on Windows if: runner.os == 'Windows' env: STATE_TEST_DATABASE_URL: ${{ steps.postgres.outputs.connection-uri }} - run: pnpm turbo run test --filter=@internal/core --filter=@internal/bundle-paths --filter=@internal/nextjs --filter=@internal/node --filter=@internal/cli --filter=@internal/local-target --filter=@internal/lowering --filter=@internal/prisma-cloud --filter=@internal/streams + run: pnpm turbo run test --filter=@internal/core --filter=@internal/bundle-paths --filter=@internal/nextjs --filter=@internal/node --filter=@internal/cli --filter=@internal/local-target --filter=@internal/prisma-cloud --filter=@internal/streams + - name: Test artifact packaging on Windows + if: runner.os == 'Windows' + run: pnpm --filter @internal/lowering exec bun test src/__tests__/artifact.test.ts - name: Test installed CLI on Windows if: runner.os == 'Windows' run: pnpm --dir test/integration exec bun test From 0ad345de83742141cd767a6bc1b7a0290b727863 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Thu, 3 Sep 2026 21:12:03 +0530 Subject: [PATCH 21/22] ci: relink built Windows package shims Signed-off-by: Aman Varshney --- .github/workflows/ci.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fca3d447..47ff5d93 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -126,6 +126,13 @@ jobs: run: pnpm install --frozen-lockfile - name: Build packages run: pnpm build + # pnpm cannot create a Windows .CMD shim for a workspace bin before its + # dist entry exists. Unix permits the equivalent dangling symlink. Link + # again after the build so the installed-CLI test exercises pnpm's real + # Windows shim; offline + ignore-scripts keeps this a pure relink. + - name: Link built workspace binaries on Windows + if: runner.os == 'Windows' + run: pnpm install --frozen-lockfile --offline --ignore-scripts - name: Test if: runner.os != 'Windows' env: From b46859a59d8e7b072231d1f7899138bd49478128 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Thu, 3 Sep 2026 22:21:52 +0530 Subject: [PATCH 22/22] chore: trim redundant comments Signed-off-by: Aman Varshney --- .github/workflows/ci.yml | 10 ++-------- .../2-authoring/bundle-paths/src/bundle-paths.ts | 10 +--------- .../2-authoring/nextjs/src/control/build.ts | 4 +--- .../cli/src/family/__tests__/fake-child.test.ts | 3 --- .../cli/src/family/__tests__/signal-listeners.test.ts | 5 ----- packages/0-framework/3-tooling/cli/src/run-alchemy.ts | 5 ++--- 6 files changed, 6 insertions(+), 31 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47ff5d93..49c1e005 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -126,10 +126,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Build packages run: pnpm build - # pnpm cannot create a Windows .CMD shim for a workspace bin before its - # dist entry exists. Unix permits the equivalent dangling symlink. Link - # again after the build so the installed-CLI test exercises pnpm's real - # Windows shim; offline + ignore-scripts keeps this a pure relink. + # Workspace binaries do not exist during the initial install. - name: Link built workspace binaries on Windows if: runner.os == 'Windows' run: pnpm install --frozen-lockfile --offline --ignore-scripts @@ -138,10 +135,7 @@ jobs: env: STATE_TEST_DATABASE_URL: ${{ steps.postgres.outputs.connection-uri }} run: pnpm test - # Windows local dev/log are explicitly unsupported today. Exercise the - # packages changed by this PR, then the installed CLI's deploy surface. - # Lowering is covered by its changed artifact test directly: its existing - # resource-reporter deadline test does not settle under Bun on Windows. + # Local dev/log are not supported on Windows. - name: Test changed packages on Windows if: runner.os == 'Windows' env: diff --git a/packages/0-framework/2-authoring/bundle-paths/src/bundle-paths.ts b/packages/0-framework/2-authoring/bundle-paths/src/bundle-paths.ts index 70178c83..6d026482 100644 --- a/packages/0-framework/2-authoring/bundle-paths/src/bundle-paths.ts +++ b/packages/0-framework/2-authoring/bundle-paths/src/bundle-paths.ts @@ -8,12 +8,7 @@ import fs from 'node:fs'; import path from 'node:path'; -/** Repairs the one piece of link metadata `fs.cp` loses on Windows: whether a - * relative symlink targets a directory. Without the explicit type, Node - * recreates it as a file link and the copied tree contains a dangling link. - * - * This is also callable after a framework stages an initially missing target: - * only then can we know that the link is a directory link. */ +/** Restores directory-link metadata lost by `fs.cp` on Windows. */ export async function repairWindowsDirectorySymlinks(root: string): Promise { if (process.platform !== 'win32') return; @@ -26,7 +21,6 @@ export async function repairWindowsDirectorySymlinks(root: string): Promise { await fs.promises.cp(source, destination, { recursive: true, verbatimSymlinks: true }); await repairWindowsDirectorySymlinks(destination); diff --git a/packages/0-framework/2-authoring/nextjs/src/control/build.ts b/packages/0-framework/2-authoring/nextjs/src/control/build.ts index 57481b60..83e093d2 100644 --- a/packages/0-framework/2-authoring/nextjs/src/control/build.ts +++ b/packages/0-framework/2-authoring/nextjs/src/control/build.ts @@ -237,9 +237,7 @@ export async function assemble(input: AssembleInput): Promise { // the assembled bundle before emitting it into the archive. await copyTreeVerbatim(standaloneRoot, bundleDir); const stagedLinkTargets = await stageMissingStandaloneLinkTargets(bundleDir, manifest); - // A pnpm link can be dangling when Next emits standalone, then become valid - // only after its omitted virtual-store target is staged above. On Windows, - // now is the first point where Node can recover that it is a directory link. + // Staging can make previously dangling directory links repairable. await repairWindowsDirectorySymlinks(bundleDir); // The documented copy: Next omits the client assets from standalone; place diff --git a/packages/0-framework/3-tooling/cli/src/family/__tests__/fake-child.test.ts b/packages/0-framework/3-tooling/cli/src/family/__tests__/fake-child.test.ts index e535079a..611a305a 100644 --- a/packages/0-framework/3-tooling/cli/src/family/__tests__/fake-child.test.ts +++ b/packages/0-framework/3-tooling/cli/src/family/__tests__/fake-child.test.ts @@ -56,9 +56,6 @@ describe('the fake child', () => { 'a lingering child scripted to report a signal names it and exits 0', async () => { const child = spawn(process.execPath, [FIXTURE, '--linger', '--on-signal', 'report']); - // Listening before the kill, and waiting for `close` rather than `exit`: - // `exit` fires when the child terminates, `close` only once its stdio has - // ended, so `close` is what says the report has actually been read. const chunks: string[] = []; child.stdout.on('data', (chunk: Buffer) => chunks.push(chunk.toString())); await new Promise((resolve) => setTimeout(resolve, 150)); diff --git a/packages/0-framework/3-tooling/cli/src/family/__tests__/signal-listeners.test.ts b/packages/0-framework/3-tooling/cli/src/family/__tests__/signal-listeners.test.ts index 50ee7c5e..98662010 100644 --- a/packages/0-framework/3-tooling/cli/src/family/__tests__/signal-listeners.test.ts +++ b/packages/0-framework/3-tooling/cli/src/family/__tests__/signal-listeners.test.ts @@ -60,8 +60,6 @@ describe('the engine is the sole signal listener', () => { expect(before.SIGINT).toBe(0); expect(before.SIGTERM).toBe(0); - // The whole point: importing the provider tree must leave the signal - // surface exactly as it found it, so the engine's handler is the only one. expect(afterConfigEvaluation.SIGINT).toBe(0); expect(afterConfigEvaluation.SIGTERM).toBe(0); expect(afterConfigEvaluation.exit).toBe(0); @@ -76,9 +74,6 @@ describe('the engine is the sole signal listener', () => { expect(afterLocalTargets.SIGINT).toBe(0); expect(afterLocalTargets.SIGTERM).toBe(0); - // The exit hook too: it is the single registration that installed all - // three upstream, so a local-target import that armed only it would slip - // past a check that looked at the two signals alone. expect(afterLocalTargets.exit).toBe(0); }, SUBPROCESS_TEST_TIMEOUT_MS, diff --git a/packages/0-framework/3-tooling/cli/src/run-alchemy.ts b/packages/0-framework/3-tooling/cli/src/run-alchemy.ts index b7d6da04..d7f7e150 100644 --- a/packages/0-framework/3-tooling/cli/src/run-alchemy.ts +++ b/packages/0-framework/3-tooling/cli/src/run-alchemy.ts @@ -2,9 +2,8 @@ * Pipeline step 7 (deploy-cli.md § The pipeline; design-notes.md's "Driving * Alchemy" call): hand the terminal to the generated stack file. * - * Resolves the workspace's installed `alchemy` bin. The actual child runner - * uses cross-spawn, which handles package-manager shims and shebangs on Windows - * without a shell while preserving argv boundaries. + * Resolves the installed `alchemy` bin and launches package-manager shims with + * cross-spawn. * * This module composes the invocation; it does not decide how the child is * started. Under the CLI the engine starts it (`ctx.spawn`), which is what