Submodule updates: - dazzle-command: v0.2.4-alpha -> v0.2.6-alpha Reset All button (sets all DCs to paused for fresh runs), state reset race condition fix (workflow UUID tracking), OUTPUT_NODE restored dev_mode.py fixes: - --standalone works when node already disabled - custom_nodes_dir config in dev_mode_local.yaml - PowerShell-first for junction creation (cmd.exe fails from bash)
1296 lines
46 KiB
Python
1296 lines
46 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
DazzleNodes Development Mode Toggle Script
|
|
|
|
Manages switching between development mode (symlinks) and publish mode (git submodules)
|
|
for DazzleNodes custom node development.
|
|
|
|
Usage:
|
|
python dev_mode.py status [--quick|--complete]
|
|
python dev_mode.py dev [node|all]
|
|
python dev_mode.py publish [node|all]
|
|
|
|
Global Options:
|
|
-v, --verbose Enable verbose output
|
|
--dry-run Show what would be done without making changes
|
|
--no-backup Skip backup creation (dangerous)
|
|
"""
|
|
|
|
import argparse
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
import shutil
|
|
import os
|
|
import configparser
|
|
|
|
# Try to import yaml with helpful error message
|
|
try:
|
|
import yaml
|
|
except ImportError:
|
|
print("Error: PyYAML is not installed.")
|
|
print(f"\nCurrent Python: {sys.executable}")
|
|
print(f"Python version: {sys.version.split()[0]}")
|
|
print("\nTo fix this, run:")
|
|
print(f" {sys.executable} -m pip install -r requirements.txt")
|
|
print("\nOr use 'python' command explicitly:")
|
|
print(" python dev_mode.py status")
|
|
sys.exit(1)
|
|
|
|
# ============================================================================
|
|
# Configuration and Constants
|
|
# ============================================================================
|
|
|
|
# Default configuration
|
|
DEFAULT_CONFIG = {
|
|
"status_default_mode": "quick", # "quick" or "complete"
|
|
"backup_enabled": True,
|
|
"verbose": False
|
|
}
|
|
|
|
# Local mappings file (not committed to git)
|
|
LOCAL_MAPPINGS_FILE = "dev_mode_local.yaml"
|
|
|
|
# ============================================================================
|
|
# Configuration Loading
|
|
# ============================================================================
|
|
|
|
def load_config():
|
|
"""Load configuration from YAML file, environment variables, and defaults."""
|
|
config = DEFAULT_CONFIG.copy()
|
|
|
|
# Try to load config file
|
|
config_file = Path(__file__).parent / "dev_mode_config.yaml"
|
|
if config_file.exists():
|
|
try:
|
|
with open(config_file, 'r') as f:
|
|
file_config = yaml.safe_load(f) or {}
|
|
config.update(file_config)
|
|
except Exception as e:
|
|
print(f"Warning: Could not load config file: {e}")
|
|
|
|
# Environment variables override config file
|
|
if os.environ.get("DEV_MODE_STATUS_DEFAULT"):
|
|
config["status_default_mode"] = os.environ["DEV_MODE_STATUS_DEFAULT"]
|
|
if os.environ.get("DEV_MODE_VERBOSE"):
|
|
config["verbose"] = os.environ["DEV_MODE_VERBOSE"].lower() in ("1", "true", "yes")
|
|
|
|
return config
|
|
|
|
# ============================================================================
|
|
# Helper Functions
|
|
# ============================================================================
|
|
|
|
def get_dazzlenodes_root():
|
|
"""Get the DazzleNodes root directory."""
|
|
script_dir = Path(__file__).parent
|
|
return script_dir.parent
|
|
|
|
def _get_submodule_version(root, node_name):
|
|
"""Read version for a node from submodule_versions.json.
|
|
|
|
Fallback for when git submodule commands aren't available
|
|
(e.g., ComfyUI Manager tarball installs without .git directory).
|
|
"""
|
|
versions_path = root / "submodule_versions.json"
|
|
if not versions_path.exists():
|
|
return None
|
|
try:
|
|
import json
|
|
versions = json.loads(versions_path.read_text())
|
|
return versions.get(f"nodes/{node_name}")
|
|
except (json.JSONDecodeError, OSError) as e:
|
|
print(f" [WARN] Could not read submodule_versions.json: {e}")
|
|
return None
|
|
|
|
|
|
def create_standalone_link(target_path, link_path, verbose=False):
|
|
"""Create a symlink (Unix) or junction (Windows) for standalone dev testing.
|
|
|
|
Args:
|
|
target_path: The source directory (local dev repo)
|
|
link_path: Where to create the link (in custom_nodes/)
|
|
|
|
Returns:
|
|
bool: True if link was created successfully
|
|
"""
|
|
target_path = Path(target_path)
|
|
link_path = Path(link_path)
|
|
|
|
if link_path.exists():
|
|
if is_symlink(link_path):
|
|
print(f" [i] Standalone link already exists: {link_path}")
|
|
return True
|
|
else:
|
|
print(f" [X] Path exists and is not a symlink: {link_path}")
|
|
return False
|
|
|
|
if not target_path.exists():
|
|
print(f" [X] Source path does not exist: {target_path}")
|
|
return False
|
|
|
|
try:
|
|
if sys.platform == "win32":
|
|
# Use PowerShell for junctions (cmd.exe mklink fails silently from bash/WSL)
|
|
result = subprocess.run(
|
|
["powershell", "-Command",
|
|
f"New-Item -ItemType Junction -Path '{link_path}' -Target '{target_path}'"],
|
|
capture_output=True, text=True
|
|
)
|
|
if result.returncode != 0:
|
|
print(f" [X] PowerShell junction failed: {result.stderr.strip()}")
|
|
return False
|
|
else:
|
|
# Unix: use symlink
|
|
link_path.symlink_to(target_path)
|
|
|
|
if verbose:
|
|
print(f" Created link: {link_path} -> {target_path}")
|
|
return True
|
|
except Exception as e:
|
|
print(f" [X] Failed to create standalone link: {e}")
|
|
return False
|
|
|
|
|
|
def remove_standalone_link(link_path, verbose=False):
|
|
"""Remove a standalone symlink/junction, verifying it IS a link first.
|
|
|
|
Returns:
|
|
bool: True if removed successfully (or didn't exist)
|
|
"""
|
|
link_path = Path(link_path)
|
|
if not link_path.exists():
|
|
return True
|
|
|
|
if not is_symlink(link_path):
|
|
print(f" [X] SAFETY: {link_path} is NOT a symlink/junction. Not removing.")
|
|
return False
|
|
|
|
try:
|
|
if sys.platform == "win32":
|
|
# On Windows, junctions are removed with rmdir (not del)
|
|
os.rmdir(str(link_path))
|
|
else:
|
|
link_path.unlink()
|
|
if verbose:
|
|
print(f" Removed standalone link: {link_path}")
|
|
return True
|
|
except Exception as e:
|
|
print(f" [X] Failed to remove standalone link: {e}")
|
|
return False
|
|
|
|
|
|
def parse_gitmodules():
|
|
"""
|
|
Parse .gitmodules file to discover node mappings.
|
|
|
|
Returns:
|
|
dict: Mapping of node_name -> source_path
|
|
Example: {"smart-resolution-calc": "C:\\code\\smart-resolution-calc-repo\\local"}
|
|
"""
|
|
root = get_dazzlenodes_root()
|
|
gitmodules_path = root / ".gitmodules"
|
|
|
|
if not gitmodules_path.exists():
|
|
print(f"Warning: .gitmodules not found at {gitmodules_path}")
|
|
return {}
|
|
|
|
# Parse git config format
|
|
config = configparser.ConfigParser()
|
|
config.read(gitmodules_path)
|
|
|
|
node_mappings = {}
|
|
|
|
for section in config.sections():
|
|
# Section format: [submodule "nodes/node-name"]
|
|
if section.startswith('submodule "nodes/'):
|
|
# Extract: nodes/smart-resolution-calc -> smart-resolution-calc
|
|
full_path = section.replace('submodule "', '').replace('"', '')
|
|
node_name = full_path.split('/')[-1]
|
|
|
|
# Get the URL (source repository path)
|
|
if 'url' in config[section]:
|
|
url = config[section]['url']
|
|
# Convert /c/code/... to C:\code\...
|
|
if url.startswith('/c/'):
|
|
url = 'C:' + url[2:].replace('/', '\\')
|
|
elif url.startswith('/'):
|
|
# Handle other drive letters if needed
|
|
url = url.replace('/', '\\')
|
|
|
|
node_mappings[node_name] = url
|
|
|
|
return node_mappings
|
|
|
|
|
|
def load_local_mappings():
|
|
"""
|
|
Load local path mappings from dev_mode_local.yaml.
|
|
|
|
This file is NOT committed to git and contains developer-specific
|
|
local paths to source repositories.
|
|
|
|
Returns:
|
|
dict: Mapping of node_name -> local_source_path
|
|
Example: {"smart-resolution-calc": "C:\\code\\smart-resolution-calc-repo\\Local"}
|
|
"""
|
|
script_dir = Path(__file__).parent
|
|
local_config_path = script_dir / LOCAL_MAPPINGS_FILE
|
|
|
|
if not local_config_path.exists():
|
|
return {}
|
|
|
|
try:
|
|
with open(local_config_path, 'r') as f:
|
|
local_config = yaml.safe_load(f) or {}
|
|
return local_config.get('node_paths', {})
|
|
except Exception as e:
|
|
print(f"Warning: Could not load local mappings: {e}")
|
|
return {}
|
|
|
|
|
|
def get_custom_nodes_dir(root):
|
|
"""Get the custom_nodes directory for standalone junction creation.
|
|
|
|
Checks dev_mode_local.yaml for explicit 'custom_nodes_dir' setting.
|
|
Falls back to root.parent (works when DazzleNodes is directly in custom_nodes/).
|
|
|
|
Args:
|
|
root: DazzleNodes root directory (Path)
|
|
|
|
Returns:
|
|
Path: The custom_nodes directory
|
|
"""
|
|
script_dir = Path(__file__).parent
|
|
local_config_path = script_dir / LOCAL_MAPPINGS_FILE
|
|
|
|
if local_config_path.exists():
|
|
try:
|
|
with open(local_config_path, 'r') as f:
|
|
local_config = yaml.safe_load(f) or {}
|
|
custom_dir = local_config.get('custom_nodes_dir')
|
|
if custom_dir:
|
|
p = Path(custom_dir)
|
|
if p.exists():
|
|
return p
|
|
else:
|
|
print(f" [!] custom_nodes_dir in yaml does not exist: {custom_dir}")
|
|
except Exception:
|
|
pass
|
|
|
|
# Fallback: assume DazzleNodes is directly in custom_nodes/
|
|
return root.parent
|
|
|
|
|
|
def is_valid_local_path(path_str):
|
|
"""
|
|
Check if a path string represents a valid local filesystem path.
|
|
|
|
Args:
|
|
path_str: Path string to check
|
|
|
|
Returns:
|
|
bool: True if path exists on local filesystem
|
|
"""
|
|
if not path_str:
|
|
return False
|
|
|
|
# Check for URL patterns (not local paths)
|
|
if path_str.startswith(('http://', 'https://', 'git@', 'ssh://')):
|
|
return False
|
|
|
|
# Check if path exists
|
|
return Path(path_str).exists()
|
|
|
|
|
|
def get_resolved_mappings(prefer_local=False, verbose=False):
|
|
"""
|
|
Get node mappings with resolved local paths.
|
|
|
|
Resolution order:
|
|
1. If prefer_local=True: Check local config first, then gitmodules
|
|
2. Otherwise: Check gitmodules first, fall back to local config if path invalid
|
|
|
|
Args:
|
|
prefer_local: If True, prefer local config over gitmodules
|
|
verbose: If True, print resolution details
|
|
|
|
Returns:
|
|
dict: Mapping of node_name -> resolved_local_path
|
|
"""
|
|
gitmodule_mappings = parse_gitmodules()
|
|
local_mappings = load_local_mappings()
|
|
|
|
resolved = {}
|
|
|
|
# Get all unique node names
|
|
all_nodes = set(gitmodule_mappings.keys()) | set(local_mappings.keys())
|
|
|
|
for node_name in all_nodes:
|
|
gitmodule_path = gitmodule_mappings.get(node_name)
|
|
local_path = local_mappings.get(node_name)
|
|
|
|
if prefer_local:
|
|
# Prefer local config
|
|
if local_path and is_valid_local_path(local_path):
|
|
resolved[node_name] = local_path
|
|
if verbose:
|
|
print(f" [{node_name}] Using local config: {local_path}")
|
|
elif gitmodule_path and is_valid_local_path(gitmodule_path):
|
|
resolved[node_name] = gitmodule_path
|
|
if verbose:
|
|
print(f" [{node_name}] Using gitmodules: {gitmodule_path}")
|
|
elif local_path:
|
|
# Local path specified but doesn't exist - still use it (will error later)
|
|
resolved[node_name] = local_path
|
|
if verbose:
|
|
print(f" [{node_name}] Using local config (NOT FOUND): {local_path}")
|
|
elif gitmodule_path:
|
|
resolved[node_name] = gitmodule_path
|
|
if verbose:
|
|
print(f" [{node_name}] Using gitmodules (NOT LOCAL): {gitmodule_path}")
|
|
else:
|
|
# Prefer gitmodules if it's a valid local path
|
|
if gitmodule_path and is_valid_local_path(gitmodule_path):
|
|
resolved[node_name] = gitmodule_path
|
|
if verbose:
|
|
print(f" [{node_name}] Using gitmodules: {gitmodule_path}")
|
|
elif local_path and is_valid_local_path(local_path):
|
|
resolved[node_name] = local_path
|
|
if verbose:
|
|
print(f" [{node_name}] Fallback to local config: {local_path}")
|
|
elif local_path:
|
|
# Local path specified but doesn't exist
|
|
resolved[node_name] = local_path
|
|
if verbose:
|
|
print(f" [{node_name}] Using local config (NOT FOUND): {local_path}")
|
|
elif gitmodule_path:
|
|
# gitmodule path is not local (URL) - use it anyway for error message
|
|
resolved[node_name] = gitmodule_path
|
|
if verbose:
|
|
print(f" [{node_name}] Using gitmodules (NOT LOCAL): {gitmodule_path}")
|
|
|
|
return resolved
|
|
|
|
|
|
def is_symlink(path):
|
|
"""
|
|
Check if path is a symlink or junction on Windows.
|
|
|
|
Args:
|
|
path: Path to check
|
|
|
|
Returns:
|
|
bool: True if path is a symlink/junction
|
|
"""
|
|
path = Path(path)
|
|
if not path.exists():
|
|
return False
|
|
|
|
# On Windows, check for both symlinks and junctions
|
|
if sys.platform == "win32":
|
|
try:
|
|
# Use dir /AL to check for symlinks/junctions
|
|
result = subprocess.run(
|
|
["cmd", "/c", "dir", "/AL", str(path.parent)],
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
return path.name in result.stdout
|
|
except Exception:
|
|
# Fallback to pathlib check
|
|
return path.is_symlink()
|
|
else:
|
|
return path.is_symlink()
|
|
|
|
def backup_node(node_path, verbose=False):
|
|
"""
|
|
Backup a node directory to nodes_bak/ with timestamp.
|
|
|
|
Args:
|
|
node_path: Path to node directory to backup
|
|
verbose: Enable verbose output
|
|
|
|
Returns:
|
|
Path: Backup directory path if successful, None otherwise
|
|
"""
|
|
node_path = Path(node_path)
|
|
if not node_path.exists():
|
|
return None
|
|
|
|
# Create backup directory with timestamp
|
|
root = get_dazzlenodes_root()
|
|
backup_root = root / "nodes_bak"
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
backup_name = f"{node_path.name}_{timestamp}"
|
|
backup_path = backup_root / backup_name
|
|
|
|
try:
|
|
backup_root.mkdir(exist_ok=True)
|
|
|
|
if verbose:
|
|
print(f" Backing up {node_path.name} to {backup_path}")
|
|
|
|
# Copy the directory (handles both real dirs and symlinks)
|
|
if is_symlink(node_path):
|
|
# For symlinks, just note it in a file
|
|
backup_path.mkdir()
|
|
link_target = node_path.resolve()
|
|
with open(backup_path / "SYMLINK_INFO.txt", "w") as f:
|
|
f.write(f"This was a symlink to: {link_target}\n")
|
|
else:
|
|
shutil.copytree(node_path, backup_path, symlinks=True)
|
|
|
|
if verbose:
|
|
print(f" [OK] Backup created: {backup_path}")
|
|
|
|
return backup_path
|
|
|
|
except Exception as e:
|
|
print(f" [X] Backup failed: {e}")
|
|
return None
|
|
|
|
def create_symlink(target, link_path, verbose=False):
|
|
"""
|
|
Create a directory symlink on Windows using mklink.
|
|
Falls back to junction if symlink creation fails.
|
|
|
|
Args:
|
|
target: Target directory path
|
|
link_path: Symlink path to create
|
|
verbose: Enable verbose output
|
|
|
|
Returns:
|
|
bool: True if successful
|
|
"""
|
|
target = Path(target).resolve()
|
|
link_path = Path(link_path)
|
|
|
|
if not target.exists():
|
|
print(f" [X] Target does not exist: {target}")
|
|
return False
|
|
|
|
if link_path.exists():
|
|
print(f" [X] Link path already exists: {link_path}")
|
|
return False
|
|
|
|
try:
|
|
# Try mklink /D first (requires admin on some systems)
|
|
if verbose:
|
|
print(f" Creating symlink: {link_path} -> {target}")
|
|
|
|
result = subprocess.run(
|
|
["cmd", "/c", "mklink", "/D", str(link_path), str(target)],
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
if result.returncode == 0:
|
|
if verbose:
|
|
print(f" [OK] Symlink created successfully")
|
|
return True
|
|
|
|
# Fallback to junction (no admin required)
|
|
if verbose:
|
|
print(f" Symlink failed, trying junction...")
|
|
|
|
result = subprocess.run(
|
|
["cmd", "/c", "mklink", "/J", str(link_path), str(target)],
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
if result.returncode == 0:
|
|
if verbose:
|
|
print(f" [OK] Junction created successfully")
|
|
return True
|
|
|
|
print(f" [X] Failed to create link: {result.stderr}")
|
|
return False
|
|
|
|
except Exception as e:
|
|
print(f" [X] Exception creating link: {e}")
|
|
return False
|
|
|
|
def remove_path(path, verbose=False):
|
|
"""
|
|
Remove a file, directory, symlink, or junction.
|
|
|
|
Args:
|
|
path: Path to remove
|
|
verbose: Enable verbose output
|
|
|
|
Returns:
|
|
bool: True if successful
|
|
"""
|
|
path = Path(path)
|
|
if not path.exists():
|
|
return True
|
|
|
|
try:
|
|
if verbose:
|
|
print(f" Removing: {path}")
|
|
|
|
if is_symlink(path):
|
|
# For symlinks/junctions, just remove the link
|
|
if sys.platform == "win32":
|
|
subprocess.run(["cmd", "/c", "rmdir", str(path)], check=True)
|
|
else:
|
|
path.unlink()
|
|
elif path.is_dir():
|
|
def _on_rm_error(func, fpath, exc_info):
|
|
"""Handle read-only files (e.g., .git/objects/pack on Windows)."""
|
|
import stat
|
|
os.chmod(fpath, stat.S_IWRITE)
|
|
func(fpath)
|
|
shutil.rmtree(path, onerror=_on_rm_error)
|
|
else:
|
|
path.unlink()
|
|
|
|
if verbose:
|
|
print(f" [OK] Removed successfully")
|
|
|
|
return True
|
|
|
|
except Exception as e:
|
|
print(f" [X] Failed to remove: {e}")
|
|
return False
|
|
|
|
# ============================================================================
|
|
# Git Operations
|
|
# ============================================================================
|
|
|
|
def get_git_status(repo_path, verbose=False):
|
|
"""
|
|
Get git status for a repository.
|
|
|
|
Args:
|
|
repo_path: Path to git repository
|
|
verbose: Enable verbose output
|
|
|
|
Returns:
|
|
dict: Git status information
|
|
"""
|
|
repo_path = Path(repo_path)
|
|
if not (repo_path / ".git").exists():
|
|
return {"error": "Not a git repository"}
|
|
|
|
try:
|
|
# Get current branch
|
|
result = subprocess.run(
|
|
["git", "-C", str(repo_path), "branch", "--show-current"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True
|
|
)
|
|
branch = result.stdout.strip()
|
|
|
|
# Get status
|
|
result = subprocess.run(
|
|
["git", "-C", str(repo_path), "status", "--short"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True
|
|
)
|
|
status = result.stdout.strip()
|
|
|
|
# Get latest commit
|
|
result = subprocess.run(
|
|
["git", "-C", str(repo_path), "log", "-1", "--oneline"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True
|
|
)
|
|
commit = result.stdout.strip()
|
|
|
|
return {
|
|
"branch": branch,
|
|
"status": status,
|
|
"commit": commit,
|
|
"has_changes": bool(status)
|
|
}
|
|
|
|
except Exception as e:
|
|
if verbose:
|
|
print(f" Git status error: {e}")
|
|
return {"error": str(e)}
|
|
|
|
# ============================================================================
|
|
# Status Command
|
|
# ============================================================================
|
|
|
|
def cmd_status(args, config):
|
|
"""Show status of all nodes (quick or complete mode)."""
|
|
root = get_dazzlenodes_root()
|
|
nodes_dir = root / "nodes"
|
|
|
|
# Load node mappings from .gitmodules
|
|
node_mappings = parse_gitmodules()
|
|
|
|
if not node_mappings:
|
|
print("Error: No nodes found in .gitmodules")
|
|
return 1
|
|
|
|
# Determine mode
|
|
mode = "complete" if args.complete else ("quick" if args.quick else config["status_default_mode"])
|
|
|
|
print(f"\n{'='*70}")
|
|
print(f"DazzleNodes Status ({mode} mode)")
|
|
print(f"{'='*70}\n")
|
|
print(f"Root: {root}\n")
|
|
|
|
# Check each node
|
|
for node_name, source_path in node_mappings.items():
|
|
node_path = nodes_dir / node_name
|
|
source_path = Path(source_path)
|
|
|
|
print(f"[*] {node_name}")
|
|
print(f" {'-'*65}")
|
|
|
|
# Check existence
|
|
exists = node_path.exists()
|
|
is_link = is_symlink(node_path) if exists else False
|
|
|
|
# Check for disabled marker
|
|
is_disabled = (node_path / "DISABLED").exists() if exists else False
|
|
|
|
if not exists:
|
|
print(f" Status: [X] MISSING")
|
|
print(f" Expected: {node_path}")
|
|
elif is_disabled:
|
|
print(f" Status: [-] DISABLED (loading skipped)")
|
|
print(f" Path: {node_path}")
|
|
elif is_link:
|
|
print(f" Status: [L] DEV MODE (symlink)")
|
|
print(f" Target: {node_path.resolve()}")
|
|
|
|
if mode == "complete":
|
|
git_info = get_git_status(source_path, args.verbose)
|
|
if "error" not in git_info:
|
|
print(f" Branch: {git_info['branch']}")
|
|
print(f" Commit: {git_info['commit']}")
|
|
if git_info['has_changes']:
|
|
print(f" Changes: [!] UNCOMMITTED CHANGES")
|
|
if args.verbose:
|
|
print(f"\n Git Status:")
|
|
for line in git_info['status'].split('\n'):
|
|
print(f" {line}")
|
|
else:
|
|
print(f" Changes: [OK] Clean")
|
|
else:
|
|
print(f" Status: [D] PUBLISH MODE (submodule)")
|
|
print(f" Path: {node_path}")
|
|
|
|
if mode == "complete":
|
|
git_info = get_git_status(node_path, args.verbose)
|
|
if "error" not in git_info:
|
|
print(f" Branch: {git_info['branch']}")
|
|
print(f" Commit: {git_info['commit']}")
|
|
if git_info['has_changes']:
|
|
print(f" Changes: [!] UNCOMMITTED CHANGES")
|
|
else:
|
|
print(f" Changes: [OK] Clean")
|
|
|
|
# Check source repository
|
|
if source_path.exists():
|
|
print(f" Source: [OK] {source_path}")
|
|
if mode == "complete" and not is_link:
|
|
source_git = get_git_status(source_path, args.verbose)
|
|
if "error" not in source_git and source_git['has_changes']:
|
|
print(f" Source has uncommitted changes!")
|
|
else:
|
|
print(f" Source: [X] NOT FOUND")
|
|
|
|
print()
|
|
|
|
print(f"{'='*70}\n")
|
|
|
|
# ============================================================================
|
|
# Dev Command
|
|
# ============================================================================
|
|
|
|
def cmd_dev(args, config):
|
|
"""Switch node(s) to dev mode (symlinks)."""
|
|
root = get_dazzlenodes_root()
|
|
nodes_dir = root / "nodes"
|
|
|
|
# Load node mappings with local path resolution
|
|
prefer_local = getattr(args, 'local', False)
|
|
if prefer_local or config.get('verbose'):
|
|
print("Resolving node paths...")
|
|
node_mappings = get_resolved_mappings(prefer_local=prefer_local, verbose=config.get('verbose', False))
|
|
|
|
if not node_mappings:
|
|
print("Error: No nodes found in .gitmodules or dev_mode_local.yaml")
|
|
print("\nTo configure local paths, create scripts/dev_mode_local.yaml:")
|
|
print(" node_paths:")
|
|
print(' smart-resolution-calc: "C:\\\\code\\\\smart-resolution-calc-repo\\\\Local"')
|
|
return 1
|
|
|
|
# Determine which nodes to process
|
|
if args.node == "all":
|
|
nodes_to_process = list(node_mappings.keys())
|
|
elif args.node in node_mappings:
|
|
nodes_to_process = [args.node]
|
|
else:
|
|
print(f"Error: Unknown node '{args.node}'")
|
|
print(f"Available nodes: {', '.join(node_mappings.keys())}")
|
|
return 1
|
|
|
|
print(f"\n{'='*70}")
|
|
print(f"Switching to DEV MODE")
|
|
print(f"{'='*70}\n")
|
|
|
|
if args.dry_run:
|
|
print("[DRY-RUN] - No changes will be made\n")
|
|
|
|
success_count = 0
|
|
for node_name in nodes_to_process:
|
|
node_path = nodes_dir / node_name
|
|
source_path = Path(node_mappings[node_name])
|
|
|
|
print(f"Processing: {node_name}")
|
|
|
|
# Check if already in dev mode
|
|
if node_path.exists() and is_symlink(node_path):
|
|
print(f" [i]Already in dev mode (symlink exists)")
|
|
success_count += 1
|
|
continue
|
|
|
|
# Check source exists
|
|
if not source_path.exists():
|
|
print(f" [X] Source not found: {source_path}")
|
|
continue
|
|
|
|
# Backup if requested and path exists
|
|
if node_path.exists() and not args.no_backup and config["backup_enabled"]:
|
|
if not args.dry_run:
|
|
backup_path = backup_node(node_path, args.verbose)
|
|
if not backup_path:
|
|
print(f" [X] Backup failed, aborting")
|
|
continue
|
|
else:
|
|
print(f" Would backup to: nodes_bak/{node_name}_[timestamp]")
|
|
|
|
# Remove existing path
|
|
if node_path.exists():
|
|
if not args.dry_run:
|
|
if not remove_path(node_path, args.verbose):
|
|
print(f" [X] Failed to remove existing path")
|
|
continue
|
|
else:
|
|
print(f" Would remove: {node_path}")
|
|
|
|
# Create symlink
|
|
if not args.dry_run:
|
|
if create_symlink(source_path, node_path, args.verbose):
|
|
print(f" [OK] Switched to dev mode")
|
|
success_count += 1
|
|
else:
|
|
print(f" [X] Failed to create symlink")
|
|
else:
|
|
print(f" Would create symlink: {node_path} -> {source_path}")
|
|
success_count += 1
|
|
|
|
print()
|
|
|
|
print(f"{'='*70}")
|
|
print(f"Result: {success_count}/{len(nodes_to_process)} nodes switched to dev mode")
|
|
print(f"{'='*70}\n")
|
|
|
|
return 0 if success_count == len(nodes_to_process) else 1
|
|
|
|
# ============================================================================
|
|
# Publish Command
|
|
# ============================================================================
|
|
|
|
def cmd_publish(args, config):
|
|
"""Switch node(s) to publish mode (submodules)."""
|
|
root = get_dazzlenodes_root()
|
|
nodes_dir = root / "nodes"
|
|
|
|
# Load node mappings from .gitmodules
|
|
node_mappings = parse_gitmodules()
|
|
|
|
if not node_mappings:
|
|
print("Error: No nodes found in .gitmodules")
|
|
return 1
|
|
|
|
# Determine which nodes to process
|
|
if args.node == "all":
|
|
nodes_to_process = list(node_mappings.keys())
|
|
elif args.node in node_mappings:
|
|
nodes_to_process = [args.node]
|
|
else:
|
|
print(f"Error: Unknown node '{args.node}'")
|
|
print(f"Available nodes: {', '.join(node_mappings.keys())}")
|
|
return 1
|
|
|
|
print(f"\n{'='*70}")
|
|
print(f"Switching to PUBLISH MODE")
|
|
print(f"{'='*70}\n")
|
|
|
|
if args.dry_run:
|
|
print("[DRY-RUN] - No changes will be made\n")
|
|
|
|
success_count = 0
|
|
for node_name in nodes_to_process:
|
|
node_path = nodes_dir / node_name
|
|
|
|
print(f"Processing: {node_name}")
|
|
|
|
# Check if already in publish mode (not a symlink)
|
|
if node_path.exists() and not is_symlink(node_path):
|
|
print(f" [i]Already in publish mode (submodule)")
|
|
success_count += 1
|
|
continue
|
|
|
|
# Backup if symlink exists
|
|
if node_path.exists() and not args.no_backup and config["backup_enabled"]:
|
|
if not args.dry_run:
|
|
backup_path = backup_node(node_path, args.verbose)
|
|
if not backup_path:
|
|
print(f" [X] Backup failed, aborting")
|
|
continue
|
|
else:
|
|
print(f" Would backup symlink info to: nodes_bak/{node_name}_[timestamp]")
|
|
|
|
# Remove symlink
|
|
if node_path.exists():
|
|
if not args.dry_run:
|
|
if not remove_path(node_path, args.verbose):
|
|
print(f" [X] Failed to remove symlink")
|
|
continue
|
|
else:
|
|
print(f" Would remove symlink: {node_path}")
|
|
|
|
# Restore submodule with git
|
|
if not args.dry_run:
|
|
try:
|
|
# Use relative path from root: nodes/node-name
|
|
submodule_path = f"nodes/{node_name}"
|
|
result = subprocess.run(
|
|
["git", "-C", str(root), "submodule", "update", "--init", submodule_path],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True
|
|
)
|
|
print(f" [OK] Restored submodule")
|
|
if args.verbose:
|
|
print(f" {result.stdout.strip()}")
|
|
success_count += 1
|
|
except subprocess.CalledProcessError as e:
|
|
# Fallback: clone from .gitmodules + submodule_versions.json
|
|
# Handles ComfyUI Manager installs (no .git directory)
|
|
print(f" [i] Git submodule failed, trying clone fallback...")
|
|
url = node_mappings[node_name]
|
|
version = _get_submodule_version(root, node_name)
|
|
if url and version:
|
|
try:
|
|
clone_cmd = ["git", "clone", "--depth", "1",
|
|
"--branch", version, url, str(node_path)]
|
|
clone_result = subprocess.run(
|
|
clone_cmd, capture_output=True, text=True, check=True)
|
|
print(f" [OK] Cloned {node_name} at {version} (fallback)")
|
|
if args.verbose:
|
|
print(f" {clone_result.stdout.strip()}")
|
|
success_count += 1
|
|
except subprocess.CalledProcessError as clone_err:
|
|
print(f" [X] Clone fallback also failed: {clone_err.stderr}")
|
|
elif not version:
|
|
print(f" [X] No version in submodule_versions.json for {node_name}")
|
|
else:
|
|
print(f" [X] No URL found for {node_name}")
|
|
else:
|
|
print(f" Would run: git submodule update --init nodes/{node_name}")
|
|
success_count += 1
|
|
|
|
print()
|
|
|
|
print(f"{'='*70}")
|
|
print(f"Result: {success_count}/{len(nodes_to_process)} nodes switched to publish mode")
|
|
print(f"{'='*70}\n")
|
|
|
|
return 0 if success_count == len(nodes_to_process) else 1
|
|
|
|
# ============================================================================
|
|
# Main Entry Point
|
|
# ============================================================================
|
|
|
|
def cmd_disable(args, config):
|
|
"""Disable node(s) — prevents loading without removing the submodule."""
|
|
root = get_dazzlenodes_root()
|
|
nodes_dir = root / "nodes"
|
|
node_mappings = parse_gitmodules()
|
|
|
|
if not node_mappings:
|
|
print("Error: No nodes found in .gitmodules")
|
|
return 1
|
|
|
|
if args.node == "all":
|
|
nodes_to_process = list(node_mappings.keys())
|
|
elif args.node in node_mappings:
|
|
nodes_to_process = [args.node]
|
|
else:
|
|
print(f"Error: Unknown node '{args.node}'")
|
|
print(f"Available nodes: {', '.join(node_mappings.keys())}")
|
|
return 1
|
|
|
|
print(f"\n{'='*70}")
|
|
print(f"DISABLING NODES")
|
|
print(f"{'='*70}\n")
|
|
|
|
if args.dry_run:
|
|
print("[DRY-RUN] - No changes will be made\n")
|
|
|
|
success_count = 0
|
|
for node_name in nodes_to_process:
|
|
node_path = nodes_dir / node_name
|
|
disabled_marker = node_path / "DISABLED"
|
|
|
|
print(f"Processing: {node_name}")
|
|
|
|
if disabled_marker.exists():
|
|
# Already disabled, but still clean web cache if present
|
|
web_dir = root / "web" / node_name
|
|
if web_dir.exists():
|
|
if not args.dry_run:
|
|
shutil.rmtree(web_dir)
|
|
print(f" [i] Already disabled (cleaned stale web cache)")
|
|
else:
|
|
print(f" [i] Already disabled (would clean stale web cache at {web_dir})")
|
|
else:
|
|
print(f" [i] Already disabled")
|
|
|
|
# Still allow --standalone junction creation even if already disabled
|
|
if getattr(args, 'standalone', False):
|
|
local_paths = load_local_mappings()
|
|
source_path = local_paths.get(node_name)
|
|
if source_path:
|
|
custom_nodes_dir = get_custom_nodes_dir(root)
|
|
link_path = custom_nodes_dir / node_name
|
|
if not link_path.exists():
|
|
if not args.dry_run:
|
|
if create_standalone_link(source_path, link_path, args.verbose):
|
|
standalone_marker = node_path / "STANDALONE_LINK"
|
|
standalone_marker.write_text(str(link_path) + "\n")
|
|
print(f" [OK] Standalone link: {link_path} -> {source_path}")
|
|
else:
|
|
print(f" [!] Standalone link creation failed")
|
|
else:
|
|
print(f" Would create standalone link: {link_path} -> {source_path}")
|
|
else:
|
|
print(f" [i] Standalone link already exists: {link_path}")
|
|
else:
|
|
print(f" [!] No local path configured for {node_name} in dev_mode_local.yaml")
|
|
print(f" Add it to use --standalone")
|
|
|
|
success_count += 1
|
|
continue
|
|
|
|
if not args.dry_run:
|
|
# Backup existing content if present
|
|
if node_path.exists() and not args.no_backup and config["backup_enabled"]:
|
|
backup_path = backup_node(node_path, args.verbose)
|
|
if not backup_path:
|
|
print(f" [X] Backup failed, aborting")
|
|
continue
|
|
|
|
# Remove existing content (submodule or symlink)
|
|
if node_path.exists():
|
|
if not remove_path(node_path, args.verbose):
|
|
print(f" [X] Failed to remove existing path")
|
|
continue
|
|
|
|
# Create empty directory with DISABLED marker and placeholder __init__.py
|
|
node_path.mkdir(exist_ok=True)
|
|
disabled_marker.write_text(f"Node disabled by dev_mode.py\nUse 'python dev_mode.py enable {node_name}' to re-enable\n")
|
|
# Placeholder __init__.py prevents DazzleNodes auto-init from restoring submodule
|
|
(node_path / "__init__.py").write_text("# Disabled node - placeholder to prevent submodule auto-init\n")
|
|
|
|
# Clean cached web files to prevent JS double-loading
|
|
web_dir = root / "web" / node_name
|
|
if web_dir.exists():
|
|
shutil.rmtree(web_dir)
|
|
if args.verbose:
|
|
print(f" Cleaned web cache: {web_dir}")
|
|
|
|
# Also clean the sync hash cache so web sync doesn't restore files
|
|
sync_hashes_file = root / ".sync_hashes.json"
|
|
if sync_hashes_file.exists():
|
|
try:
|
|
import json
|
|
with open(sync_hashes_file, 'r') as f:
|
|
hashes = json.load(f)
|
|
if node_name in hashes:
|
|
del hashes[node_name]
|
|
with open(sync_hashes_file, 'w') as f:
|
|
json.dump(hashes, f, indent=2)
|
|
if args.verbose:
|
|
print(f" Cleaned sync hash for {node_name}")
|
|
except Exception as e:
|
|
print(f" [!] Warning: Could not clean sync hash: {e}")
|
|
|
|
print(f" [OK] Disabled (web cache cleaned)")
|
|
|
|
# Create standalone junction/symlink if requested
|
|
if getattr(args, 'standalone', False):
|
|
local_paths = load_local_mappings()
|
|
source_path = local_paths.get(node_name)
|
|
if source_path:
|
|
# Junction goes in the parent custom_nodes directory
|
|
custom_nodes_dir = get_custom_nodes_dir(root)
|
|
link_path = custom_nodes_dir / node_name
|
|
if not args.dry_run:
|
|
if create_standalone_link(source_path, link_path, args.verbose):
|
|
# Write marker so 'enable' knows where to clean up
|
|
standalone_marker = node_path / "STANDALONE_LINK"
|
|
standalone_marker.write_text(str(link_path) + "\n")
|
|
print(f" [OK] Standalone link: {link_path} -> {source_path}")
|
|
else:
|
|
print(f" [!] Standalone link creation failed (node still disabled)")
|
|
else:
|
|
print(f" Would create standalone link: {custom_nodes_dir / node_name} -> {source_path}")
|
|
else:
|
|
print(f" [!] No local path configured for {node_name} in dev_mode_local.yaml")
|
|
print(f" Add it to use --standalone")
|
|
|
|
success_count += 1
|
|
else:
|
|
print(f" Would disable: {node_path}")
|
|
if getattr(args, 'standalone', False):
|
|
local_paths = load_local_mappings()
|
|
source_path = local_paths.get(node_name)
|
|
if source_path:
|
|
print(f" Would create standalone link: {root.parent / node_name} -> {source_path}")
|
|
success_count += 1
|
|
|
|
print()
|
|
|
|
print(f"{'='*70}")
|
|
print(f"Result: {success_count}/{len(nodes_to_process)} nodes disabled")
|
|
print(f"{'='*70}\n")
|
|
|
|
return 0 if success_count == len(nodes_to_process) else 1
|
|
|
|
|
|
def cmd_enable(args, config):
|
|
"""Re-enable disabled node(s) by restoring the submodule."""
|
|
root = get_dazzlenodes_root()
|
|
nodes_dir = root / "nodes"
|
|
node_mappings = parse_gitmodules()
|
|
|
|
if not node_mappings:
|
|
print("Error: No nodes found in .gitmodules")
|
|
return 1
|
|
|
|
if args.node == "all":
|
|
nodes_to_process = list(node_mappings.keys())
|
|
elif args.node in node_mappings:
|
|
nodes_to_process = [args.node]
|
|
else:
|
|
print(f"Error: Unknown node '{args.node}'")
|
|
print(f"Available nodes: {', '.join(node_mappings.keys())}")
|
|
return 1
|
|
|
|
print(f"\n{'='*70}")
|
|
print(f"ENABLING NODES")
|
|
print(f"{'='*70}\n")
|
|
|
|
success_count = 0
|
|
for node_name in nodes_to_process:
|
|
node_path = nodes_dir / node_name
|
|
disabled_marker = node_path / "DISABLED"
|
|
|
|
print(f"Processing: {node_name}")
|
|
|
|
if not disabled_marker.exists():
|
|
print(f" [i] Not disabled, skipping")
|
|
success_count += 1
|
|
continue
|
|
|
|
if not args.dry_run:
|
|
# Clean up standalone link if one was created during disable
|
|
standalone_marker = node_path / "STANDALONE_LINK"
|
|
if standalone_marker.exists():
|
|
link_path_str = standalone_marker.read_text().strip()
|
|
link_path = Path(link_path_str)
|
|
if remove_standalone_link(link_path, args.verbose):
|
|
print(f" [OK] Removed standalone link: {link_path}")
|
|
else:
|
|
print(f" [!] Could not remove standalone link: {link_path}")
|
|
print(f" Please remove it manually before continuing")
|
|
|
|
# Remove disabled placeholder directory
|
|
if not remove_path(node_path, args.verbose):
|
|
print(f" [X] Failed to remove disabled placeholder")
|
|
continue
|
|
|
|
# Restore submodule
|
|
try:
|
|
submodule_path = f"nodes/{node_name}"
|
|
result = subprocess.run(
|
|
["git", "-C", str(root), "submodule", "update", "--init", submodule_path],
|
|
capture_output=True, text=True, check=True
|
|
)
|
|
print(f" [OK] Re-enabled (submodule restored)")
|
|
if args.verbose and result.stdout.strip():
|
|
print(f" {result.stdout.strip()}")
|
|
success_count += 1
|
|
except subprocess.CalledProcessError as e:
|
|
# Fallback: clone from submodule_versions.json (no .git)
|
|
print(f" [i] Git submodule failed, trying clone fallback...")
|
|
url = node_mappings[node_name]
|
|
version = _get_submodule_version(root, node_name)
|
|
if url and version:
|
|
try:
|
|
clone_cmd = ["git", "clone", "--depth", "1",
|
|
"--branch", version, url, str(node_path)]
|
|
clone_result = subprocess.run(
|
|
clone_cmd, capture_output=True, text=True, check=True)
|
|
print(f" [OK] Re-enabled via clone at {version} (fallback)")
|
|
success_count += 1
|
|
except subprocess.CalledProcessError as clone_err:
|
|
print(f" [X] Clone fallback also failed: {clone_err.stderr}")
|
|
else:
|
|
print(f" [X] Failed to restore: {e.stderr}")
|
|
else:
|
|
print(f" Would enable: remove placeholder + standalone link, restore submodule")
|
|
success_count += 1
|
|
|
|
print()
|
|
|
|
print(f"{'='*70}")
|
|
print(f"Result: {success_count}/{len(nodes_to_process)} nodes enabled")
|
|
print(f"{'='*70}\n")
|
|
|
|
return 0 if success_count == len(nodes_to_process) else 1
|
|
|
|
|
|
def main():
|
|
"""Main entry point."""
|
|
# Load configuration
|
|
config = load_config()
|
|
|
|
# Create main parser
|
|
parser = argparse.ArgumentParser(
|
|
description="DazzleNodes Development Mode Toggle Script",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="""
|
|
Examples:
|
|
python dev_mode.py status # Quick status (default)
|
|
python dev_mode.py status --complete # Detailed status with git info
|
|
python dev_mode.py dev smart-resolution-calc # Switch one node to dev mode
|
|
python dev_mode.py dev all # Switch all nodes to dev mode
|
|
python dev_mode.py publish all # Restore all submodules
|
|
python dev_mode.py disable smart-resolution-calc # Disable node (skip loading)
|
|
python dev_mode.py enable smart-resolution-calc # Re-enable (restore submodule)
|
|
python dev_mode.py -v dev all # Verbose mode
|
|
python dev_mode.py --dry-run publish all # Show what would be done
|
|
"""
|
|
)
|
|
|
|
# Global options
|
|
parser.add_argument("-v", "--verbose", action="store_true",
|
|
help="Enable verbose output")
|
|
parser.add_argument("--dry-run", action="store_true",
|
|
help="Show what would be done without making changes")
|
|
parser.add_argument("--no-backup", action="store_true",
|
|
help="Skip backup creation (dangerous)")
|
|
|
|
# Subcommands
|
|
subparsers = parser.add_subparsers(dest="command", help="Command to execute")
|
|
|
|
# Status command
|
|
status_parser = subparsers.add_parser("status",
|
|
help="Show current status of all nodes")
|
|
status_group = status_parser.add_mutually_exclusive_group()
|
|
status_group.add_argument("--quick", action="store_true",
|
|
help="Quick status (default)")
|
|
status_group.add_argument("--complete", action="store_true",
|
|
help="Complete status with git information")
|
|
|
|
# Dev command
|
|
dev_parser = subparsers.add_parser("dev",
|
|
help="Switch node(s) to dev mode (symlinks)")
|
|
dev_parser.add_argument("node", nargs="?",
|
|
help="Node name or 'all' (required)")
|
|
dev_parser.add_argument("--local", action="store_true",
|
|
help="Prefer local config (dev_mode_local.yaml) over .gitmodules")
|
|
|
|
# Publish command
|
|
publish_parser = subparsers.add_parser("publish",
|
|
help="Switch node(s) to publish mode (submodules)")
|
|
publish_parser.add_argument("node", nargs="?",
|
|
help="Node name or 'all' (required)")
|
|
|
|
# Disable command
|
|
disable_parser = subparsers.add_parser("disable",
|
|
help="Disable node(s) — skip loading without removing submodule")
|
|
disable_parser.add_argument("node", nargs="?",
|
|
help="Node name or 'all' (required)")
|
|
disable_parser.add_argument("--standalone", action="store_true",
|
|
help="Create standalone symlink/junction in custom_nodes parent dir")
|
|
|
|
# Enable command
|
|
enable_parser = subparsers.add_parser("enable",
|
|
help="Re-enable disabled node(s) (restores submodule)")
|
|
enable_parser.add_argument("node", nargs="?",
|
|
help="Node name or 'all' (required)")
|
|
|
|
# Parse arguments
|
|
args = parser.parse_args()
|
|
|
|
# Override config with command line args
|
|
if args.verbose:
|
|
config["verbose"] = True
|
|
if args.no_backup:
|
|
config["backup_enabled"] = False
|
|
|
|
# Execute command
|
|
if args.command == "status":
|
|
return cmd_status(args, config)
|
|
elif args.command == "dev":
|
|
if not args.node:
|
|
node_mappings = parse_gitmodules()
|
|
print("Error: Node name required")
|
|
print(f"Usage: python dev_mode.py dev [node|all]")
|
|
if node_mappings:
|
|
print(f"Available nodes: {', '.join(node_mappings.keys())}")
|
|
return 1
|
|
return cmd_dev(args, config)
|
|
elif args.command == "publish":
|
|
if not args.node:
|
|
node_mappings = parse_gitmodules()
|
|
print("Error: Node name required")
|
|
print(f"Usage: python dev_mode.py publish [node|all]")
|
|
if node_mappings:
|
|
print(f"Available nodes: {', '.join(node_mappings.keys())}")
|
|
return 1
|
|
return cmd_publish(args, config)
|
|
elif args.command == "disable":
|
|
if not args.node:
|
|
node_mappings = parse_gitmodules()
|
|
print("Error: Node name required")
|
|
print(f"Usage: python dev_mode.py disable [node|all]")
|
|
if node_mappings:
|
|
print(f"Available nodes: {', '.join(node_mappings.keys())}")
|
|
return 1
|
|
return cmd_disable(args, config)
|
|
elif args.command == "enable":
|
|
if not args.node:
|
|
node_mappings = parse_gitmodules()
|
|
print("Error: Node name required")
|
|
print(f"Usage: python dev_mode.py enable [node|all]")
|
|
if node_mappings:
|
|
print(f"Available nodes: {', '.join(node_mappings.keys())}")
|
|
return 1
|
|
return cmd_enable(args, config)
|
|
else:
|
|
parser.print_help()
|
|
return 0
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|