Download electron-builder.config.mjs from SaylorTwift/openhands: direct link, hf CLI and curl.
- Browser
- Download file 19.4 kB
-
https://huggingface.co/SaylorTwift/openhands/resolve/main/electron-builder.config.mjs
- Command line
-
hf download hf://SaylorTwift/openhands/electron-builder.config.mjs
-
curl -L -o electron-builder.config.mjs https://huggingface.co/SaylorTwift/openhands/resolve/main/electron-builder.config.mjs
19.4 kB
| /** | |
| * electron-builder configuration for the Agent Canvas desktop app. | |
| * | |
| * `directories.app: 'electron'` tells electron-builder to use electron/package.json | |
| * as the app manifest (with `"main": "main.mjs"`). This sidesteps the root | |
| * package.json's `"main": "./dist/index.cjs"` without any afterPack patching. | |
| * | |
| * NODE_MODULES NOTE: | |
| * | |
| * Even with zero `dependencies` in electron/package.json, electron-builder's | |
| * "search for node modules" routine walks UP from directories.app looking | |
| * for the first node_modules in scope. It runs `npm list --json` in the | |
| * project root, gets the full hoisted tree (~342 dirs, ~600 MB: Vite, | |
| * React, Monaco, HeroUI, etc.), and copies all of it into the packaged | |
| * app at Resources/app/node_modules/. | |
| * | |
| * Almost none of those packages are imported at desktop runtime β main.mjs | |
| * and the launcher (dev-with-automation.mjs) use only Node built-ins. The | |
| * exception is the two child-process servers: static-server.mjs imports | |
| * `sirv` and ingress.mjs (via proxy-utils.mjs) imports `httpxy`. Node | |
| * resolves those bare specifiers by walking UP from the script's own path, | |
| * so an app tested from dist-electron/ inside a repo checkout accidentally | |
| * resolves them against the repo's node_modules and works β while the same | |
| * app in /Applications crashes both servers with ERR_MODULE_NOT_FOUND and | |
| * the splash times out waiting for port 8000. We can't disable the search | |
| * from the config (it's hardcoded in app-builder-lib), and creating an | |
| * empty electron/node_modules/ doesn't help because `npm list` from the | |
| * project root still reports the full hoisted tree. | |
| * | |
| * The fix is the `afterPack` hook below: after electron-builder has | |
| * finished copying files, we rm -rf the bundled Resources/app/node_modules/ | |
| * directory, then copy back the dependency closure of RUNTIME_PACKAGES | |
| * (~200 KB). The build wastes a few seconds copying files we immediately | |
| * delete, but the final artifact is correctly tiny (~10 MB vs ~600 MB). | |
| * | |
| * Packaged app layout (Resources/app/ = electron/ contents): | |
| * main.mjs β Electron entry point | |
| * loading.html β loading splash | |
| * package.json β {"main":"main.mjs"} (from electron/package.json) | |
| * scripts/ β backend scripts | |
| * node_modules/ β runtime closure of RUNTIME_PACKAGES (restored by afterPack) | |
| * config/ β defaults.json | |
| * build/ β static frontend (npm run build:app output) | |
| * | |
| * The bundled uv binary (resources/bin/) lands in <Resources>/bin/ via | |
| * extraResources so Electron can inject it into PATH on startup. | |
| * | |
| * The bundled Node.js distribution (resources/node/) lands in | |
| * <Resources>/node/ via extraResources β except for its root-level | |
| * node_modules (npm itself), which electron-builder refuses to copy and the | |
| * afterPack hook restores; see restoreBundledNodeNpm below. | |
| * | |
| * Electron prepends its bin dir to PATH at startup so backend scripts (`node | |
| * scripts/ingress.mjs` etc.) and stdio MCP servers spawned via `npx -y β¦` | |
| * (Slack, GitHub, Figma, etc.) can find a working node/npm/npx β the OS gives | |
| * a Finder-launched .app a minimal PATH (/usr/bin:/bin) that has none of those. | |
| */ | |
| import { cp, rm } from "node:fs/promises"; | |
| import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; | |
| import { dirname, join, relative } from "node:path"; | |
| import { fileURLToPath } from "node:url"; | |
| // npm packages the packaged app's child-process scripts import at runtime: | |
| // scripts/static-server.mjs β sirv | |
| // scripts/proxy-utils.mjs β httpxy (imported by ingress.mjs) | |
| // Their dependency closure is copied back into Resources/app/node_modules | |
| // after the strip below. If a spawned script gains a new bare import, add | |
| // the package here β a missing one crashes that service in the installed | |
| // app with ERR_MODULE_NOT_FOUND (invisible under Finder, where stdout goes | |
| // to /dev/null) and the splash times out waiting for port 8000. | |
| const RUNTIME_PACKAGES = ["sirv", "httpxy"]; | |
| const repoRoot = dirname(fileURLToPath(import.meta.url)); | |
| // Root package.json is the single source of truth for the app version | |
| // (release-please bumps it). electron/package.json is a minimal manifest | |
| // stub pinned at 1.0.0 β `extraMetadata` below overrides its version at | |
| // pack time so artifact names and app.getVersion() carry the released | |
| // version instead. | |
| const rootPackageJson = JSON.parse( | |
| readFileSync(join(repoRoot, "package.json"), "utf8"), | |
| ); | |
| /** | |
| * Resolve the packaged app's Resources directory. | |
| * | |
| * On macOS it lives inside the `.app` bundle; on Linux/Windows it's a flat | |
| * `resources/` subdirectory. We resolve both shapes from `context.appOutDir` | |
| * + the productFilename. | |
| */ | |
| function packagedResourcesDir(context) { | |
| const platform = context.electronPlatformName; | |
| const productFilename = context.packager.appInfo.productFilename; | |
| return platform === "darwin" || platform === "mas" | |
| ? join(context.appOutDir, `${productFilename}.app`, "Contents", "Resources") | |
| : join(context.appOutDir, "resources"); | |
| } | |
| /** | |
| * afterPack entry point. Invoked by electron-builder once per platform target | |
| * after the unpacked directory has been populated but before installer-format | |
| * packaging (DMG, NSIS, deb). | |
| */ | |
| async function afterPack(context) { | |
| await stripBundledNodeModules(context); | |
| await restoreBundledNodeNpm(context); | |
| } | |
| /** | |
| * Strip the auto-bundled node_modules from the packaged app, then restore | |
| * the small runtime closure of RUNTIME_PACKAGES. | |
| * | |
| * See the NODE_MODULES NOTE in the file header for the why. | |
| */ | |
| async function stripBundledNodeModules(context) { | |
| const appDir = join(packagedResourcesDir(context), "app"); | |
| const nm = join(appDir, "node_modules"); | |
| if (existsSync(nm)) { | |
| // Best-effort size report so the log line shows what we saved. Skip if | |
| // walking the tree fails for any reason β the rm below is what matters. | |
| let sizeMb = null; | |
| try { | |
| sizeMb = Math.round(getDirSizeBytes(nm) / (1024 * 1024)); | |
| } catch {} | |
| await rm(nm, { recursive: true, force: true }); | |
| const rel = relative(process.cwd(), nm); | |
| const human = sizeMb != null ? ` (~${sizeMb} MB)` : ""; | |
| // eslint-disable-next-line no-console -- electron-builder build log | |
| console.log( | |
| `[electron-builder] stripped bundled node_modules${human}: ${rel}`, | |
| ); | |
| } | |
| await restoreRuntimeNodeModules(appDir); | |
| } | |
| /** | |
| * Copy the dependency closure of RUNTIME_PACKAGES from the repo's | |
| * node_modules into the packaged app's Resources/app/node_modules so the | |
| * spawned `node scripts/β¦` servers can resolve their bare imports outside | |
| * a repo checkout (see RUNTIME_PACKAGES above). | |
| * | |
| * Resolution is deliberately simple: every package (and every transitive | |
| * `dependencies` entry) is looked up at the repo root's flat npm tree, and | |
| * a miss throws so the build fails loudly instead of shipping a DMG whose | |
| * ingress/static-server crash on launch. | |
| */ | |
| async function restoreRuntimeNodeModules(appDir) { | |
| const rootNodeModules = join(repoRoot, "node_modules"); | |
| // name β source dir, walking `dependencies` breadth-first. | |
| const packages = new Map(); | |
| const queue = [...RUNTIME_PACKAGES]; | |
| while (queue.length) { | |
| const name = queue.shift(); | |
| if (packages.has(name)) continue; | |
| const srcDir = join(rootNodeModules, ...name.split("/")); | |
| const manifestPath = join(srcDir, "package.json"); | |
| if (!existsSync(manifestPath)) { | |
| throw new Error( | |
| `[electron-builder] runtime package "${name}" not found in ` + | |
| `${rootNodeModules} β run npm install and rebuild`, | |
| ); | |
| } | |
| packages.set(name, srcDir); | |
| const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); | |
| queue.push(...Object.keys(manifest.dependencies ?? {})); | |
| } | |
| let totalBytes = 0; | |
| for (const [name, srcDir] of packages) { | |
| const destDir = join(appDir, "node_modules", ...name.split("/")); | |
| await cp(srcDir, destDir, { recursive: true }); | |
| totalBytes += getDirSizeBytes(destDir); | |
| } | |
| // eslint-disable-next-line no-console -- electron-builder build log | |
| console.log( | |
| `[electron-builder] restored runtime node_modules ` + | |
| `(${[...packages.keys()].join(", ")}; ~${Math.round(totalBytes / 1024)} KB)`, | |
| ); | |
| } | |
| /** | |
| * Restore the bundled Node distribution's root-level `node_modules` (npm | |
| * itself), which electron-builder silently refuses to copy, then verify the | |
| * packed result. | |
| * | |
| * WHY THIS IS NEEDED β app-builder-lib's copy filter drops a `node_modules` | |
| * directory sitting at the ROOT of a `from` dir, before any `filter` pattern | |
| * is consulted (packages/app-builder-lib/src/util/filter.ts): | |
| * | |
| * // filter the root node_modules, but not a subnode_modules | |
| * if (relative === "node_modules") { | |
| * return false | |
| * } | |
| * | |
| * That check is reached for extraResources too β `copyFiles()` builds the | |
| * copy filter with the same `createFilter()`. Our extraResources entry copies | |
| * `resources/node/` β `<Resources>/node/`, and the Windows Node zip puts npm | |
| * at `<root>/node_modules/npm`, i.e. exactly `node_modules` relative to the | |
| * copy root. So it is skipped, and no `filter` value can opt back in. | |
| * | |
| * POSIX tarballs put npm at `<root>/lib/node_modules/npm`, which is not the | |
| * root entry and copies fine β which is why this only ever broke Windows and | |
| * went unnoticed on macOS. | |
| * | |
| * Symptom when it goes wrong: `<Resources>/node/` ships a working `node.exe` | |
| * next to `npm.cmd` / `npx.cmd` shims that exec | |
| * `%~dp0\node_modules\npm\bin\npx-cli.js` β a file that isn't there. Every | |
| * `npx -y <pkg>` spawn (stdio MCP servers, ACP servers) then dies with | |
| * MODULE_NOT_FOUND, and because injectBundledNode() PREPENDS this directory | |
| * to PATH it also shadows the user's own working npm installation. | |
| */ | |
| async function restoreBundledNodeNpm(context) { | |
| const isWin = context.electronPlatformName === "win32"; | |
| const nodeDir = join(packagedResourcesDir(context), "node"); | |
| // No bundled Node at all β `npm run download-node` was skipped. That is a | |
| // different (and already loudly reported) situation; main.mjs warns at | |
| // startup. Don't turn it into a build failure here. | |
| if (!existsSync(nodeDir)) return; | |
| const srcNodeModules = join(repoRoot, "resources", "node", "node_modules"); | |
| const destNodeModules = join(nodeDir, "node_modules"); | |
| if (!existsSync(destNodeModules) && existsSync(srcNodeModules)) { | |
| await cp(srcNodeModules, destNodeModules, { recursive: true }); | |
| const rel = relative(process.cwd(), destNodeModules); | |
| // eslint-disable-next-line no-console -- electron-builder build log | |
| console.log( | |
| `[electron-builder] restored bundled Node node_modules: ${rel}`, | |
| ); | |
| } | |
| // Mirror the check scripts/download-node.mjs runs on the source tree, but | |
| // against the packed output β the last point where a broken npm is still a | |
| // build failure instead of a broken install. | |
| const npmCli = isWin | |
| ? join(nodeDir, "node_modules", "npm", "bin", "npm-cli.js") | |
| : join(nodeDir, "lib", "node_modules", "npm", "bin", "npm-cli.js"); | |
| if (!existsSync(npmCli)) { | |
| throw new Error( | |
| `[electron-builder] packaged Node is missing npm-cli.js at ${npmCli} β ` + | |
| "npm/npx would fail with MODULE_NOT_FOUND in the installed app. " + | |
| "Run `npm run download-node` and rebuild.", | |
| ); | |
| } | |
| } | |
| function getDirSizeBytes(dir) { | |
| // Synchronous walk so we can run it before the rm without async juggling | |
| // in the hook. The directory we're sizing is always small enough (<1 GB) | |
| // that this is negligible compared to the rm itself. | |
| let total = 0; | |
| const stack = [dir]; | |
| while (stack.length) { | |
| const next = stack.pop(); | |
| let entries; | |
| try { | |
| entries = readdirSync(next, { withFileTypes: true }); | |
| } catch { | |
| // Best-effort: skip unreadable dirs (symlink races, permission | |
| // errors on platform-specific node_modules subtrees, etc.). The | |
| // size number is only used in a build-log line, so under-counting | |
| // is preferable to aborting the strip. | |
| 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; | |
| } | |
| /** @type {import('electron-builder').Configuration} */ | |
| const config = { | |
| appId: "dev.openhands.agent-canvas", | |
| productName: "OpenHands Agent Canvas", | |
| copyright: "Copyright Β© 2025 OpenHands contributors", | |
| // Stamp the packaged app with the released version (see rootPackageJson | |
| // note above). | |
| extraMetadata: { version: rootPackageJson.version }, | |
| // Treat electron/ as the app root. electron/package.json provides the | |
| // Electron entry point without touching the npm-published root package.json. | |
| // `buildResources` points at electron/build-resources so electron-builder | |
| // can auto-discover the committed icon.icns / icon.ico (generated from the | |
| // 1024Γ1024 icon.png master via `npm run generate-icons`) and, for Linux, | |
| // icon.png itself. | |
| directories: { | |
| app: "electron", | |
| output: "dist-electron", | |
| buildResources: "electron/build-resources", | |
| }, | |
| // Do not pack into asar β scripts are spawned as child processes by | |
| // dev-with-automation.mjs and must exist as real files on disk. | |
| asar: false, | |
| // Skip native-module rebuild β the app has no native deps. | |
| npmRebuild: false, | |
| // Strip auto-bundled node_modules (see NODE_MODULES NOTE at top of file), | |
| // then restore the bundled Node distribution's own npm (see | |
| // restoreBundledNodeNpm). | |
| afterPack, | |
| // Files included in the packaged app. | |
| // Paths with `from` are relative to directories.app (electron/). | |
| // Bare globs are also relative to directories.app. | |
| files: [ | |
| // electron/ base files (main.mjs, loading.html, package.json) | |
| "**/*", | |
| // Bundle the raw 1024Γ1024 PNG into Resources/app/build-resources/ so | |
| // main.mjs can set it as the BrowserWindow icon at runtime (used for the | |
| // Linux taskbar; macOS reads from the .icns inside the .app bundle). | |
| "build-resources/icon.png", | |
| // Windows runtime BrowserWindow icon (main.mjs picks .ico on win32). | |
| "build-resources/icon.ico", | |
| // Scripts from project root. Mostly Node built-ins; the two spawned | |
| // servers additionally need RUNTIME_PACKAGES, restored into | |
| // Resources/app/node_modules by the afterPack hook. | |
| { from: "../scripts", to: "scripts", filter: ["**/*.mjs", "**/*.cjs"] }, | |
| // Centralised version / port / path config | |
| { from: "../config", to: "config" }, | |
| // Pre-built static frontend (npm run build:app output) | |
| { from: "../build", to: "build" }, | |
| // Custom Python tools (canvas_ui_tool.py). dev-safe.mjs sets | |
| // OH_EXTRA_PYTHON_PATH to this directory so the agent-server can import | |
| // canvas_ui_tool at runtime. The path is computed as ../tools relative to | |
| // scripts/dev-safe.mjs, which resolves correctly in both dev and packaged mode. | |
| { from: "../tools", to: "tools" }, | |
| ], | |
| // Bundled prerequisites β placed in <Resources>/ so Electron can put | |
| // them on PATH before starting the backend stack. | |
| // bin/ β uv + uvx (downloaded by `npm run download-uv`) | |
| // node/ β official Node.js distribution; provides `node` plus the | |
| // bundled `npm` / `npx` that stdio MCP servers (Slack, GitHub, | |
| // Figma, etc.) spawn via `npx -y <package>` (downloaded by | |
| // `npm run download-node`) | |
| // `from` is relative to the project root (not directories.app). | |
| // build:desktop calls both download scripts before invoking electron-builder. | |
| extraResources: [ | |
| { from: "resources/bin/", to: "bin/", filter: ["**/*"] }, | |
| { from: "resources/node/", to: "node/", filter: ["**/*"] }, | |
| ], | |
| // ββ macOS ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // | |
| // Default to the native CPU arch so day-to-day `npm run build:desktop` | |
| // is fast (one Electron binary, one packaging pass). | |
| // | |
| // For a distributable universal build set ELECTRON_ARCH=universal: | |
| // ELECTRON_ARCH=universal npm run build:desktop | |
| // | |
| // Or use the dedicated script: | |
| // npm run build:desktop:universal | |
| // | |
| // CAUTION: the bundled uv/node extraResources are downloaded for the | |
| // BUILD HOST's architecture only (scripts/download-uv.mjs and | |
| // download-node.mjs have no arch override), so a "universal" build still | |
| // ships single-arch runtimes and breaks on the other architecture. Don't | |
| // distribute universal DMGs until the download scripts support multi-arch. | |
| // | |
| mac: { | |
| category: "public.app-category.developer-tools", | |
| target: [ | |
| { | |
| target: "dmg", | |
| arch: [ | |
| process.env.ELECTRON_ARCH ?? | |
| (process.arch === "arm64" ? "arm64" : "x64"), | |
| ], | |
| }, | |
| ], | |
| // Icon auto-discovered from directories.buildResources/icon.icns | |
| // (committed; regenerate with `npm run generate-icons`). | |
| }, | |
| dmg: { | |
| title: "OpenHands Agent Canvas", | |
| contents: [ | |
| { x: 130, y: 220 }, | |
| { x: 410, y: 220, type: "link", path: "/Applications" }, | |
| ], | |
| window: { width: 540, height: 380 }, | |
| // Default is "OpenHands Agent Canvas-<version>-<arch>.dmg"; GitHub release | |
| // assets mangle spaces, so keep the asset name literal (matches the nsis | |
| // convention). ${version}/${arch}/${ext} are electron-builder macros. | |
| artifactName: "OpenHands-Agent-Canvas-${version}-${arch}.${ext}", | |
| }, | |
| // ββ Windows ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| win: { | |
| target: [{ target: "nsis", arch: ["x64"] }], | |
| // Icon auto-discovered from directories.buildResources/icon.ico | |
| // (committed; regenerate with `npm run generate-icons`). Also used for | |
| // the NSIS installer/uninstaller and the rcedit exe icon resource. | |
| }, | |
| nsis: { | |
| oneClick: false, | |
| perMachine: false, | |
| allowToChangeInstallationDirectory: true, | |
| createDesktopShortcut: true, | |
| createStartMenuShortcut: true, | |
| // The default artifact name is "OpenHands Agent Canvas Setup <version>.exe"; | |
| // GitHub release assets mangle spaces, so ship a space-free name. | |
| // ${version}/${ext} are electron-builder macros, not JS interpolation. | |
| artifactName: "OpenHands-Agent-Canvas-Setup-${version}.${ext}", | |
| }, | |
| // ββ Linux ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| linux: { | |
| target: [ | |
| { target: "AppImage", arch: ["x64"] }, | |
| { target: "deb", arch: ["x64"] }, | |
| ], | |
| category: "Development", | |
| // Icon auto-discovered from directories.buildResources/icon.png. | |
| // fpm-backed targets (deb) require a maintainer with an email address; | |
| // electron/package.json carries no author, so set it here. Without this | |
| // the deb step fails with "Please specify author 'email'". | |
| maintainer: "OpenHands <contact@all-hands.dev>", | |
| }, | |
| }; | |
| export default config; | |