diff --git a/backend/apps/outputs/outputs.py b/backend/apps/outputs/outputs.py index 31306b88..894363f1 100644 --- a/backend/apps/outputs/outputs.py +++ b/backend/apps/outputs/outputs.py @@ -230,7 +230,7 @@ def ensure_webapp_workspace_seeded_and_registered( from backend.apps.outputs.runtime import _find_free_port frontend_port = _find_free_port() seed_webapp_template_workspace(folder, frontend_port) - 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(load_app_builder_skill()) existing = [o for o in _load_all() if o.workspace_id == workspace_id] if existing: @@ -311,11 +311,11 @@ async def seed_workspace(body: WorkspaceSeedRequest): # SKILL.md still goes in workspace root; agent reads it for # context. Live content (user-editable via Skills page) is # injected into the system prompt regardless. - 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(load_app_builder_skill()) meta = body.meta or {} if body.meta and not already_seeded: - 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) # Create (or look up) the Output record so the app appears in # the Apps sidebar the moment the user kicks off generation. @@ -360,12 +360,12 @@ async def seed_workspace(body: WorkspaceSeedRequest): 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 VIEW_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) # Seed the workspace's SKILL.md with the LIVE skill content so an @@ -374,11 +374,11 @@ async def seed_workspace(body: WorkspaceSeedRequest): # already-seeded workspaces (the system-prompt injection in # agent_manager reads live, so the agent always has the latest # rules regardless of this on-disk copy). - 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(load_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), "template_mode": "flat"} @@ -494,7 +494,7 @@ async def write_workspace_file(workspace_id: str, filepath: str, body: dict): if full_path != folder_norm and not full_path.startswith(folder_norm + os.sep): 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} diff --git a/backend/apps/outputs/view_builder_templates.py b/backend/apps/outputs/view_builder_templates.py index 0db29bdb..3cc089f7 100644 --- a/backend/apps/outputs/view_builder_templates.py +++ b/backend/apps/outputs/view_builder_templates.py @@ -467,7 +467,7 @@ def _ensure_warm_python_venv() -> str | None: logger.warning("warm-venv pip install failed: %s", r.stderr[-1500:]) return None - with open(sentinel, "w") as fh: + with open(sentinel, "w", encoding="utf-8") as fh: fh.write("ok\n") logger.info("webapp-template: warm backend venv ready at %s", venv_dir) return venv_dir @@ -568,6 +568,16 @@ def seed_webapp_template_workspace(workspace_dir: str, frontend_port: int) -> No src_example = os.path.join(WEBAPP_TEMPLATE_DIR, ".env.example") if os.path.exists(src_example): shutil.copyfile(src_example, env_path) + else: + # .env.example can be absent from a packaged build whose copy step + # stripped dotfiles (the Windows build's recursive '.env.*' exclude did + # exactly this). Write the default directly so the workspace always has + # a .env with BACKEND_PORT=NONE; without it run.sh sees no BACKEND_PORT, + # takes the backend branch, and dies on a backend that isn't there, + # leaving the app stuck on the splash. Mac was unaffected because its + # build anchors the exclude and ships .env.example. + with open(env_path, "w", encoding="utf-8") as f: + f.write("BACKEND_PORT=NONE\nFRONTEND_PORT=4949\n") _patch_env_port(env_path, "FRONTEND_PORT", str(frontend_port)) _patch_env_port(env_example_path, "FRONTEND_PORT", str(frontend_port)) diff --git a/backend/apps/outputs/webapp_template/run.sh b/backend/apps/outputs/webapp_template/run.sh index cd182530..40ed3fbd 100755 --- a/backend/apps/outputs/webapp_template/run.sh +++ b/backend/apps/outputs/webapp_template/run.sh @@ -39,8 +39,14 @@ cleanup() { } trap cleanup EXIT -if [[ "${BACKEND_PORT}" == "NONE" ]]; then - echo "BACKEND_PORT=NONE — running frontend only (no backend)." +if [[ "${BACKEND_PORT}" == "NONE" || -z "${BACKEND_PORT}" || ! -f "$ROOT_DIR/backend/run.sh" ]]; then + # Frontend-only is the safe default: BACKEND_PORT=NONE (frontend-only app), + # OR unset/empty (e.g. .env missing — never start a backend that isn't + # configured), OR there is genuinely no backend/run.sh to run. Without the + # last two guards an unset BACKEND_PORT fell through to the backend branch + # and `bash backend/run.sh` died with "No such file or directory", tearing + # the whole app down before the frontend could show. + echo "Running frontend only (no backend configured)." echo "" bash "$ROOT_DIR/frontend/run.sh" 2>&1 | awk '{printf "\033[32m[frontend]\033[0m %s\n", $0; fflush()}' & diff --git a/scripts/build-app-win.ps1 b/scripts/build-app-win.ps1 index 3273040d..ce27c4ea 100644 --- a/scripts/build-app-win.ps1 +++ b/scripts/build-app-win.ps1 @@ -329,6 +329,20 @@ function Copy-Excluded($Source, $Dest, $Exclude) { Copy-Excluded ` (Join-Path $ProjectRoot 'backend') (Join-Path $Staging 'backend') ` @{ Dirs = @('__pycache__','.venv','data','uv-bin','tests'); Files = @('*.pyc','.env','.env.*') } +# The '.env.*' exclude above is recursive, so it also strips the vendored +# webapp_template/.env.example that seed_workspace copies into each new app's +# .env (BACKEND_PORT=NONE). The mac build anchors its exclude to avoid this; +# here we restore the one file. Without it, Windows-built apps seed with no +# .env, run.sh takes the backend branch, and the app dies on a missing backend. +# (seed_workspace also now writes a default .env when this is absent, but +# shipping it keeps the template snapshot complete and matches mac.) +$EnvExampleSrc = Join-Path $ProjectRoot 'backend\apps\outputs\webapp_template\.env.example' +$EnvExampleDst = Join-Path $Staging 'backend\apps\outputs\webapp_template\.env.example' +if (Test-Path $EnvExampleSrc) { + New-Item -ItemType Directory -Force -Path (Split-Path $EnvExampleDst -Parent) | Out-Null + Copy-Item -Force $EnvExampleSrc $EnvExampleDst + Write-Host "Restored webapp_template/.env.example (stripped by the .env.* exclude)" +} # data: backend/config/paths.py points DATA_ROOT at %APPDATA%/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