ComfyUI's web server doesn't follow symlinks for security reasons, causing 404 errors when trying to load JavaScript files. Simplified sync_web_files.py to always copy files instead of attempting symlinks. Changes: - Removed dev/prod mode detection and symlink creation logic - Always copy files from source to web/ directory - Hash-based caching still prevents unnecessary re-syncs - Auto-sync on ComfyUI startup keeps files current - Updated README.md to reflect copy-only behavior Workflow: After editing source files, run `python scripts/sync_web_files.py --force` then restart ComfyUI to see changes. Tested: Manual copies work, UI loads correctly, cache prevents redundant syncs
403 lines
13 KiB
Python
403 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Auto-sync web resources from nodes to web/ directory.
|
|
|
|
Sources (priority order):
|
|
- web_src/core/ (shared libraries)
|
|
- core_nodes/*/ (built-in nodes)
|
|
- nodes/*/ (submodules or symlinks)
|
|
|
|
Target:
|
|
- web/*/ (generated, gitignored)
|
|
|
|
Usage:
|
|
python sync_web_files.py # Auto-detect mode, use cache
|
|
python sync_web_files.py --mode=dev # Force dev mode (symlinks)
|
|
python sync_web_files.py --mode=prod # Force prod mode (copies)
|
|
python sync_web_files.py --force # Skip cache check
|
|
python sync_web_files.py --quiet # Suppress output
|
|
"""
|
|
|
|
import sys
|
|
import shutil
|
|
from pathlib import Path
|
|
import argparse
|
|
import hashlib
|
|
import time
|
|
|
|
# ============================================================================
|
|
# Configuration
|
|
# ============================================================================
|
|
|
|
# Web resource sources (priority order)
|
|
WEB_SOURCES = [
|
|
("web_src/core", "core"), # Shared libraries (highest priority)
|
|
("core_nodes", "core_nodes"), # Built-in nodes
|
|
("nodes", "nodes"), # Submodules/symlinks
|
|
]
|
|
|
|
# ============================================================================
|
|
# Mode Detection
|
|
# ============================================================================
|
|
|
|
def detect_dev_mode():
|
|
"""
|
|
Detect if we're in dev or prod mode by checking if nodes/* are symlinks.
|
|
|
|
Returns:
|
|
"dev" if ANY node is a symlink (development workflow)
|
|
"prod" if ALL nodes are real directories (production/submodule workflow)
|
|
"""
|
|
nodes_dir = Path("nodes")
|
|
if not nodes_dir.exists():
|
|
return "prod" # Default
|
|
|
|
# Check ALL nodes - if ANY is a symlink, we're in dev mode
|
|
has_symlink = False
|
|
has_real_dir = False
|
|
|
|
for node_dir in nodes_dir.iterdir():
|
|
if node_dir.is_dir():
|
|
if node_dir.is_symlink():
|
|
has_symlink = True
|
|
else:
|
|
has_real_dir = True
|
|
|
|
# If ANY node is a symlink, treat as dev mode
|
|
# (Allows mixed state during transition)
|
|
return "dev" if has_symlink else "prod"
|
|
|
|
# ============================================================================
|
|
# Caching (avoid unnecessary syncs)
|
|
# ============================================================================
|
|
|
|
def compute_source_hash(source_dirs):
|
|
"""Compute hash of all source web files for change detection."""
|
|
hasher = hashlib.md5()
|
|
|
|
for source_dir, _ in source_dirs:
|
|
source_path = Path(source_dir)
|
|
if not source_path.exists():
|
|
continue
|
|
|
|
# Hash all .js files in web/ subdirectories
|
|
for js_file in sorted(source_path.rglob("web/**/*.js")):
|
|
if js_file.is_file():
|
|
hasher.update(str(js_file).encode())
|
|
try:
|
|
hasher.update(js_file.read_bytes())
|
|
except:
|
|
pass # Skip unreadable files
|
|
|
|
# Also hash direct .js files in web_src/core (no web/ subdir)
|
|
if source_path.name == "core" and source_path.parent.name == "web_src":
|
|
for js_file in sorted(source_path.glob("*.js")):
|
|
if js_file.is_file():
|
|
hasher.update(str(js_file).encode())
|
|
try:
|
|
hasher.update(js_file.read_bytes())
|
|
except:
|
|
pass
|
|
|
|
return hasher.hexdigest()
|
|
|
|
def needs_sync(web_dir, source_hash):
|
|
"""Check if sync needed by comparing hashes."""
|
|
hash_file = web_dir / ".sync_hash"
|
|
|
|
if not hash_file.exists():
|
|
return True # First sync
|
|
|
|
try:
|
|
cached_hash = hash_file.read_text().strip()
|
|
return cached_hash != source_hash
|
|
except:
|
|
return True # Assume sync needed on error
|
|
|
|
def save_sync_hash(web_dir, source_hash):
|
|
"""Save current source hash."""
|
|
hash_file = web_dir / ".sync_hash"
|
|
hash_file.write_text(source_hash)
|
|
|
|
# ============================================================================
|
|
# Sync Logic
|
|
# ============================================================================
|
|
|
|
def create_link_or_copy(source_file, target_file, use_symlink=True, verbose=False):
|
|
"""
|
|
Create symlink to source file, or copy if symlink fails.
|
|
|
|
Args:
|
|
source_file: Source file path
|
|
target_file: Target file path
|
|
use_symlink: Try to create symlink (True) or just copy (False)
|
|
verbose: Print debug info
|
|
|
|
Returns:
|
|
"symlink", "copy", or "failed"
|
|
"""
|
|
if not source_file.exists() or not source_file.is_file():
|
|
if verbose:
|
|
print(f" [!] Source not found: {source_file}")
|
|
return "failed"
|
|
|
|
if use_symlink:
|
|
try:
|
|
# Try creating symlink with relative path
|
|
target_file.symlink_to(source_file)
|
|
if verbose:
|
|
print(f" [LINK] Symlinked: {target_file.name}")
|
|
return "symlink"
|
|
except (OSError, NotImplementedError) as e:
|
|
if verbose:
|
|
print(f" [!] Symlink failed ({e}), copying instead")
|
|
# Fall through to copy
|
|
|
|
# Copy file
|
|
try:
|
|
shutil.copy2(source_file, target_file)
|
|
if verbose:
|
|
print(f" [COPY] Copied: {target_file.name}")
|
|
return "copy"
|
|
except Exception as e:
|
|
if verbose:
|
|
print(f" [X] Copy failed: {e}")
|
|
return "failed"
|
|
|
|
def sync_web_files(mode=None, force=False, quiet=False, verbose=False):
|
|
"""
|
|
Main sync function.
|
|
|
|
Args:
|
|
mode: Deprecated, ignored (always copies files for ComfyUI compatibility)
|
|
force: Skip cache check, always sync
|
|
quiet: Suppress output
|
|
verbose: Extra debug output
|
|
|
|
Returns:
|
|
dict with sync stats
|
|
"""
|
|
start_time = time.time()
|
|
web_dir = Path("web")
|
|
|
|
# Note: Always copy files (ComfyUI web server doesn't follow symlinks)
|
|
if not quiet:
|
|
print(f"[DazzleNodes] Syncing web resources...")
|
|
|
|
# Check if sync needed (unless forced)
|
|
source_hash = compute_source_hash(WEB_SOURCES)
|
|
if not force:
|
|
if not needs_sync(web_dir, source_hash):
|
|
if not quiet:
|
|
print("[DazzleNodes] Web resources up-to-date (cached)")
|
|
return {
|
|
"cached": True,
|
|
"nodes": 0,
|
|
"files": 0,
|
|
"time": time.time() - start_time
|
|
}
|
|
|
|
# Create web directory if needed
|
|
web_dir.mkdir(exist_ok=True)
|
|
|
|
# Create temp directory for atomic operation
|
|
temp_dir = web_dir / ".sync_temp"
|
|
if temp_dir.exists():
|
|
shutil.rmtree(temp_dir)
|
|
temp_dir.mkdir()
|
|
|
|
# Track seen node names (prevent duplicates)
|
|
seen_nodes = {}
|
|
total_files = 0
|
|
copy_count = 0
|
|
|
|
# Process each source
|
|
for source_path_str, source_type in WEB_SOURCES:
|
|
source_path = Path(source_path_str)
|
|
|
|
if not source_path.exists():
|
|
if verbose:
|
|
print(f" [Skip] {source_path} does not exist")
|
|
continue
|
|
|
|
# Special case: web_src/core has .js files directly (no web/ subdir)
|
|
if source_type == "core" and source_path.name == "core":
|
|
if verbose:
|
|
print(f" [Core] Processing {source_path}")
|
|
|
|
target_dir = temp_dir / "core"
|
|
target_dir.mkdir(exist_ok=True)
|
|
|
|
for js_file in source_path.glob("*.js"):
|
|
target_file = target_dir / js_file.name
|
|
|
|
# Always copy (ComfyUI web server doesn't follow symlinks)
|
|
result = create_link_or_copy(
|
|
js_file,
|
|
target_file,
|
|
use_symlink=False,
|
|
verbose=verbose
|
|
)
|
|
|
|
if result == "copy":
|
|
copy_count += 1
|
|
total_files += 1
|
|
|
|
if not quiet and total_files > 0:
|
|
print(f" [Core] Synced core library ({total_files} files)")
|
|
|
|
continue
|
|
|
|
# Normal case: scan for nodes with web/ directories
|
|
if verbose:
|
|
print(f" [Scan] {source_path}")
|
|
|
|
for node_dir in source_path.iterdir():
|
|
if not node_dir.is_dir():
|
|
continue
|
|
|
|
# Check for web/ subdirectory
|
|
web_source = node_dir / "web"
|
|
if not web_source.exists():
|
|
if verbose:
|
|
print(f" [Skip] {node_dir.name} (no web/)")
|
|
continue
|
|
|
|
node_name = node_dir.name
|
|
|
|
# Check for duplicate names across sources
|
|
if node_name in seen_nodes:
|
|
print(f"\n[DazzleNodes] ERROR: Duplicate node name '{node_name}'")
|
|
print(f" Source 1: {seen_nodes[node_name]}")
|
|
print(f" Source 2: {node_dir}")
|
|
print(f"\nPlease rename one of these nodes to avoid conflicts.")
|
|
sys.exit(1)
|
|
|
|
seen_nodes[node_name] = str(node_dir)
|
|
|
|
# Create target directory and copy files
|
|
# (Always copy - ComfyUI web server doesn't follow symlinks)
|
|
target_dir = temp_dir / node_name
|
|
target_dir.mkdir(exist_ok=True)
|
|
|
|
node_files = 0
|
|
|
|
for js_file in web_source.glob("*.js"):
|
|
target_file = target_dir / js_file.name
|
|
|
|
result = create_link_or_copy(
|
|
js_file,
|
|
target_file,
|
|
use_symlink=False,
|
|
verbose=verbose
|
|
)
|
|
|
|
if result == "copy":
|
|
copy_count += 1
|
|
node_files += 1
|
|
|
|
total_files += node_files
|
|
|
|
if not quiet and node_files > 0:
|
|
print(f" [{source_type}] {node_name} ({node_files} files)")
|
|
|
|
# Atomic replace: Move temp to final location
|
|
old_dir = web_dir / ".sync_old"
|
|
if old_dir.exists():
|
|
shutil.rmtree(old_dir)
|
|
old_dir.mkdir()
|
|
|
|
# Move current web/* to .sync_old (preserve README.md, .sync_hash)
|
|
for item in web_dir.iterdir():
|
|
if item.name not in [".sync_temp", ".sync_hash", ".sync_old", "README.md"]:
|
|
try:
|
|
item.rename(old_dir / item.name)
|
|
except:
|
|
pass # Ignore errors, will be overwritten
|
|
|
|
# Move temp contents to web/
|
|
for item in temp_dir.iterdir():
|
|
try:
|
|
item.rename(web_dir / item.name)
|
|
except Exception as e:
|
|
if not quiet:
|
|
print(f" [!] Failed to move {item.name}: {e}")
|
|
|
|
# Cleanup
|
|
try:
|
|
temp_dir.rmdir()
|
|
except:
|
|
pass
|
|
|
|
if old_dir.exists():
|
|
try:
|
|
shutil.rmtree(old_dir)
|
|
except:
|
|
pass
|
|
|
|
# Save hash
|
|
save_sync_hash(web_dir, source_hash)
|
|
|
|
elapsed = time.time() - start_time
|
|
|
|
if not quiet:
|
|
print(f"[DazzleNodes] Synced {len(seen_nodes)} nodes, {total_files} files ({copy_count} copies) in {elapsed:.2f}s")
|
|
|
|
return {
|
|
"cached": False,
|
|
"nodes": len(seen_nodes),
|
|
"files": total_files,
|
|
"copies": copy_count,
|
|
"time": elapsed
|
|
}
|
|
|
|
# ============================================================================
|
|
# CLI
|
|
# ============================================================================
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Sync DazzleNodes web resources (always copies files)",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="""
|
|
Examples:
|
|
python sync_web_files.py # Use cache, sync only if changed
|
|
python sync_web_files.py --force # Skip cache, always sync
|
|
python sync_web_files.py --quiet # Suppress output
|
|
python sync_web_files.py --verbose # Extra debug info
|
|
|
|
Note: Always copies files (ComfyUI web server doesn't follow symlinks)
|
|
"""
|
|
)
|
|
parser.add_argument("--force", action="store_true", help="Skip cache check, always sync")
|
|
parser.add_argument("--quiet", action="store_true", help="Suppress output")
|
|
parser.add_argument("--verbose", action="store_true", help="Extra debug output")
|
|
|
|
args = parser.parse_args()
|
|
|
|
try:
|
|
stats = sync_web_files(
|
|
force=args.force,
|
|
quiet=args.quiet,
|
|
verbose=args.verbose
|
|
)
|
|
|
|
if args.verbose and not args.quiet:
|
|
print(f"\nSync Stats:")
|
|
print(f" Cached: {stats['cached']}")
|
|
print(f" Nodes: {stats['nodes']}")
|
|
print(f" Files: {stats['files']}")
|
|
if not stats['cached']:
|
|
print(f" Copies: {stats.get('copies', 0)}")
|
|
print(f" Time: {stats['time']:.3f}s")
|
|
|
|
except Exception as e:
|
|
print(f"Error: {e}", file=sys.stderr)
|
|
import traceback
|
|
if args.verbose:
|
|
traceback.print_exc()
|
|
sys.exit(1)
|
|
|
|
if __name__ == "__main__":
|
|
main()
|