diff --git a/glob/manager_core.py b/glob/manager_core.py index e0b3a6fe..9153db46 100644 --- a/glob/manager_core.py +++ b/glob/manager_core.py @@ -921,7 +921,7 @@ class UnifiedManager: except: return version.parse("0.0.0") - def execute_install_script(self, url, repo_path, instant_execution=False, lazy_mode=False, no_deps=False): + def execute_install_script(self, url, repo_path, instant_execution=False, lazy_mode=False, no_deps=False, selected_dependencies=None): install_script_path = os.path.join(repo_path, "install.py") requirements_path = os.path.join(repo_path, "requirements.txt") @@ -933,8 +933,19 @@ class UnifiedManager: if os.path.exists(requirements_path) and not no_deps: print("Install: pip packages") pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, manager_files_path) + + # Create a set of selected dependency lines for quick lookup + selected_lines = set() + if selected_dependencies: + for dep in selected_dependencies: + selected_lines.add(dep.get('line', '').strip()) + lines = manager_util.robust_readlines(requirements_path) for line in lines: + # Skip if selected_dependencies is provided and this line is not in the selected list + if selected_dependencies is not None and line.strip() not in selected_lines: + continue + package_name = remap_pip_package(line.strip()) if package_name and not package_name.startswith('#') and package_name not in self.processed_install: self.processed_install.add(package_name) @@ -1342,7 +1353,7 @@ class UnifiedManager: return result - def repo_install(self, url: str, repo_path: str, instant_execution=False, no_deps=False, return_postinstall=False): + def repo_install(self, url: str, repo_path: str, instant_execution=False, no_deps=False, return_postinstall=False, selected_dependencies=None): result = ManagedResult('install-git') result.append(url) @@ -1369,7 +1380,7 @@ class UnifiedManager: repo.close() def postinstall(): - return self.execute_install_script(url, repo_path, instant_execution=instant_execution, no_deps=no_deps) + return self.execute_install_script(url, repo_path, instant_execution=instant_execution, no_deps=no_deps, selected_dependencies=selected_dependencies) if return_postinstall: return result.with_postinstall(postinstall) @@ -1468,7 +1479,7 @@ class UnifiedManager: else: return self.cnr_switch_version(node_id, instant_execution=instant_execution, no_deps=no_deps, return_postinstall=return_postinstall).with_ver('cnr') - async def install_by_id(self, node_id: str, version_spec=None, channel=None, mode=None, instant_execution=False, no_deps=False, return_postinstall=False): + async def install_by_id(self, node_id: str, version_spec=None, channel=None, mode=None, instant_execution=False, no_deps=False, return_postinstall=False, selected_dependencies=None): """ priority if version_spec == None 1. CNR latest @@ -1519,7 +1530,7 @@ class UnifiedManager: self.unified_disable(node_id, False) to_path = os.path.abspath(os.path.join(get_default_custom_nodes_path(), node_id)) - res = self.repo_install(repo_url, to_path, instant_execution=instant_execution, no_deps=no_deps, return_postinstall=return_postinstall) + res = self.repo_install(repo_url, to_path, instant_execution=instant_execution, no_deps=no_deps, return_postinstall=return_postinstall, selected_dependencies=selected_dependencies) if res.result: if version_spec == 'unknown': self.unknown_active_nodes[node_id] = repo_url, to_path @@ -1968,7 +1979,7 @@ def __win_check_git_pull(path): process.wait() -def execute_install_script(url, repo_path, lazy_mode=False, instant_execution=False, no_deps=False): +def execute_install_script(url, repo_path, lazy_mode=False, instant_execution=False, no_deps=False, selected_dependencies=None): # import ipdb; ipdb.set_trace() install_script_path = os.path.join(repo_path, "install.py") requirements_path = os.path.join(repo_path, "requirements.txt") @@ -1980,6 +1991,13 @@ def execute_install_script(url, repo_path, lazy_mode=False, instant_execution=Fa if os.path.exists(requirements_path) and not no_deps: print("Install: pip packages") pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, manager_files_path) + + # Create a set of selected dependency lines for quick lookup + selected_lines = set() + if selected_dependencies: + for dep in selected_dependencies: + selected_lines.add(dep.get('line', '').strip()) + with open(requirements_path, "r") as requirements_file: for line in requirements_file: #handle comments @@ -1990,6 +2008,10 @@ def execute_install_script(url, repo_path, lazy_mode=False, instant_execution=Fa else: line = line.split('#')[0].strip() + # Skip if selected_dependencies is provided and this line is not in the selected list + if selected_dependencies is not None and line.strip() not in selected_lines: + continue + package_name = remap_pip_package(line.strip()) if package_name and not package_name.startswith('#'): diff --git a/glob/manager_server.py b/glob/manager_server.py index eff7c032..cc66e19b 100644 --- a/glob/manager_server.py +++ b/glob/manager_server.py @@ -443,7 +443,12 @@ async def task_worker(): global tasks_in_progress async def do_install(item) -> str: - ui_id, node_spec_str, channel, mode, skip_post_install = item + if len(item) == 6: + ui_id, node_spec_str, channel, mode, skip_post_install, selected_dependencies = item + else: + # Backward compatibility + ui_id, node_spec_str, channel, mode, skip_post_install = item + selected_dependencies = [] try: node_spec = core.unified_manager.resolve_node_spec(node_spec_str) @@ -452,7 +457,7 @@ async def task_worker(): return f"Cannot resolve install target: '{node_spec_str}'" node_name, version_spec, is_specified = node_spec - res = await core.unified_manager.install_by_id(node_name, version_spec, channel, mode, return_postinstall=skip_post_install) + res = await core.unified_manager.install_by_id(node_name, version_spec, channel, mode, return_postinstall=skip_post_install, selected_dependencies=selected_dependencies) # discard post install if skip_post_install mode if res.action not in ['skip', 'enable', 'install-git', 'install-cnr', 'switch-cnr']: @@ -1303,7 +1308,9 @@ async def install_custom_node(request): logging.error(SECURITY_MESSAGE_GENERAL) return web.Response(status=404, text="A security error has occurred. Please check the terminal logs") - install_item = json_data.get('ui_id'), node_spec_str, json_data['channel'], json_data['mode'], skip_post_install + # Get selected dependencies if provided + selected_dependencies = json_data.get('selectedDependencies', []) + install_item = json_data.get('ui_id'), node_spec_str, json_data['channel'], json_data['mode'], skip_post_install, selected_dependencies task_queue.put(("install", install_item)) return web.Response(status=200) @@ -1383,6 +1390,272 @@ async def install_custom_node_pip(request): return web.Response(status=200) +@routes.post("/customnode/analyze_dependencies") +async def analyze_dependencies(request): + """ + Analyze dependencies for a custom node from git URL. + Fetches requirements.txt, checks installed packages, and returns dependency list with status. + """ + try: + json_data = await request.json() + url = json_data.get('url') + commit_id = json_data.get('commitId') + branch = json_data.get('branch') + + if not url: + return web.json_response({'error': 'URL is required'}, status=400) + + # Fetch requirements.txt from git repository + requirements_content = await fetch_requirements_from_git(url, commit_id, branch) + + if requirements_content is None: + return web.json_response({ + 'success': True, + 'requirements': None, + 'dependencies': [], + 'noRequirementsFile': True + }) + + # Parse requirements + dependencies = parse_requirements(requirements_content) + + # Get installed packages + installed_packages = manager_util.get_installed_packages() + + # Analyze each dependency with subdependencies + analyzed_dependencies = [] + for dep_line in dependencies: + if not dep_line.strip() or dep_line.strip().startswith('#'): + continue + + # Parse dependency line + parsed = manager_util.parse_requirement_line(dep_line) + if not parsed: + continue + + package_name = parsed.get('package') + if not package_name: + # Fallback: extract from line if package is missing + import re + match = re.match(r'^([a-zA-Z0-9_.-]+)', dep_line.strip()) + package_name = match.group(1) if match else "Unknown" + + version_spec = parsed.get('version') + # Convert version_spec to string if it's a StrictVersion object + if version_spec is not None: + version_spec = str(version_spec) + + normalized_name = package_name.lower().replace('-', '_') + + # Check if already installed + installed_version = installed_packages.get(normalized_name) + + status = 'new' + if installed_version: + status = 'installed' + + # Convert version to string if it's not already (handle StrictVersion objects) + current_version_str = str(installed_version) if installed_version else None + + # Get subdependencies using pip install --dry-run + # This is optional and failures should not block the main flow + subdependencies = [] + # Skip subdependency analysis for already installed packages (not needed) + if status != 'installed': + try: + import subprocess + import sys + + # Run pip install --dry-run to get subdependencies + # Some packages like pymeshlab can take longer due to complex dependency resolution + # Use a reasonable timeout - if it times out, we'll continue without subdependencies + result = subprocess.run( + [sys.executable, '-m', 'pip', 'install', '--dry-run', dep_line.strip()], + capture_output=True, + text=True, + timeout=45 # Increased timeout to 45 seconds + ) + + output = result.stdout + result.stderr + if output: + subdependencies = parse_dry_run_output(output, package_name, installed_packages) + except subprocess.TimeoutExpired: + # Timeout is not critical - continue without subdependencies + logging.debug(f"Subdependency analysis timed out for {package_name} (skipping subdependencies)") + subdependencies = [] + except Exception as e: + # Any other error is not critical - continue without subdependencies + logging.debug(f"Failed to analyze subdependencies for {package_name}: {e}") + subdependencies = [] + + # Add main dependency (always add, even if subdependency analysis failed) + # Ensure all fields are properly set and clean + clean_package_name = str(package_name).strip() if package_name else "Unknown" + # Remove any None/null strings that might have been concatenated + clean_package_name = clean_package_name.replace('None', '').replace('null', '').strip() + if not clean_package_name: + clean_package_name = "Unknown" + + analyzed_dependencies.append({ + 'name': clean_package_name, + 'version': str(version_spec) if version_spec else None, + 'line': dep_line.strip(), + 'status': status, + 'currentVersion': current_version_str, + 'selected': status != 'installed', # Deselect if already installed + 'subdependencies': subdependencies + }) + + return web.json_response({ + 'success': True, + 'requirements': requirements_content, + 'dependencies': analyzed_dependencies, + 'noRequirementsFile': False + }) + + except Exception as e: + logging.error(f"Error analyzing dependencies: {e}") + traceback.print_exc() + return web.json_response({'error': str(e)}, status=500) + + +def parse_requirements(content): + """Parse requirements.txt content into list of dependency lines.""" + lines = [] + for line in content.split('\n'): + line = line.strip() + if line and not line.startswith('#'): + lines.append(line) + return lines + + +def parse_dry_run_output(output, parent_name, installed_packages): + """Parse pip install --dry-run output to extract subdependencies.""" + import re + subdependencies = [] + subdeps_map = {} + + lines = output.split('\n') + for line in lines: + line = line.strip() + + # Look for "Collecting package==version" lines + if 'Collecting ' in line and 'Using cached' not in line: + # Match: "Collecting package==version" or "Collecting package" + match = re.search(r'Collecting\s+([a-zA-Z0-9_.-]+(?:\[[^\]]+\])?)(?:\s*==\s*([^\s\(]+))?', line) + if match: + dep_name = match.group(1).split('[')[0].strip() + # Clean the name - remove any None/null strings + if dep_name: + dep_name = dep_name.replace('None', '').replace('null', '').strip() + dep_version = match.group(2).strip() if match.group(2) else None + # Clean version too + if dep_version: + dep_version = dep_version.replace('None', '').replace('null', '').strip() or None + + # Skip the parent package itself + if dep_name.lower() == parent_name.lower(): + continue + + # Normalize name + normalized_name = dep_name.lower().replace('-', '_') + + # Check if already in map (avoid duplicates) + if normalized_name not in subdeps_map: + # Check if already installed + installed_version = installed_packages.get(normalized_name) + status = 'installed' if installed_version else 'new' + current_version_str = str(installed_version) if installed_version else None + + # Ensure name is always a string, not None + if not dep_name: + dep_name = "Unknown" + + # Clean the name - remove any None/null strings + clean_dep_name = str(dep_name).strip().replace('None', '').replace('null', '').strip() + if not clean_dep_name: + clean_dep_name = "Unknown" + + subdeps_map[normalized_name] = { + 'name': clean_dep_name, + 'version': str(dep_version) if dep_version else None, + 'status': status, + 'currentVersion': current_version_str, + 'selected': status != 'installed' + } + + # Also look for "Would install" lines which have more accurate version info + if 'Would install' in line: + # Match: "Would install package-version" + match = re.search(r'Would install\s+([a-zA-Z0-9_.-]+)-([\d.]+)', line) + if match: + dep_name = match.group(1) + dep_version = match.group(2) + normalized_name = dep_name.lower().replace('-', '_') + + if normalized_name in subdeps_map: + # Update with more accurate version + subdeps_map[normalized_name]['version'] = dep_version + + # Convert map to list + for normalized_name, dep_info in subdeps_map.items(): + subdependencies.append(dep_info) + + return subdependencies + + +async def fetch_requirements_from_git(url, commit_id=None, branch=None): + """ + Fetch requirements.txt from a git repository URL. + Supports GitHub URLs by converting to raw.githubusercontent.com. + """ + try: + # Extract GitHub repo info + if 'github.com' in url: + # Convert to raw GitHub URL + url = url.rstrip('/') + if url.endswith('.git'): + url = url[:-4] + + # Extract owner/repo + match = re.search(r'github\.com[:/]([^/]+)/([^/]+)', url) + if not match: + return None + + owner = match.group(1) + repo = match.group(2) + + # Build raw URL + if commit_id: + raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/{commit_id}/requirements.txt" + elif branch: + raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/requirements.txt" + else: + raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/main/requirements.txt" + + # Try to fetch using aiohttp + async with aiohttp.ClientSession() as session: + async with session.get(raw_url) as response: + if response.status == 200: + return await response.text() + # Try with master branch if main fails + if 'main' in raw_url: + raw_url = raw_url.replace('/main/', '/master/') + async with session.get(raw_url) as response2: + if response2.status == 200: + return await response2.text() + else: + # For non-GitHub URLs, we'd need to clone temporarily + # For now, return None (can be enhanced later) + logging.warning(f"Non-GitHub URL not fully supported for dependency analysis: {url}") + return None + + return None + except Exception as e: + logging.error(f"Error fetching requirements from git: {e}") + return None + + @routes.post("/manager/queue/uninstall") async def uninstall_custom_node(request): if not is_allowed_security_level('middle'): diff --git a/js/custom-nodes-manager.js b/js/custom-nodes-manager.js index b290df61..aede868d 100644 --- a/js/custom-nodes-manager.js +++ b/js/custom-nodes-manager.js @@ -67,6 +67,7 @@ export class CustomNodesManager { this.filter = ''; this.keywords = ''; this.restartMap = {}; + this.analyzeDependenciesBeforeInstall = false; // Default: false this.init(); @@ -77,6 +78,36 @@ export class CustomNodesManager { } init() { + // Create checkbox for dependency analysis + const analyzeDepsCheckbox = $el("input", { + type: "checkbox", + id: "cn-analyze-deps-checkbox", + checked: this.analyzeDependenciesBeforeInstall, + onchange: (e) => { + this.analyzeDependenciesBeforeInstall = e.target.checked; + }, + style: { + marginRight: "6px", + cursor: "pointer" + } + }); + + const analyzeDepsLabel = $el("label", { + for: "cn-analyze-deps-checkbox", + style: { + display: "flex", + alignItems: "center", + cursor: "pointer", + color: "#fff", + fontSize: "12px", + marginRight: "10px", + whiteSpace: "nowrap" + } + }, [ + analyzeDepsCheckbox, + $el("span", { textContent: "Analyse dependencies before node installation" }) + ]); + const header = $el("div.cn-manager-header.px-2", {}, [ // $el("label", {}, [ // $el("span", { textContent: "Filter" }), @@ -84,6 +115,7 @@ export class CustomNodesManager { // ]), createSettingsCombo("Filter", $el("select.cn-manager-filter")), $el("input.cn-manager-keywords.p-inputtext.p-component", { type: "search", placeholder: "Search" }), + analyzeDepsLabel, $el("div.cn-manager-status"), $el("div.cn-flex-auto"), $el("div.cn-manager-channel") @@ -105,6 +137,421 @@ export class CustomNodesManager { this.initGrid(); } + showDependencySelectorDialog(dependencies, onSelect) { + const dialog = new ComfyDialog(); + dialog.element.style.zIndex = 1100; + dialog.element.style.width = "900px"; + dialog.element.style.maxHeight = "80vh"; + dialog.element.style.padding = "0"; + dialog.element.style.backgroundColor = "#2a2a2a"; + dialog.element.style.border = "1px solid #3a3a3a"; + dialog.element.style.borderRadius = "8px"; + dialog.element.style.boxSizing = "border-box"; + dialog.element.style.overflow = "hidden"; + + const contentStyle = { + width: "100%", + display: "flex", + flexDirection: "column", + padding: "20px", + boxSizing: "border-box", + gap: "15px" + }; + + // Create scrollable table container with sticky header + const tableContainer = $el("div", { + style: { + maxHeight: "500px", + overflowY: "auto", + border: "1px solid #4a4a4a", + borderRadius: "4px", + backgroundColor: "#1a1a1a", + position: "relative" + } + }); + + // Create table + const table = $el("table", { + style: { + width: "100%", + borderCollapse: "separate", + borderSpacing: "0", + fontSize: "14px" + } + }); + + // Create table header with sticky positioning + const thead = $el("thead", { + style: { + position: "sticky", + top: "0", + zIndex: "10", + backgroundColor: "#2a2a2a", + boxShadow: "0 2px 4px rgba(0,0,0,0.3)" + } + }, [ + $el("tr", { + style: { + backgroundColor: "#2a2a2a", + borderBottom: "2px solid #4a4a4a" + } + }, [ + $el("th", { + textContent: "", + style: { + padding: "10px", + textAlign: "left", + width: "40px", + color: "#fff" + } + }), + $el("th", { + textContent: "Dependency Name", + style: { + padding: "10px", + textAlign: "left", + color: "#fff", + fontWeight: "bold" + } + }), + $el("th", { + textContent: "Current Version", + style: { + padding: "10px", + textAlign: "left", + color: "#fff", + fontWeight: "bold" + } + }), + $el("th", { + textContent: "Incoming Version", + style: { + padding: "10px", + textAlign: "left", + color: "#fff", + fontWeight: "bold" + } + }) + ]) + ]); + + // Create table body + const tbody = $el("tbody", {}); + + // Create table rows for each dependency and its subdependencies + let rowIndex = 0; + dependencies.forEach((dep) => { + // Ensure name is not null/undefined and clean it + let depName = dep.name; + if (!depName || depName === 'null' || depName === 'None') { + // Fallback: extract from line + if (dep.line) { + depName = dep.line.split(/[=<>!~]/)[0].trim(); + } else { + depName = "Unknown"; + } + } + // Remove any "null" suffix that might have been appended + depName = String(depName).replace(/null$/i, '').trim(); + + const isInstalled = dep.status === 'installed'; + const incomingVersion = dep.version || "NA"; + const currentVersion = dep.currentVersion || "NA"; + + // Main dependency row + const row = $el("tr", { + style: { + backgroundColor: rowIndex % 2 === 0 ? "#1a1a1a" : "#222222", + borderBottom: "1px solid #3a3a3a" + } + }, [ + $el("td", { + style: { + padding: "10px", + textAlign: "center" + } + }, [ + $el("input", { + type: "checkbox", + checked: dep.selected, + onchange: (e) => { + dep.selected = e.target.checked; + }, + style: { + cursor: "pointer", + width: "18px", + height: "18px" + } + }) + ]), + $el("td", { + style: { + padding: "10px", + color: isInstalled ? "#888" : "#fff" + } + }, [ + $el("span", { + textContent: depName, + style: { + fontWeight: "500", + marginRight: isInstalled ? "8px" : "0" + } + }), + isInstalled ? $el("span", { + textContent: "Installed", + style: { + display: "inline-block", + backgroundColor: "#2a4a2a", + color: "#4a9", + padding: "2px 6px", + borderRadius: "3px", + fontSize: "10px", + fontWeight: "bold", + border: "1px solid #4a9" + } + }) : '' + ]), + $el("td", { + textContent: currentVersion, + style: { + padding: "10px", + color: isInstalled ? "#4a9" : "#aaa", + fontFamily: "monospace" + } + }), + $el("td", { + textContent: incomingVersion, + style: { + padding: "10px", + color: "#fff", + fontFamily: "monospace" + } + }) + ]); + + tbody.appendChild(row); + rowIndex++; + + // Add subdependencies as indented rows + if(dep.subdependencies && dep.subdependencies.length > 0) { + dep.subdependencies.forEach((subdep) => { + // Ensure subdependency name is not null/undefined and clean it + let subdepName = subdep.name; + if (!subdepName || subdepName === 'null' || subdepName === 'None') { + subdepName = "Unknown"; + } + // Remove any "null" suffix that might have been appended + subdepName = String(subdepName).replace(/null$/i, '').trim(); + + const subIsInstalled = subdep.status === 'installed'; + const subIncomingVersion = subdep.version || "NA"; + const subCurrentVersion = subdep.currentVersion || "NA"; + + const subRow = $el("tr", { + style: { + backgroundColor: rowIndex % 2 === 0 ? "#1a1a1a" : "#222222", + borderBottom: "1px solid #3a3a3a" + } + }, [ + $el("td", { + style: { + padding: "10px", + textAlign: "center" + } + }, [ + $el("input", { + type: "checkbox", + checked: subdep.selected, + onchange: (e) => { + subdep.selected = e.target.checked; + }, + style: { + cursor: "pointer", + width: "18px", + height: "18px" + } + }) + ]), + $el("td", { + style: { + padding: "10px 10px 10px 30px", + color: subIsInstalled ? "#888" : "#aaa", + fontSize: "13px" + } + }, [ + $el("span", { + textContent: "└─ " + subdepName, + style: { + fontWeight: "400", + marginRight: subIsInstalled ? "8px" : "0" + } + }), + subIsInstalled ? $el("span", { + textContent: "Installed", + style: { + display: "inline-block", + backgroundColor: "#2a4a2a", + color: "#4a9", + padding: "2px 6px", + borderRadius: "3px", + fontSize: "10px", + fontWeight: "bold", + border: "1px solid #4a9" + } + }) : '' + ]), + $el("td", { + textContent: subCurrentVersion, + style: { + padding: "10px", + color: subIsInstalled ? "#4a9" : "#666", + fontFamily: "monospace", + fontSize: "13px" + } + }), + $el("td", { + textContent: subIncomingVersion, + style: { + padding: "10px", + color: "#aaa", + fontFamily: "monospace", + fontSize: "13px" + } + }) + ]); + + tbody.appendChild(subRow); + rowIndex++; + }); + } + }); + + table.appendChild(thead); + table.appendChild(tbody); + tableContainer.appendChild(table); + + const content = $el("div", { + style: contentStyle + }, [ + $el("h3", { + textContent: "Select Dependencies to Install", + style: { + color: "#ffffff", + backgroundColor: "#1a1a1a", + padding: "10px 15px", + margin: "0 0 10px 0", + width: "100%", + textAlign: "center", + borderRadius: "4px", + boxSizing: "border-box" + } + }), + $el("div", { + textContent: `${dependencies.filter(d => d.status === 'installed').length} already installed, ${dependencies.filter(d => d.status !== 'installed').length} to install`, + style: { + color: "#aaa", + fontSize: "12px", + marginBottom: "5px" + } + }), + tableContainer, + $el("div", { + style: { + display: "flex", + justifyContent: "space-between", + width: "100%", + gap: "10px", + marginTop: "10px" + } + }, [ + $el("button", { + textContent: "Cancel", + onclick: () => { + onSelect(null); // Pass null to indicate cancellation + dialog.close(); + }, + style: { + flex: "1", + padding: "8px", + backgroundColor: "#4a4a4a", + color: "#ffffff", + border: "none", + borderRadius: "4px", + cursor: "pointer" + } + }), + $el("button", { + textContent: "Select All", + onclick: () => { + dependencies.forEach(dep => { + if (dep.status !== 'installed') { + dep.selected = true; + } + // Also select subdependencies + if(dep.subdependencies) { + dep.subdependencies.forEach(subdep => { + if(subdep.status !== 'installed') { + subdep.selected = true; + } + }); + } + }); + // Update checkboxes in the table + const checkboxes = tableContainer.querySelectorAll('input[type="checkbox"]'); + checkboxes.forEach((checkbox) => { + if(!checkbox.disabled) { + checkbox.checked = true; + } + }); + }, + style: { + padding: "8px 15px", + backgroundColor: "#4a6a4a", + color: "#ffffff", + border: "none", + borderRadius: "4px", + cursor: "pointer" + } + }), + $el("button", { + textContent: "Install Selected", + onclick: () => { + // Collect all selected dependencies (main + subdependencies) + const selected = []; + dependencies.forEach(d => { + if(d.selected) { + selected.push(d); + } + // Also include selected subdependencies + if(d.subdependencies) { + d.subdependencies.forEach(subdep => { + if(subdep.selected) { + selected.push(subdep); + } + }); + } + }); + onSelect(selected); + dialog.close(); + }, + style: { + flex: "1", + padding: "8px", + backgroundColor: "#4CAF50", + color: "#ffffff", + border: "none", + borderRadius: "4px", + cursor: "pointer" + } + }), + ]) + ]); + + console.log('[Dependency Dialog] Showing dialog with', dependencies.length, 'dependencies'); + dialog.show(content); + console.log('[Dependency Dialog] Dialog shown'); + } + showVersionSelectorDialog(versions, onSelect) { const dialog = new ComfyDialog(); dialog.element.style.zIndex = 1100; @@ -1470,6 +1917,101 @@ export class CustomNodesManager { } } + // For install mode, analyze dependencies BEFORE starting installation + let selectedDependencies = []; + let dependencyDialogShown = false; // Track if dialog was shown + if(mode === "install" && this.analyzeDependenciesBeforeInstall) { + // Analyze dependencies for all items first (only if checkbox is enabled) + for (const hash of list) { + const item = this.grid.getRowItemBy("hash", hash); + if (!item) { + console.log('[Dependency Analysis] Item not found for hash:', hash); + continue; + } + + const data = item.originalData; + console.log('[Dependency Analysis] Item data:', { + title: item.title, + files: data.files, + repository: data.repository, + hasFiles: !!data.files, + filesLength: data.files ? data.files.length : 0 + }); + + // Try multiple ways to get the git URL + let gitUrl = null; + if(data.files && data.files.length > 0) { + gitUrl = data.files[0]; + } else if(data.repository) { + gitUrl = data.repository; + } + + if(gitUrl && (gitUrl.includes('github.com') || gitUrl.includes('.git'))) { + try { + this.showStatus(`Analyzing dependencies for ${item.title}...`); + console.log('[Dependency Analysis] Fetching dependencies for:', gitUrl); + + const analyzeRes = await api.fetchApi('/customnode/analyze_dependencies', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url: gitUrl, + commitId: data.commit_id, + branch: data.branch + }) + }); + + console.log('[Dependency Analysis] Response status:', analyzeRes.status); + + if(analyzeRes.status === 200) { + const analyzeData = await analyzeRes.json(); + console.log('[Dependency Analysis] Response data:', { + success: analyzeData.success, + hasDependencies: !!analyzeData.dependencies, + dependenciesCount: analyzeData.dependencies ? analyzeData.dependencies.length : 0, + noRequirementsFile: analyzeData.noRequirementsFile + }); + + if(analyzeData.success && analyzeData.dependencies && analyzeData.dependencies.length > 0) { + console.log('[Dependency Analysis] Showing dialog with', analyzeData.dependencies.length, 'dependencies'); + dependencyDialogShown = true; + + // Show dependency selection dialog and wait for user + const userSelection = await new Promise((resolve) => { + this.showDependencySelectorDialog(analyzeData.dependencies, (selected) => { + console.log('[Dependency Analysis] User selected:', selected); + resolve(selected); + }); + }); + + // If user cancelled (null), stop installation + if(userSelection === null) { + console.log('[Dependency Analysis] User cancelled installation'); + this.showStatus("Installation cancelled"); + return; + } + + selectedDependencies = userSelection || []; + console.log('[Dependency Analysis] Selected dependencies:', selectedDependencies.length); + } else if(analyzeData.noRequirementsFile) { + console.log('[Dependency Analysis] No requirements.txt file found'); + } else { + console.log('[Dependency Analysis] No dependencies to show'); + } + } else { + const errorText = await analyzeRes.text(); + console.error('[Dependency Analysis] API error:', analyzeRes.status, errorText); + } + } catch(e) { + console.error('[Dependency Analysis] Exception:', e); + // Continue with installation even if dependency analysis fails + } + } else { + console.log('[Dependency Analysis] Not a GitHub URL or no URL found:', gitUrl); + } + } + } + target.classList.add("cn-btn-loading"); this.showError(""); @@ -1505,6 +2047,46 @@ export class CustomNodesManager { data.mode = this.mode; data.ui_id = hash; + // Add selected dependencies to data (including subdependencies) + // Only install selected dependencies - respect user's selection + const allSelected = []; + if(selectedDependencies.length > 0) { + selectedDependencies.forEach(d => { + // Add main dependency if selected + if(d.selected) { + allSelected.push({ + name: d.name, + version: d.version, + line: d.line + }); + } + // Add selected subdependencies + if(d.subdependencies) { + d.subdependencies.forEach(subdep => { + if(subdep.selected) { + allSelected.push({ + name: subdep.name, + version: subdep.version, + line: `${subdep.name}${subdep.version ? '==' + subdep.version : ''}` + }); + } + }); + } + }); + } + // Set selectedDependencies: + // - If dialog was shown: always set (even if empty) to respect user's selection + // - If dialog was not shown: don't set (install all dependencies - original behavior) + if(dependencyDialogShown) { + // User saw the dialog, respect their selection (even if empty) + data.selectedDependencies = allSelected; + } else if(allSelected.length > 0) { + // Dialog wasn't shown but we have selections (shouldn't happen, but just in case) + data.selectedDependencies = allSelected; + } + // If dialog wasn't shown and no selections, don't set selectedDependencies + // This means backend will install all dependencies (original behavior) + let install_mode = mode; if(mode == 'switch') { install_mode = 'install';