[eric] app-builder: cross-platform workspace runtime so apps build and run on windows (resolve bash/python/npm, venv Scripts layout, bundled-node PATH, online npm retry)

This commit is contained in:
Eric
2026-05-26 04:18:38 -07:00
parent 9605454ebf
commit bf6d7a9235
4 changed files with 159 additions and 29 deletions
+27 -2
View File
@@ -3,11 +3,28 @@
import asyncio
import logging
import os
import shutil
import sys
from collections import deque, OrderedDict
from dataclasses import dataclass
from typing import Callable, Optional
def _resolve_bash() -> str:
# Windows: Python's subprocess uses Windows-style PATH resolution and doesn't follow Git Bash's Unix-style entries like /mingw64/bin/..., so a bare "bash" call hits [WinError 2]. shutil.which goes through Windows PATHEXT lookup; fall back to the conventional Git for Windows install path so users without bash in their Windows PATH still work. POSIX: just return "bash" since the kernel finds it via PATH like any other exec.
found = shutil.which("bash")
if found:
return found
if sys.platform == "win32":
for candidate in (
r"C:\Program Files\Git\bin\bash.exe",
r"C:\Program Files\Git\usr\bin\bash.exe",
r"C:\Program Files (x86)\Git\bin\bash.exe",
):
if os.path.exists(candidate):
return candidate
return "bash"
from .runtime_proc import (
_ERROR_PATTERNS,
_FRONTEND_BIND_POLL_INTERVAL,
@@ -225,7 +242,7 @@ class AppRuntime:
try:
self.process = await asyncio.create_subprocess_exec(
"bash", "run.sh",
_resolve_bash(), "run.sh",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=self.workspace_path,
@@ -364,7 +381,15 @@ class AppRuntime:
"""Inherited env minus the install token. Backend.py can hit our
REST API back via its own creds if it really needs to, but it
shouldn't inherit the host process's token by default."""
return {k: v for k, v in os.environ.items() if k != "OPENSWARM_AUTH_TOKEN"}
env = {k: v for k, v in os.environ.items() if k != "OPENSWARM_AUTH_TOKEN"}
# Hand the workspace's backend/run.sh the exact interpreter we're
# running on. In the packaged build that's the bundled standalone
# Python, so a fresh machine with no system `python3` still works;
# in dev it's whatever launched uvicorn. OPENSWARM_NODE_PATH already
# rides in via os.environ (set by the Electron shell) for run.sh's
# Node resolution.
env["OPENSWARM_PYTHON"] = sys.executable
return env
async def stop(self) -> None:
async with self._lock:
+58 -14
View File
@@ -6,11 +6,50 @@ import os
import re
import shutil
import subprocess
import sys
import tarfile
import threading
logger = logging.getLogger(__name__)
def _resolve_npm() -> list[str] | None:
"""Resolve an invokable npm command. Windows ships npm as npm.cmd (a
batch shim), which Python's subprocess won't find via a bare "npm";
and the packaged Electron build bundles only node.exe (no npm) but
exports OPENSWARM_NODE_PATH, so we also probe node's own bundled
npm-cli.js. Returns an argv prefix, or None when npm is genuinely
absent (caller treats warm-cache as a skippable optimization)."""
node_path = os.environ.get("OPENSWARM_NODE_PATH")
if node_path and os.path.exists(node_path):
node_dir = os.path.dirname(node_path)
for shim in ("npm.cmd", "npm"):
cand = os.path.join(node_dir, shim)
if os.path.exists(cand):
return [cand]
# node.exe with no sibling npm: invoke npm-cli.js directly via node.
for rel in (
os.path.join("node_modules", "npm", "bin", "npm-cli.js"),
os.path.join(node_dir, "node_modules", "npm", "bin", "npm-cli.js"),
):
cli = rel if os.path.isabs(rel) else os.path.join(node_dir, rel)
if os.path.exists(cli):
return [node_path, cli]
for name in ("npm.cmd", "npm") if sys.platform == "win32" else ("npm",):
found = shutil.which(name)
if found:
return [found]
return None
def _resolve_python() -> str:
"""The interpreter to build warm/workspace venvs with. sys.executable
is the running backend's python (bundled standalone in the packaged
build, system python in dev) and is always valid, sidestepping the
Windows `python3` Microsoft-Store alias shim that shutil.which finds
first and which exits non-zero with 'Python was not found'."""
return sys.executable
# Absolute path to the bundled skill source. Surfaced as a constant so the
# skills subsystem can register it as a built-in skill (copy into
# ~/.claude/skills/ on first boot) without re-deriving the path.
@@ -265,17 +304,33 @@ def _ensure_warm_cache() -> str | None:
tmpl_lock = os.path.join(WEBAPP_TEMPLATE_DIR, "frontend", "package-lock.json")
shutil.copyfile(tmpl_pkg, os.path.join(cache_dir, "package.json"))
base_flags = ["--prefer-offline", "--no-audit", "--no-fund", "--loglevel=error"]
npm = _resolve_npm()
if npm is None:
logger.info("webapp-template: no npm available; skipping warm cache (workspace will install on first run)")
return None
if os.path.exists(tmpl_lock):
shutil.copyfile(tmpl_lock, os.path.join(cache_dir, "package-lock.json"))
cmd = ["npm", "ci", *base_flags]
cmd = [*npm, "ci", *base_flags]
else:
# No lockfile yet; `npm install` resolves the tree and
# writes one into the cache dir for future use.
cmd = ["npm", "install", *base_flags]
cmd = [*npm, "install", *base_flags]
logger.info("webapp-template: warming node_modules cache at %s", cache_dir)
result = subprocess.run(
cmd, cwd=cache_dir, capture_output=True, text=True, timeout=600
)
# --prefer-offline reuses npm's metadata cache, which can be
# stale: if a pinned transitive (e.g. a @babel/* helper) was
# published after the cache snapshot, resolution fails ETARGET
# even though the registry has it. Retry once online (drops
# --prefer-offline) so a partially-stale cache self-heals
# instead of dead-ending the whole App Builder frontend.
if result.returncode != 0 and "ETARGET" in (result.stderr or ""):
online_cmd = [c for c in cmd if c != "--prefer-offline"]
logger.info("webapp-template: warm-cache offline pass hit ETARGET; retrying online")
result = subprocess.run(
online_cmd, cwd=cache_dir, capture_output=True, text=True, timeout=600
)
if result.returncode != 0:
logger.warning(
"webapp-template warm-cache install failed (rc=%s): %s",
@@ -378,18 +433,7 @@ def _ensure_warm_python_venv() -> str | None:
# `python.exe`. On macOS/Linux the versioned candidates
# match first so we don't accidentally pick a system
# Python 2.x via the bare name.
py = None
candidates = (
"python3.13", "python3.12", "python3.11", "python3.10",
"python3", "python",
)
for candidate in candidates:
if shutil.which(candidate):
py = candidate
break
if py is None:
logger.warning("webapp-template warm-venv: no python on PATH")
return None
py = _resolve_python()
# Wipe any half-populated venv from a previous crashed run.
if os.path.isdir(venv_dir):
@@ -21,24 +21,56 @@ fi
BACKEND_DIR_ABSPATH="$(dirname "$RUN_BACKEND_ABSPATH")"
# Windows (Git Bash / MSYS) reports OSTYPE=msys|cygwin|win32; venv layout
# is Scripts\ + python.exe, and the bare interpreter is `python` not
# `python3`. Branch once here so every later path is correct.
IS_WIN=0
case "$OSTYPE" in
msys*|cygwin*|win32*) IS_WIN=1 ;;
esac
# --- Find a working Python 3 ---
# Prefer an explicit path the host passed us (OPENSWARM_PYTHON, set by the
# packaged Electron shell to the bundled standalone Python so a fresh
# Windows machine with no system Python still works). Fall back to PATH
# probing for dev. `python` is first on Windows since python3.x aliases
# usually don't exist there.
PYTHON=""
for candidate in python3.13 python3.12 python3.11 python3.10 python3; do
if command -v "$candidate" &>/dev/null && "$candidate" -c "print('ok')" &>/dev/null; then
PYTHON="$candidate"
break
if [[ -n "${OPENSWARM_PYTHON:-}" ]] && "${OPENSWARM_PYTHON}" -c "import sys; sys.exit(0 if sys.version_info[0]==3 else 1)" &>/dev/null; then
PYTHON="${OPENSWARM_PYTHON}"
else
if [[ "$IS_WIN" == "1" ]]; then
CANDIDATES="python python3 python3.13 python3.12 python3.11 python3.10"
else
CANDIDATES="python3.13 python3.12 python3.11 python3.10 python3 python"
fi
done
for candidate in $CANDIDATES; do
if command -v "$candidate" &>/dev/null && "$candidate" -c "import sys; sys.exit(0 if sys.version_info[0]==3 else 1)" &>/dev/null; then
PYTHON="$candidate"
break
fi
done
fi
if [[ -z "$PYTHON" ]]; then
echo "Error: No working Python 3 found."
exit 1
fi
echo "Using Python: $PYTHON ($($PYTHON --version 2>&1))"
echo "Using Python: $PYTHON ($("$PYTHON" --version 2>&1))"
# --- Create virtual environment if it doesn't exist ---
VENV_DIR="$BACKEND_DIR_ABSPATH/.venv"
SENTINEL="$VENV_DIR/.openswarm_installed"
# Resolve the venv interpreter by OS layout instead of `source activate`,
# whose path (bin/ vs Scripts/) and shell semantics differ across
# platforms. Calling the venv python directly is portable and avoids the
# activate-script fork entirely.
if [[ "$IS_WIN" == "1" ]]; then
VENV_PY="$VENV_DIR/Scripts/python.exe"
else
VENV_PY="$VENV_DIR/bin/python"
fi
# Fast path on every restart: if .venv exists AND we've already
# installed the workspace's deps once, skip the entire venv-create +
# pip-install dance (saves ~25s per workspace cold-restart). The
@@ -47,7 +79,6 @@ SENTINEL="$VENV_DIR/.openswarm_installed"
# and retries.
if [[ -d "$VENV_DIR" && -f "$SENTINEL" ]]; then
echo "Dependencies already installed — skipping venv create + pip install."
source "$VENV_DIR/bin/activate"
else
if [[ ! -d "$VENV_DIR" ]]; then
echo "Creating virtual environment..."
@@ -57,16 +88,15 @@ else
exit 1
fi
fi
source "$VENV_DIR/bin/activate"
# --- Install Python dependencies ---
echo "Installing dependencies..."
cd "$BACKEND_DIR_ABSPATH"
if [[ -n "${OPENSWARM_DEBUGGER_PATH:-}" && -d "$OPENSWARM_DEBUGGER_PATH" ]]; then
echo "Installing OpenSwarm debugger (swarm_debug) from $OPENSWARM_DEBUGGER_PATH"
pip install -e "$OPENSWARM_DEBUGGER_PATH"
"$VENV_PY" -m pip install -e "$OPENSWARM_DEBUGGER_PATH"
fi
pip install -e .
"$VENV_PY" -m pip install -e .
if [[ $? -ne 0 ]]; then
echo "Error: Failed to install Python dependencies."
exit 1
@@ -84,4 +114,4 @@ fi
# clean SIGTERM and restarts via this same script.
echo "Starting backend server on http://0.0.0.0:${BACKEND_PORT:-8324} ..."
cd "$BACKEND_DIR_ABSPATH/.."
python -m uvicorn backend.main:app --host 0.0.0.0 --port "${BACKEND_PORT:-8324}"
"$VENV_PY" -m uvicorn backend.main:app --host 0.0.0.0 --port "${BACKEND_PORT:-8324}"
@@ -18,6 +18,25 @@ FRONTEND_DIR_ABSPATH="$(dirname "$RUN_FRONTEND_ABSPATH")"
cd "$FRONTEND_DIR_ABSPATH"
# Put the bundled Node on PATH so `npm`, `node`, and the vite child
# processes all resolve even on a machine with no system Node. The
# packaged Electron shell exports OPENSWARM_NODE_PATH (e.g.
# .../node/x64/node.exe on Windows, .../node/<arch>/bin/node on POSIX);
# its directory holds node + the npm/npx shims. Dev leaves it unset and
# falls back to system Node on PATH.
NPM="npm"
if [[ -n "${OPENSWARM_NODE_PATH:-}" && -x "${OPENSWARM_NODE_PATH}" ]]; then
NODE_DIR="$(dirname "$OPENSWARM_NODE_PATH")"
export PATH="$NODE_DIR:$PATH"
# Windows bundles npm.cmd next to node.exe; POSIX bundles an `npm` shim
# in the same bin/ dir. Prefer the colocated one, else trust PATH.
if [[ -f "$NODE_DIR/npm.cmd" ]]; then
NPM="$NODE_DIR/npm.cmd"
elif [[ -x "$NODE_DIR/npm" ]]; then
NPM="$NODE_DIR/npm"
fi
fi
# Fast path: the seeder usually symlinks node_modules to a shared warm
# cache (~/.openswarm/cache/webapp_template_node_modules/<hash>), so the
# dependency install has already been done once and we can skip straight
@@ -28,11 +47,23 @@ if [ -d node_modules ] && [ -n "$(ls -A node_modules 2>/dev/null)" ]; then
echo "Dependencies already present — skipping install."
else
echo "Installing dependencies..."
npm install --prefer-offline --no-audit --no-fund
"$NPM" install --prefer-offline --no-audit --no-fund
fi
echo "Building with development mode..."
npm run dev
# Prefer `npm run dev` (honors package.json script + flags). But the
# packaged build ships node.exe WITHOUT npm, so on a machine with no
# system npm we fall back to invoking vite directly through the bundled
# node — node_modules is already populated (warm-cache symlink or seed),
# so vite's bin is present and this needs no package manager at all.
if command -v "$NPM" &>/dev/null || [[ "$NPM" != "npm" ]]; then
"$NPM" run dev
elif [[ -n "${OPENSWARM_NODE_PATH:-}" && -x "${OPENSWARM_NODE_PATH}" && -f node_modules/vite/bin/vite.js ]]; then
echo "npm not found; running vite directly via bundled node."
"$OPENSWARM_NODE_PATH" node_modules/vite/bin/vite.js
else
"$NPM" run dev
fi
# exit back to the dir that we were in before
cd -