Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
4817066
fix(cli): launch child commands cross-platform with Execa
AmanVarshney01 Sep 2, 2026
82caeda
refactor(cli): narrow Windows spawning to cross-spawn
AmanVarshney01 Sep 2, 2026
2428395
ci: verify package-bin spawning on Windows
AmanVarshney01 Sep 2, 2026
cd00ba6
ci: build dependencies before Windows diagnostic
AmanVarshney01 Sep 2, 2026
b1b4950
ci: remove Windows spawn diagnostic
AmanVarshney01 Sep 2, 2026
ccb6955
test(cli): verify spawned argument boundaries
AmanVarshney01 Sep 2, 2026
aec590f
ci: test spawn adapters on Windows and macOS
AmanVarshney01 Sep 3, 2026
817e526
ci: run full suite on Windows and macOS
AmanVarshney01 Sep 3, 2026
8796349
fix(scripts): quote skill glob cross-platform
AmanVarshney01 Sep 3, 2026
1ec10c4
ci: use PostgreSQL 16 across platform tests
AmanVarshney01 Sep 3, 2026
4504365
fix(build): handle Windows paths and symlinks
AmanVarshney01 Sep 3, 2026
8b2ef04
fix(build): preserve directory links on Windows
AmanVarshney01 Sep 3, 2026
bb728d9
fix(nextjs): repair staged directory links on Windows
AmanVarshney01 Sep 3, 2026
cbf364e
test: make platform suite portable
AmanVarshney01 Sep 3, 2026
5563fda
fix: make cross-platform validation reliable
AmanVarshney01 Sep 3, 2026
3f49bbf
test: align platform suites with supported surfaces
AmanVarshney01 Sep 3, 2026
4b50ccf
test: run supported Windows integration surface
AmanVarshney01 Sep 3, 2026
6397179
test: resolve installed CLI shims from PATH
AmanVarshney01 Sep 3, 2026
9198029
ci: focus Windows checks on changed packages
AmanVarshney01 Sep 3, 2026
aec3c10
ci: isolate Windows artifact coverage
AmanVarshney01 Sep 3, 2026
0ad345d
ci: relink built Windows package shims
AmanVarshney01 Sep 3, 2026
b46859a
chore: trim redundant comments
AmanVarshney01 Sep 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
});
}
}
};
Expand Down
31 changes: 31 additions & 0 deletions packages/0-framework/2-authoring/bundle-paths/src/bundle-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
if (process.platform !== 'win32') return;

const visit = async (directory: string): Promise<void> => {
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<void> {
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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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);
Expand All @@ -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'),
Expand Down Expand Up @@ -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: {} }));

Expand Down Expand Up @@ -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);
Expand Down
26 changes: 12 additions & 14 deletions packages/0-framework/2-authoring/nextjs/src/control/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -230,29 +235,22 @@ export async function assemble(input: AssembleInput): Promise<Bundle> {
// 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
// .next/standalone/ && cp -r .next/static .next/standalone/.next/`).
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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);

Expand All @@ -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 () => {
Expand All @@ -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);

Expand All @@ -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 () => {
Expand All @@ -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({
Expand All @@ -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(
Expand All @@ -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);

Expand Down Expand Up @@ -715,6 +726,7 @@ describe('assemble() — the directory form', () => {
fs.symlinkSync(
path.relative(serviceNodeModules, workspacePackage),
path.join(serviceNodeModules, 'runtime-fixture'),
'dir',
);
writeServiceModule(serviceDir);

Expand Down Expand Up @@ -779,6 +791,7 @@ describe('assemble() — the directory form', () => {
fs.symlinkSync(
path.relative(serviceNodeModules, storePackage),
path.join(serviceNodeModules, 'dep'),
'dir',
);
writeServiceModule(serviceDir);

Expand All @@ -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);

Expand Down Expand Up @@ -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);

Expand All @@ -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);
});
Loading
Loading