Files
Dustin f6b8ccb87a Simplify sync script to always copy files (remove symlink mode)
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
2025-11-02 13:50:08 -05:00

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()