From bf6d7a9235e32c41fdd45b61ea2e2048827af610 Mon Sep 17 00:00:00 2001 From: Eric Date: Tue, 26 May 2026 04:18:38 -0700 Subject: [PATCH] [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) --- backend/apps/outputs/runtime.py | 29 +++++++- .../apps/outputs/view_builder_templates.py | 72 +++++++++++++++---- .../outputs/webapp_template/backend/run.sh | 52 +++++++++++--- .../outputs/webapp_template/frontend/run.sh | 35 ++++++++- 4 files changed, 159 insertions(+), 29 deletions(-) diff --git a/backend/apps/outputs/runtime.py b/backend/apps/outputs/runtime.py index 39c2e284..4251e2ce 100644 --- a/backend/apps/outputs/runtime.py +++ b/backend/apps/outputs/runtime.py @@ -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: diff --git a/backend/apps/outputs/view_builder_templates.py b/backend/apps/outputs/view_builder_templates.py index 38a378d5..0db29bdb 100644 --- a/backend/apps/outputs/view_builder_templates.py +++ b/backend/apps/outputs/view_builder_templates.py @@ -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): diff --git a/backend/apps/outputs/webapp_template/backend/run.sh b/backend/apps/outputs/webapp_template/backend/run.sh index fff84a3c..52f7405e 100755 --- a/backend/apps/outputs/webapp_template/backend/run.sh +++ b/backend/apps/outputs/webapp_template/backend/run.sh @@ -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}" diff --git a/backend/apps/outputs/webapp_template/frontend/run.sh b/backend/apps/outputs/webapp_template/frontend/run.sh index 52a7c688..1ea9dfe8 100755 --- a/backend/apps/outputs/webapp_template/frontend/run.sh +++ b/backend/apps/outputs/webapp_template/frontend/run.sh @@ -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//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/), 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 -