File size: 14,887 Bytes
c0af099 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 | #!/usr/bin/env node
/**
* Download the official Node.js distribution for the current platform into
* resources/node/, so electron-builder can bundle it as an extraResource.
*
* The packaged Electron desktop app uses this bundled Node to provide
* `node`, `npm`, and `npx` to spawned subprocesses β most importantly the
* stdio MCP servers in the marketplace (Slack, GitHub, Figma, etc.) whose
* commands start with `npx -y <package>`.
*
* Why bundle Node instead of using Electron-as-Node (ELECTRON_RUN_AS_NODE=1)?
*
* We tried that first. Electron-as-Node works fine for our backend
* helper scripts (static-server.mjs, ingress.mjs) which mostly do
* networking, but it is **not** reliable for stdio JSON-RPC servers.
* When npx-cli.js (running under Electron-as-Node) spawned the MCP
* server, the child's stdin pipe semantics differed from vanilla Node
* on macOS (the parent is a windowed Electron process, not a clean
* command-line Node binary) β the server appeared to start, then
* immediately exited with "McpError: Connection closed" before the
* first JSON-RPC handshake message could land. Bundling the real
* Node binary sidesteps all of that.
*
* The downloaded Node.js distribution already includes npm and npx at
* `bin/npm` / `bin/npx` (POSIX) or `npm.cmd` / `npx.cmd` (Windows), so we
* do **not** need a separate npm download (this script supersedes the
* earlier download-npm.mjs).
*
* Usage:
* node scripts/download-node.mjs # uses NODE_BUNDLE_VERSION below
* NODE_VERSION=22.10.0 node scripts/download-node.mjs
*
* Output (per platform):
* POSIX: resources/node/bin/{node,npm,npx} + resources/node/lib/node_modules/npm/...
* Windows: resources/node/{node.exe,npm.cmd,npx.cmd} + resources/node/node_modules/npm/...
*/
import {
chmodSync,
createWriteStream,
existsSync,
lstatSync,
mkdirSync,
readdirSync,
readlinkSync,
rmSync,
statSync,
unlinkSync,
} from "node:fs";
import { get } from "node:https";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { execFileSync } from "node:child_process";
const __dirname = dirname(fileURLToPath(import.meta.url));
const projectRoot = join(__dirname, "..");
const outDir = join(projectRoot, "resources", "node");
// Pinned Node version. Electron 42 ships Node 22, so we bundle a 22.x
// LTS release to match the embedded runtime's ABI/native-module surface.
// We intentionally use 22.12.0 β the repo's own support floor
// (package.json engines.node >=22.12.0, volta 22.12.0) β rather than
// Electron 42.3.2's exact embedded Node patch level: the bundled binary
// runs this repo's launcher scripts, and native modules only need ABI
// parity (NODE_MODULE_VERSION 127, shared by all 22.x builds).
// Override at build time with NODE_VERSION=β¦ (e.g. to test against a
// newer release). Major version >=22 only; engines.node in npm 10.x
// requires ^18.17.0 || >=20.5.0.
const NODE_BUNDLE_VERSION = "22.12.0";
// ββ Platform detection βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
const PLATFORM = process.platform; // 'darwin' | 'linux' | 'win32'
const ARCH = process.arch; // 'x64' | 'arm64' | 'ia32'
/**
* Map (platform, arch) β Node's published distribution name.
* Names come straight from https://nodejs.org/dist/<version>/.
*
* macOS arm64 β node-v<ver>-darwin-arm64.tar.gz
* macOS x64 β node-v<ver>-darwin-x64.tar.gz
* linux x64 β node-v<ver>-linux-x64.tar.gz
* linux arm64 β node-v<ver>-linux-arm64.tar.gz
* win32 x64 β node-v<ver>-win-x64.zip
* win32 arm64 β node-v<ver>-win-arm64.zip
*/
function getPlatformSpec(version) {
const base = `node-v${version}`;
if (PLATFORM === "darwin") {
const arch = ARCH === "arm64" ? "arm64" : "x64";
return { name: `${base}-darwin-${arch}`, ext: "tar.gz" };
}
if (PLATFORM === "linux") {
const arch = ARCH === "arm64" ? "arm64" : "x64";
return { name: `${base}-linux-${arch}`, ext: "tar.gz" };
}
if (PLATFORM === "win32") {
const arch = ARCH === "arm64" ? "arm64" : "x64";
return { name: `${base}-win-${arch}`, ext: "zip" };
}
throw new Error(`Unsupported platform for Node download: ${PLATFORM}/${ARCH}`);
}
// ββ Version resolution βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
function resolveVersion() {
const requested = process.env.NODE_VERSION?.replace(/^v/, "");
return requested || NODE_BUNDLE_VERSION;
}
// ββ HTTP helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
function downloadFile(url, dest) {
return new Promise((resolve, reject) => {
const file = createWriteStream(dest);
function doGet(u) {
get(u, { headers: { "User-Agent": "agent-canvas-build" } }, (res) => {
if (res.statusCode === 301 || res.statusCode === 302) {
return doGet(res.headers.location);
}
if (res.statusCode !== 200) {
file.destroy();
return reject(new Error(`GET ${u} β HTTP ${res.statusCode}`));
}
res.pipe(file);
file.on("finish", () => file.close(resolve));
file.on("error", reject);
res.on("error", reject);
}).on("error", (err) => {
file.destroy();
reject(err);
});
}
doGet(url);
});
}
// ββ Extraction βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
function extract(archivePath, targetDir, ext) {
// Both tar.gz and zip extract via system `tar`:
// GNU/BSD tar (macOS/Linux) handles .tar.gz natively.
// bsdtar (Windows 10+) handles both .tar.gz and .zip.
// --strip-components=1 drops the "node-vX.Y.Z-<platform>-<arch>/" top dir.
void ext; // archive content is identified by tar's own magic bytes
execFileSync(
"tar",
["-xf", archivePath, "-C", targetDir, "--strip-components=1"],
{ stdio: "inherit" },
);
}
function ensureExecutable(p) {
if (process.platform === "win32") return;
try {
chmodSync(p, 0o755);
} catch {}
}
// ββ Layout verification ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
/**
* Confirm the extracted tree has the binaries we depend on.
* On Unix Node puts them in bin/; on Windows they live at the root.
*/
function verifyLayout() {
const isWin = PLATFORM === "win32";
const required = isWin
? ["node.exe", "npm.cmd", "npx.cmd"]
: ["bin/node", "bin/npm", "bin/npx"];
for (const rel of required) {
const p = join(outDir, rel);
if (!existsSync(p)) {
throw new Error(
`Expected ${rel} in extracted Node distribution but it is missing ` +
`at ${p}. Did the tarball layout change?`,
);
}
ensureExecutable(p);
}
// npm/npx are wrapper scripts that invoke node against npm's JS entry
// points; verify the targets exist too so a packaged build doesn't ship
// a half-broken installation.
const npmCli = isWin
? join(outDir, "node_modules", "npm", "bin", "npm-cli.js")
: join(outDir, "lib", "node_modules", "npm", "bin", "npm-cli.js");
if (!existsSync(npmCli)) {
throw new Error(`Bundled Node is missing npm-cli.js at ${npmCli}`);
}
}
// ββ Pruning ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
/**
* Drop pieces of the Node distribution that are only useful when building
* native modules from source or for human-readable documentation. Stripping
* these shrinks the bundled Node from ~170 MB β ~115 MB on Linux x64 (the
* Node binary itself is the bulk of what remains and can't be reduced).
*
* Kept intentionally:
* bin/node, bin/npm, bin/npx β runtime binaries / wrappers
* lib/node_modules/{npm,corepack} β npm itself
* LICENSE β required by the BSD-style Node license
*/
function pruneUnusedFiles() {
// IMPORTANT: every entry that points into a directory we delete must also
// delete any symlink/shim that targets into it, otherwise electron-builder
// hits ENOENT trying to stat() the dangling symlink while copying the
// extraResource into the .app bundle.
//
// Example: Node's POSIX tarball ships `bin/corepack` as a symlink to
// `../lib/node_modules/corepack/dist/corepack.js`. If we drop the corepack
// module under lib/ but leave the symlink, `electron-builder` fails with
// ENOENT: ... Resources/node/bin/corepack
const candidates =
PLATFORM === "win32"
? [
// Windows Node zip lays out npm directly under node_modules/, not lib/.
// Strip docs, headers, and node_modules/corepack (the npm runtime
// doesn't need corepack to run, and we don't ship yarn/pnpm).
"CHANGELOG.md",
"README.md",
"node_modules/corepack",
// Windows ships corepack as both a Bash wrapper and a cmd.exe wrapper
// at the distribution root; both proxy into node_modules/corepack.
"corepack",
"corepack.cmd",
]
: [
// POSIX layout β keep bin/ and lib/node_modules/npm; drop the rest.
"include",
"share",
"CHANGELOG.md",
"README.md",
"lib/node_modules/corepack",
// Symlink in bin/ targets the corepack we just deleted.
"bin/corepack",
];
for (const rel of candidates) {
const p = join(outDir, rel);
// `rmSync(force: true)` resolves the path through symlinks, so once the
// corepack target directory is deleted the now-dangling `bin/corepack`
// link reads as "already gone" and silently survives β the exact ENOENT
// trap failOnDanglingSymlinks() exists to catch. Remove files/symlinks
// with `unlinkSync` (lstat semantics, works on dangling links) first and
// fall back to `rmSync` for directories.
try {
unlinkSync(p);
} catch {
try {
rmSync(p, { recursive: true, force: true });
} catch {
// best-effort
}
}
}
}
// ββ Dangling-symlink check βββββββββββββββββββββββββββββββββββββββββββββββββββ
/**
* Walk the pruned tree and refuse to finish if any symlink points at a path
* that no longer exists. electron-builder calls `stat()` (which follows
* symlinks) on every entry it copies into the .app bundle, so a single
* dangling symlink blows up the whole `build:desktop` step with a confusing
* ENOENT β fail at download time instead, with a message that says which
* pruned directory the symlink was reaching into.
*/
function failOnDanglingSymlinks() {
const broken = [];
const stack = [outDir];
while (stack.length) {
const next = stack.pop();
let entries;
try {
entries = readdirSync(next, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const p = join(next, entry.name);
if (entry.isSymbolicLink()) {
try {
// statSync follows the link; if the target is gone this throws.
statSync(p);
} catch {
let target = "<unreadable>";
try {
if (lstatSync(p).isSymbolicLink()) target = readlinkSync(p);
} catch {
// ignore β best-effort labelling
}
broken.push(`${p} β ${target}`);
}
} else if (entry.isDirectory()) {
stack.push(p);
}
}
}
if (broken.length) {
console.error(
"[download-node] Dangling symlinks remain after pruning β these would " +
"crash electron-builder later with ENOENT. Add the dangling symlink " +
"(or its target) to pruneUnusedFiles() in this script:",
);
for (const entry of broken) console.error(" β’", entry);
throw new Error(`${broken.length} dangling symlink(s) in resources/node/`);
}
}
// ββ Size report ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
function dirSizeBytes(dir) {
let total = 0;
const stack = [dir];
while (stack.length) {
const next = stack.pop();
let entries;
try {
entries = readdirSync(next, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const p = join(next, entry.name);
if (entry.isDirectory()) {
stack.push(p);
} else {
try {
total += statSync(p).size;
} catch {}
}
}
}
return total;
}
// ββ Main βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
async function main() {
const version = resolveVersion();
const spec = getPlatformSpec(version);
const archiveName = `${spec.name}.${spec.ext}`;
const url = `https://nodejs.org/dist/v${version}/${archiveName}`;
const tmpFile = join(tmpdir(), `node-download-${Date.now()}.${spec.ext}`);
console.log(
`[download-node] Downloading Node v${version} for ${PLATFORM}/${ARCH}`,
);
console.log(`[download-node] URL: ${url}`);
try {
// Clear any previous output so stale files (different Node version, or
// a stale resources/npm/ from the previous wrapper approach) don't
// linger in the bundle.
if (existsSync(outDir)) rmSync(outDir, { recursive: true, force: true });
mkdirSync(outDir, { recursive: true });
console.log(`[download-node] Downloading to ${tmpFile}`);
await downloadFile(url, tmpFile);
console.log(`[download-node] Extracting to ${outDir}`);
extract(tmpFile, outDir, spec.ext);
verifyLayout();
pruneUnusedFiles();
failOnDanglingSymlinks();
const mb = Math.round(dirSizeBytes(outDir) / (1024 * 1024));
console.log(`[download-node] β Node v${version} ready at ${outDir} (~${mb} MB)`);
} finally {
try {
rmSync(tmpFile, { force: true });
} catch {}
}
}
main().catch((err) => {
console.error("[download-node] Error:", err.message);
process.exit(1);
});
|