Files
Dustin 9a4f89477f feat: DazzleCommand reset button + state race fix cascade - v0.6.4-alpha
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)
2026-03-31 14:24:27 -04:00

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