mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-24 21:42:22 +02:00
The pin added earlier read the local keychain unconditionally, which was right for publish.sh and wrong for release-macos.yml: on CI the cert arrives as a base64 .p12 in CSC_LINK and electron-builder imports it into a temporary keychain LATER than this preflight runs, so the probe would have found nothing and aborted every CI mac build before it started. Fixed by branching on where the cert actually comes from -- keychain locally, the .p12 itself on CI -- and running the profile check against whichever cert that resolves to, since both sources can drift from the embedded profile. Verified with controls on both paths: the .p12 reader returns the true SHA-1 of a synthetic cert and returns nothing (rather than a bogus hash) under a wrong password, and the local path still passes against the live cert and profile. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wtspwSFzZmjCx9UNPAorQ
662 lines
32 KiB
Bash
Executable File
662 lines
32 KiB
Bash
Executable File
#!/bin/bash
|
|
set -euo pipefail
|
|
|
|
# Killing this build (Ctrl-C, pkill) used to orphan electron-builder's 7za child, which then sat
|
|
# for HOURS holding a ~300MB archive open (found one aged 20h). On INT/TERM, walk and kill the whole
|
|
# descendant tree. Deliberately NOT on EXIT: a trap that fires on normal completion flips a green
|
|
# build's exit code under set -e and publish.sh would read success as failure (ENG-247).
|
|
kill_descendants() {
|
|
local pid kids
|
|
for pid in "$@"; do
|
|
kids=$(pgrep -P "$pid" 2>/dev/null || true)
|
|
[ -n "$kids" ] && kill_descendants $kids
|
|
kill -TERM "$pid" 2>/dev/null || true
|
|
done
|
|
}
|
|
trap 'kill_descendants $(pgrep -P $$ 2>/dev/null || true); exit 130' INT TERM
|
|
|
|
# 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
|
|
|
|
# Arch targets for this run. Publish always builds both DMGs; otherwise
|
|
# OSW_BUILD_ARCH (arm64|x64|both) overrides, defaulting to the host. Node,
|
|
# python-env, and the electron-builder flags below all derive from this ONE
|
|
# list, so a staged-arch vs packed-arch mismatch can't happen (the class of
|
|
# bug that shipped arm64 python inside the x64 DMG).
|
|
if $PUBLISH_MODE; then
|
|
BUILD_ARCHS=(arm64 x64)
|
|
else
|
|
case "${OSW_BUILD_ARCH:-host}" in
|
|
both) BUILD_ARCHS=(arm64 x64) ;;
|
|
x64) BUILD_ARCHS=(x64) ;;
|
|
arm64) BUILD_ARCHS=(arm64) ;;
|
|
*) if [[ "$(uname -m)" == "x86_64" ]]; then BUILD_ARCHS=(x64); else BUILD_ARCHS=(arm64); fi ;;
|
|
esac
|
|
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
|
|
|
|
# Where the signing cert comes from decides how it can be checked, and getting this
|
|
# backwards breaks the OTHER platform silently. Locally there is a keychain to inspect.
|
|
# On CI (release-macos.yml) there is not: electron-builder imports CSC_LINK's .p12 into a
|
|
# temporary keychain of its own, LATER than this preflight runs, so a keychain probe here
|
|
# would find nothing and abort every CI build before it started.
|
|
if [[ -z "${CSC_LINK:-}" ]]; then
|
|
# Local keychain path. Pin by SHA-1, never by name: `security find-identity` reports
|
|
# FIVE entries all called "Developer ID Application: Haik Decie (Y26NUZH4NG)" -- one
|
|
# live cert and four copies of the one whose private key died with the old Mac. The
|
|
# dead copies still list as valid and only fail at the moment they are asked to sign,
|
|
# so letting auto-discovery guess is a coin flip between a real build and a crash
|
|
# forty minutes into packing a 4.8 GB .app.
|
|
: "${CSC_NAME:=DDA5A43B430F88DE89A34B4EA908355E3C3274C6}"
|
|
export CSC_NAME
|
|
if ! security find-identity -v -p codesigning | grep -q "$CSC_NAME"; then
|
|
echo "ERROR: signing identity $CSC_NAME is not in the keychain."
|
|
echo " Import the Developer ID Application cert AND its private key, then retry."
|
|
exit 1
|
|
fi
|
|
# Presence is not usability, and that distinction is the whole reason this exists: a
|
|
# cert whose private key is missing or locked still reports valid. The only honest
|
|
# test is a signature, so spend one here rather than discovering it at the end.
|
|
_sigprobe_dir="$(mktemp -d)"
|
|
cp /bin/echo "$_sigprobe_dir/probe"
|
|
if ! codesign --force --sign "$CSC_NAME" --timestamp=none "$_sigprobe_dir/probe" 2>/dev/null; then
|
|
rm -rf "$_sigprobe_dir"
|
|
echo "ERROR: identity $CSC_NAME is present but cannot sign."
|
|
echo " Its private key is missing or the login keychain is locked."
|
|
echo " Try: security unlock-keychain ~/Library/Keychains/login.keychain-db"
|
|
exit 1
|
|
fi
|
|
rm -rf "$_sigprobe_dir"
|
|
_signing_sha1="$CSC_NAME"
|
|
echo "==> Signing identity pinned and proven from keychain: $CSC_NAME"
|
|
else
|
|
# CI path. The .p12 is the source of truth, so read the cert out of it instead of
|
|
# guessing, and let electron-builder do the actual import.
|
|
if [[ -z "${CSC_KEY_PASSWORD:-}" ]]; then
|
|
echo "ERROR: CSC_LINK is set but CSC_KEY_PASSWORD is empty."
|
|
echo " electron-builder cannot open the .p12 without it."
|
|
exit 1
|
|
fi
|
|
_p12_dir="$(mktemp -d)"
|
|
if ! printf '%s' "$CSC_LINK" | base64 --decode > "$_p12_dir/cert.p12" 2>/dev/null; then
|
|
rm -rf "$_p12_dir"
|
|
echo "ERROR: CSC_LINK is not valid base64."
|
|
exit 1
|
|
fi
|
|
_signing_sha1="$(openssl pkcs12 -in "$_p12_dir/cert.p12" -nokeys -clcerts \
|
|
-passin env:CSC_KEY_PASSWORD 2>/dev/null \
|
|
| openssl x509 -noout -fingerprint -sha1 2>/dev/null \
|
|
| sed 's/.*=//; s/://g' | tr '[:lower:]' '[:upper:]')"
|
|
rm -rf "$_p12_dir"
|
|
if [[ -z "$_signing_sha1" ]]; then
|
|
echo "ERROR: could not read a certificate out of CSC_LINK."
|
|
echo " The .p12 is corrupt or CSC_KEY_PASSWORD is wrong."
|
|
exit 1
|
|
fi
|
|
echo "==> Signing identity read from CSC_LINK: $_signing_sha1"
|
|
fi
|
|
|
|
# The embedded profile is load-bearing, not decorative: entitlements request
|
|
# keychain-access-groups, which macOS honors only when an embedded profile lists the very
|
|
# cert that signed the app. A profile built for a DIFFERENT cert signs without a single
|
|
# complaint and then loses passkeys and Touch ID at runtime -- a green build that ships
|
|
# broken, which is the worst shape a failure can take. Same check on both paths, because
|
|
# both can drift.
|
|
_pp="$PROJECT_ROOT/electron/build/embedded.provisionprofile"
|
|
if [[ -f "$_pp" ]]; then
|
|
if ! security cms -D -i "$_pp" 2>/dev/null | python3 -c 'import sys,plistlib,hashlib; d=plistlib.loads(sys.stdin.buffer.read()); sys.exit(0 if sys.argv[1].upper() in [hashlib.sha1(c).hexdigest().upper() for c in d.get("DeveloperCertificates",[])] else 1)' "$_signing_sha1"; then
|
|
echo "ERROR: $_pp does not list the signing certificate $_signing_sha1."
|
|
echo " The profile was issued for a different cert, so keychain-access-groups"
|
|
echo " (passkeys / Touch ID) would silently stop working in the shipped app."
|
|
echo " Regenerate it: developer.apple.com -> Profiles -> OpenSwarm Developer ID"
|
|
echo " -> Edit -> select the certificate matching $_signing_sha1 -> Save."
|
|
exit 1
|
|
fi
|
|
echo "==> Embedded profile matches the signing certificate"
|
|
fi
|
|
# A signed build is a build users actually run, so its Widevine VMP signature
|
|
# is mandatory: the afterPack hook hard-fails on a missing/failed signature
|
|
# instead of shipping a DMG whose Spotify/Netflix audio is silently dead.
|
|
# Respect an explicit outer VMP_REQUIRE_SIGN=0: a cred-less local cut may consciously ship DRM-limited (CI always has EVS secrets, so releases from CI keep the hard gate).
|
|
export VMP_REQUIRE_SIGN=${VMP_REQUIRE_SIGN:-1}
|
|
fi
|
|
|
|
# Step 0: Ensure bundled uv + uvx binaries exist.
|
|
# IMPORTANT: uvx is a tiny ~700KB shim that just resolves to a sibling `uv`
|
|
# binary on disk. It does NOT contain the package-installer logic itself;
|
|
# at runtime `uvx` errors with "Could not find the `uv` binary at either of:
|
|
# .../uv-bin/uv .../uv-bin/uv" if `uv` is missing. So we must ship both,
|
|
# even though only Google Workspace MCP uses uvx as its `command`. A prior
|
|
# revision tried to save ~30MB by shipping only uvx — that broke MCP boot
|
|
# on fresh Macs. Don't repeat the mistake.
|
|
UV_BIN_DIR="$PROJECT_ROOT/backend/uv-bin"
|
|
mkdir -p "$UV_BIN_DIR"
|
|
NEED_UV=false
|
|
[[ ! -f "$UV_BIN_DIR/uv" ]] && NEED_UV=true
|
|
[[ ! -f "$UV_BIN_DIR/uvx" ]] && NEED_UV=true
|
|
# A dev flow can leave a THIN host-arch uv here and the per-arch slice then dies 20 minutes in;
|
|
# present is not enough, it must be universal.
|
|
if ! $NEED_UV && [[ "$(uname)" == "Darwin" ]]; then
|
|
for B in uv uvx; do
|
|
ARCHS=$(lipo -archs "$UV_BIN_DIR/$B" 2>/dev/null || echo "")
|
|
if [[ "$ARCHS" != *arm64* || "$ARCHS" != *x86_64* ]]; then
|
|
echo "[0] $B is not universal (archs: ${ARCHS:-unreadable}), re-downloading."
|
|
NEED_UV=true
|
|
fi
|
|
done
|
|
fi
|
|
if $NEED_UV; then
|
|
# Pinned uv version. "latest" used to mean a fresh uv could appear in any
|
|
# build with zero warning, breaking reproducibility (pillar 3). Override
|
|
# with UV_VERSION when deliberately bumping; keep Windows
|
|
# (build-app-win.ps1) in lockstep. 0.11.16 is what "latest" resolved to
|
|
# when this was pinned.
|
|
UV_VERSION="${UV_VERSION:-0.11.16}"
|
|
echo "[0] Downloading uv + uvx $UV_VERSION binaries (universal arm64+x64)..."
|
|
TMPDIR_UV=$(mktemp -d)
|
|
curl -sL "https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-aarch64-apple-darwin.tar.gz" | tar xz -C "$TMPDIR_UV"
|
|
curl -sL "https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-x86_64-apple-darwin.tar.gz" | tar xz -C "$TMPDIR_UV"
|
|
lipo -create "$TMPDIR_UV/uv-aarch64-apple-darwin/uv" "$TMPDIR_UV/uv-x86_64-apple-darwin/uv" -output "$UV_BIN_DIR/uv"
|
|
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/uv" "$UV_BIN_DIR/uvx"
|
|
rm -rf "$TMPDIR_UV"
|
|
echo "uv + uvx downloaded and bundled."
|
|
else
|
|
echo "[0] uv + uvx 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/<output>.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/<dir>/ 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'
|
|
# YouTube gets its own build: upstream imports the 526-API googleapis monolith (plus a mongodb
|
|
# driver rider) for exactly ONE surface, google.youtube. Aliasing googleapis to a single-API shim
|
|
# shrinks the bundle 32.6M -> 3.7M, MCP-handshake-verified identical (same serverInfo, same 8 tools).
|
|
build_youtube_mcp_bundle() {
|
|
local out_file="$MCP_BUNDLE_DIR/kirbah-mcp-youtube.js"
|
|
if [[ -f "$out_file" && -z "${OPENSWARM_REBUILD_BUNDLES:-}" ]]; then
|
|
echo "[0b] @kirbah/mcp-youtube bundle already present (set OPENSWARM_REBUILD_BUNDLES=1 to force rebuild)."
|
|
return
|
|
fi
|
|
echo "[0b] Bundling @kirbah/mcp-youtube (googleapis -> @googleapis/youtube shim) ..."
|
|
local tmp_dir; tmp_dir=$(mktemp -d)
|
|
(
|
|
cd "$tmp_dir"
|
|
npm install '@kirbah/mcp-youtube' '@googleapis/youtube' --silent 2>/dev/null
|
|
printf '%s\n' 'const { youtube } = require("@googleapis/youtube");' 'module.exports = { google: { youtube } };' > googleapis-shim.js
|
|
local banner='const __OPENSWARM_IMPORT_META_URL__ = require("url").pathToFileURL(__filename).href;'
|
|
npx esbuild 'node_modules/@kirbah/mcp-youtube/dist/index.js' --bundle --platform=node --format=cjs --target=node22 --legal-comments=none \
|
|
--define:import.meta.url=__OPENSWARM_IMPORT_META_URL__ \
|
|
"--banner:js=$banner" \
|
|
--alias:googleapis=./googleapis-shim.js \
|
|
--outfile="$out_file"
|
|
)
|
|
rm -rf "$tmp_dir"
|
|
echo "@kirbah/mcp-youtube bundled ($(du -h "$out_file" | cut -f1))."
|
|
}
|
|
build_youtube_mcp_bundle
|
|
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 ci (not install): installs exactly what package-lock.json pins, never
|
|
# silently mutates the lock, and fails loudly on any drift. Reproducible builds
|
|
# (pillar 3) depend on the lock being boss.
|
|
npm ci
|
|
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 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 3a: Bundled Python env, one per target arch (must run AFTER the
|
|
# build-staging reset above or the freshly staged envs get wiped).
|
|
echo "[3a] Building bundled Python env(s): ${BUILD_ARCHS[*]}"
|
|
for A in "${BUILD_ARCHS[@]}"; do
|
|
bash "$SCRIPT_DIR/build-python-env.sh" "$A"
|
|
if [[ ! -f "$STAGING_DIR/python-env/$A/bin/python3.13" ]]; then
|
|
echo "ERROR: python-env ($A) missing at $STAGING_DIR/python-env/$A"
|
|
exit 1
|
|
fi
|
|
done
|
|
echo "Python environment(s) ready."
|
|
echo ""
|
|
|
|
# Step 3b: Bundle a real Node.js binary so 9Router and MCP servers don't
|
|
# fall back to ELECTRON_RUN_AS_NODE on user machines without system node.
|
|
# Two wins:
|
|
# 1. Dock cleanliness — Electron-as-Node fallback is the second probable
|
|
# source of the bouncing "exec" icon next to OpenSwarm on fresh Macs
|
|
# (Python.app wrapping addresses the first). Real node is a clean
|
|
# background process that LaunchServices never registers in the dock.
|
|
# 2. Cold-start speed — re-execing the OpenSwarm Electron binary as Node
|
|
# pays the full Electron startup cost (~5-15s on first launch incl.
|
|
# Gatekeeper/XProtect verification), then more for the Next.js server
|
|
# to boot. Real node starts in ~50ms. Shrinks the splash window
|
|
# proportionally and reduces the "frontend up but nothing works"
|
|
# tail (analytics.py:196 awaits 9Router during backend lifespan).
|
|
# Pinned to Node 20 LTS (NODE_MODULE_VERSION 115). 9router 0.3.60 has zero
|
|
# native bindings (sql.js, not better-sqlite3), so any Node 18+ works
|
|
# regardless. The bundled MCP servers (mcp-bundles/) are esbuild outputs
|
|
# with target=node22 — Node 20 covers the syntax + builtins they use.
|
|
echo "[3b/5] Bundling Node.js runtime..."
|
|
NODE_VERSION="v20.18.1"
|
|
NODE_STAGE_DIR="$STAGING_DIR/node"
|
|
mkdir -p "$NODE_STAGE_DIR"
|
|
|
|
# Per-arch download helper. Stages each arch under its own subdir so the
|
|
# .app can ship both and pick at runtime via process.arch (see
|
|
# electron/main.js getBundledNodePath). Slightly larger DMG (~25MB extra
|
|
# per arch we ship) but eliminates any beforePack-hook complexity in
|
|
# electron-builder's publish-mode dual-arch flow.
|
|
download_node_for_arch() {
|
|
local arch="$1" # arm64 | x64
|
|
local out_dir="$NODE_STAGE_DIR/$arch"
|
|
if [[ -f "$out_dir/bin/node" ]]; then
|
|
echo "[3b] Node $NODE_VERSION ($arch) already cached"
|
|
return 0
|
|
fi
|
|
rm -rf "$out_dir"
|
|
mkdir -p "$out_dir/bin"
|
|
local tarball="node-${NODE_VERSION}-darwin-${arch}.tar.gz"
|
|
local url="https://nodejs.org/dist/${NODE_VERSION}/${tarball}"
|
|
echo "[3b] Downloading $tarball..."
|
|
local tmp; tmp=$(mktemp -d)
|
|
curl -fsSL --progress-bar -o "$tmp/node.tar.gz" "$url"
|
|
tar xzf "$tmp/node.tar.gz" -C "$tmp"
|
|
cp "$tmp/node-${NODE_VERSION}-darwin-${arch}/bin/node" "$out_dir/bin/node"
|
|
chmod +x "$out_dir/bin/node"
|
|
# Bundle npm too so packaged apps with custom deps can `npm install` them (the bare node can't).
|
|
mkdir -p "$out_dir/lib/node_modules"
|
|
cp -R "$tmp/node-${NODE_VERSION}-darwin-${arch}/lib/node_modules/npm" "$out_dir/lib/node_modules/npm"
|
|
# An explicit wrapper, not the dist's bin/npm symlink (packaging may not preserve symlinks): always runs our bundled node + npm-cli.js, no PATH/system-node reliance. run.sh picks it up as $NODE_DIR/npm.
|
|
cat > "$out_dir/bin/npm" <<'NPMSH'
|
|
#!/bin/sh
|
|
here="$(cd "$(dirname "$0")" && pwd)"
|
|
exec "$here/node" "$here/../lib/node_modules/npm/bin/npm-cli.js" "$@"
|
|
NPMSH
|
|
chmod +x "$out_dir/bin/npm"
|
|
rm -rf "$tmp"
|
|
echo "[3b] Node $NODE_VERSION ($arch) staged ($(du -h "$out_dir/bin/node" | cut -f1))"
|
|
}
|
|
|
|
# Stage node for every arch this run packs (BUILD_ARCHS decides, top of file).
|
|
for A in "${BUILD_ARCHS[@]}"; do
|
|
download_node_for_arch "$A"
|
|
done
|
|
echo ""
|
|
|
|
# Step 3c: Pre-build the webapp-template node_modules archive so first-app
|
|
# create on a fresh user install decompresses (~3 s) instead of running a
|
|
# live `npm install` (~22 s). The backend's _try_extract_bundled_archive
|
|
# is sha-tagged + falls through cleanly if the archive is missing or
|
|
# stale, so this step is purely an optimization — skip silently if the
|
|
# template snapshot or npm aren't available.
|
|
if [[ -f "$PROJECT_ROOT/backend/apps/outputs/webapp_template/frontend/package.json" ]] \
|
|
&& command -v npm >/dev/null 2>&1; then
|
|
echo "[3c/5] Pre-building webapp-template node_modules archive..."
|
|
bash "$PROJECT_ROOT/scripts/build-template-archive.sh"
|
|
echo ""
|
|
fi
|
|
|
|
# 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' \
|
|
--exclude='/uv-bin' \
|
|
--exclude='apps/outputs/webapp_template_cache' \
|
|
--exclude='tests' --exclude='**/tests' \
|
|
--exclude='/.env' --exclude='/.env.*' \
|
|
"$PROJECT_ROOT/backend/" "$STAGING_DIR/backend/"
|
|
# /data: backend/config/paths.py points DATA_ROOT at ~/Library/Application Support/OpenSwarm/data
|
|
# in packaged mode and no code seeds from the bundle, so the entire shipped
|
|
# backend/data/ tree was dead weight (and was leaking the dev machine's
|
|
# auth.token + install_id + dev session artifacts).
|
|
# /uv-bin: source dir holds the universal binary so `bash run.sh` works on either
|
|
# host arch; we stage per-arch thin slices below so each DMG ships only its slice.
|
|
# webapp_template_cache: a pre-built node_modules.tar.gz that gets shipped
|
|
# to speed up first-app-create. Apple notarization extracts it and rejects
|
|
# the build because upstream native binaries inside (esbuild, fsevents, etc.)
|
|
# aren't signed with our Developer ID. Backend's _try_extract_bundled_archive
|
|
# in view_builder_templates.py falls through cleanly when the archive is
|
|
# missing, so first-app create just runs `npm install` (about 90s extra).
|
|
# Long-term fix: sign native binaries before tarring in build-template-archive.sh.
|
|
# Note: .env exclude is anchored to the backend/ source root (`/.env` /
|
|
# `/.env.*`), not recursive. The vendored webapp-template snapshot at
|
|
# backend/apps/outputs/webapp_template/.env.example MUST be shipped so
|
|
# new App workspaces can seed from it; recursive `**/.env*` excludes
|
|
# would strip it. The top-level backend/.env is still excluded (it's
|
|
# (re)generated at the production .env step below).
|
|
|
|
# Production .env: just the OAuth helper base URL. Google client_id/secret are no
|
|
# longer shipped: nothing in backend/ or frontend/ reads GOOGLE_OAUTH_CLIENT_{ID,
|
|
# SECRET} at runtime, so we don't bake a secret into the packaged app.
|
|
SHIP_OAUTH_BASE_URL="${OPENSWARM_OAUTH_BASE_URL_OVERRIDE:-https://api.openswarm.com}"
|
|
mkdir -p "$STAGING_DIR/backend"
|
|
cat > "$STAGING_DIR/backend/.env" <<EOF
|
|
# OAuth helper base URL.
|
|
OPENSWARM_OAUTH_BASE_URL=${SHIP_OAUTH_BASE_URL}
|
|
EOF
|
|
echo "Staged production .env"
|
|
|
|
# Per-arch slice of the universal uv/uvx for shipping. The source-tree uv-bin/
|
|
# stays universal so dev (`bash run.sh`) works on either host arch; thinning
|
|
# into staging means each per-arch DMG ships only its slice (~48 MB savings
|
|
# vs the 97 MB universal binary the build used to put in both DMGs).
|
|
echo "Slicing uv per-arch into staging..."
|
|
for arch in arm64 x64; do
|
|
lipo_arch=$arch
|
|
[[ "$arch" == "x64" ]] && lipo_arch=x86_64
|
|
mkdir -p "$STAGING_DIR/uv-bin/$arch"
|
|
lipo "$UV_BIN_DIR/uv" -thin "$lipo_arch" -output "$STAGING_DIR/uv-bin/$arch/uv"
|
|
lipo "$UV_BIN_DIR/uvx" -thin "$lipo_arch" -output "$STAGING_DIR/uv-bin/$arch/uvx"
|
|
chmod +x "$STAGING_DIR/uv-bin/$arch/uv" "$STAGING_DIR/uv-bin/$arch/uvx"
|
|
done
|
|
|
|
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 ""
|
|
|
|
# Provenance stamp: record the exact commit this artifact was built from.
|
|
# electron/build-info.json ships inside the asar; main.js reads it for the
|
|
# startup [provenance] log line and the About panel. Gitignored + regenerated.
|
|
BUILD_SHA=$(git -C "$PROJECT_ROOT" rev-parse HEAD 2>/dev/null || echo unknown)
|
|
BUILD_VERSION=$(node -e "console.log(require('$PROJECT_ROOT/electron/package.json').version)")
|
|
BUILD_CHANNEL=stable; [[ "$BUILD_VERSION" == *-* ]] && BUILD_CHANNEL=experimental
|
|
cat > "$PROJECT_ROOT/electron/build-info.json" <<EOF
|
|
{"sha":"$BUILD_SHA","shortSha":"${BUILD_SHA:0:12}","builtAt":"$(date -u +%Y-%m-%dT%H:%M:%SZ)","channel":"$BUILD_CHANNEL","version":"$BUILD_VERSION"}
|
|
EOF
|
|
echo "Stamped build-info.json: sha=${BUILD_SHA:0:12} channel=$BUILD_CHANNEL"
|
|
|
|
# Step 5: Package with electron-builder
|
|
echo "[5/5] Packaging with electron-builder..."
|
|
cd "$PROJECT_ROOT/electron"
|
|
# npm ci: lockfile-exact, no drift. See frontend note above.
|
|
npm ci
|
|
|
|
# macOS mouse-clamp native addon: compile both arches into build-staging/mouseclamp/<arch>
|
|
# so extraResources (mouseclamp/${arch}) is populated whichever target gets packed.
|
|
# Cheap (~2s each); fails the build loudly if a slice can't compile rather than
|
|
# silently shipping the crash. macOS-only.
|
|
if [[ "$(uname)" == "Darwin" ]]; then
|
|
echo "Building mouse-clamp native addon (arm64 + x64)..."
|
|
bash scripts/build-mouseclamp.sh arm64
|
|
bash scripts/build-mouseclamp.sh x64
|
|
bash scripts/build-haptics.sh arm64
|
|
bash scripts/build-haptics.sh x64
|
|
echo "Building whisper-server for dictation (arm64 + x64)..."
|
|
bash scripts/build-whisper.sh arm64
|
|
bash scripts/build-whisper.sh x64
|
|
echo "Building fn-watcher for dictation's fn key (arm64 + x64)..."
|
|
bash scripts/build-fn-watcher.sh arm64
|
|
bash scripts/build-fn-watcher.sh x64
|
|
fi
|
|
|
|
# Node's default ~4 GB heap OOMs while codesign'ing the .app on dual-arch
|
|
# publish runs (the .app is ~4.8 GB and electron-builder walks every file
|
|
# to hash + sign, holding paths + metadata in memory). Bump the old-space
|
|
# ceiling so V8 has headroom; 12 GB covers both arches in one invocation.
|
|
# Caller's NODE_OPTIONS is respected if already set.
|
|
export NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=12288}"
|
|
|
|
# Pack exactly the arches we staged for (BUILD_ARCHS, top of file).
|
|
EB_ARCH_FLAGS=()
|
|
for A in "${BUILD_ARCHS[@]}"; do
|
|
EB_ARCH_FLAGS+=("--$A")
|
|
done
|
|
|
|
if $PUBLISH_MODE; then
|
|
npx electron-builder --mac "${EB_ARCH_FLAGS[@]}" --publish always
|
|
elif $SIGN_MODE; then
|
|
npx electron-builder --mac "${EB_ARCH_FLAGS[@]}" --publish never
|
|
else
|
|
export CSC_IDENTITY_AUTO_DISCOVERY=false
|
|
npx electron-builder --mac "${EB_ARCH_FLAGS[@]}" --publish never
|
|
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 ""
|