authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-14 09:10:43-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-14 09:10:43-07:00
logae67c5b8159cb49a68c16f96f7bb64a2e3c6b1c6
treefa705bf4a9c4dd9365840cd75977426b5a68e5f9
parent8b1ea4dd2465f3cf3cfa02d6878beaa1ec22e71a
signature Signed by PGP key B5690EEEBB952194

fix(release): stop npm from pruning the platform packages' shipped node_modules (#11750)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

2 files changed, 67 insertions(+), 4 deletions(-)

scripts/build-npm-platform-packages.test.ts+21-1
...@@ -55,9 +55,22 @@ const makeFakeArchives = Effect.fn("test.makeFakeArchives")(function* () {...@@ -55,9 +55,22 @@ const makeFakeArchives = Effect.fn("test.makeFakeArchives")(function* () {
55 const stem = `t3-${VERSION}-${key}`;55 const stem = `t3-${VERSION}-${key}`;
56 const stage = path.join(root, "stage", key);56 const stage = path.join(root, "stage", key);
57 const contentDir = path.join(stage, stem);57 const contentDir = path.join(stage, stem);
58 for (const dir of ["client", "resource-monitor", "node_modules/node-pty"]) {58 for (const dir of [
59 "client",
60 "resource-monitor",
61 "node_modules/node-pty",
62 "node_modules/@ff-labs/fff-node",
63 ]) {
59 yield* fs.makeDirectory(path.join(contentDir, dir), { recursive: true });64 yield* fs.makeDirectory(path.join(contentDir, dir), { recursive: true });
60 }65 }
66 yield* fs.writeFileString(
67 path.join(contentDir, "node_modules/node-pty/package.json"),
68 '{ "name": "node-pty", "version": "1.1.0" }\n',
69 );
70 yield* fs.writeFileString(
71 path.join(contentDir, "node_modules/@ff-labs/fff-node/package.json"),
72 '{ "name": "@ff-labs/fff-node", "version": "0.9.4" }\n',
73 );
61 yield* fs.writeFileString(path.join(contentDir, "client/index.html"), "<html></html>\n");74 yield* fs.writeFileString(path.join(contentDir, "client/index.html"), "<html></html>\n");
62 yield* fs.writeFileString(75 yield* fs.writeFileString(
63 path.join(contentDir, "t3"),76 path.join(contentDir, "t3"),
...@@ -127,6 +140,13 @@ it.layer(NodeServices.layer)("build-npm-platform-packages", (it) => {...@@ -127,6 +140,13 @@ it.layer(NodeServices.layer)("build-npm-platform-packages", (it) => {
127 ]);140 ]);
128 assert.equal(linuxManifest.preferUnplugged, true);141 assert.equal(linuxManifest.preferUnplugged, true);
129 assert.isUndefined(linuxManifest.bin);142 assert.isUndefined(linuxManifest.bin);
143 // The shipped node_modules is declared, or npm prunes it as extraneous
144 // on the next install in the same project and the executable breaks.
145 assert.deepStrictEqual(linuxManifest.dependencies, {
146 "@ff-labs/fff-node": "0.9.4",
147 "node-pty": "1.1.0",
148 });
149 assert.deepStrictEqual(linuxManifest.bundleDependencies, ["@ff-labs/fff-node", "node-pty"]);
130 // Archive contents sit at the package root, not under the archive stem.150 // Archive contents sit at the package root, not under the archive stem.
131 assert.isTrue(yield* fs.exists(path.join(linuxDir, "client/index.html")));151 assert.isTrue(yield* fs.exists(path.join(linuxDir, "client/index.html")));
132 // A root README, or npm would display a bundled dependency's.152 // A root README, or npm would display a bundled dependency's.
scripts/build-npm-platform-packages.ts+46-3
...@@ -88,9 +88,22 @@ export function npmPlatformPackageName(platformKey: CliArchivePlatformKey): stri...@@ -88,9 +88,22 @@ export function npmPlatformPackageName(platformKey: CliArchivePlatformKey): stri
88 return `${NPM_PLATFORM_PACKAGE_SCOPE}/t3-${platformKey}`;88 return `${NPM_PLATFORM_PACKAGE_SCOPE}/t3-${platformKey}`;
89}89}
9090
91/** package.json for one platform package; `os`/`cpu` let npm skip the other five. */91/**
92export function npmPlatformPackageManifest(platformKey: CliArchivePlatformKey, version: string) {92 * package.json for one platform package; `os`/`cpu` let npm skip the other
93 * five. The archive's runtime `node_modules` (native addons and their
94 * loaders) ships inside the tarball, and npm only keeps a nested tree it can
95 * account for: anything not declared is extraneous and pruned on the next
96 * `npm install` in that project, which then breaks the executable. Declaring
97 * every bundled package as a bundled dependency at the exact version on disk
98 * makes npm treat the tree as part of this package and leave it alone.
99 */
100export function npmPlatformPackageManifest(
101 platformKey: CliArchivePlatformKey,
102 version: string,
103 bundled: Readonly<Record<string, string>>,
104) {
93 const [os, cpu] = platformKey.split("-") as [string, string];105 const [os, cpu] = platformKey.split("-") as [string, string];
106 const bundleDependencies = Object.keys(bundled).sort();
94 return {107 return {
95 name: npmPlatformPackageName(platformKey),108 name: npmPlatformPackageName(platformKey),
96 version,109 version,
...@@ -101,9 +114,38 @@ export function npmPlatformPackageManifest(platformKey: CliArchivePlatformKey, v...@@ -101,9 +114,38 @@ export function npmPlatformPackageManifest(platformKey: CliArchivePlatformKey, v
101 cpu: [cpu],114 cpu: [cpu],
102 files: ["t3", "t3.exe", "client", "resource-monitor", "node_modules"],115 files: ["t3", "t3.exe", "client", "resource-monitor", "node_modules"],
103 preferUnplugged: true,116 preferUnplugged: true,
117 dependencies: Object.fromEntries(bundleDependencies.map((name) => [name, bundled[name]])),
118 bundleDependencies,
104 };119 };
105}120}
106121
122const PackageVersion = Schema.Struct({ version: Schema.String });
123const decodePackageVersion = Schema.decodeUnknownEffect(Schema.fromJsonString(PackageVersion));
124
125/** Every top-level package under `node_modules`, scoped ones included, at the version its manifest names. */
126const readBundledPackages = Effect.fn("readBundledPackages")(function* (nodeModulesDir: string) {
127 const fs = yield* FileSystem.FileSystem;
128 const path = yield* Path.Path;
129 const bundled: Record<string, string> = {};
130 const packageDirs: Array<{ readonly name: string; readonly dir: string }> = [];
131 for (const entry of yield* fs.readDirectory(nodeModulesDir)) {
132 if (entry.startsWith(".")) continue;
133 const dir = path.join(nodeModulesDir, entry);
134 if (entry.startsWith("@")) {
135 for (const scoped of yield* fs.readDirectory(dir)) {
136 packageDirs.push({ name: `${entry}/${scoped}`, dir: path.join(dir, scoped) });
137 }
138 } else {
139 packageDirs.push({ name: entry, dir });
140 }
141 }
142 for (const { name, dir } of packageDirs) {
143 const manifest = yield* fs.readFileString(path.join(dir, "package.json"));
144 bundled[name] = (yield* decodePackageVersion(manifest)).version;
145 }
146 return bundled;
147});
148
107/**149/**
108 * README for one platform package. Without one at the package root, npm150 * README for one platform package. Without one at the package root, npm
109 * shows the first README it finds in the tarball, which is a bundled151 * shows the first README it finds in the tarball, which is a bundled
...@@ -302,9 +344,10 @@ const stagePlatformPackage = Effect.fn("stagePlatformPackage")(function* (input:...@@ -302,9 +344,10 @@ const stagePlatformPackage = Effect.fn("stagePlatformPackage")(function* (input:
302 if (executableName === "t3") {344 if (executableName === "t3") {
303 yield* fs.chmod(executable, 0o755);345 yield* fs.chmod(executable, 0o755);
304 }346 }
347 const bundled = yield* readBundledPackages(path.join(contentDir, "node_modules"));
305 yield* fs.writeFileString(348 yield* fs.writeFileString(
306 path.join(contentDir, "package.json"),349 path.join(contentDir, "package.json"),
307 `${yield* encodePackageJson(npmPlatformPackageManifest(input.key, input.version))}\n`,350 `${yield* encodePackageJson(npmPlatformPackageManifest(input.key, input.version, bundled))}\n`,
308 );351 );
309 yield* fs.writeFileString(352 yield* fs.writeFileString(
310 path.join(contentDir, "README.md"),353 path.join(contentDir, "README.md"),