#!/bin/bash set -euo pipefail # Master build script for the OpenSwarm desktop app. # # Usage: # bash scripts/build-app.sh Local dev build (unsigned) # bash scripts/build-app.sh --publish Production build (signed, notarized, published to GitHub Releases) SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" ENV_FILE="$PROJECT_ROOT/backend/.env" if [[ -f "$ENV_FILE" ]]; then set -a source "$ENV_FILE" set +a fi PUBLISH_MODE=false SIGN_MODE=false if [[ "${1:-}" == "--publish" ]]; then PUBLISH_MODE=true SIGN_MODE=true elif [[ "${1:-}" == "--sign" ]]; then SIGN_MODE=true fi # Defensive: detach any leftover OpenSwarm DMG volumes from prior failed builds. # hdiutil's "Resource busy" / volume-name-collision errors almost always trace # back to a stale mount in /Volumes (e.g. after a build crash or a still-open # Finder window from the last run). shopt -s nullglob for vol in /Volumes/OpenSwarm*; do if [[ -d "$vol" ]]; then echo "Detaching leftover DMG mount: $vol" hdiutil detach -force "$vol" 2>/dev/null || hdiutil detach "$vol" 2>/dev/null || true fi done shopt -u nullglob echo "========================================" echo " OpenSwarm Desktop App Builder" if $PUBLISH_MODE; then echo " Mode: PRODUCTION (sign + notarize + publish)" elif $SIGN_MODE; then echo " Mode: SIGNED (sign + notarize, no publish)" else echo " Mode: LOCAL (unsigned)" fi echo "========================================" echo "" if $SIGN_MODE; then missing_vars=() [[ -z "${APPLE_ID:-}" ]] && missing_vars+=("APPLE_ID") [[ -z "${APPLE_APP_SPECIFIC_PASSWORD:-}" ]] && missing_vars+=("APPLE_APP_SPECIFIC_PASSWORD") [[ -z "${APPLE_TEAM_ID:-}" ]] && missing_vars+=("APPLE_TEAM_ID") if $PUBLISH_MODE; then [[ -z "${GH_TOKEN:-}" ]] && missing_vars+=("GH_TOKEN") fi if [[ ${#missing_vars[@]} -gt 0 ]]; then echo "ERROR: Missing required environment variables:" printf ' - %s\n' "${missing_vars[@]}" echo "" echo "See script header for details." exit 1 fi fi # Step 0: Ensure bundled uvx binary exists. # We deliberately ship ONLY uvx (~681KB universal), NOT uv (~97MB universal). # tools_lib.py probes for both, but the only tool config that uses either is # Google Workspace (cmd: "uvx"). Bare `uv` (the package installer/manager) is # never invoked at runtime by any shipped tool — it's a build-time tool. # Saves 97MB from the Mac DMG. If a future MCP needs `uv`, this is a one-line # add-back here. UV_BIN_DIR="$PROJECT_ROOT/backend/uv-bin" # Defensively drop a stale `uv` from prior builds so it doesn't ride along. if [[ -f "$UV_BIN_DIR/uv" ]]; then echo "[0] Removing legacy uv binary (we now ship uvx only)..." rm -f "$UV_BIN_DIR/uv" fi if [[ ! -f "$UV_BIN_DIR/uvx" ]]; then echo "[0] Downloading uvx binary..." mkdir -p "$UV_BIN_DIR" TMPDIR_UV=$(mktemp -d) curl -sL "https://github.com/astral-sh/uv/releases/latest/download/uv-aarch64-apple-darwin.tar.gz" | tar xz -C "$TMPDIR_UV" curl -sL "https://github.com/astral-sh/uv/releases/latest/download/uv-x86_64-apple-darwin.tar.gz" | tar xz -C "$TMPDIR_UV" # Only lipo uvx — skip uv entirely. lipo -create "$TMPDIR_UV/uv-aarch64-apple-darwin/uvx" "$TMPDIR_UV/uv-x86_64-apple-darwin/uvx" -output "$UV_BIN_DIR/uvx" chmod +x "$UV_BIN_DIR/uvx" rm -rf "$TMPDIR_UV" echo "uvx downloaded and bundled." else echo "[0] uvx binary already present." fi echo "" # Step 0a: Sync the splash icon. electron-builder excludes `build/` from # the shipped asar (it's the icon-source directory used to generate .icns/.ico), # so the splash window can't read build/icon.png at runtime. We keep a copy # at electron/splash/icon.png which IS shipped. See electron/main.js comment # at iconPngPath for context. cp "$PROJECT_ROOT/electron/build/icon.png" "$PROJECT_ROOT/electron/splash/icon.png" # Step 0b: Bundle npm MCP servers via esbuild # Each bundle compiles down to a single ~5-15 MB CommonJS file under # backend/mcp-bundles/, runs on Electron's bundled Node at runtime # (ELECTRON_RUN_AS_NODE=1), and is preferred by tools_lib.py:521 over # any pre-installed node_modules tree. Bundling instead of shipping # node_modules cuts the installer file count from ~28k -> ~9k, the # dominant lever on NSIS install time + Defender scan cost. MCP_BUNDLE_DIR="$PROJECT_ROOT/backend/mcp-bundles" mkdir -p "$MCP_BUNDLE_DIR" # Single-file CJS bundles. Output path is mcp-bundles/.js. Use for # packages that don't read sibling files at runtime. The import.meta.url # polyfill is applied uniformly because nearly every modern ESM package # uses createRequire(import.meta.url) somewhere in its dependency tree — # without the polyfill, esbuild's ESM->CJS transform leaves import.meta.url # as undefined and the bundle crashes at module load. build_mcp_bundle_single() { local pkg_name="$1" local entry_subpath="$2" local output_name="$3" local out_file="$MCP_BUNDLE_DIR/$output_name" if [[ -f "$out_file" && -z "${OPENSWARM_REBUILD_BUNDLES:-}" ]]; then echo "[0b] $pkg_name bundle already present (set OPENSWARM_REBUILD_BUNDLES=1 to force rebuild)." return fi echo "[0b] Bundling $pkg_name -> $output_name ..." local tmp_dir; tmp_dir=$(mktemp -d) ( cd "$tmp_dir" npm install "$pkg_name" --silent 2>/dev/null local entry="node_modules/$entry_subpath" if [[ ! -f "$entry" ]]; then echo "ERROR: $pkg_name entry not found at $entry" >&2; exit 1; fi local banner='const __OPENSWARM_IMPORT_META_URL__ = require("url").pathToFileURL(__filename).href;' npx esbuild "$entry" --bundle --platform=node --format=cjs --target=node22 --legal-comments=none \ --define:import.meta.url=__OPENSWARM_IMPORT_META_URL__ \ "--banner:js=$banner" \ --outfile="$out_file" ) rm -rf "$tmp_dir" echo "$pkg_name bundled ($(du -h "$out_file" | cut -f1))." } # Multi-file bundle. Output is a directory mcp-bundles// that mirrors the # upstream SDK's "package_root/dist/index.js + ../package.json" layout. Use this # for packages whose source reads __dirname/../package.json (for --version) or # other sibling data files (e.g. @softeria/ms-365-mcp-server reads endpoints.json). # `extras` is a space-separated list of "src=dst" pairs relative to node_modules # and the bundle dir respectively (e.g. "@softeria/ms-365-mcp-server/dist/endpoints.json=dist/endpoints.json"). # `external` is a comma-separated list of npm package names to leave unbundled # (e.g. "keytar" — the SDK gracefully degrades when keytar can't be imported). build_mcp_bundle_dir() { local pkg_name="$1" local entry_subpath="$2" local out_dir_name="$3" local extras="$4" # e.g. "@softeria/ms-365-mcp-server/dist/endpoints.json=dist/endpoints.json" local external="$5" # comma-separated package names local out_dir="$MCP_BUNDLE_DIR/$out_dir_name" if [[ -f "$out_dir/dist/index.js" && -z "${OPENSWARM_REBUILD_BUNDLES:-}" ]]; then echo "[0b] $pkg_name bundle dir already present." return fi echo "[0b] Bundling $pkg_name -> $out_dir_name/ ..." local tmp_dir; tmp_dir=$(mktemp -d) rm -rf "$out_dir" mkdir -p "$out_dir/dist" ( cd "$tmp_dir" npm install "$pkg_name" --silent 2>/dev/null local entry="node_modules/$entry_subpath" if [[ ! -f "$entry" ]]; then echo "ERROR: $pkg_name entry not found at $entry" >&2; exit 1; fi # Stripped sibling package.json — the SDK reads packageJson.version. # Critically OMIT "type":"module" so Node treats the CJS bundle correctly. local sdk_version sdk_version=$(node -e "console.log(require('./node_modules/$pkg_name/package.json').version)") printf '{"name":"%s","version":"%s"}' "$pkg_name" "$sdk_version" > "$out_dir/package.json" # Copy any sibling data files the SDK reads at runtime if [[ -n "$extras" ]]; then for pair in $extras; do local src="${pair%%=*}" local dst="${pair##*=}" mkdir -p "$(dirname "$out_dir/$dst")" cp "node_modules/$src" "$out_dir/$dst" done fi # Banner polyfills `require` for the import.meta.url polyfill. local banner='const __OPENSWARM_IMPORT_META_URL__ = require("url").pathToFileURL(__filename).href;' local external_args="" if [[ -n "$external" ]]; then # Portable comma-split (works in bash and zsh) — `read -ra` is bash-only. local _old_ifs="$IFS" IFS=',' local ext for ext in $external; do external_args="$external_args --external:$ext"; done IFS="$_old_ifs" fi npx esbuild "$entry" --bundle --platform=node --format=cjs --target=node22 --legal-comments=none \ --define:import.meta.url=__OPENSWARM_IMPORT_META_URL__ \ "--banner:js=$banner" \ $external_args \ --outfile="$out_dir/dist/index.js" ) rm -rf "$tmp_dir" echo "$pkg_name bundled ($(du -sh "$out_dir" | cut -f1))." } build_mcp_bundle_single 'reddit-mcp-buddy' 'reddit-mcp-buddy/dist/index.js' 'reddit-mcp-buddy.js' build_mcp_bundle_dir '@notionhq/notion-mcp-server' '@notionhq/notion-mcp-server/bin/cli.mjs' \ 'notionhq-notion-mcp-server' \ '@notionhq/notion-mcp-server/scripts/notion-openapi.json=scripts/notion-openapi.json' \ '' build_mcp_bundle_dir '@softeria/ms-365-mcp-server' '@softeria/ms-365-mcp-server/dist/index.js' \ 'softeria-ms-365-mcp-server' \ '@softeria/ms-365-mcp-server/dist/endpoints.json=dist/endpoints.json' \ 'keytar' # Wipe the legacy single-file Notion bundle if the dir bundle now supersedes it. if [[ -f "$MCP_BUNDLE_DIR/notionhq-notion-mcp-server.js" && -d "$MCP_BUNDLE_DIR/notionhq-notion-mcp-server" ]]; then rm -f "$MCP_BUNDLE_DIR/notionhq-notion-mcp-server.js" fi # Defensively wipe any legacy npm-servers/ tree from prior builds so it # doesn't ride along into the installer (would re-introduce ~19k files). LEGACY_NPM_SERVERS="$PROJECT_ROOT/backend/npm-servers" if [[ -d "$LEGACY_NPM_SERVERS" ]]; then echo "[0b] Removing legacy backend/npm-servers/ (superseded by mcp-bundles)..." rm -rf "$LEGACY_NPM_SERVERS" fi echo "" # Step 1: Build frontend echo "[1/4] Building frontend..." cd "$PROJECT_ROOT/frontend" npm install npm run build if [[ ! -f "$PROJECT_ROOT/frontend/dist/index.html" ]]; then echo "ERROR: Frontend build failed — dist/index.html not found" exit 1 fi echo "Frontend build complete." echo "" # Step 2: Build Python environment echo "[2/4] Building Python environment..." bash "$SCRIPT_DIR/build-python-env.sh" if [[ ! -d "$PROJECT_ROOT/electron/python-env" ]]; then echo "ERROR: Python environment not found at electron/python-env/" exit 1 fi echo "Python environment ready." echo "" # Step 3: Fetch Router from npm # The 9router Next.js server is published as an npm package with a pre-built # standalone output. We install it into a scratch dir and stage it directly # rather than vendoring the source + rebuilding here. echo "[3/5] Fetching Router from npm..." STAGING_DIR="$PROJECT_ROOT/electron/build-staging" rm -rf "$STAGING_DIR" mkdir -p "$STAGING_DIR" bash "$PROJECT_ROOT/scripts/fetch-router.sh" "$STAGING_DIR/router" if [[ ! -f "$STAGING_DIR/router/server.js" ]]; then echo "ERROR: Router fetch failed — server.js not found in staged dir" exit 1 fi echo "Router staged." echo "" # Step 4: Snapshot source directories for packaging # (Router was already staged in step 3; do not touch STAGING_DIR/router/ here.) echo "[4/5] Snapshotting source directories..." rsync -a \ --exclude='__pycache__' --exclude='**/__pycache__' \ --exclude='*.pyc' --exclude='.venv' \ --exclude='data/tools' \ --exclude='tests' --exclude='**/tests' \ --exclude='.env' --exclude='.env.*' --exclude='**/.env' --exclude='**/.env.*' \ "$PROJECT_ROOT/backend/" "$STAGING_DIR/backend/" # Generate a SAFE production .env containing ONLY the OAuth provider # credentials (the "OpenSwarm public OAuth app" identifiers — these are # the desktop-app pattern where the client secret isn't truly secret). # We deliberately DROP everything else from the dev .env: # - APPLE_ID / APPLE_APP_SPECIFIC_PASSWORD / APPLE_TEAM_ID (signing creds — must NOT ship) # - GH_TOKEN (release upload token — must NOT ship) # - BACKEND_PORT / DISCORD_BOT_PERMISSIONS (dev-only or trivially recomputable) # Backend's tools_lib.py:34 calls load_dotenv() on this path at startup, # so the OAuth flows pick these up automatically. Per-user API keys # (Anthropic, OpenAI, Gemini) come from the user's Settings UI, not from .env. SHIP_ENV_KEYS='^(GOOGLE_OAUTH_CLIENT_ID|GOOGLE_OAUTH_CLIENT_SECRET|NOTION_OAUTH_CLIENT_ID|NOTION_OAUTH_CLIENT_SECRET|AIRTABLE_OAUTH_CLIENT_ID|AIRTABLE_OAUTH_CLIENT_SECRET|HUBSPOT_OAUTH_CLIENT_ID|HUBSPOT_OAUTH_CLIENT_SECRET|DISCORD_OAUTH_CLIENT_ID|DISCORD_OAUTH_CLIENT_SECRET|DISCORD_BOT_TOKEN)=' if [[ -f "$PROJECT_ROOT/backend/.env" ]]; then # Only emit lines whose key matches the allow-list. Comment lines and # blank lines are dropped (they're not needed in the shipped file). grep -E "$SHIP_ENV_KEYS" "$PROJECT_ROOT/backend/.env" > "$STAGING_DIR/backend/.env" || true KEY_COUNT=$(wc -l < "$STAGING_DIR/backend/.env" | tr -d ' ') echo "Staged OAuth credentials: $KEY_COUNT keys (release secrets + personal API keys excluded)" else echo "WARNING: backend/.env not found — packaged build will have no OAuth credentials configured." : > "$STAGING_DIR/backend/.env" fi # Create empty tools directory so the app has a place to write mkdir -p "$STAGING_DIR/backend/data/tools" rsync -a \ --exclude='__pycache__' --exclude='**/__pycache__' \ --exclude='*.pyc' --exclude='.venv' --exclude='**/.venv' \ --exclude='**/node_modules' \ "$PROJECT_ROOT/debugger/" "$STAGING_DIR/debugger/" rsync -a "$PROJECT_ROOT/frontend/dist/" "$STAGING_DIR/frontend/" echo "" printf '\033[1;42;97m%s\033[0m\n' "========================================" printf '\033[1;42;97m%s\033[0m\n' " ✅ SOURCE SNAPSHOT COMPLETE " printf '\033[1;42;97m%s\033[0m\n' " It is now safe to modify your codebase." printf '\033[1;42;97m%s\033[0m\n' "========================================" echo "" # Step 5: Package with electron-builder echo "[5/5] Packaging with electron-builder..." cd "$PROJECT_ROOT/electron" npm install if $PUBLISH_MODE; then npx electron-builder --mac --arm64 --x64 --publish always elif $SIGN_MODE; then ARCH=$(uname -m) if [[ "$ARCH" == "arm64" ]]; then npx electron-builder --mac --arm64 --publish never elif [[ "$ARCH" == "x86_64" ]]; then npx electron-builder --mac --x64 --publish never else npx electron-builder --mac --publish never fi else export CSC_IDENTITY_AUTO_DISCOVERY=false ARCH=$(uname -m) if [[ "$ARCH" == "arm64" ]]; then npx electron-builder --mac --arm64 --publish never elif [[ "$ARCH" == "x86_64" ]]; then npx electron-builder --mac --x64 --publish never else npx electron-builder --mac --publish never fi fi rm -rf "$PROJECT_ROOT/electron/build-staging" echo "" echo "========================================" echo " Build Complete!" echo "========================================" echo "" echo "Output files:" ls -lh "$PROJECT_ROOT/electron/dist/"*.dmg 2>/dev/null || true ls -lh "$PROJECT_ROOT/electron/dist/"*.zip 2>/dev/null || true echo ""