#!/usr/bin/env python3 """DeerFlow Web App 产物构建脚本(纯标准库,可在沙箱与宿主两侧运行)。 子命令: check 能力探测:模板 / node_modules / node 二进制是否就绪(技能第 0 步调用) scaffold 从模板脚手架一个新站点目录(排除 node_modules、dist 等运行产物) build 链接预置 node_modules 并执行 vite build,产出 dist/ status 查询站点构建状态(dist 信息 + 最近一次构建结果) 所有输出都是单行 JSON(stdout),失败时 exit code != 0 且 JSON 里带完整原因, 供智能体自修复或前端展示。 环境变量: DEERFLOW_WEBAPP_TEMPLATE 模板目录(默认:脚本所在目录的上一级) DEERFLOW_WEBAPP_NODE node 可执行文件(默认:PATH 上的 node) DEERFLOW_WEBAPP_TIMEOUT 构建超时秒数(默认 180) """ from __future__ import annotations import json import os import platform import re import shutil import subprocess import sys import time from pathlib import Path DEFAULT_TIMEOUT = 180 SITE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") IGNORE_ON_SCAFFOLD = shutil.ignore_patterns( "node_modules", "dist", "scripts", ".build-result.json", ".git", "*.log" ) def _emit(payload: dict, ok: bool) -> int: payload["ok"] = ok try: sys.stdout.reconfigure(encoding="utf-8") except Exception: # noqa: BLE001 - 老版本 Python 没有 reconfigure pass print(json.dumps(payload, ensure_ascii=False)) return 0 if ok else 1 def _template_dir() -> Path: env_dir = os.environ.get("DEERFLOW_WEBAPP_TEMPLATE") if env_dir: return Path(env_dir).resolve() # 沙箱内也常挂载到 /mnt/web-app-template mounted = Path("/mnt/web-app-template") if mounted.is_dir() and (mounted / "scripts" / "build_site.py").exists(): return mounted return Path(__file__).resolve().parent.parent def _find_node() -> str | None: env_node = os.environ.get("DEERFLOW_WEBAPP_NODE") if env_node and Path(env_node).exists(): return env_node which = shutil.which("node") if which: return which candidates = ( ["/usr/bin/node", "/usr/local/bin/node"] if platform.system() != "Windows" else [ r"C:\Program Files\nodejs\node.exe", r"C:\Program Files (x86)\nodejs\node.exe", ] ) for c in candidates: if Path(c).exists(): return c return None def _resolve_site_dir(raw: str) -> Path: """站点目录解析:相对路径(相对 cwd)、/mnt/user-data 前缀(沙箱视图)、 或 LocalSandbox 已改写为宿主绝对路径的形态(须位于 user-data/outputs 之下)。 拒绝目录穿越与越界绝对路径。 """ if not raw or ".." in Path(raw).parts or "\x00" in raw: raise ValueError(f"非法站点目录: {raw!r}") p = Path(raw) if p.is_absolute(): norm = str(p.resolve()).replace("\\", "/") if norm.startswith("/mnt/user-data") or norm.startswith("/mnt/acp-workspace"): return p # LocalSandbox 会把命令里的 /mnt/user-data/... 改写成宿主绝对路径; # 只接受位于 user-data/outputs 目录树内的改写结果 marker = "/user-data/outputs/" idx = norm.find(marker) if idx > 0: user_data_root = Path(norm[: idx + len("/user-data")]) if user_data_root.is_dir(): return Path(norm) raise ValueError(f"绝对路径须位于 user-data/outputs 之下: {raw!r}") return (Path.cwd() / p).resolve() def _validate_site_name(name: str) -> None: if not SITE_NAME_RE.match(name): raise ValueError( f"站点目录名只允许字母数字开头,含 [A-Za-z0-9._-]: {name!r}" ) def _read_build_result(site_dir: Path) -> dict | None: f = site_dir / ".build-result.json" if not f.exists(): return None try: return json.loads(f.read_text(encoding="utf-8")) except Exception: # noqa: BLE001 return None def _dist_info(site_dir: Path) -> dict: dist = site_dir / "dist" if not dist.is_dir(): return {"exists": False} files = [p for p in dist.rglob("*") if p.is_file()] latest = max((p.stat().st_mtime for p in files), default=None) return { "exists": True, "file_count": len(files), "total_bytes": sum(p.stat().st_size for p in files), "mtime": latest, } def _web_app_enabled_from_config() -> tuple[bool, str]: """读取 config.yaml 的 ``web_app.enabled``(宿主侧生效;沙箱内读不到配置时视为开启)。 优先级:env ``DEERFLOW_WEBAPP_ENABLED`` > ``DEERFLOW_WEBAPP_CONFIG`` 指定的 config.yaml > 模板目录向上查找的 config.yaml。沙箱内模板挂载在 /mnt/web-app-template(向上找不到 config.yaml),此时回退 True —— 沙箱侧的 开关由「平台是否挂载模板」表达(config.yaml sandbox.mounts 注释即关闭)。 """ env_flag = os.environ.get("DEERFLOW_WEBAPP_ENABLED", "").strip().lower() if env_flag in ("1", "true", "yes", "on"): return True, "env DEERFLOW_WEBAPP_ENABLED" if env_flag in ("0", "false", "no", "off"): return False, "env DEERFLOW_WEBAPP_ENABLED" config_candidates: list[Path] = [] env_config = os.environ.get("DEERFLOW_WEBAPP_CONFIG", "").strip() if env_config: config_candidates.append(Path(env_config)) else: # 宿主侧布局:/templates/web-app-template/scripts/ → /config.yaml config_candidates.append(Path(__file__).resolve().parents[3] / "config.yaml") for config_path in config_candidates: if not config_path.is_file(): continue try: lines = config_path.read_text(encoding="utf-8").splitlines() except OSError: continue in_section = False for line in lines: stripped = line.split("#", 1)[0].rstrip() if not stripped or stripped.lstrip().startswith("-"): continue indent = len(stripped) - len(stripped.lstrip()) if not stripped.startswith(" ") and stripped.endswith(":"): in_section = stripped[:-1].strip() == "web_app" continue if in_section and indent >= 2: key, _, value = stripped.strip().partition(":") if key.strip() == "enabled": return value.strip().lower() in ("1", "true", "yes", "on"), str(config_path) # 配置文件存在但没有 web_app 段 → 与平台 pydantic 默认一致:False return False, str(config_path) # 沙箱视图:向上找不到 config.yaml(模板挂在 /mnt/web-app-template), # 开关由「平台是否挂载模板」表达 → 视为开启 return True, "config not found (sandbox view: mount presence decides)" def cmd_check(_args: list[str]) -> int: template = _template_dir() node = _find_node() vite_bin = template / "node_modules" / "vite" / "bin" / "vite.js" enabled, enabled_source = _web_app_enabled_from_config() checks = { "enabled": (enabled, enabled_source), "template": (template.is_dir() and (template / "site.json").exists(), str(template)), "node_modules": (vite_bin.exists(), str(vite_bin)), "node": (node is not None, node or ""), } available = all(ok for ok, _ in checks.values()) return _emit( { "available": available, "template_dir": str(template), "node_bin": node or "", "checks": {k: {"ok": ok, "detail": detail} for k, (ok, detail) in checks.items()}, }, ok=True, # check 本身永远成功,可用性看 available 字段 ) def cmd_scaffold(args: list[str]) -> int: if len(args) != 1: return _emit({"error": "用法: build_site.py scaffold <站点目录>"}, ok=False) try: site_dir = _resolve_site_dir(args[0]) _validate_site_name(site_dir.name) template = _template_dir() if not (template / "site.json").exists(): return _emit({"error": f"模板不存在或不完整: {template}"}, ok=False) if not site_dir.exists(): site_dir.mkdir(parents=True, exist_ok=True) shutil.copytree(template, site_dir, dirs_exist_ok=True, ignore=IGNORE_ON_SCAFFOLD) if not (site_dir / "site.json").exists(): return _emit({"error": "脚手架后缺少 site.json,模板不完整"}, ok=False) return _emit({"site_dir": str(site_dir), "scaffolded": True}, ok=True) except Exception as exc: # noqa: BLE001 return _emit({"error": f"scaffold 失败: {exc}"}, ok=False) def _link_node_modules(site_dir: Path, template: Path) -> str: """把模板的 node_modules 链接进站点目录(符号链接 → Windows junction → 整拷贝兜底)。 注意:Python 3.8+ 的 shutil.rmtree 遇到 junction 只解除链接不穿透删除, 因此清理失效链接时对宿主模板是安全的。 """ target = template / "node_modules" link = site_dir / "node_modules" def _valid(p: Path) -> bool: return (p / "vite" / "bin" / "vite.js").exists() if link.exists() or link.is_symlink(): if _valid(link): return "reused" try: if link.is_symlink() or link.is_file(): link.unlink() else: shutil.rmtree(link, ignore_errors=True) except OSError: pass try: os.symlink(target, link, target_is_directory=True) if _valid(link): return "symlink" link.unlink() except (OSError, NotImplementedError): pass if platform.system() == "Windows": try: proc = subprocess.run( ["cmd", "/c", "mklink", "/J", str(link), str(target)], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30, ) if proc.returncode == 0 and _valid(link): return "junction" except Exception: # noqa: BLE001 pass shutil.copytree(target, link, dirs_exist_ok=True) return "copied" def cmd_build(args: list[str]) -> int: if len(args) != 1: return _emit({"error": "用法: build_site.py build <站点目录>"}, ok=False) started = time.time() try: site_dir = _resolve_site_dir(args[0]) if not (site_dir / "site.json").exists(): return _emit( {"error": f"站点目录缺少 site.json(不是 Web App 站点): {site_dir}"}, ok=False ) template = _template_dir() node = _find_node() if node is None: return _emit({"error": "找不到 node 可执行文件(PATH 或 DEERFLOW_WEBAPP_NODE)"}, ok=False) vite_bin = template / "node_modules" / "vite" / "bin" / "vite.js" if not vite_bin.exists(): return _emit( {"error": f"模板依赖未预置(缺 {vite_bin}),离线部署需先执行 node_modules 预置"}, ok=False ) link_mode = _link_node_modules(site_dir, template) timeout = int(os.environ.get("DEERFLOW_WEBAPP_TIMEOUT", str(DEFAULT_TIMEOUT))) env = dict(os.environ) env["NODE_ENV"] = "production" proc = subprocess.run( [node, str(vite_bin), "build"], cwd=str(site_dir), capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout, env=env, ) duration_ms = int((time.time() - started) * 1000) result = { "site_dir": str(site_dir), "success": proc.returncode == 0, "duration_ms": duration_ms, "node_modules": link_mode, "stdout_tail": proc.stdout[-8000:], "stderr_tail": proc.stderr[-8000:], "finished_at": time.time(), } if proc.returncode != 0: result["error"] = f"vite build 退出码 {proc.returncode},详见 stderr_tail" result["dist"] = _dist_info(site_dir) (site_dir / ".build-result.json").write_text( json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8" ) return _emit(result, ok=proc.returncode == 0) except subprocess.TimeoutExpired: result = { "site_dir": args[0] if args else "", "success": False, "error": f"构建超时(>{os.environ.get('DEERFLOW_WEBAPP_TIMEOUT', DEFAULT_TIMEOUT)}s)已终止", "duration_ms": int((time.time() - started) * 1000), "finished_at": time.time(), } try: (Path(args[0]) / ".build-result.json").write_text( json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8" ) except Exception: # noqa: BLE001 pass return _emit(result, ok=False) except Exception as exc: # noqa: BLE001 return _emit({"error": f"build 失败: {exc}"}, ok=False) def cmd_status(args: list[str]) -> int: if len(args) != 1: return _emit({"error": "用法: build_site.py status <站点目录>"}, ok=False) try: site_dir = _resolve_site_dir(args[0]) return _emit( { "site_dir": str(site_dir), "exists": site_dir.is_dir(), "has_site_json": (site_dir / "site.json").exists(), "dist": _dist_info(site_dir), "last_build": _read_build_result(site_dir), }, ok=True, ) except Exception as exc: # noqa: BLE001 return _emit({"error": f"status 失败: {exc}"}, ok=False) def main() -> int: commands = {"check": cmd_check, "scaffold": cmd_scaffold, "build": cmd_build, "status": cmd_status} if len(sys.argv) < 2 or sys.argv[1] not in commands: return _emit( {"error": "用法: build_site.py [站点目录]"}, ok=False ) return commands[sys.argv[1]](sys.argv[2:]) if __name__ == "__main__": sys.exit(main())