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);
});