From 58c241442b6ff4d6ef94ea2d4d7eb02f1c305b96 Mon Sep 17 00:00:00 2001 From: Arnav Naval Date: Mon, 20 Apr 2026 01:14:39 -0500 Subject: [PATCH] add local.sh Windows compatability --- backend/.gitignore | 4 +- backend/apps/app_builder/app_builder.py | 20 ++-- .../apps/app_builder/templates/templates.py | 2 +- .../apps/app_builder/utils/walk_directory.py | 2 +- backend/apps/dashboards/dashboards.py | 2 +- backend/apps/settings/settings.py | 6 +- backend/apps/skills/SkillStore/SkillStore.py | 12 +-- backend/apps/skills/skills.py | 8 +- .../utils/discover_mcp_tools_sse.py | 1 - backend/apps/tools/tools.py | 4 +- backend/core/db/PydanticStore.py | 8 +- .../agent/Message/prompt_utils.py | 2 +- backend/ports.py | 2 +- backend/run.sh | 21 ++-- frontend/run.sh | 22 ++--- linter/print_errors.sh | 5 +- run/local.sh | 95 +++++++++++------- run/utils/platform.sh | 96 +++++++++++++++++++ 18 files changed, 214 insertions(+), 98 deletions(-) create mode 100644 run/utils/platform.sh diff --git a/backend/.gitignore b/backend/.gitignore index 6e519548..dfa780f7 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -15,4 +15,6 @@ apps/db/snips/* !apps/db/snips/.gitkeep # ignore everything in data/ -data/** \ No newline at end of file +data/** + +.venv/ \ No newline at end of file diff --git a/backend/apps/app_builder/app_builder.py b/backend/apps/app_builder/app_builder.py index b8614c00..8bbd3387 100644 --- a/backend/apps/app_builder/app_builder.py +++ b/backend/apps/app_builder/app_builder.py @@ -47,7 +47,7 @@ async def serve_app_file(app_id: str, filepath: str): raise HTTPException(status_code=403, detail="Path traversal not allowed") if not os.path.isfile(full_path): raise HTTPException(status_code=404, detail="File not found") - with open(full_path) as f: + with open(full_path, encoding="utf-8") as f: content = f.read() mime, _ = mimetypes.guess_type(filepath) return Response(content=content, media_type=mime or "text/plain") @@ -61,7 +61,7 @@ async def serve_app_file(app_id: str, filepath: str): raise HTTPException(status_code=403, detail="Path traversal not allowed") if not os.path.isfile(full_path): raise HTTPException(status_code=404, detail="File not found in app") - with open(full_path) as f: + with open(full_path, encoding="utf-8") as f: content = f.read() mime, _ = mimetypes.guess_type(filepath) return Response(content=content, media_type=mime or "text/plain") @@ -101,17 +101,17 @@ async def seed_app(body: OpenSwarmAppSeedRequest): if not full_path.startswith(os.path.normpath(folder)): continue os.makedirs(os.path.dirname(full_path), exist_ok=True) - with open(full_path, "w") as f: + with open(full_path, "w", encoding="utf-8") as f: f.write(content) else: for rel_path, content in APP_BUILDER_TEMPLATE_FILES.items(): full_path = os.path.join(folder, rel_path) - with open(full_path, "w") as f: + with open(full_path, "w", encoding="utf-8") as f: f.write(content) - with open(os.path.join(folder, "SKILL.md"), "w") as f: + with open(os.path.join(folder, "SKILL.md"), "w", encoding="utf-8") as f: f.write(APP_BUILDER_SKILL) if body.meta: - with open(os.path.join(folder, "meta.json"), "w") as f: + with open(os.path.join(folder, "meta.json"), "w", encoding="utf-8") as f: json.dump(body.meta, f, indent=2) return {"path": os.path.abspath(folder)} @@ -125,7 +125,7 @@ async def write_app_file(app_id: str, filepath: str, body: dict): if not full_path.startswith(os.path.normpath(folder)): raise HTTPException(status_code=403, detail="Path traversal not allowed") os.makedirs(os.path.dirname(full_path), exist_ok=True) - with open(full_path, "w") as f: + with open(full_path, "w", encoding="utf-8") as f: f.write(body.get("content", "")) return {"ok": True} @@ -186,9 +186,9 @@ async def create_app(body: OpenSwarmAppCreate): if not full_path.startswith(os.path.normpath(folder)): continue os.makedirs(os.path.dirname(full_path), exist_ok=True) - with open(full_path, "w") as f: + with open(full_path, "w", encoding="utf-8") as f: f.write(content) - with open(os.path.join(folder, "SKILL.md"), "w") as f: + with open(os.path.join(folder, "SKILL.md"), "w", encoding="utf-8") as f: f.write(APP_BUILDER_SKILL) P_APP_METADATA_STORE.save(app) return {"ok": True, "app": app.model_dump()} @@ -239,7 +239,7 @@ def _read_app_file(app_id: str, filename: str) -> Optional[str]: path = os.path.join(APP_BUILDER_CONTENT_DIR, app_id, filename) if not os.path.isfile(path): return None - with open(path) as f: + with open(path, encoding="utf-8") as f: return f.read() @app_builder.router.post("/execute") diff --git a/backend/apps/app_builder/templates/templates.py b/backend/apps/app_builder/templates/templates.py index 9b9c367f..e07022c8 100644 --- a/backend/apps/app_builder/templates/templates.py +++ b/backend/apps/app_builder/templates/templates.py @@ -9,7 +9,7 @@ P_CONTENT_DIR = os.path.join(P_SELF_DIR, "content") @typechecked def p_read(filename: str) -> str: - with open(os.path.join(P_CONTENT_DIR, filename)) as f: + with open(os.path.join(P_CONTENT_DIR, filename), encoding="utf-8") as f: return f.read() APP_BUILDER_SKILL: str = p_read("app_builder_skill.md") diff --git a/backend/apps/app_builder/utils/walk_directory.py b/backend/apps/app_builder/utils/walk_directory.py index 85a5fc30..6c414c54 100644 --- a/backend/apps/app_builder/utils/walk_directory.py +++ b/backend/apps/app_builder/utils/walk_directory.py @@ -12,7 +12,7 @@ def walk_directory(folder: str) -> Dict[str, str]: full_path = os.path.join(root, fname) rel_path = os.path.relpath(full_path, folder) try: - with open(full_path) as f: + with open(full_path, encoding="utf-8") as f: files[rel_path] = f.read() except Exception: pass diff --git a/backend/apps/dashboards/dashboards.py b/backend/apps/dashboards/dashboards.py index eb643609..0d8e1d3d 100644 --- a/backend/apps/dashboards/dashboards.py +++ b/backend/apps/dashboards/dashboards.py @@ -168,7 +168,7 @@ async def duplicate_dashboard(dashboard_id: str): "browser_cards": source_data.get("layout", {}).get("browser_cards", {}), }, } - with open(os.path.join(DB_ROOT, "dashboards", f"{new_id}.json"), "w") as f: + with open(os.path.join(DB_ROOT, "dashboards", f"{new_id}.json"), "w", encoding="utf-8") as f: json.dump(new_dashboard, f, indent=2) return new_dashboard diff --git a/backend/apps/settings/settings.py b/backend/apps/settings/settings.py index edecf298..7e6b5382 100644 --- a/backend/apps/settings/settings.py +++ b/backend/apps/settings/settings.py @@ -22,7 +22,7 @@ settings = SubApp("settings", settings_lifespan) def load_settings() -> AppSettings: """Load settings from JSON file, returning defaults if not found.""" if os.path.exists(SETTINGS_FILE): - with open(SETTINGS_FILE) as f: + with open(SETTINGS_FILE, encoding="utf-8") as f: settings = AppSettings(**json.load(f)) if settings.default_system_prompt is None: settings.default_system_prompt = DEFAULT_SYSTEM_PROMPT @@ -38,7 +38,7 @@ async def get_settings(): @settings.router.put("/update_settings") async def update_settings(body: AppSettings): os.makedirs(SETTINGS_DIR, exist_ok=True) - with open(SETTINGS_FILE, "w") as f: + with open(SETTINGS_FILE, "w", encoding="utf-8") as f: json.dump(body.model_dump(), f, indent=2) return {"ok": True, "settings": body.model_dump()} @@ -48,7 +48,7 @@ async def reset_system_prompt(): current = load_settings() current.default_system_prompt = DEFAULT_SYSTEM_PROMPT os.makedirs(SETTINGS_DIR, exist_ok=True) - with open(SETTINGS_FILE, "w") as f: + with open(SETTINGS_FILE, "w", encoding="utf-8") as f: json.dump(current.model_dump(), f, indent=2) return {"ok": True, "settings": current.model_dump()} diff --git a/backend/apps/skills/SkillStore/SkillStore.py b/backend/apps/skills/SkillStore/SkillStore.py index 15eaaa1f..e26ce44b 100644 --- a/backend/apps/skills/SkillStore/SkillStore.py +++ b/backend/apps/skills/SkillStore/SkillStore.py @@ -22,13 +22,13 @@ class SkillStore(BaseModel): @typechecked def p_load_index(self) -> Dict[str, dict]: if os.path.exists(self.index_path): - with open(self.index_path) as f: + with open(self.index_path, encoding="utf-8") as f: return json.load(f) return {} @typechecked def p_save_index(self, index: Dict[str, dict]) -> None: - with open(self.index_path, "w") as f: + with open(self.index_path, "w", encoding="utf-8") as f: json.dump(index, f, indent=2) @staticmethod @@ -50,7 +50,7 @@ class SkillStore(BaseModel): if not fname.endswith(".md"): continue fpath = os.path.join(self.skills_dir, fname) - with open(fpath) as f: + with open(fpath, encoding="utf-8") as f: content = f.read() skill_id = fname.removesuffix(".md") meta = index.get(skill_id, {}) @@ -75,7 +75,7 @@ class SkillStore(BaseModel): def create(self, name: str, description: str, content: str, command: str = "") -> Skill: slug = self.slug(name) fpath = self.p_skill_path(slug) - with open(fpath, "w") as f: + with open(fpath, "w", encoding="utf-8") as f: f.write(content) index = self.p_load_index() index[slug] = {"name": name, "description": description, "command": command or slug} @@ -91,7 +91,7 @@ class SkillStore(BaseModel): if not os.path.exists(fpath): raise FileNotFoundError(skill_id) if content is not None: - with open(fpath, "w") as f: + with open(fpath, "w", encoding="utf-8") as f: f.write(content) index = self.p_load_index() meta = index.get(skill_id, {}) @@ -103,7 +103,7 @@ class SkillStore(BaseModel): meta["command"] = command index[skill_id] = meta self.p_save_index(index) - with open(fpath) as f: + with open(fpath, encoding="utf-8") as f: content = f.read() return Skill(id=skill_id, name=meta.get("name", skill_id), description=meta.get("description", ""), diff --git a/backend/apps/skills/skills.py b/backend/apps/skills/skills.py index a2af8b2f..6360b176 100644 --- a/backend/apps/skills/skills.py +++ b/backend/apps/skills/skills.py @@ -67,14 +67,14 @@ async def read_skill_workspace(workspace_id: str): skill_content: Optional[str] = None skill_path = os.path.join(folder, "SKILL.md") if os.path.isfile(skill_path): - with open(skill_path) as f: + with open(skill_path, encoding="utf-8") as f: skill_content = f.read() meta: Optional[dict] = None meta_path = os.path.join(folder, "meta.json") if os.path.isfile(meta_path): try: - with open(meta_path) as f: + with open(meta_path, encoding="utf-8") as f: meta = json.load(f) except json.JSONDecodeError: pass @@ -95,10 +95,10 @@ async def seed_skill_workspace(body: _WorkspaceSeedBody): folder = os.path.join(SKILLS_WORKSPACE_DIR, body.workspace_id) os.makedirs(folder, exist_ok=True) if body.skill_content: - with open(os.path.join(folder, "SKILL.md"), "w") as f: + with open(os.path.join(folder, "SKILL.md"), "w", encoding="utf-8") as f: f.write(body.skill_content) if body.meta: - with open(os.path.join(folder, "meta.json"), "w") as f: + with open(os.path.join(folder, "meta.json"), "w", encoding="utf-8") as f: json.dump(body.meta, f, indent=2) return {"path": os.path.abspath(folder)} diff --git a/backend/apps/tools/discover_tools/utils/discover_mcp_tools_sse.py b/backend/apps/tools/discover_tools/utils/discover_mcp_tools_sse.py index f6c8ea2a..536de461 100644 --- a/backend/apps/tools/discover_tools/utils/discover_mcp_tools_sse.py +++ b/backend/apps/tools/discover_tools/utils/discover_mcp_tools_sse.py @@ -1,7 +1,6 @@ from mcp.client.sse import sse_client from mcp import ClientSession from mcp.types import Implementation -from exceptiongroup import BaseExceptionGroup from backend.apps.tools.discover_tools.DiscoveryError import DiscoveryError from typeguard import typechecked diff --git a/backend/apps/tools/tools.py b/backend/apps/tools/tools.py index a96879ad..d41d6281 100644 --- a/backend/apps/tools/tools.py +++ b/backend/apps/tools/tools.py @@ -65,13 +65,13 @@ async def list_builtin_tools() -> dict: def load_builtin_permissions() -> dict[str, TOOL_PERMISSIONS]: if not os.path.exists(BUILTIN_PERMS_PATH): return {} - with open(BUILTIN_PERMS_PATH) as f: + with open(BUILTIN_PERMS_PATH, encoding="utf-8") as f: return json.load(f) def save_builtin_permissions(perms: dict[str, str]) -> None: os.makedirs(os.path.dirname(BUILTIN_PERMS_PATH), exist_ok=True) - with open(BUILTIN_PERMS_PATH, "w") as f: + with open(BUILTIN_PERMS_PATH, "w", encoding="utf-8") as f: json.dump(perms, f, indent=2) diff --git a/backend/core/db/PydanticStore.py b/backend/core/db/PydanticStore.py index 6fb6145c..9128cc84 100644 --- a/backend/core/db/PydanticStore.py +++ b/backend/core/db/PydanticStore.py @@ -49,7 +49,7 @@ class PydanticStore(BaseModel, Generic[T]): path = os.path.join(self.data_dir, fname) size = os.path.getsize(path) debug("load_all: loading %s (%s bytes)", fname, size, table=False) - with open(path) as f: + with open(path, encoding="utf-8") as f: raw = f.read() debug("load_all: raw content length=%s, first 100 chars: %s", len(raw), raw[:100], table=False) result.append(self.model_cls(**json.loads(raw))) @@ -62,7 +62,7 @@ class PydanticStore(BaseModel, Generic[T]): path = self.p_path(item_id) debug("save: writing %s to %s", item_id, path, table=False) with tempfile.NamedTemporaryFile( - "w", dir=self.data_dir, suffix=".tmp", delete=False + "w", dir=self.data_dir, suffix=".tmp", delete=False, encoding="utf-8" ) as tmp: json.dump(self.p_dump(item), tmp, indent=2) tmp_path = tmp.name @@ -74,7 +74,7 @@ class PydanticStore(BaseModel, Generic[T]): path = self.p_path(item_id) if not os.path.exists(path): raise HTTPException(status_code=404, detail=self.not_found_detail) - with open(path) as f: + with open(path, encoding="utf-8") as f: return self.model_cls(**json.load(f)) @typechecked @@ -82,7 +82,7 @@ class PydanticStore(BaseModel, Generic[T]): path = self.p_path(item_id) if not os.path.exists(path): return None - with open(path) as f: + with open(path, encoding="utf-8") as f: return self.model_cls(**json.load(f)) @typechecked diff --git a/backend/core/shared_structs/agent/Message/prompt_utils.py b/backend/core/shared_structs/agent/Message/prompt_utils.py index d964806d..a7bf0a9f 100644 --- a/backend/core/shared_structs/agent/Message/prompt_utils.py +++ b/backend/core/shared_structs/agent/Message/prompt_utils.py @@ -55,7 +55,7 @@ def resolve_context_paths(context_paths: List[ContextPath]) -> str: continue if cp_type == "file" and os.path.isfile(path): try: - with open(path, "r", errors="replace") as f: + with open(path, "r", encoding="utf-8", errors="replace") as f: content: str = f.read(MAX_FILE_READ_BYTES) sections.append( f'\n{content}\n' diff --git a/backend/ports.py b/backend/ports.py index cc690a6d..731ce2ec 100644 --- a/backend/ports.py +++ b/backend/ports.py @@ -8,7 +8,7 @@ import json import os _config_path = os.path.join(os.path.dirname(__file__), "..", "ports.config.json") -with open(_config_path) as _f: +with open(_config_path, encoding="utf-8") as _f: _cfg = json.load(_f) BACKEND_DEV_PORT: int = _cfg["backend"]["dev"] diff --git a/backend/run.sh b/backend/run.sh index bab8e2ac..d556bda4 100755 --- a/backend/run.sh +++ b/backend/run.sh @@ -1,16 +1,15 @@ #!/bin/bash # The comment above is shebang, DO NOT REMOVE DEV_ABSPATH="$(readlink -f "${BASH_SOURCE[0]}")" -if [[ "$OSTYPE" == "darwin"* ]]; then - sed -i '' 's/\r//g' "$DEV_ABSPATH" -else - sed -i 's/\r//g' "$DEV_ABSPATH" -fi +BACKEND_DIR_ABSPATH="$(dirname "$DEV_ABSPATH")" +PROJECT_ROOT_ABSPATH="$(dirname "$BACKEND_DIR_ABSPATH")" + +# shellcheck source=../run/utils/platform.sh +source "$PROJECT_ROOT_ABSPATH/run/utils/platform.sh" +ensure_lf "$DEV_ABSPATH" chmod +x "$DEV_ABSPATH" -PROJECT_ROOT_ABSPATH="$(dirname "$(dirname "$DEV_ABSPATH")")" -BACKEND_DIR_ABSPATH="$PROJECT_ROOT_ABSPATH/backend" -UV_BIN="$BACKEND_DIR_ABSPATH/uv-bin/uv" +UV_BIN="$BACKEND_DIR_ABSPATH/uv-bin/uv${EXE_EXT}" cleanup() { echo "Shutting down..." @@ -32,8 +31,8 @@ if [[ $? -ne 0 ]]; then exit 1 fi -# --- Read dev port from ports.config.json --- -BACKEND_PORT=$(python3 -c "import json; print(json.load(open('$PROJECT_ROOT_ABSPATH/ports.config.json'))['backend']['dev'])") +# --- Read dev port from ports.config.json (path via argv so MSYS converts it) --- +BACKEND_PORT=$("$PY" -c "import json,sys; print(json.load(open(sys.argv[1], encoding='utf-8'))['backend']['dev'])" "$PROJECT_ROOT_ABSPATH/ports.config.json") # --- Start the backend server --- echo "Starting backend server on http://0.0.0.0:${BACKEND_PORT} ..." @@ -41,4 +40,4 @@ cd "$PROJECT_ROOT_ABSPATH" WATCHFILES_FORCE_POLLING=true "$UV_BIN" run --project "$BACKEND_DIR_ABSPATH" python -m uvicorn backend.main:app \ --host 0.0.0.0 --port "$BACKEND_PORT" --reload \ --reload-dir "$BACKEND_DIR_ABSPATH" \ - --reload-exclude '*.pyc' \ No newline at end of file + --reload-exclude '*.pyc' diff --git a/frontend/run.sh b/frontend/run.sh index 0fa200ab..4bb6bb85 100755 --- a/frontend/run.sh +++ b/frontend/run.sh @@ -1,20 +1,13 @@ #!/bin/bash # The comment above is shebang, DO NOT REMOVE DEV_ABSPATH="$(readlink -f "${BASH_SOURCE[0]}")" -if [[ "$OSTYPE" == "darwin"* ]]; then - # echo "In macOS server sed START" - # echo "SERVER_ABSPATH: $SERVER_ABSPATH" - sed -i '' 's/\r//g' "$DEV_ABSPATH" - # echo "In macOS server sed END" -else - # echo "NOT in macOS server START" - # echo "SERVER_ABSPATH: $SERVER_ABSPATH" - sed -i 's/\r//g' "$DEV_ABSPATH" - # echo "NOT in macOS server START" -fi -chmod +x "$DEV_ABSPATH" - FRONTEND_DIR_ABSPATH="$(dirname "$DEV_ABSPATH")" +PROJECT_ROOT_ABSPATH="$(dirname "$FRONTEND_DIR_ABSPATH")" + +# shellcheck source=../run/utils/platform.sh +source "$PROJECT_ROOT_ABSPATH/run/utils/platform.sh" +ensure_lf "$DEV_ABSPATH" +chmod +x "$DEV_ABSPATH" echo "Installing dependencies..." cd "$FRONTEND_DIR_ABSPATH" @@ -23,5 +16,4 @@ npm install echo "Building with development mode..." npm run dev -# exit back to the dir that we were in before -cd - \ No newline at end of file +cd - diff --git a/linter/print_errors.sh b/linter/print_errors.sh index ac6c8d89..2f1dc36f 100755 --- a/linter/print_errors.sh +++ b/linter/print_errors.sh @@ -5,12 +5,15 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="${1:-$(dirname "$SCRIPT_DIR")}" +# shellcheck source=../run/utils/platform.sh +source "$(dirname "$SCRIPT_DIR")/run/utils/platform.sh" + YELLOW='\033[33m' CYAN='\033[36m' BOLD='\033[1m' RESET='\033[0m' -LINT_OUTPUT=$(python3 "$SCRIPT_DIR/lint.py" --root "$ROOT_DIR" 2>&1) +LINT_OUTPUT=$("$PY" "$SCRIPT_DIR/lint.py" --root "$ROOT_DIR" 2>&1) LINT_EXIT=$? if [ $LINT_EXIT -ne 0 ]; then diff --git a/run/local.sh b/run/local.sh index c8aec1c1..4baf465a 100755 --- a/run/local.sh +++ b/run/local.sh @@ -1,16 +1,14 @@ #!/bin/bash # The comment above is shebang, DO NOT REMOVE SCRIPT_ABSPATH="$(readlink -f "${BASH_SOURCE[0]}")" -if [[ "$OSTYPE" == "darwin"* ]]; then - sed -i '' 's/\r//g' "$SCRIPT_ABSPATH" -else - sed -i 's/\r//g' "$SCRIPT_ABSPATH" -fi -chmod +x "$SCRIPT_ABSPATH" - RUN_DIR_ROOT="$(dirname "$SCRIPT_ABSPATH")" PROJECT_ROOT="$(dirname "$RUN_DIR_ROOT")" +# shellcheck source=utils/platform.sh +source "$RUN_DIR_ROOT/utils/platform.sh" +ensure_lf "$SCRIPT_ABSPATH" +chmod +x "$SCRIPT_ABSPATH" + BLUE='\033[0;34m' GREEN='\033[0;32m' RED='\033[0;31m' @@ -23,16 +21,6 @@ FRONTEND_PID="" ELECTRON_PID="" SHUTTING_DOWN=false -kill_tree() { - local pid=$1 sig=${2:-TERM} - local children - children=$(pgrep -P "$pid" 2>/dev/null) - for child in $children; do - kill_tree "$child" "$sig" - done - kill -"$sig" "$pid" 2>/dev/null -} - cleanup() { $SHUTTING_DOWN && return SHUTTING_DOWN=true @@ -45,7 +33,13 @@ cleanup() { for pid in $ELECTRON_PID $FRONTEND_PID; do [[ -n "$pid" ]] && kill_tree "$pid" TERM done - [[ -n "$BACKEND_PID" ]] && kill -TERM "$BACKEND_PID" 2>/dev/null + if [[ -n "$BACKEND_PID" ]]; then + if [[ "$IS_WINDOWS" == "true" ]]; then + kill_tree "$BACKEND_PID" TERM + else + kill -TERM "$BACKEND_PID" 2>/dev/null + fi + fi local elapsed=0 while (( elapsed < 10 )); do @@ -71,35 +65,63 @@ trap cleanup EXIT # --- Ensure bundled uv/uvx for MCP servers --- UV_BIN_DIR="$PROJECT_ROOT/backend/uv-bin" -if [ ! -f "$UV_BIN_DIR/uvx" ]; then +if [ ! -f "$UV_BIN_DIR/uvx${EXE_EXT}" ]; then echo -e "${YELLOW}${BOLD}[uv]${RESET} Downloading uv/uvx..." mkdir -p "$UV_BIN_DIR" ARCH=$(uname -m) - if [[ "$ARCH" == "arm64" ]]; then - curl -sL "https://github.com/astral-sh/uv/releases/latest/download/uv-aarch64-apple-darwin.tar.gz" | tar xz -C /tmp - cp /tmp/uv-aarch64-apple-darwin/uv "$UV_BIN_DIR/uv" - cp /tmp/uv-aarch64-apple-darwin/uvx "$UV_BIN_DIR/uvx" + # NOTE: do not name this `TMP` -- on Windows, `TMP` is an exported env var + # and bash assignment preserves the export attribute, so child processes + # (e.g. uv) would inherit it and crash once we `rm -rf` the directory. + UV_DL_TMP=$(mktemp -d) + if [[ "$IS_WINDOWS" == "true" ]]; then + case "$ARCH" in + aarch64|arm64) UV_PKG="uv-aarch64-pc-windows-msvc.zip" ;; + *) UV_PKG="uv-x86_64-pc-windows-msvc.zip" ;; + esac + curl -fsSL "https://github.com/astral-sh/uv/releases/latest/download/${UV_PKG}" -o "$UV_DL_TMP/uv.zip" + # Use Python's zipfile module: GNU tar in MSYS/Git Bash can't read zips, + # and Windows' libarchive tar.exe is shadowed on PATH by GNU tar. + # The Windows zip stores uv.exe/uvx.exe at the archive root (no wrapper dir), + # unlike the Linux/macOS tarballs which have a uv-/ prefix. + "$PY" -m zipfile -e "$(py_path "$UV_DL_TMP/uv.zip")" "$(py_path "$UV_DL_TMP")" + cp "$UV_DL_TMP/uv.exe" "$UV_BIN_DIR/uv.exe" + cp "$UV_DL_TMP/uvx.exe" "$UV_BIN_DIR/uvx.exe" + elif [[ "$IS_MAC" == "true" ]]; then + if [[ "$ARCH" == "arm64" ]]; then + curl -sL "https://github.com/astral-sh/uv/releases/latest/download/uv-aarch64-apple-darwin.tar.gz" | tar xz -C "$UV_DL_TMP" + cp "$UV_DL_TMP/uv-aarch64-apple-darwin/uv" "$UV_BIN_DIR/uv" + cp "$UV_DL_TMP/uv-aarch64-apple-darwin/uvx" "$UV_BIN_DIR/uvx" + else + curl -sL "https://github.com/astral-sh/uv/releases/latest/download/uv-x86_64-apple-darwin.tar.gz" | tar xz -C "$UV_DL_TMP" + cp "$UV_DL_TMP/uv-x86_64-apple-darwin/uv" "$UV_BIN_DIR/uv" + cp "$UV_DL_TMP/uv-x86_64-apple-darwin/uvx" "$UV_BIN_DIR/uvx" + fi + chmod +x "$UV_BIN_DIR/uv" "$UV_BIN_DIR/uvx" else - curl -sL "https://github.com/astral-sh/uv/releases/latest/download/uv-x86_64-apple-darwin.tar.gz" | tar xz -C /tmp - cp /tmp/uv-x86_64-apple-darwin/uv "$UV_BIN_DIR/uv" - cp /tmp/uv-x86_64-apple-darwin/uvx "$UV_BIN_DIR/uvx" + case "$ARCH" in + aarch64|arm64) UV_PKG_BASE="uv-aarch64-unknown-linux-gnu" ;; + *) UV_PKG_BASE="uv-x86_64-unknown-linux-gnu" ;; + esac + curl -sL "https://github.com/astral-sh/uv/releases/latest/download/${UV_PKG_BASE}.tar.gz" | tar xz -C "$UV_DL_TMP" + cp "$UV_DL_TMP/$UV_PKG_BASE/uv" "$UV_BIN_DIR/uv" + cp "$UV_DL_TMP/$UV_PKG_BASE/uvx" "$UV_BIN_DIR/uvx" + chmod +x "$UV_BIN_DIR/uv" "$UV_BIN_DIR/uvx" fi - chmod +x "$UV_BIN_DIR/uv" "$UV_BIN_DIR/uvx" - rm -rf /tmp/uv-*-apple-darwin + rm -rf "$UV_DL_TMP" fi -# --- Read ports from config --- -BACKEND_PORT=$(python3 -c "import json; print(json.load(open('$PROJECT_ROOT/ports.config.json'))['backend']['dev'])") -FRONTEND_PORT=$(python3 -c "import json; print(json.load(open('$PROJECT_ROOT/ports.config.json'))['frontend']['dev'])") +# --- Read ports from config (path passed via argv so MSYS converts it) --- +BACKEND_PORT=$("$PY" -c "import json,sys; print(json.load(open(sys.argv[1]))['backend']['dev'])" "$PROJECT_ROOT/ports.config.json") +FRONTEND_PORT=$("$PY" -c "import json,sys; print(json.load(open(sys.argv[1]))['frontend']['dev'])" "$PROJECT_ROOT/ports.config.json") # --- Run structural linter (warnings only, non-blocking) --- -LINT_OUTPUT=$(python3 "$PROJECT_ROOT/linter/lint.py" --root "$PROJECT_ROOT" 2>&1) +LINT_OUTPUT=$("$PY" "$PROJECT_ROOT/linter/lint.py" --root "$PROJECT_ROOT" 2>&1) LINT_EXIT=$? if [ $LINT_EXIT -ne 0 ]; then echo "" echo -e "${YELLOW}${BOLD}[structlint] Violations found:${RESET}" echo "$LINT_OUTPUT" | grep -v "^structlint:" | while IFS= read -r line; do - echo -e "${YELLOW} $line${RESET}" + printf '%b %s%b\n' "$YELLOW" "$line" "$RESET" done LINT_COUNT=$(echo "$LINT_OUTPUT" | grep -oE '[0-9]+ error' | head -1 | grep -oE '[0-9]+') echo -e "${YELLOW}${BOLD} ${LINT_COUNT} violation(s) — fix or add exceptions in linter/config/config.json${RESET}" @@ -114,6 +136,7 @@ bash "$PROJECT_ROOT/backend/run.sh" > >( done ) 2>&1 & BACKEND_PID=$! +track_winpid "$BACKEND_PID" # --- Wait for backend to become healthy --- echo -e "${YELLOW}${BOLD}Waiting for backend (http://localhost:${BACKEND_PORT}) to be ready...${RESET}" @@ -145,6 +168,7 @@ bash "$PROJECT_ROOT/frontend/run.sh" > >( done ) 2>&1 & FRONTEND_PID=$! +track_winpid "$FRONTEND_PID" # --- Wait for frontend dev server to become available --- echo -e "${YELLOW}${BOLD}Waiting for frontend (http://localhost:${FRONTEND_PORT}) to be ready...${RESET}" @@ -177,8 +201,8 @@ if [ ! -d "$PROJECT_ROOT/electron/node_modules" ]; then done fi -# --- Sign Electron VMP for DRM (if EVS account exists) --- -if [ -f "$PROJECT_ROOT/electron/scripts/sign-vmp.sh" ]; then +# --- Sign Electron VMP for DRM (macOS only; Windows uses a different DRM flow) --- +if [[ "$IS_WINDOWS" != "true" ]] && [ -f "$PROJECT_ROOT/electron/scripts/sign-vmp.sh" ]; then echo -e "${YELLOW}${BOLD}[vmp]${RESET} Checking VMP signature..." bash "$PROJECT_ROOT/electron/scripts/sign-vmp.sh" 2>&1 | while IFS= read -r line; do printf "${YELLOW}${BOLD}%s${RESET}\n" "$line" @@ -193,6 +217,7 @@ echo -e "${MAGENTA}${BOLD}[electron]${RESET} Launching Electron dev shell..." done ) 2>&1 & ELECTRON_PID=$! +track_winpid "$ELECTRON_PID" echo "" echo -e "${BOLD}All services are running. Press Ctrl+C to stop.${RESET}" diff --git a/run/utils/platform.sh b/run/utils/platform.sh new file mode 100644 index 00000000..776a5443 --- /dev/null +++ b/run/utils/platform.sh @@ -0,0 +1,96 @@ +#!/bin/bash +# Cross-platform helpers for the dev shell scripts. +# Sourced by: run/local.sh, backend/run.sh, frontend/run.sh, linter/print_errors.sh +# +# Exports: +# IS_WINDOWS / IS_MAC / IS_LINUX ("true" or empty) +# EXE_EXT (".exe" on Windows, "" elsewhere) +# PY Working Python 3 interpreter command +# ensure_lf Strip CRLF from a file (BSD/GNU sed safe) +# py_path Convert /c/... to C:/... on Windows +# track_winpid Snapshot Win32 pid for later taskkill +# kill_tree [SIG] Kill a process tree (taskkill on Win) + +case "$OSTYPE" in + msys*|cygwin*|mingw*) IS_WINDOWS=true; EXE_EXT=".exe" ;; + darwin*) IS_MAC=true; EXE_EXT="" ;; + *) IS_LINUX=true; EXE_EXT="" ;; +esac + +# Verify a candidate command actually runs Python 3. Rejects the Microsoft +# Store python.exe / python3.exe stubs, which exit 0 on --version but print +# nothing (and would silently open the Store on real invocation). +_is_real_python3() { + local out + out=$("$@" --version 2>&1) || return 1 + [[ "$out" == Python\ 3.* ]] +} + +PY="" +if [[ "$IS_WINDOWS" == "true" ]]; then + _PY_CANDIDATES=("python" "py -3" "python3") +else + _PY_CANDIDATES=("python3" "python") +fi +for _c in "${_PY_CANDIDATES[@]}"; do + if _is_real_python3 $_c; then + PY="$_c" + break + fi +done +unset _c _PY_CANDIDATES +if [[ -z "$PY" ]]; then + echo "ERROR: no working Python 3 interpreter found in PATH" >&2 + echo " Install Python 3 from https://www.python.org/downloads/" >&2 + exit 1 +fi + +ensure_lf() { + local f=$1 + if [[ "$IS_MAC" == "true" ]]; then + sed -i '' 's/\r//g' "$f" + else + sed -i 's/\r//g' "$f" + fi +} + +py_path() { + if [[ "$IS_WINDOWS" == "true" ]]; then + cygpath -m "$1" + else + printf '%s' "$1" + fi +} + +# On Windows, taskkill needs the Win32 pid. /proc//winpid disappears +# once the bash wrapper exits, so we snapshot it right after backgrounding. +# Guarded because `declare -gA` requires bash >= 4.2; macOS system bash is 3.2. +if [[ "$IS_WINDOWS" == "true" ]]; then + declare -gA WINPIDS=() +fi +track_winpid() { + [[ "$IS_WINDOWS" == "true" ]] || return 0 + local pid=$1 wp + wp=$(cat "/proc/$pid/winpid" 2>/dev/null) && WINPIDS[$pid]=$wp +} + +kill_tree() { + local pid=$1 sig=${2:-TERM} + if [[ "$IS_WINDOWS" == "true" ]]; then + local wp=${WINPIDS[$pid]:-} + [[ -z "$wp" ]] && wp=$(cat "/proc/$pid/winpid" 2>/dev/null) + [[ -z "$wp" ]] && return 0 + if [[ "$sig" == "KILL" ]]; then + taskkill //F //T //PID "$wp" >/dev/null 2>&1 + else + taskkill //T //PID "$wp" >/dev/null 2>&1 + fi + else + local children + children=$(pgrep -P "$pid" 2>/dev/null) + for child in $children; do + kill_tree "$child" "$sig" + done + kill -"$sig" "$pid" 2>/dev/null + fi +}