diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a52285be..49c1e005 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,6 +101,53 @@ jobs: - name: Test scripts (cast-ratchet unit tests) run: pnpm test:scripts + platform-tests: + name: Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + 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: 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 + # 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 + - name: Test + if: runner.os != 'Windows' + env: + STATE_TEST_DATABASE_URL: ${{ steps.postgres.outputs.connection-uri }} + run: pnpm test + # Local dev/log are not supported 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/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 + node-floor: name: Node 22.18 floor # The published packages declare `engines.node: >=22.18.0`, but every suite 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": { 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/bundle-paths/src/bundle-paths.ts b/packages/0-framework/2-authoring/bundle-paths/src/bundle-paths.ts index 332c8fef..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,6 +8,37 @@ import fs from 'node:fs'; import path from 'node:path'; +/** Restores directory-link metadata lost by `fs.cp` on Windows. */ +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 { + continue; + } + await fs.promises.unlink(full); + await fs.promises.symlink(target, full, 'dir'); + } else if (entry.isDirectory()) { + await visit(full); + } + } + }; + + await visit(root); +} + +export async function copyTreeVerbatim(source: string, destination: string): Promise { + await fs.promises.cp(source, destination, { recursive: true, verbatimSymlinks: true }); + await repairWindowsDirectorySymlinks(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..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, isWithin } from '../bundle-paths.ts'; +export { + assertBundleSymlinksStayInside, + copyTreeVerbatim, + isWithin, + repairWindowsDirectorySymlinks, +} 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 ea8245ea..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 @@ -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); @@ -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'), @@ -200,7 +203,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 +232,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/nextjs/src/control/build.ts b/packages/0-framework/2-authoring/nextjs/src/control/build.ts index 85dde5e7..83e093d2 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, 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'; @@ -184,7 +189,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,11 +235,10 @@ 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); + // Staging can make previously dangling directory links repairable. + 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 @@ -242,17 +246,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 99a0959c..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 @@ -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); @@ -804,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); @@ -842,6 +857,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 +867,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..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), }; } @@ -214,7 +213,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()) { diff --git a/packages/0-framework/3-tooling/cli/package.json b/packages/0-framework/3-tooling/cli/package.json index b466fa3a..a430a245 100644 --- a/packages/0-framework/3-tooling/cli/package.json +++ b/packages/0-framework/3-tooling/cli/package.json @@ -23,11 +23,13 @@ "@internal/foundation": "workspace:0.16.0", "@prisma/cli-engine": "0.3.0", "c12": "^3.3.4", - "chokidar": "^4.0.3" + "chokidar": "^4.0.3", + "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/__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/__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..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 @@ -52,29 +52,32 @@ 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']); + 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/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/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..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 @@ -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 value" ? 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 value'], + 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/__tests__/signal-listeners.test.ts b/packages/0-framework/3-tooling/cli/src/family/__tests__/signal-listeners.test.ts index 64ce9f9b..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 @@ -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,30 @@ 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); - }); + 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); + expect(afterLocalTargets.exit).toBe(0); + }, + SUBPROCESS_TEST_TIMEOUT_MS, + ); }); 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..22cdc4aa 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 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'; 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/0-framework/3-tooling/cli/src/run-alchemy.ts b/packages/0-framework/3-tooling/cli/src/run-alchemy.ts index cce38864..d7f7e150 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,9 @@ /** * 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 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 @@ -13,10 +11,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 spawn from 'cross-spawn'; /** Walks up from `startDir` looking for `node_modules/.bin/alchemy`. */ export function resolveAlchemyBin(startDir: string): string { 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/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); }); 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__/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); }); 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/packages/9-public/composer-cli/package.json b/packages/9-public/composer-cli/package.json index 58905cb7..2f8810f6 100644 --- a/packages/9-public/composer-cli/package.json +++ b/packages/9-public/composer-cli/package.json @@ -24,6 +24,7 @@ "@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" }, diff --git a/packages/9-public/composer/package.json b/packages/9-public/composer/package.json index 8d0f90fe..ef5ed429 100644 --- a/packages/9-public/composer/package.json +++ b/packages/9-public/composer/package.json @@ -39,6 +39,7 @@ "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", "@prisma/management-api-sdk": "^1.60.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f50e75b1..a361d943 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -747,6 +747,9 @@ importers: chokidar: specifier: ^4.0.3 version: 4.0.3 + cross-spawn: + specifier: ^7.0.6 + version: 7.0.6 devDependencies: '@internal/tsdown-config': specifier: workspace:0.16.0 @@ -754,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 @@ -1228,6 +1234,9 @@ 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 @@ -1286,6 +1295,9 @@ 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 @@ -1435,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) @@ -3418,6 +3436,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==} @@ -7693,6 +7714,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': {} 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..7e36752a 100644 --- a/test/integration/test/cli.engine-shell.test.ts +++ b/test/integration/test/cli.engine-shell.test.ts @@ -15,23 +15,16 @@ * `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'; - -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 = spawnSync(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 a7f24e64..f7ad854b 100644 --- a/test/integration/test/cli.extension-config.test.ts +++ b/test/integration/test/cli.extension-config.test.ts @@ -20,11 +20,9 @@ * 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 { 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 = spawnSync('bun', [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 = spawnSync('bun', [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, + }); +}