Files
MDMAchine-ComfyUI_MD_Nodes/utility/MD_GlobalUpdateManager.py
T

602 lines
27 KiB
Python
Raw 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.
# ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
# █▓▒░ ░▒▓█
# █▓▒░ MD_Nodes/Utilities – Global Update Architect v2.0.0 ░▒▓█
# █▓▒░ ░▒▓█
# ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
# ╠═ © 2026 MDMAchine
# ╠═ License: GNU General Public License v3.0 (GPL v3)
# ║
# ║ This program is free software: you can redistribute it and/or modify
# ║ it under the terms of the GNU General Public License as published by
# ║ the Free Software Foundation, either version 3 of the License, or
# ║ (at your option) any later version.
# ║
# ║ This program is distributed in the hope that it will be useful,
# ║ but WITHOUT ANY WARRANTY; without even the implied warranty of
# ║ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# ║ GNU General Public License for more details.
# ║
# ║ You should have received a copy of the GNU General Public License
# ║ along with this program. If not, see <https://www.gnu.org/licenses/>.
# ╠════════════════════════════════════════════════════════════════════════════
# ║ ░▒▓ ORIGIN & DEV:
# ║ • Cast into the void by: MDMAchine
# ║ • Enhanced by: Gemini
# ║
# ║ ░▒▓ DESCRIPTION:
# ║ The Architect is the ultimate command center. It combines the Fixer's
# ║ repair tools, the Sniper's targeting precision, and the Diplomat's
# ║ dependency analysis into one unified node.
# ║ NOTE: As an admin utility, this runs entirely in the public wrapper domain.
# ║
# ║ ░▒▓ FEATURES:
# ║ ✓ Sniper Mode: Update a single node by name ('Target Single Node')
# ║ ✓ Repair Mode: Convert manual installs to Git repos ('Inject Git')
# ║ ✓ Atomic Rollback: Cleans up failed git inits automatically
# ║ ✓ Full Lifecycle: Scan -> Repair -> Update -> Verify
# ║
# ║ ░▒▓ CHANGELOG:
# ║ - v2.0.0 (Enterprise Standards - Feb 2026):
# ║ • ADDED: PerformanceProfiler class (v1.5.3 standard).
# ║ • ADDED: debug_mode parameter for git tracking.
# ║ • REFACTOR: Tooltips strictly updated to 5-part v1.5.4 standard.
# ║ - v1.9.0 (The Architect):
# ║ • MERGED: All features from Sniper and Fixer.
# ║ • ADDED: 'Update Single Node' mode.
# ╚════════════════════════════════════════════════════════════════════════════
# =================================================================================
# == Standard Library Imports
# =================================================================================
VERSION = "v2.0.0" # UPS v1.5.8
import os
import sys
import subprocess
import time
import shutil
import datetime
import glob
import re
import json
import logging
# =================================================================================
# == Configuration Constants
# =================================================================================
NODE_VERSION = "2.0.0"
CONST_GIT_TIMEOUT = 45
CONST_BACKUP_DIR_NAME = "_Global_Backups"
CONST_STATE_FILE = "_md_batch_state.json"
CONST_REPORT_FILE = "md_global_report.json"
CONST_MAX_RETRIES = 3
CONST_JS_MAX_SAFE_INTEGER = 9007199254740991
CONST_SEED_MIN = 0
CONST_INTERNAL_IGNORE = [
"MD_Nodes", "ComfyUI-Manager",
".git", "__pycache__", ".disabled",
".vscode", ".idea"
]
CONST_IGNORE_EXT = [".rar", ".zip", ".7z", ".json", ".example", ".txt", ".bat", ".sh"]
CONST_MODES = [
"📊 Global Status Check",
"🎯 Update Single Node (Target ID)",
"🛠️ Inject Git (Convert Manual to Repo)",
"🔍 Analyze Dependency Conflicts",
"📦 Create Global Snapshot",
"🚀 Batch Update (Stateful - Resume/Next Chunk)",
"⚠️ Update ALL + Install Deps (High Risk)"
]
# Colors
CONST_CLR_GREEN = "\033[92m"
CONST_CLR_RED = "\033[91m"
CONST_CLR_YELLOW = "\033[93m"
CONST_CLR_CYAN = "\033[96m"
CONST_CLR_RESET = "\033[0m"
# =================================================================================
# == Helper Classes (Enterprise Standards)
# =================================================================================
class PerformanceProfiler:
"""Standard performance profiler for MD_Nodes."""
def __init__(self, enabled=True):
self.enabled = enabled
self.timings = {}
self.start_times = {}
def start(self, operation_name):
if not self.enabled: return
self.start_times[operation_name] = time.perf_counter()
def stop(self, operation_name):
if not self.enabled: return
if operation_name in self.start_times:
elapsed = time.perf_counter() - self.start_times[operation_name]
if operation_name not in self.timings:
self.timings[operation_name] = []
self.timings[operation_name].append(elapsed)
del self.start_times[operation_name]
def get_total_time(self):
if not self.enabled or not self.timings: return 0.0
return sum(sum(times) for times in self.timings.values())
def print_report(self):
if not self.enabled or not self.timings: return
logging.info("\n⏱️ PERFORMANCE (SYS/IO):")
total = self.get_total_time()
logging.info(f" • Total Time: {total:.2f}s")
for op_name, times in sorted(self.timings.items()):
avg = sum(times) / len(times)
if len(times) == 1:
logging.info(f" • {op_name}: {avg:.3f}s")
else:
logging.info(f" • {op_name}: {avg:.3f}s avg ({len(times)}x)")
# =================================================================================
# == Utility Functions
# =================================================================================
def log_console(msg, color=CONST_CLR_RESET):
logging.info(f"{color}[MD_Architect] {msg}{CONST_CLR_RESET}")
def run_git_command(args, cwd):
for attempt in range(CONST_MAX_RETRIES):
try:
result = subprocess.run(
["git"] + args,
cwd=cwd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=CONST_GIT_TIMEOUT,
check=False
)
if result.returncode == 0:
return (True, result.stdout.strip(), result.stderr.strip())
time.sleep(1 * (attempt + 1))
except Exception as e:
if attempt == CONST_MAX_RETRIES - 1:
return (False, "", str(e))
return (False, "", result.stderr.strip())
def detect_branch(repo_path):
s, out, _ = run_git_command(["symbolic-ref", "--short", "HEAD"], repo_path)
if s and out: return out
s, out, _ = run_git_command(["branch", "--show-current"], repo_path)
if s and out: return out
return None
def parse_ignore_list(ignore_string):
if not ignore_string: return []
clean = ignore_string.replace("\n", ",").replace(";", ",")
user_list = [x.strip() for x in clean.split(",") if x.strip()]
return list(set(user_list + CONST_INTERNAL_IGNORE))
def scan_custom_nodes_folder():
current_dir = os.path.dirname(os.path.realpath(__file__))
candidate = current_dir
for _ in range(4):
if os.path.basename(candidate) == "custom_nodes":
return candidate
parent = os.path.dirname(candidate)
if parent == candidate: break
candidate = parent
return os.path.dirname(os.path.dirname(current_dir))
def backup_node_folder(node_path, backup_root, tag="PRE_UPDATE"):
node_name = os.path.basename(node_path)
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
zip_name = f"{node_name}_{tag}_{timestamp}"
save_path = os.path.join(backup_root, zip_name)
try:
shutil.make_archive(save_path, 'zip', node_path)
return True, f"{zip_name}.zip"
except Exception as e:
return False, str(e)
def perform_single_update(nodes_root, target_id, global_backup_dir, dry_run, do_backup):
target_path = os.path.join(nodes_root, target_id)
if not os.path.exists(target_path):
return f"❌ Error: Folder '{target_id}' not found."
if not os.path.exists(os.path.join(target_path, ".git")):
return f"❌ Error: '{target_id}' is not a Git repo. Use 'Inject Git' first."
log_console(f"Targeting: {target_id}", CONST_CLR_YELLOW)
if dry_run:
return f"🟦 [Dry Run] Would backup and pull '{target_id}'."
if do_backup:
os.makedirs(global_backup_dir, exist_ok=True)
bs, bmsg = backup_node_folder(target_path, global_backup_dir, tag="SNIPER_UPDATE")
if not bs: return f"❌ Backup Failed: {bmsg}"
log_console("Backup Secure.", CONST_CLR_GREEN)
s, o, e = run_git_command(["pull"], target_path)
if s:
log_console("Update Successful.", CONST_CLR_GREEN)
return f"✅ '{target_id}' Updated Successfully.\n{o}\n⚠️ Restart ComfyUI."
else:
return f"❌ Git Pull Failed: {e}"
def repair_manual_install(nodes_root, target_folder, repo_url, global_backup_dir, dry_run):
target_path = os.path.join(nodes_root, target_folder)
if not os.path.exists(target_path): return f"❌ Folder not found: {target_folder}"
if os.path.exists(os.path.join(target_path, ".git")): return "⚠️ Already a Git repository."
if not repo_url.startswith("http"): return "❌ Invalid URL."
log_console(f"Repairing {target_folder} -> {repo_url}", CONST_CLR_YELLOW)
if dry_run: return "🟦 [Dry Run] Would inject git."
os.makedirs(global_backup_dir, exist_ok=True)
bs, bmsg = backup_node_folder(target_path, global_backup_dir, tag="PRE_REPAIR")
if not bs: return f"❌ Backup Failed: {bmsg}"
git_dir = os.path.join(target_path, ".git")
try:
s, o, e = run_git_command(["init"], target_path)
if not s: raise Exception(f"Init: {e}")
s, o, e = run_git_command(["remote", "add", "origin", repo_url], target_path)
if not s: raise Exception(f"Remote: {e}")
log_console("Fetching remote...", CONST_CLR_CYAN)
s, o, e = run_git_command(["fetch", "origin"], target_path)
if not s: raise Exception(f"Fetch: {e}")
branch = "main"
s, _, _ = run_git_command(["rev-parse", "--verify", "origin/main"], target_path)
if not s:
branch = "master"
s, _, _ = run_git_command(["rev-parse", "--verify", "origin/master"], target_path)
if not s: raise Exception("Remote branch not found.")
log_console(f"Aligning to origin/{branch}...", CONST_CLR_YELLOW)
s, o, e = run_git_command(["reset", "--hard", f"origin/{branch}"], target_path)
if not s: raise Exception(f"Reset: {e}")
run_git_command(["branch", "--set-upstream-to=origin/" + branch, branch], target_path)
return f"✅ SUCCESS: {target_folder} repaired on '{branch}'."
except Exception as e:
if os.path.exists(git_dir):
time.sleep(0.5)
try:
def onerror(func, path, exc_info):
import stat
if not os.access(path, os.W_OK):
os.chmod(path, stat.S_IWUSR)
func(path)
shutil.rmtree(git_dir, onerror=onerror)
log_console("♻️ Rolled back partial git initialization.", CONST_CLR_RED)
except Exception: pass
return f"❌ Repair Failed & Rolled Back: {str(e)}"
def analyze_conflicts(nodes_root, ignored_folders):
pkg_map = {}
subdirs = [d for d in os.listdir(nodes_root) if os.path.isdir(os.path.join(nodes_root, d))]
for folder in subdirs:
if folder in ignored_folders or folder.startswith("."): continue
req_path = os.path.join(nodes_root, folder, "requirements.txt")
if not os.path.exists(req_path): continue
try:
with open(req_path, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"): continue
match = re.match(r'^([a-zA-Z0-9_\-\[\]]+)(.*)', line.split(';')[0])
if match:
pkg = match.group(1).lower()
ver = match.group(2).strip() or "any"
if pkg not in pkg_map: pkg_map[pkg] = []
pkg_map[pkg].append((folder, ver))
except Exception: pass
report = ["🔍 DEPENDENCY CONFLICT REPORT", "="*60]
conflict_count = 0
for pkg, consumers in pkg_map.items():
if len(consumers) < 2: continue
versions = set()
has_strict = False
for node, ver in consumers:
versions.add(ver)
if "==" in ver: has_strict = True
if has_strict and len(versions) > 1:
conflict_count += 1
report.append(f"\n🔴 CONFLICT: '{pkg}'")
for node, ver in consumers:
report.append(f" • {node:<30} requires {ver}")
return "\n".join(report) if conflict_count > 0 else "✅ No strict conflicts found."
def create_global_snapshot(nodes_root):
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
parent_dir = os.path.dirname(nodes_root)
zip_path = os.path.join(parent_dir, f"ComfyUI_Global_Snapshot_{timestamp}")
try:
shutil.make_archive(zip_path, 'zip', nodes_root)
return True, f"{zip_path}.zip"
except Exception as e:
return False, str(e)
def save_json_report(nodes_root, data):
path = os.path.join(nodes_root, CONST_REPORT_FILE)
try:
with open(path, 'w') as f: json.dump(data, f, indent=4)
return True
except Exception: return False
# =================================================================================
# == Core Node Class
# =================================================================================
class MD_GlobalUpdateManager:
"""The Architect. Targeted updates, batch processing, and full repair suite."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"mode": (CONST_MODES, {
"default": "📊 Global Status Check",
"tooltip": (
"OPERATION MODE\n"
"• Purpose: Select the maintenance action.\n"
"• Status Check: View current versions safely.\n"
"• Update Single: Target specific node (Requires ID).\n"
"• Inject Git: Fix manual installs (Requires URL).\n"
"• Batch Update: Process multiple nodes safely.\n"
"\n⭐ Recommended: Status Check before any updates."
)
}),
"batch_size": ("INT", {
"default": 10, "min": 1, "max": 200,
"tooltip": (
"BATCH SIZE\n"
"• Purpose: Number of nodes to process per run.\n"
"• Why: Prevents ComfyUI UI timeouts during massive global updates.\n"
"\n⭐ Recommended: 10."
)
}),
"dry_run": ("BOOLEAN", {
"default": False,
"tooltip": (
"DRY RUN (SIMULATION)\n"
"• Purpose: Test actions without making permanent changes.\n"
"• Effect: Prints what *would* happen instead of doing it.\n"
"\n⭐ Recommended: True when testing new modes."
)
}),
"do_backup": ("BOOLEAN", {
"default": True,
"tooltip": (
"AUTO-BACKUP\n"
"• Purpose: Create a zip snapshot before modifying a folder.\n"
"• Trade-offs: Slower execution, but 100% safe rollback if update breaks.\n"
"\n⭐ Recommended: True."
)
}),
"ignore_list": ("STRING", {
"default": "", "multiline": True,
"tooltip": (
"IGNORE LIST\n"
"• Purpose: Comma-separated list of folder names to skip.\n"
"• Note: 'MD_Nodes' and 'ComfyUI-Manager' are ignored by default."
)
}),
"target_node_ID": ("STRING", {
"default": "",
"tooltip": (
"TARGET NODE FOLDER (ID)\n"
"• Purpose: The exact folder name for 'Single Node' or 'Inject Git' modes.\n"
"• Example: 'ComfyUI-VideoHelperSuite'"
)
}),
"repair_url": ("STRING", {
"default": "",
"tooltip": (
"REPAIR URL\n"
"• Purpose: The GitHub repository URL used for 'Inject Git' mode.\n"
"• Example: 'https://github.com/Kosinkadink/ComfyUI-VideoHelperSuite'"
)
}),
},
"optional": {
"trigger": ("INT", {
"default": 0,
"min": CONST_SEED_MIN,
"max": CONST_JS_MAX_SAFE_INTEGER,
"tooltip": "EXECUTION TRIGGER\n• Connect a button or changing seed to force execution."
}),
"debug_mode": (["0 - Silent", "1 - Info"], {
"default": "0 - Silent",
"tooltip": "LOGGING VERBOSITY\n• Controls console output and System/IO profiling."
}),
}
}
RETURN_TYPES = ("STRING", "INT")
RETURN_NAMES = ("report_text", "updates_pending")
FUNCTION = "execute_executive_scan"
CATEGORY = "MD_Nodes/Utility"
def execute_executive_scan(self, mode, batch_size, dry_run, do_backup, ignore_list, target_node_ID, repair_url, trigger=0, debug_mode="0 - Silent"):
debug_level = int(debug_mode.split(" ")[0])
profiler = PerformanceProfiler(enabled=(debug_level >= 1))
profiler.start("total_execution")
nodes_root = scan_custom_nodes_folder()
ignored_folders = parse_ignore_list(ignore_list)
state_file_path = os.path.join(nodes_root, CONST_STATE_FILE)
global_backup_dir = os.path.join(nodes_root, CONST_BACKUP_DIR_NAME)
if not nodes_root or not os.path.exists(nodes_root):
return ("❌ Error: 'custom_nodes' not found.", 0)
# --- TARGETED MODES ---
profiler.start("targeted_action")
if "Inject Git" in mode:
res = repair_manual_install(nodes_root, target_node_ID.strip(), repair_url.strip(), global_backup_dir, dry_run)
profiler.stop("targeted_action")
return (res, 0)
if "Target Single Node" in mode:
if not target_node_ID: return ("❌ Missing 'target_node_ID'.", 0)
res = perform_single_update(nodes_root, target_node_ID.strip(), global_backup_dir, dry_run, do_backup)
profiler.stop("targeted_action")
return (res, 0)
if "Analyze Dependency" in mode:
res = analyze_conflicts(nodes_root, ignored_folders)
profiler.stop("targeted_action")
return (res, 0)
if "Create Global Snapshot" in mode:
if dry_run: return ("🟦 Dry Run: Global Zip", 0)
log_console("Creating Global Snapshot...", CONST_CLR_YELLOW)
_, path = create_global_snapshot(nodes_root)
profiler.stop("targeted_action")
return (f"📦 Global Snapshot: {path}", 0)
profiler.stop("targeted_action")
# --- BATCH LOGIC ---
is_batch_mode = "Batch Update" in mode
pending_nodes = []
processed_log = []
loose_files_count = 0
ignored_files_count = 0
if do_backup and not dry_run and "Update" in mode:
os.makedirs(global_backup_dir, exist_ok=True)
if is_batch_mode and os.path.exists(state_file_path):
try:
with open(state_file_path, 'r') as f:
state_data = json.load(f)
pending_nodes = state_data.get("pending", [])
processed_log = state_data.get("log", [])
log_console(f"Resume: {len(pending_nodes)} left.", CONST_CLR_CYAN)
except Exception: log_console("State corrupt.", CONST_CLR_RED)
profiler.start("directory_scan")
if not pending_nodes and (not is_batch_mode or not os.path.exists(state_file_path)):
log_console("Scanning...", CONST_CLR_YELLOW)
try:
for item in sorted(os.listdir(nodes_root)):
path = os.path.join(nodes_root, item)
if os.path.isfile(path):
if os.path.splitext(item)[1].lower() in CONST_IGNORE_EXT or item == "md_global_report.json":
ignored_files_count += 1
else: loose_files_count += 1
continue
if item in ignored_folders or item.startswith("."): continue
pending_nodes.append(item)
except Exception as e: return (f"Scan Error: {e}", 0)
profiler.stop("directory_scan")
target_chunk = pending_nodes[:batch_size] if is_batch_mode else pending_nodes
remaining_nodes = pending_nodes[batch_size:] if is_batch_mode else []
log = [f"🌍 Architect v{NODE_VERSION}", f"📂 Root: {nodes_root}"]
if dry_run: log.append("🟦 DRY RUN")
updates_found = 0
current_run_report = []
profiler.start("git_operations")
for i, folder in enumerate(target_chunk):
repo_path = os.path.join(nodes_root, folder)
is_git = os.path.exists(os.path.join(repo_path, ".git"))
if not is_git:
branch, status, action = "-", "⚠️ ZIP", "Skip"
else:
branch = detect_branch(repo_path)
if not branch:
branch, status, action = "HEAD?", "⚠️ DETACHED", "Skip"
else:
s, _, _ = run_git_command(["fetch", "origin"], repo_path)
if not s: status, action = "❌ Err", "Retry"
else:
s2, count, _ = run_git_command(["rev-list", "--count", f"HEAD..origin/{branch}"], repo_path)
commits = int(count) if s2 and count.isdigit() else 0
if commits == 0: status, action = "✅ Sync", "-"
else:
status = f"⬇️ {commits}"
updates_found += 1
if "Update" in mode:
if dry_run: action = "🟦 Pull"
else:
if do_backup: backup_node_folder(repo_path, global_backup_dir)
ps, _, _ = run_git_command(["pull"], repo_path)
if ps:
action = "🚀 Done"
if "Install Deps" in mode:
req = os.path.join(repo_path, "requirements.txt")
if os.path.exists(req):
try:
subprocess.check_call([sys.executable, "-m", "pip", "install", "-r", req], cwd=repo_path)
action += "+Deps"
except Exception: action += "+Fail"
else: action = "❌ Fail"
entry = f"{folder:<30} | {branch:<10} | {status:<10} | {action}"
current_run_report.append(entry)
log_console(f"[{i+1}/{len(target_chunk)}] {folder}: {status}", CONST_CLR_CYAN)
profiler.stop("git_operations")
processed_log.extend(current_run_report)
if is_batch_mode:
if remaining_nodes:
state = {"pending": remaining_nodes, "log": processed_log, "timestamp": time.time()}
with open(state_file_path, 'w') as f: json.dump(state, f)
log.append(f"\n⏸️ PAUSED. {len(remaining_nodes)} left. Queue again.")
else:
if os.path.exists(state_file_path): os.remove(state_file_path)
log.append("\n✅ BATCH DONE.")
save_json_report(nodes_root, {"version": NODE_VERSION, "log": processed_log})
footer = []
if loose_files_count: footer.append(f"ℹ️ {loose_files_count} loose files.")
header = f"{'NODE':<30} | {'BRANCH':<10} | {'STATUS':<10} | {'ACTION'}\n" + ("-"*80)
profiler.stop("total_execution")
if debug_level >= 1: profiler.print_report()
return ("\n".join(log + [header] + processed_log + footer), updates_found)
NODE_CLASS_MAPPINGS = {"MD_GlobalUpdateManager": MD_GlobalUpdateManager}
NODE_DISPLAY_NAME_MAPPINGS = {"MD_GlobalUpdateManager": "MD: Global Update Architect"}
# =================================================================================
# == Development & Testing
# =================================================================================
if __name__ == "__main__":
logging.info("🧪 Running Self-Tests for MD_GlobalUpdateManager...")
try:
assert CONST_JS_MAX_SAFE_INTEGER == 9007199254740991
logging.info("✅ Constants: PASSED")
test_str = "NodeA, NodeB"
ignored = parse_ignore_list(test_str)
assert "NodeA" in ignored
assert "MD_Nodes" in ignored
logging.info("✅ Ignore List Logic: PASSED")
s, o, e = run_git_command(["--version"], ".")
logging.info(f"✅ Git Wrapper: {'Available' if s else 'Not Found'}")
except Exception as e:
logging.error(f"❌ Test Failed: {e}")