Files

372 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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:
# 宿主侧布局:<backend>/templates/web-app-template/scripts/ → <backend>/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 <check|scaffold|build|status> [站点目录]"}, ok=False
)
return commands[sys.argv[1]](sys.argv[2:])
if __name__ == "__main__":
sys.exit(main())