372 lines
14 KiB
Python
372 lines
14 KiB
Python
#!/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())
|