From ae67c5b8159cb49a68c16f96f7bb64a2e3c6b1c6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 14 Sep 2026 09:10:43 -0700 Subject: [PATCH] fix(release): stop npm from pruning the platform packages' shipped node_modules (#11750) Co-authored-by: Claude Fable 5 --- scripts/build-npm-platform-packages.test.ts | 22 ++++++++- scripts/build-npm-platform-packages.ts | 49 +++++++++++++++++++-- 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/scripts/build-npm-platform-packages.test.ts b/scripts/build-npm-platform-packages.test.ts index 47568afbcb67cec592c44269c7b00c77bb5d4515..5fc0d859fc3d37d7b636f32c0aa2b404bcda3f6f 100644 --- a/scripts/build-npm-platform-packages.test.ts +++ b/scripts/build-npm-platform-packages.test.ts @@ -55,9 +55,22 @@ const makeFakeArchives = Effect.fn("test.makeFakeArchives")(function* () { const stem = `t3-${VERSION}-${key}`; const stage = path.join(root, "stage", key); const contentDir = path.join(stage, stem); - for (const dir of ["client", "resource-monitor", "node_modules/node-pty"]) { + for (const dir of [ + "client", + "resource-monitor", + "node_modules/node-pty", + "node_modules/@ff-labs/fff-node", + ]) { yield* fs.makeDirectory(path.join(contentDir, dir), { recursive: true }); } + yield* fs.writeFileString( + path.join(contentDir, "node_modules/node-pty/package.json"), + '{ "name": "node-pty", "version": "1.1.0" }\n', + ); + yield* fs.writeFileString( + path.join(contentDir, "node_modules/@ff-labs/fff-node/package.json"), + '{ "name": "@ff-labs/fff-node", "version": "0.9.4" }\n', + ); yield* fs.writeFileString(path.join(contentDir, "client/index.html"), "\n"); yield* fs.writeFileString( path.join(contentDir, "t3"), @@ -127,6 +140,13 @@ it.layer(NodeServices.layer)("build-npm-platform-packages", (it) => { ]); assert.equal(linuxManifest.preferUnplugged, true); assert.isUndefined(linuxManifest.bin); + // The shipped node_modules is declared, or npm prunes it as extraneous + // on the next install in the same project and the executable breaks. + assert.deepStrictEqual(linuxManifest.dependencies, { + "@ff-labs/fff-node": "0.9.4", + "node-pty": "1.1.0", + }); + assert.deepStrictEqual(linuxManifest.bundleDependencies, ["@ff-labs/fff-node", "node-pty"]); // Archive contents sit at the package root, not under the archive stem. assert.isTrue(yield* fs.exists(path.join(linuxDir, "client/index.html"))); // A root README, or npm would display a bundled dependency's. diff --git a/scripts/build-npm-platform-packages.ts b/scripts/build-npm-platform-packages.ts index f73610d10aa8be7a924c42d9c706d2a1f843c215..fd8c4999a17a333d2330cd790f5f58069e3a4047 100644 --- a/scripts/build-npm-platform-packages.ts +++ b/scripts/build-npm-platform-packages.ts @@ -88,9 +88,22 @@ export function npmPlatformPackageName(platformKey: CliArchivePlatformKey): stri return `${NPM_PLATFORM_PACKAGE_SCOPE}/t3-${platformKey}`; } -/** package.json for one platform package; `os`/`cpu` let npm skip the other five. */ -export function npmPlatformPackageManifest(platformKey: CliArchivePlatformKey, version: string) { +/** + * package.json for one platform package; `os`/`cpu` let npm skip the other + * five. The archive's runtime `node_modules` (native addons and their + * loaders) ships inside the tarball, and npm only keeps a nested tree it can + * account for: anything not declared is extraneous and pruned on the next + * `npm install` in that project, which then breaks the executable. Declaring + * every bundled package as a bundled dependency at the exact version on disk + * makes npm treat the tree as part of this package and leave it alone. + */ +export function npmPlatformPackageManifest( + platformKey: CliArchivePlatformKey, + version: string, + bundled: Readonly>, +) { const [os, cpu] = platformKey.split("-") as [string, string]; + const bundleDependencies = Object.keys(bundled).sort(); return { name: npmPlatformPackageName(platformKey), version, @@ -101,9 +114,38 @@ export function npmPlatformPackageManifest(platformKey: CliArchivePlatformKey, v cpu: [cpu], files: ["t3", "t3.exe", "client", "resource-monitor", "node_modules"], preferUnplugged: true, + dependencies: Object.fromEntries(bundleDependencies.map((name) => [name, bundled[name]])), + bundleDependencies, }; } +const PackageVersion = Schema.Struct({ version: Schema.String }); +const decodePackageVersion = Schema.decodeUnknownEffect(Schema.fromJsonString(PackageVersion)); + +/** Every top-level package under `node_modules`, scoped ones included, at the version its manifest names. */ +const readBundledPackages = Effect.fn("readBundledPackages")(function* (nodeModulesDir: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const bundled: Record = {}; + const packageDirs: Array<{ readonly name: string; readonly dir: string }> = []; + for (const entry of yield* fs.readDirectory(nodeModulesDir)) { + if (entry.startsWith(".")) continue; + const dir = path.join(nodeModulesDir, entry); + if (entry.startsWith("@")) { + for (const scoped of yield* fs.readDirectory(dir)) { + packageDirs.push({ name: `${entry}/${scoped}`, dir: path.join(dir, scoped) }); + } + } else { + packageDirs.push({ name: entry, dir }); + } + } + for (const { name, dir } of packageDirs) { + const manifest = yield* fs.readFileString(path.join(dir, "package.json")); + bundled[name] = (yield* decodePackageVersion(manifest)).version; + } + return bundled; +}); + /** * README for one platform package. Without one at the package root, npm * shows the first README it finds in the tarball, which is a bundled @@ -302,9 +344,10 @@ const stagePlatformPackage = Effect.fn("stagePlatformPackage")(function* (input: if (executableName === "t3") { yield* fs.chmod(executable, 0o755); } + const bundled = yield* readBundledPackages(path.join(contentDir, "node_modules")); yield* fs.writeFileString( path.join(contentDir, "package.json"), - `${yield* encodePackageJson(npmPlatformPackageManifest(input.key, input.version))}\n`, + `${yield* encodePackageJson(npmPlatformPackageManifest(input.key, input.version, bundled))}\n`, ); yield* fs.writeFileString( path.join(contentDir, "README.md"), -- 2.54.0