mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-20 11:42:23 +02:00
[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:
@@ -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:
|
||||
|
||||
@@ -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 -
|
||||
|
||||
Reference in New Issue
Block a user