diff --git a/scripts/npm-platform.ts b/scripts/npm-platform.ts new file mode 100644 index 0000000000..e60f349e42 --- /dev/null +++ b/scripts/npm-platform.ts @@ -0,0 +1,21 @@ +/** npm `os`/`cpu` package-field matching for build and release scripts. */ + +/** + * Test whether an npm `os` or `cpu` field admits a value, following npm's install checks. + * @param value - Raw manifest field; `undefined` admits every value. + * @param current - Target platform or architecture. + * @returns Whether the package may install for the target. + * @throws When the field is not an array of strings. + */ +export function acceptsNpmPlatform(value: unknown, current: string): boolean { + if (value === undefined) return true + if (!Array.isArray(value) || !value.every((tag): tag is string => typeof tag === 'string')) { + throw new Error('npm platform constraints must be arrays of strings') + } + let accepted = value.every(tag => tag.startsWith('!')) + for (const tag of value) { + if (tag === `!${current}`) return false + if (tag === current || (tag === 'any' && value.length === 1)) accepted = true + } + return accepted +} diff --git a/scripts/primary-runtime/prepare.spec.ts b/scripts/primary-runtime/prepare.spec.ts index 29a53f63e3..1f39e92aa4 100644 --- a/scripts/primary-runtime/prepare.spec.ts +++ b/scripts/primary-runtime/prepare.spec.ts @@ -168,25 +168,40 @@ it('keeps carrier pnpm versions aligned with the shared payload build', () => { expect(root.packageManager).toBe(`pnpm@${root.devDependencies.pnpm}`) }) -it('keeps only pnpm-bundled native packages that the target platform and architecture admit', async () => { +it.each([ + ['darwin', 'arm64', ['reflink', 'reflink-darwin-arm64'], ['.bin', '@reflink', 'any-cpu', 'not-windows', 'semver']], + ['darwin', 'x64', ['reflink', 'reflink-darwin-x64'], ['.bin', '@reflink', 'any-cpu', 'not-windows', 'semver']], + ['win32', 'x64', ['reflink', 'reflink-win32-x64-msvc'], ['.bin', '@reflink', 'any-cpu', 'not-darwin', 'semver']], +] as const)('keeps only pnpm-bundled packages that %s/%s admits', async (platform, arch, reflink, top) => { const root = await mkdtemp(join(tmpdir(), 'primary-pnpm-natives-')) try { const modules = join(root, 'dist', 'node_modules') const packages: Record = { - 'semver': {}, '@reflink/reflink': {}, + 'semver': {}, '@reflink/reflink': {}, 'any-cpu': { cpu: ['any'] }, '@reflink/reflink-darwin-arm64': { os: ['darwin'], cpu: ['arm64'] }, '@reflink/reflink-darwin-x64': { os: ['darwin'], cpu: ['x64'] }, '@reflink/reflink-win32-x64-msvc': { os: ['win32'], cpu: ['x64'] }, 'not-windows': { os: ['!win32'] }, 'not-darwin': { os: ['!darwin'] }, + 'contradictory': { os: ['darwin', 'win32', '!darwin', '!win32'] }, } for (const [name, fields] of Object.entries(packages)) { await mkdir(join(modules, name), { recursive: true }) await writeFile(join(modules, name, 'package.json'), JSON.stringify({ name, ...fields })) } await mkdir(join(modules, '.bin')) - prunePnpmForeignNativePackages(root, 'darwin', 'arm64') - expect((await readdir(modules)).sort()).toEqual(['.bin', '@reflink', 'not-windows', 'semver']) - expect((await readdir(join(modules, '@reflink'))).sort()).toEqual(['reflink', 'reflink-darwin-arm64']) + await writeFile(join(modules, '.modules.yaml'), '') + prunePnpmForeignNativePackages(root, platform, arch) + expect((await readdir(modules)).filter(name => name !== '.modules.yaml').sort()).toEqual(top) + expect((await readdir(join(modules, '@reflink'))).sort()).toEqual(reflink) + } finally { await rm(root, { recursive: true, force: true }) } +}) + +it('names the bundled manifest whose platform field is invalid', async () => { + const root = await mkdtemp(join(tmpdir(), 'primary-pnpm-invalid-')) + try { + await mkdir(join(root, 'dist', 'node_modules', 'invalid'), { recursive: true }) + await writeFile(join(root, 'dist', 'node_modules', 'invalid', 'package.json'), JSON.stringify({ os: 'darwin' })) + expect(() => { prunePnpmForeignNativePackages(root, 'darwin', 'arm64') }).toThrow(/invalid.package\.json: npm platform constraints must be arrays of strings/u) } finally { await rm(root, { recursive: true, force: true }) } }) diff --git a/scripts/primary-runtime/prepare.ts b/scripts/primary-runtime/prepare.ts index 523b870590..5437086c6e 100644 --- a/scripts/primary-runtime/prepare.ts +++ b/scripts/primary-runtime/prepare.ts @@ -6,11 +6,12 @@ import { cpSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, r import { cp } from 'node:fs/promises' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' -import { dirname, join, resolve } from 'node:path' +import { basename, dirname, join, resolve } from 'node:path' import { parseArgs } from 'node:util' import extractZip from 'extract-zip' import { x as extractTar } from 'tar' import { copyPrimaryRuntime, parsePrimaryRuntime, workspaceDependencyPaths, type PrimaryRuntimeManifest } from '@deepseek-ai/dsh-tool-workspace-dependencies' +import { acceptsNpmPlatform } from '../npm-platform.ts' import lock from './lock.json' with { type: 'json' } import { prunePrimaryRuntimePythonTests } from './prune-python-tests.ts' @@ -149,21 +150,25 @@ export async function unpackPrimaryRuntimeWheel(archive: string, destination: st } /** - * Remove packages bundled under pnpm's dist/node_modules whose npm `os`/`cpu` fields exclude the target. + * Remove packages bundled directly under pnpm's dist/node_modules whose npm `os`/`cpu` fields exclude the target. * The pnpm tarball carries every platform's optional native packages, such as `@reflink/reflink-darwin-x64`. + * Nested node_modules and the `libc` field are not inspected; pnpm 11 bundles neither nested native packages nor Linux variants. * @param pnpm - Unpacked pnpm package directory. * @param platform - Target Node.js platform. * @param arch - Target Node.js architecture. + * @throws When a bundled package manifest is missing or has invalid `os`/`cpu` fields. */ export function prunePnpmForeignNativePackages(pnpm: string, platform: NodeJS.Platform, arch: NodeJS.Architecture): void { const modules = join(pnpm, 'dist', 'node_modules') - const admits = (values: string[] | undefined, value: string): boolean => values === undefined - || (values.some(entry => !entry.startsWith('!')) ? values.includes(value) : !values.includes(`!${value}`)) - for (const name of readdirSync(modules).filter(entry => entry !== '.bin')) { - for (const directory of name.startsWith('@') ? readdirSync(join(modules, name)).map(child => join(modules, name, child)) : [join(modules, name)]) { - const { os, cpu } = JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8')) as { os?: string[]; cpu?: string[] } - if (!admits(os, platform) || !admits(cpu, arch)) rmSync(directory, { recursive: true }) + const directories = (parent: string): string[] => readdirSync(parent, { withFileTypes: true }) + .filter(entry => entry.isDirectory() && entry.name !== '.bin').map(entry => join(parent, entry.name)) + for (const directory of directories(modules).flatMap(entry => basename(entry).startsWith('@') ? directories(entry) : [entry])) { + const { os, cpu } = JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8')) as { os?: unknown; cpu?: unknown } + let admitted: boolean + try { admitted = acceptsNpmPlatform(os, platform) && acceptsNpmPlatform(cpu, arch) } catch (error) { + throw new Error(`primary runtime: ${directory}/package.json: ${(error as Error).message}`, { cause: error }) } + if (!admitted) rmSync(directory, { recursive: true }) } } diff --git a/scripts/release/verify-packed-install.ts b/scripts/release/verify-packed-install.ts index 08ab4860c0..1770ce00d8 100644 --- a/scripts/release/verify-packed-install.ts +++ b/scripts/release/verify-packed-install.ts @@ -22,6 +22,7 @@ import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import { parseArgs } from 'node:util' +import { acceptsNpmPlatform } from '../npm-platform.ts' import { releaseFamily } from './families.ts' import { capture, isEntry } from './process.ts' import { packedManifest } from './tarball.ts' @@ -45,19 +46,6 @@ function consumerEnvironment(consumerRoot: string): NodeJS.ProcessEnv { return environment } -function acceptsPlatform(value: unknown, current: string): boolean { - if (value === undefined) return true - if (!Array.isArray(value) || !value.every((tag): tag is string => typeof tag === 'string')) { - throw new Error('packed platform constraints must be arrays of strings') - } - let accepted = value.every(tag => tag.startsWith('!')) - for (const tag of value) { - if (tag === `!${current}`) return false - if (tag === current || (tag === 'any' && value.length === 1)) accepted = true - } - return accepted -} - /** * Packed tarballs and their compatibility with the verification host, as `file:` dependency entries. * @@ -77,7 +65,7 @@ function packedDependencies(directories: readonly string[]): Map