Files

381 lines
15 KiB
Python

from __future__ import annotations
import json
import os
import re
import shutil
import socket
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Any, Iterable
from urllib.parse import urlparse
COMFY_MANAGER_NODE_MAP_URL = "https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main/extension-node-map.json"
PROTECTED_DEPENDENCIES = {
"torch",
"torchvision",
"torchaudio",
"xformers",
"triton",
"onnxruntime",
"onnxruntime-gpu",
}
FRONTEND_ONLY_NODE_HINTS = ("note", "markdown", "注释", "comment", "reroute")
def missing_node_types(node_types: Iterable[Any], registered: Iterable[str]) -> list[str]:
known = set(registered)
return sorted(
{
str(node_type).strip()
for node_type in node_types
if isinstance(node_type, str)
and node_type.strip()
and node_type not in known
and not any(hint in node_type.lower() for hint in FRONTEND_ONLY_NODE_HINTS)
},
key=str.lower,
)
def missing_workflow_node_types(nodes: Iterable[Any], registered: Iterable[str]) -> list[str]:
return missing_node_types(
(
node.get("type")
for node in nodes
if isinstance(node, dict)
and node.get("active") is not False
and node.get("frontend_registered") is not True
),
registered,
)
def missing_workflow_node_packages(nodes: Iterable[Any], registered: Iterable[str]) -> list[dict[str, Any]]:
missing = set(missing_workflow_node_types(nodes, registered))
packages: dict[str, dict[str, Any]] = {}
for node in nodes:
if not isinstance(node, dict) or str(node.get("type") or "").strip() not in missing:
continue
node_type = str(node["type"]).strip()
package_id = str(node.get("package_id") or "").strip()
key = package_id.lower() if package_id else f"__unknown__:{node_type.lower()}"
package = packages.setdefault(
key,
{
"id": package_id or "__unknown__",
"title": package_id or "未知包",
"known": bool(package_id),
"version": str(node.get("package_version") or "").strip(),
"node_types": [],
"node_ids": [],
"count": 0,
},
)
if node_type not in package["node_types"]:
package["node_types"].append(node_type)
node_id = node.get("id")
if node_id is not None and str(node_id) not in package["node_ids"]:
package["node_ids"].append(str(node_id))
package["count"] += 1
return sorted(packages.values(), key=lambda item: (not item["known"], item["title"].lower()))
def normalize_repo_url(value: str) -> str:
url = value.strip().rstrip("/")
if url.endswith(".git"):
url = url[:-4]
return url.lower()
def allowed_repo_url(value: str) -> bool:
parsed = urlparse(value)
return parsed.scheme == "https" and parsed.hostname == "github.com" and len(parsed.path.strip("/").split("/")) >= 2
def github_fallback_candidates(
package_id: str,
node_type: str,
node_map: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
"""Build installable candidates only from workflow or official-manager GitHub mappings."""
candidates: dict[str, dict[str, Any]] = {}
normalized_package = package_id.strip().strip("/")
if re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", normalized_package):
repo_url = f"https://github.com/{normalized_package}"
candidates[normalize_repo_url(repo_url)] = {
"id": normalized_package,
"title": normalized_package,
"author": normalized_package.split("/", 1)[0],
"description": "工作流 aux_id 提供的 GitHub 插件仓库",
"repo_url": repo_url,
"confidence": 1.0,
"reason": "workflow_package_github",
"installable": True,
"pip": [],
"apt_dependency": [],
}
for repo_url, value in (node_map or {}).items():
if (
allowed_repo_url(str(repo_url))
and isinstance(value, list)
and value
and isinstance(value[0], list)
and node_type in value[0]
):
normalized = normalize_repo_url(str(repo_url))
parts = normalized.rsplit("/", 2)
candidates[normalized] = {
"id": "/".join(parts[-2:]),
"title": parts[-1],
"author": parts[-2],
"description": "ComfyUI-Manager 官方节点映射提供的 GitHub 插件仓库",
"repo_url": str(repo_url).rstrip("/"),
"confidence": 0.99,
"reason": "comfy_manager_github",
"installable": True,
"pip": [],
"apt_dependency": [],
}
return sorted(candidates.values(), key=lambda item: (-item["confidence"], item["title"].lower()))
def _comfy_root(folder_paths_module: Any) -> Path:
module_file = getattr(folder_paths_module, "__file__", None)
if module_file:
return Path(module_file).resolve().parent
return Path(folder_paths_module.models_dir).resolve().parent
def _launcher_preferences(root: Path) -> dict[str, Any]:
path = root / ".launcher" / "preference.json"
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
def _resolve_tool(root: Path, configured: str | None, fallback: str) -> str:
if configured:
path = (root / configured).resolve()
if path.exists():
return str(path)
return fallback
def _resolve_python(root: Path, configured: str | None) -> str:
candidates = [
(root / configured).resolve() if configured else None,
Path(sys.executable).resolve(),
(root.parent / "python_embeded" / "python.exe").resolve(),
(root / "venv" / "Scripts" / "python.exe").resolve(),
]
portable_root = root.parent.resolve()
for candidate in candidates:
if candidate and candidate.exists() and (portable_root == candidate.parent or portable_root in candidate.parents):
return str(candidate)
return sys.executable
def _install_environment(root: Path) -> tuple[str, str, dict[str, str]]:
preference = _launcher_preferences(root)
expert = preference.get("expert_settings") or {}
network = preference.get("network_preference") or {}
python = _resolve_python(root, expert.get("python_path_override"))
git = _resolve_tool(root, expert.get("git_path_override"), shutil.which("git") or "git")
env = os.environ.copy()
proxy = str(network.get("proxy_address") or "").strip()
if proxy and not _proxy_available(proxy):
proxy = _windows_proxy()
if proxy and network.get("proxy_env"):
env.update({"HTTP_PROXY": proxy, "HTTPS_PROXY": proxy, "http_proxy": proxy, "https_proxy": proxy})
return python, git, env
def _proxy_available(value: str) -> bool:
parsed = urlparse(value if "://" in value else f"http://{value}")
try:
with socket.create_connection((parsed.hostname or "", parsed.port or 80), timeout=1):
return True
except OSError:
return False
def _windows_proxy() -> str:
try:
import winreg
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\Internet Settings") as key:
enabled = winreg.QueryValueEx(key, "ProxyEnable")[0]
server = str(winreg.QueryValueEx(key, "ProxyServer")[0])
return f"http://{server}" if enabled and server else ""
except (ImportError, OSError):
return ""
def _run(
command: list[str], cwd: Path, env: dict[str, str], timeout: int = 900, allow_failure: bool = False
) -> str:
result = subprocess.run(
command,
cwd=cwd,
env=env,
text=True,
encoding="utf-8",
errors="replace",
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
timeout=timeout,
check=False,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
if result.returncode and not allow_failure:
raise RuntimeError(result.stdout.strip() or f"Command failed: {command[0]}")
return result.stdout.strip()
def _requirement_files(repo: Path) -> list[Path]:
result = []
queue = [repo / "requirements.txt"]
visited = set()
while queue:
path = queue.pop(0).resolve()
if path in visited or not path.is_file() or repo.resolve() not in path.parents:
continue
visited.add(path)
result.append(path)
for raw in path.read_text(encoding="utf-8", errors="replace").splitlines():
line = raw.strip()
if line.startswith(("-r ", "--requirement ")):
nested = line.split(maxsplit=1)[1].strip()
queue.append(path.parent / nested)
return result
def dependency_conflicts(repo: Path) -> list[str]:
conflicts = []
for path in _requirement_files(repo):
for raw in path.read_text(encoding="utf-8", errors="replace").splitlines():
line = raw.strip()
if not line or line.startswith("#") or line.startswith(("-r ", "--requirement ")):
continue
if line.startswith("-") or ("://" in line and not line.startswith("git+https://github.com/")):
conflicts.append(f"不安全或无法预检的依赖声明: {line}")
continue
package = re.split(r"[\s\[<>=!~;]", line, maxsplit=1)[0].lower().replace("_", "-")
if package in PROTECTED_DEPENDENCIES and re.search(r"[<>=!~]", line):
conflicts.append(f"插件要求修改核心运行依赖: {line}")
return conflicts
def declared_dependency_conflicts(requirements: Iterable[Any]) -> list[str]:
conflicts = []
for value in requirements:
line = str(value).strip()
if not line:
continue
if line.startswith("-") or ("://" in line and not line.startswith("git+https://github.com/")):
conflicts.append(f"不安全或无法预检的市场依赖声明: {line}")
continue
package = re.split(r"[\s\[<>=!~;]", line, maxsplit=1)[0].lower().replace("_", "-")
if package in PROTECTED_DEPENDENCIES and re.search(r"[<>=!~]", line):
conflicts.append(f"插件要求修改核心运行依赖: {line}")
return conflicts
def _existing_repo(custom_nodes: Path, repo_url: str, git: str, env: dict[str, str]) -> Path | None:
wanted = normalize_repo_url(repo_url)
for directory in custom_nodes.iterdir():
if not directory.is_dir() or not (directory / ".git").exists():
continue
try:
remote = _run([git, "remote", "get-url", "origin"], directory, env, timeout=30)
except RuntimeError:
continue
if normalize_repo_url(remote) == wanted:
return directory
return None
def install_plugin(
folder_paths_module: Any,
candidate: dict[str, Any],
install_dependencies: bool = True,
) -> dict[str, Any]:
repo_url = str(candidate.get("repo_url") or "")
if not candidate.get("installable") or not allowed_repo_url(repo_url):
raise RuntimeError("只允许安装工作流 aux_id、ComfyUI-Manager 官方映射或用户指定的 GitHub 插件")
root = _comfy_root(folder_paths_module)
custom_nodes = root / "custom_nodes"
python, git, env = _install_environment(root)
existing = _existing_repo(custom_nodes, repo_url, git, env)
installed_path = existing
action = "updated" if existing else "installed"
temp_path: Path | None = None
try:
if existing:
_run([git, "pull", "--ff-only"], existing, env)
repo = existing
else:
repo_name = re.sub(r"[^A-Za-z0-9_.-]+", "-", urlparse(repo_url).path.rstrip("/").rsplit("/", 1)[-1])
target = custom_nodes / repo_name
if target.exists():
raise RuntimeError(f"目标目录已存在但不是同一仓库: {target.name}")
temp_path = Path(tempfile.mkdtemp(prefix=".findmodels-install-", dir=custom_nodes))
_run([git, "clone", "--depth", "1", repo_url, str(temp_path)], custom_nodes, env)
repo = temp_path
declared = candidate.get("pip") or []
conflicts = dependency_conflicts(repo) + declared_dependency_conflicts(declared) if install_dependencies else []
if install_dependencies and candidate.get("apt_dependency"):
conflicts.append("插件需要系统级 apt 依赖,当前 Windows 环境无法安全自动安装")
if conflicts:
raise RuntimeError("\n".join(conflicts))
requirements = _requirement_files(repo)
conflicts_before = set()
if install_dependencies:
conflicts_before = set(
_run([python, "-m", "pip", "check"], repo, env, allow_failure=True).splitlines()
)
for requirement in requirements:
_run([python, "-m", "pip", "install", "--dry-run", "-r", str(requirement)], repo, env)
for requirement in requirements:
_run([python, "-m", "pip", "install", "-r", str(requirement)], repo, env)
if declared:
_run([python, "-m", "pip", "install", "--dry-run", *map(str, declared)], repo, env)
_run([python, "-m", "pip", "install", *map(str, declared)], repo, env)
if install_dependencies and (repo / "install.py").is_file():
_run([python, "install.py"], repo, env)
conflicts_after = (
set(_run([python, "-m", "pip", "check"], repo, env, allow_failure=True).splitlines())
if install_dependencies else conflicts_before
)
new_conflicts = sorted(conflicts_after - conflicts_before)
if new_conflicts:
raise RuntimeError(
"安装依赖后发现新的环境冲突,插件未启用:\n" + "\n".join(new_conflicts)
)
if temp_path:
temp_path.replace(target)
installed_path = target
temp_path = None
return {
"action": action,
"title": candidate.get("title"),
"path": str(installed_path),
"dependencies": len(requirements) if install_dependencies else 0,
"dependencies_skipped": not install_dependencies,
"existing_conflicts": sorted(conflicts_before),
"new_conflicts": new_conflicts,
"pip_check": "No new broken requirements found." if not new_conflicts else "\n".join(new_conflicts),
"restart_required": True,
}
finally:
if temp_path and temp_path.exists():
shutil.rmtree(temp_path, ignore_errors=True)