comfygit-core 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. comfygit_core/analyzers/custom_node_scanner.py +109 -0
  2. comfygit_core/analyzers/git_change_parser.py +156 -0
  3. comfygit_core/analyzers/model_scanner.py +318 -0
  4. comfygit_core/analyzers/node_classifier.py +58 -0
  5. comfygit_core/analyzers/node_git_analyzer.py +77 -0
  6. comfygit_core/analyzers/status_scanner.py +362 -0
  7. comfygit_core/analyzers/workflow_dependency_parser.py +143 -0
  8. comfygit_core/caching/__init__.py +16 -0
  9. comfygit_core/caching/api_cache.py +210 -0
  10. comfygit_core/caching/base.py +212 -0
  11. comfygit_core/caching/comfyui_cache.py +100 -0
  12. comfygit_core/caching/custom_node_cache.py +320 -0
  13. comfygit_core/caching/workflow_cache.py +797 -0
  14. comfygit_core/clients/__init__.py +4 -0
  15. comfygit_core/clients/civitai_client.py +412 -0
  16. comfygit_core/clients/github_client.py +349 -0
  17. comfygit_core/clients/registry_client.py +230 -0
  18. comfygit_core/configs/comfyui_builtin_nodes.py +1614 -0
  19. comfygit_core/configs/comfyui_models.py +62 -0
  20. comfygit_core/configs/model_config.py +151 -0
  21. comfygit_core/constants.py +82 -0
  22. comfygit_core/core/environment.py +1635 -0
  23. comfygit_core/core/workspace.py +898 -0
  24. comfygit_core/factories/environment_factory.py +419 -0
  25. comfygit_core/factories/uv_factory.py +61 -0
  26. comfygit_core/factories/workspace_factory.py +109 -0
  27. comfygit_core/infrastructure/sqlite_manager.py +156 -0
  28. comfygit_core/integrations/__init__.py +7 -0
  29. comfygit_core/integrations/uv_command.py +318 -0
  30. comfygit_core/logging/logging_config.py +15 -0
  31. comfygit_core/managers/environment_git_orchestrator.py +316 -0
  32. comfygit_core/managers/environment_model_manager.py +296 -0
  33. comfygit_core/managers/export_import_manager.py +116 -0
  34. comfygit_core/managers/git_manager.py +667 -0
  35. comfygit_core/managers/model_download_manager.py +252 -0
  36. comfygit_core/managers/model_symlink_manager.py +166 -0
  37. comfygit_core/managers/node_manager.py +1378 -0
  38. comfygit_core/managers/pyproject_manager.py +1321 -0
  39. comfygit_core/managers/user_content_symlink_manager.py +436 -0
  40. comfygit_core/managers/uv_project_manager.py +569 -0
  41. comfygit_core/managers/workflow_manager.py +1944 -0
  42. comfygit_core/models/civitai.py +432 -0
  43. comfygit_core/models/commit.py +18 -0
  44. comfygit_core/models/environment.py +293 -0
  45. comfygit_core/models/exceptions.py +378 -0
  46. comfygit_core/models/manifest.py +132 -0
  47. comfygit_core/models/node_mapping.py +201 -0
  48. comfygit_core/models/protocols.py +248 -0
  49. comfygit_core/models/registry.py +63 -0
  50. comfygit_core/models/shared.py +356 -0
  51. comfygit_core/models/sync.py +42 -0
  52. comfygit_core/models/system.py +204 -0
  53. comfygit_core/models/workflow.py +914 -0
  54. comfygit_core/models/workspace_config.py +71 -0
  55. comfygit_core/py.typed +0 -0
  56. comfygit_core/repositories/migrate_paths.py +49 -0
  57. comfygit_core/repositories/model_repository.py +958 -0
  58. comfygit_core/repositories/node_mappings_repository.py +246 -0
  59. comfygit_core/repositories/workflow_repository.py +57 -0
  60. comfygit_core/repositories/workspace_config_repository.py +121 -0
  61. comfygit_core/resolvers/global_node_resolver.py +459 -0
  62. comfygit_core/resolvers/model_resolver.py +250 -0
  63. comfygit_core/services/import_analyzer.py +218 -0
  64. comfygit_core/services/model_downloader.py +422 -0
  65. comfygit_core/services/node_lookup_service.py +251 -0
  66. comfygit_core/services/registry_data_manager.py +161 -0
  67. comfygit_core/strategies/__init__.py +4 -0
  68. comfygit_core/strategies/auto.py +72 -0
  69. comfygit_core/strategies/confirmation.py +69 -0
  70. comfygit_core/utils/comfyui_ops.py +125 -0
  71. comfygit_core/utils/common.py +164 -0
  72. comfygit_core/utils/conflict_parser.py +232 -0
  73. comfygit_core/utils/dependency_parser.py +231 -0
  74. comfygit_core/utils/download.py +216 -0
  75. comfygit_core/utils/environment_cleanup.py +111 -0
  76. comfygit_core/utils/filesystem.py +178 -0
  77. comfygit_core/utils/git.py +1184 -0
  78. comfygit_core/utils/input_signature.py +145 -0
  79. comfygit_core/utils/model_categories.py +52 -0
  80. comfygit_core/utils/pytorch.py +71 -0
  81. comfygit_core/utils/requirements.py +211 -0
  82. comfygit_core/utils/retry.py +242 -0
  83. comfygit_core/utils/symlink_utils.py +119 -0
  84. comfygit_core/utils/system_detector.py +258 -0
  85. comfygit_core/utils/uuid.py +28 -0
  86. comfygit_core/utils/uv_error_handler.py +158 -0
  87. comfygit_core/utils/version.py +73 -0
  88. comfygit_core/utils/workflow_hash.py +90 -0
  89. comfygit_core/validation/resolution_tester.py +297 -0
  90. comfygit_core-0.2.0.dist-info/METADATA +939 -0
  91. comfygit_core-0.2.0.dist-info/RECORD +93 -0
  92. comfygit_core-0.2.0.dist-info/WHEEL +4 -0
  93. comfygit_core-0.2.0.dist-info/licenses/LICENSE.txt +661 -0
@@ -0,0 +1,1378 @@
1
+ # managers/node_manager.py
2
+ from __future__ import annotations
3
+
4
+ import shutil
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING
7
+
8
+ from ..logging.logging_config import get_logger
9
+ from ..managers.pyproject_manager import PyprojectManager
10
+ from ..managers.uv_project_manager import UVProjectManager
11
+ from ..models.exceptions import (
12
+ CDDependencyConflictError,
13
+ CDEnvironmentError,
14
+ CDNodeConflictError,
15
+ CDNodeNotFoundError,
16
+ DependencyConflictContext,
17
+ NodeAction,
18
+ NodeConflictContext,
19
+ )
20
+ from ..models.shared import NodeInfo, NodePackage, NodeRemovalResult, UpdateResult
21
+ from ..services.node_lookup_service import NodeLookupService
22
+ from ..strategies.confirmation import AutoConfirmStrategy, ConfirmationStrategy
23
+ from ..utils.conflict_parser import extract_conflicting_packages
24
+ from ..utils.dependency_parser import parse_dependency_string
25
+ from ..utils.git import is_github_url, normalize_github_url
26
+ from ..validation.resolution_tester import ResolutionTester
27
+
28
+ if TYPE_CHECKING:
29
+ from ..repositories.node_mappings_repository import NodeMappingsRepository
30
+
31
+ logger = get_logger(__name__)
32
+
33
+
34
+ class NodeManager:
35
+ """Manages all node operations for an environment."""
36
+
37
+ def __init__(
38
+ self,
39
+ pyproject: PyprojectManager,
40
+ uv: UVProjectManager,
41
+ node_lookup: NodeLookupService,
42
+ resolution_tester: ResolutionTester,
43
+ custom_nodes_path: Path,
44
+ node_repository: NodeMappingsRepository,
45
+ ):
46
+ self.pyproject = pyproject
47
+ self.uv = uv
48
+ self.node_lookup = node_lookup
49
+ self.resolution_tester = resolution_tester
50
+ self.custom_nodes_path = custom_nodes_path
51
+ self.node_repository = node_repository
52
+
53
+ def _find_node_by_name(self, name: str) -> tuple[str, NodeInfo] | None:
54
+ """Find a node by name across all identifiers (case-insensitive).
55
+
56
+ Returns:
57
+ Tuple of (identifier, node_info) if found, None otherwise
58
+ """
59
+ existing_nodes = self.pyproject.nodes.get_existing()
60
+ name_lower = name.lower()
61
+ for identifier, node_info in existing_nodes.items():
62
+ if node_info.name.lower() == name_lower:
63
+ return identifier, node_info
64
+ return None
65
+
66
+ def _install_node_from_info(self, node_info: NodeInfo, no_test: bool = False) -> NodeInfo:
67
+ """Install a node given a pre-fetched NodeInfo object.
68
+
69
+ This bypasses the lookup/cache layer and directly installs the node
70
+ using the provided node info. Useful for update operations where we've
71
+ already fetched fresh data from the API.
72
+
73
+ Args:
74
+ node_info: Pre-fetched node information from API
75
+ no_test: Skip dependency resolution testing
76
+
77
+ Returns:
78
+ NodeInfo of the installed node
79
+
80
+ Raises:
81
+ CDEnvironmentError: If installation fails
82
+ CDNodeConflictError: If dependency conflicts detected
83
+ """
84
+ # Download to cache
85
+ cache_path = self.node_lookup.download_to_cache(node_info)
86
+ if not cache_path:
87
+ raise CDEnvironmentError(f"Failed to download node '{node_info.name}'")
88
+
89
+ # Scan requirements from cached directory
90
+ requirements = self.node_lookup.scan_requirements(cache_path)
91
+
92
+ # Create node package
93
+ node_package = NodePackage(node_info=node_info, requirements=requirements)
94
+
95
+ # TEST DEPENDENCIES FIRST (before any filesystem or pyproject changes)
96
+ if not no_test and node_package.requirements:
97
+ logger.info(f"Testing dependency resolution for '{node_package.name}' before installation")
98
+ test_result = self._test_requirements_in_isolation(node_package.requirements)
99
+ if not test_result.success:
100
+ self._raise_dependency_conflict(node_package.name, test_result)
101
+
102
+ # === BEGIN TRANSACTIONAL SECTION ===
103
+ # Snapshot state before any modifications for rollback
104
+ pyproject_snapshot = self.pyproject.snapshot()
105
+ target_path = self.custom_nodes_path / node_info.name
106
+ disabled_path = self.custom_nodes_path / f"{node_info.name}.disabled"
107
+ disabled_existed = disabled_path.exists()
108
+
109
+ try:
110
+ # STEP 1: Filesystem changes
111
+ if disabled_existed:
112
+ logger.info(f"Removing old disabled version of {node_info.name}")
113
+ shutil.rmtree(disabled_path)
114
+
115
+ shutil.copytree(cache_path, target_path, dirs_exist_ok=True)
116
+ logger.info(f"Installed node '{node_info.name}' to {target_path}")
117
+
118
+ # STEP 2: Pyproject changes
119
+ self.add_node_package(node_package)
120
+
121
+ # STEP 3: Environment sync (quiet - users see our high-level messages)
122
+ self.uv.sync_project(quiet=True, all_groups=True)
123
+
124
+ except Exception as e:
125
+ # === ROLLBACK ===
126
+ logger.warning(f"Installation failed for '{node_info.name}', rolling back...")
127
+
128
+ # 1. Restore pyproject.toml
129
+ try:
130
+ self.pyproject.restore(pyproject_snapshot)
131
+ logger.debug("Restored pyproject.toml to pre-installation state")
132
+ except Exception as restore_err:
133
+ logger.error(f"Failed to restore pyproject.toml: {restore_err}")
134
+
135
+ # 2. Clean up filesystem
136
+ if target_path.exists():
137
+ try:
138
+ shutil.rmtree(target_path)
139
+ logger.debug(f"Removed {target_path}")
140
+ except Exception as fs_err:
141
+ logger.error(f"Failed to clean up {target_path}: {fs_err}")
142
+
143
+ # 3. Restore disabled version if it existed
144
+ if disabled_existed:
145
+ try:
146
+ # Note: We can't restore disabled_path since we already deleted it
147
+ # This is acceptable - user can re-disable manually if needed
148
+ logger.debug("Cannot restore disabled version (already removed)")
149
+ except Exception:
150
+ pass
151
+
152
+ raise CDEnvironmentError(f"Failed to install node '{node_info.name}': {e}") from e
153
+
154
+ logger.info(f"Successfully added node: {node_info.name}")
155
+ return node_info
156
+
157
+ def add_node_package(self, node_package: NodePackage) -> None:
158
+ """Add a complete node package with requirements and source tracking.
159
+
160
+ This is the low-level method for adding pre-prepared node packages.
161
+ """
162
+ # Check for duplicates by name (regardless of identifier)
163
+ existing = self._find_node_by_name(node_package.name)
164
+ if existing:
165
+ existing_id, existing_node = existing
166
+ node_type = "development" if existing_node.version == 'dev' else "regular"
167
+
168
+ context = NodeConflictContext(
169
+ conflict_type='already_tracked',
170
+ node_name=node_package.name,
171
+ existing_identifier=existing_id,
172
+ is_development=(existing_node.version == 'dev'),
173
+ suggested_actions=[
174
+ NodeAction(
175
+ action_type='remove_node',
176
+ node_identifier=existing_id,
177
+ description=f"Remove existing {node_type} node"
178
+ )
179
+ ]
180
+ )
181
+
182
+ raise CDNodeConflictError(
183
+ f"Node '{node_package.name}' already exists as {node_type} node (identifier: '{existing_id}')",
184
+ context=context
185
+ )
186
+
187
+ # Snapshot sources before processing
188
+ existing_sources = self.pyproject.uv_config.get_source_names()
189
+
190
+ # Generate collision-resistant group name for UV dependencies
191
+ group_name = self.pyproject.nodes.generate_group_name(
192
+ node_package.node_info, node_package.identifier
193
+ )
194
+
195
+ # Add requirements if any
196
+ if node_package.requirements:
197
+ self.uv.add_requirements_with_sources(
198
+ node_package.requirements, group=group_name, no_sync=True, raw=True
199
+ )
200
+
201
+ # Detect new sources after processing
202
+ current_sources = self.pyproject.uv_config.get_source_names()
203
+ new_sources = current_sources - existing_sources
204
+
205
+ # Update node with detected sources
206
+ if new_sources:
207
+ node_package.node_info.dependency_sources = sorted(new_sources)
208
+
209
+ # Store node configuration
210
+ self.pyproject.nodes.add(node_package.node_info, node_package.identifier)
211
+
212
+ def add_node(
213
+ self,
214
+ identifier: str,
215
+ is_development: bool = False,
216
+ no_test: bool = False,
217
+ force: bool = False,
218
+ confirmation_strategy: 'ConfirmationStrategy | None' = None,
219
+ ) -> NodeInfo:
220
+ """Add a custom node to the environment.
221
+
222
+ Args:
223
+ identifier: Registry ID or GitHub URL of the node (supports @version)
224
+ is_development: If the node is a development node
225
+ no_test: Skip testing the node
226
+ force: Force replacement of existing nodes
227
+ confirmation_strategy: Strategy for confirming replacements
228
+
229
+ Raises:
230
+ CDNodeNotFoundError: If node not found
231
+ CDNodeConflictError: If node has dependency conflicts
232
+ CDEnvironmentError: If node with same name already exists
233
+ """
234
+ logger.info(f"Adding node: {identifier}")
235
+
236
+ # Handle development nodes
237
+ if is_development:
238
+ return self._add_development_node(identifier)
239
+
240
+ # Check for existing installation by registry ID (if GitHub URL provided)
241
+ registry_id = None
242
+ github_url = None
243
+ user_specified_version = '@' in identifier # Track if user explicitly specified a version
244
+
245
+ if is_github_url(identifier):
246
+ github_url = identifier
247
+ # Try to resolve GitHub URL to registry ID
248
+ if resolved := self.node_repository.resolve_github_url(identifier):
249
+ registry_id = resolved.id
250
+ logger.info(f"Resolved GitHub URL to registry ID: {registry_id}")
251
+ else:
252
+ # Not in registry - fall through to direct git installation
253
+ # This allows installation of any GitHub repo, not just registered ones
254
+ logger.info(f"GitHub URL not in registry, will install as pure git node: {identifier}")
255
+ else:
256
+ # Parse base identifier (strip version if present)
257
+ base_identifier = identifier.split('@')[0] if '@' in identifier else identifier
258
+ registry_id = base_identifier
259
+
260
+ # Get node info from lookup service (this parses @version)
261
+ node_info = self.node_lookup.get_node(identifier)
262
+
263
+ # Enhance with dual-source information if available
264
+ if github_url and registry_id:
265
+ node_info.registry_id = registry_id
266
+ node_info.repository = github_url
267
+ logger.info(f"Enhanced node info with dual sources: registry_id={registry_id}, github_url={github_url}")
268
+
269
+ # Check for existing installation and handle version replacement
270
+ existing_entry = self._find_node_by_name(node_info.name)
271
+ if existing_entry:
272
+ existing_identifier, existing_node = existing_entry
273
+
274
+ # If user didn't specify a version, error (don't auto-upgrade to latest)
275
+ if not user_specified_version:
276
+ raise CDNodeConflictError(
277
+ f"Node '{node_info.name}' is already installed (version {existing_node.version})",
278
+ context=NodeConflictContext(
279
+ conflict_type='already_tracked',
280
+ node_name=node_info.name,
281
+ existing_identifier=existing_identifier,
282
+ is_development=(existing_node.version == 'dev'),
283
+ suggested_actions=[
284
+ NodeAction(
285
+ action_type='update_node',
286
+ node_identifier=existing_identifier,
287
+ description="Update to latest version"
288
+ ),
289
+ NodeAction(
290
+ action_type='add_node_version',
291
+ node_identifier=f"{existing_identifier}@<version>",
292
+ description="Install specific version"
293
+ )
294
+ ]
295
+ )
296
+ )
297
+
298
+ # Check if same version
299
+ if existing_node.version == node_info.version:
300
+ raise CDNodeConflictError(
301
+ f"Node '{node_info.name}' version {node_info.version} is already installed",
302
+ context=NodeConflictContext(
303
+ conflict_type='already_tracked',
304
+ node_name=node_info.name,
305
+ existing_identifier=existing_identifier,
306
+ is_development=(existing_node.version == 'dev')
307
+ )
308
+ )
309
+
310
+ # Different version - handle replacement
311
+ if existing_node.source == 'development':
312
+ # Dev node replacement requires confirmation unless forced
313
+ if not force:
314
+ if confirmation_strategy is None:
315
+ raise CDNodeConflictError(
316
+ f"Cannot replace development node '{node_info.name}' without confirmation. "
317
+ f"Use --force to replace or provide confirmation strategy.",
318
+ context=NodeConflictContext(
319
+ conflict_type='dev_node_replacement',
320
+ node_name=node_info.name,
321
+ existing_identifier=existing_identifier,
322
+ is_development=True
323
+ )
324
+ )
325
+
326
+ # Use strategy to confirm (with fallbacks for None versions)
327
+ current_ver = existing_node.version or 'unknown'
328
+ new_ver = node_info.version or 'unknown'
329
+ confirmed = confirmation_strategy.confirm_replace_dev_node(
330
+ node_info.name, current_ver, new_ver
331
+ )
332
+
333
+ if not confirmed:
334
+ raise CDNodeConflictError(
335
+ f"User declined replacement of development node '{node_info.name}'",
336
+ context=NodeConflictContext(
337
+ conflict_type='user_cancelled',
338
+ node_name=node_info.name,
339
+ existing_identifier=existing_identifier,
340
+ is_development=True
341
+ )
342
+ )
343
+
344
+ # Remove existing node (for both dev and regular nodes after confirmation)
345
+ logger.info(f"Replacing {node_info.name} {existing_node.version} → {node_info.version}")
346
+ self.remove_node(existing_identifier)
347
+
348
+ # Check for filesystem conflicts before proceeding
349
+ if not force:
350
+ has_conflict, conflict_msg, conflict_context = self._check_filesystem_conflict(
351
+ node_info.name,
352
+ expected_repo_url=node_info.repository
353
+ )
354
+ if has_conflict:
355
+ raise CDNodeConflictError(conflict_msg, context=conflict_context)
356
+
357
+ # Download to cache (but don't install yet)
358
+ cache_path = self.node_lookup.download_to_cache(node_info)
359
+ if not cache_path:
360
+ raise CDEnvironmentError(f"Failed to download node '{node_info.name}'")
361
+
362
+ # Scan requirements from cached directory
363
+ requirements = self.node_lookup.scan_requirements(cache_path)
364
+
365
+ # Create node package
366
+ node_package = NodePackage(node_info=node_info, requirements=requirements)
367
+
368
+ # TEST DEPENDENCIES FIRST (before any filesystem or pyproject changes)
369
+ if not no_test and node_package.requirements:
370
+ logger.info(f"Testing dependency resolution for '{node_package.name}' before installation")
371
+ test_result = self._test_requirements_in_isolation(node_package.requirements)
372
+ if not test_result.success:
373
+ self._raise_dependency_conflict(node_package.name, test_result)
374
+
375
+ # === BEGIN TRANSACTIONAL SECTION ===
376
+ # Snapshot state before any modifications for rollback
377
+ pyproject_snapshot = self.pyproject.snapshot()
378
+ target_path = self.custom_nodes_path / node_info.name
379
+ disabled_path = self.custom_nodes_path / f"{node_info.name}.disabled"
380
+ disabled_existed = disabled_path.exists()
381
+
382
+ try:
383
+ # STEP 1: Filesystem changes
384
+ if disabled_existed:
385
+ logger.info(f"Removing old disabled version of {node_info.name}")
386
+ shutil.rmtree(disabled_path)
387
+
388
+ shutil.copytree(cache_path, target_path, dirs_exist_ok=True)
389
+ logger.info(f"Installed node '{node_info.name}' to {target_path}")
390
+
391
+ # STEP 2: Pyproject changes
392
+ self.add_node_package(node_package)
393
+
394
+ # STEP 3: Environment sync (quiet - users see our high-level messages)
395
+ self.uv.sync_project(quiet=True, all_groups=True)
396
+
397
+ except Exception as e:
398
+ # === ROLLBACK ===
399
+ logger.warning(f"Installation failed for '{node_info.name}', rolling back...")
400
+
401
+ # 1. Restore pyproject.toml
402
+ try:
403
+ self.pyproject.restore(pyproject_snapshot)
404
+ logger.debug("Restored pyproject.toml to pre-installation state")
405
+ except Exception as restore_err:
406
+ logger.error(f"Failed to restore pyproject.toml: {restore_err}")
407
+
408
+ # 2. Clean up filesystem
409
+ if target_path.exists():
410
+ try:
411
+ shutil.rmtree(target_path)
412
+ logger.debug(f"Removed {target_path}")
413
+ except Exception as fs_err:
414
+ logger.warning(f"Could not remove {target_path} during rollback: {fs_err}")
415
+
416
+ # 3. Note about disabled directory (cannot restore - already deleted)
417
+ if disabled_existed:
418
+ logger.warning(
419
+ f"Cannot restore {disabled_path.name} "
420
+ f"(was deleted before rollback)"
421
+ )
422
+
423
+ # 4. Re-raise with appropriate error type
424
+ from ..models.exceptions import UVCommandError
425
+ from ..utils.uv_error_handler import format_uv_error_for_user, log_uv_error
426
+
427
+ if isinstance(e, UVCommandError):
428
+ # Log full error details for debugging
429
+ log_uv_error(logger, e, node_package.name)
430
+ # Format concise message for user
431
+ user_msg = format_uv_error_for_user(e)
432
+ raise CDNodeConflictError(
433
+ f"Node '{node_package.name}' dependency sync failed: {user_msg}"
434
+ ) from e
435
+ elif "already exists" in str(e):
436
+ raise CDEnvironmentError(str(e)) from e
437
+ else:
438
+ raise CDEnvironmentError(
439
+ f"Failed to add node '{node_package.name}': {e}"
440
+ ) from e
441
+
442
+ # === END TRANSACTIONAL SECTION ===
443
+
444
+ logger.info(f"Successfully added node '{node_package.name}'")
445
+ return node_package.node_info
446
+
447
+ def remove_node(self, identifier: str):
448
+ """Remove a custom node by identifier or name (case-insensitive).
449
+
450
+ Handles filesystem changes imperatively based on node type:
451
+ - Development nodes: Renamed to .disabled suffix (preserved)
452
+ - Registry/Git nodes: Deleted from filesystem (cached globally)
453
+
454
+ Returns:
455
+ NodeRemovalResult: Details about the removal
456
+
457
+ Raises:
458
+ CDNodeNotFoundError: If node not found
459
+ """
460
+ existing_nodes = self.pyproject.nodes.get_existing()
461
+ identifier_lower = identifier.lower()
462
+
463
+ # Try case-insensitive identifier lookup
464
+ actual_identifier = None
465
+ removed_node = None
466
+
467
+ for key, node in existing_nodes.items():
468
+ if key.lower() == identifier_lower:
469
+ actual_identifier = key
470
+ removed_node = node
471
+ break
472
+
473
+ if not actual_identifier:
474
+ # Try name-based lookup as fallback
475
+ found = self._find_node_by_name(identifier)
476
+ if found:
477
+ actual_identifier, removed_node = found
478
+ else:
479
+ raise CDNodeNotFoundError(f"Node '{identifier}' not found in environment")
480
+
481
+ # At this point both must be set
482
+ assert actual_identifier is not None
483
+ assert removed_node is not None
484
+
485
+ # Determine node type and filesystem action
486
+ is_development = removed_node.source == 'development'
487
+ node_path = self.custom_nodes_path / removed_node.name
488
+
489
+ # Handle filesystem imperatively
490
+ filesystem_action = "none"
491
+ if node_path.exists():
492
+ if is_development:
493
+ # Preserve development node with .disabled suffix
494
+ disabled_path = self.custom_nodes_path / f"{removed_node.name}.disabled"
495
+
496
+ # Handle existing .disabled directory (backup with timestamp BEFORE .disabled)
497
+ if disabled_path.exists():
498
+ import time
499
+ backup_path = self.custom_nodes_path / f"{removed_node.name}.{int(time.time())}.disabled"
500
+ shutil.move(disabled_path, backup_path)
501
+ logger.info(f"Backed up old .disabled to {backup_path.name}")
502
+
503
+ shutil.move(node_path, disabled_path)
504
+ filesystem_action = "disabled"
505
+ logger.info(f"Disabled development node: {removed_node.name}")
506
+ else:
507
+ # Delete registry/git node (cached globally, can re-download)
508
+ shutil.rmtree(node_path)
509
+ filesystem_action = "deleted"
510
+ logger.info(f"Removed {removed_node.name} (cached, can reinstall)")
511
+
512
+ # Remove from pyproject.toml
513
+ removed = self.pyproject.nodes.remove(actual_identifier)
514
+ if not removed:
515
+ raise CDNodeNotFoundError(f"Node '{identifier}' not found in environment")
516
+
517
+ # Clean up orphaned UV sources for registry/git nodes
518
+ if not is_development:
519
+ removed_sources = removed_node.dependency_sources or []
520
+ self.pyproject.uv_config.cleanup_orphaned_sources(removed_sources)
521
+
522
+ # Sync Python environment to remove orphaned packages (quiet - users see our high-level messages)
523
+ self.uv.sync_project(quiet=True, all_groups=True)
524
+
525
+ logger.info(f"Removed node '{actual_identifier}' from tracking")
526
+
527
+ return NodeRemovalResult(
528
+ identifier=actual_identifier,
529
+ name=removed_node.name,
530
+ source=removed_node.source,
531
+ filesystem_action=filesystem_action
532
+ )
533
+
534
+ def sync_nodes_to_filesystem(self, remove_extra: bool = False, callbacks=None):
535
+ """Sync custom nodes directory to match expected state from pyproject.toml.
536
+
537
+ Args:
538
+ remove_extra: If True, aggressively remove ALL extra nodes (except ComfyUI builtins).
539
+ If False, only warn about extra nodes.
540
+ callbacks: Optional NodeInstallCallbacks for progress feedback.
541
+
542
+ Strategy:
543
+ - Install missing registry/git nodes
544
+ - Remove extra nodes (if remove_extra=True) or warn (if False)
545
+
546
+ Note: When remove_extra=True, ALL untracked nodes are deleted regardless of whether
547
+ they appear to be dev nodes. User confirmation is required before calling with this flag.
548
+ """
549
+ import shutil
550
+
551
+ logger.info("Syncing custom nodes to filesystem...")
552
+
553
+ # Ensure directory exists
554
+ self.custom_nodes_path.mkdir(exist_ok=True)
555
+
556
+ # Get expected nodes from pyproject.toml
557
+ expected_nodes = self.pyproject.nodes.get_existing()
558
+
559
+ # Get existing active nodes (not .disabled)
560
+ existing_nodes = {
561
+ d.name: d for d in self.custom_nodes_path.iterdir()
562
+ if d.is_dir() and not d.name.endswith('.disabled')
563
+ }
564
+
565
+ expected_names = {info.name for info in expected_nodes.values()}
566
+ untracked = set(existing_nodes.keys()) - expected_names
567
+
568
+ if remove_extra:
569
+ # ComfyUI's built-in files that should not be removed
570
+ COMFYUI_BUILTINS = {'example_node.py.example', 'websocket_image_save.py', '__pycache__'}
571
+
572
+ # Remove ALL untracked nodes (user confirmed deletion in repair preview)
573
+ for node_name in untracked:
574
+ # Skip ComfyUI built-in example files
575
+ if node_name in COMFYUI_BUILTINS:
576
+ continue
577
+
578
+ node_path = self.custom_nodes_path / node_name
579
+ shutil.rmtree(node_path)
580
+ logger.info(f"Removed extra node: {node_name}")
581
+ else:
582
+ # Warn about extra nodes (don't auto-delete during manual sync)
583
+ for node_name in untracked:
584
+ logger.warning(f"Untracked node found: {node_name}")
585
+ logger.warning(f" Run 'comfygit node add {node_name} --dev' to track it")
586
+
587
+ # Install missing registry/git nodes
588
+ nodes_to_install = [
589
+ node_info for node_info in expected_nodes.values()
590
+ if node_info.source != 'development' and not (self.custom_nodes_path / node_info.name).exists()
591
+ ]
592
+
593
+ if callbacks and callbacks.on_batch_start and nodes_to_install:
594
+ callbacks.on_batch_start(len(nodes_to_install))
595
+
596
+ success_count = 0
597
+ for idx, node_info in enumerate(nodes_to_install):
598
+ node_path = self.custom_nodes_path / node_info.name
599
+
600
+ if callbacks and callbacks.on_node_start:
601
+ callbacks.on_node_start(node_info.name, idx + 1, len(nodes_to_install))
602
+
603
+ logger.info(f"Installing missing node: {node_info.name}")
604
+ try:
605
+ # Download to cache
606
+ cache_path = self.node_lookup.download_to_cache(node_info)
607
+ if cache_path:
608
+ shutil.copytree(cache_path, node_path, dirs_exist_ok=True)
609
+ logger.info(f"Successfully installed node: {node_info.name}")
610
+ success_count += 1
611
+ if callbacks and callbacks.on_node_complete:
612
+ callbacks.on_node_complete(node_info.name, True, None)
613
+ else:
614
+ logger.warning(f"Could not download node '{node_info.name}'")
615
+ if callbacks and callbacks.on_node_complete:
616
+ callbacks.on_node_complete(node_info.name, False, "Download failed")
617
+ except Exception as e:
618
+ logger.warning(f"Could not download node '{node_info.name}': {e}")
619
+ if callbacks and callbacks.on_node_complete:
620
+ callbacks.on_node_complete(node_info.name, False, str(e))
621
+
622
+ if callbacks and callbacks.on_batch_complete and nodes_to_install:
623
+ callbacks.on_batch_complete(success_count, len(nodes_to_install))
624
+
625
+ logger.info("Finished syncing custom nodes")
626
+
627
+ def reconcile_nodes_for_rollback(self, old_nodes: dict[str, NodeInfo], new_nodes: dict[str, NodeInfo]):
628
+ """Reconcile filesystem nodes after rollback with full context.
629
+
630
+ Args:
631
+ old_nodes: Nodes that were in pyproject before rollback
632
+ new_nodes: Nodes that are in pyproject after rollback
633
+ """
634
+ import shutil
635
+ import time
636
+
637
+ # Nodes that were removed (in old, not in new)
638
+ removed_node_names = set(old_nodes.keys()) - set(new_nodes.keys())
639
+
640
+ for identifier in removed_node_names:
641
+ old_node_info = old_nodes[identifier]
642
+ node_path = self.custom_nodes_path / old_node_info.name
643
+
644
+ if not node_path.exists():
645
+ continue # Already gone
646
+
647
+ # We KNOW what type it was from old_nodes - no guessing needed!
648
+ if old_node_info.source == 'development':
649
+ # Dev node - preserve with .disabled suffix
650
+ disabled_path = self.custom_nodes_path / f"{old_node_info.name}.disabled"
651
+
652
+ # Handle existing .disabled directory (backup with timestamp)
653
+ if disabled_path.exists():
654
+ backup_path = self.custom_nodes_path / f"{old_node_info.name}.{int(time.time())}.disabled"
655
+ shutil.move(disabled_path, backup_path)
656
+ logger.info(f"Backed up old .disabled to {backup_path.name}")
657
+
658
+ shutil.move(node_path, disabled_path)
659
+ logger.info(f"Disabled dev node '{old_node_info.name}' (rollback)")
660
+ else:
661
+ # Registry/git node - delete it (cached globally, can reinstall)
662
+ shutil.rmtree(node_path)
663
+ logger.info(f"Removed '{old_node_info.name}' (rollback, cached)")
664
+
665
+ # Nodes that were added (in new, not in old)
666
+ added_node_identifiers = set(new_nodes.keys()) - set(old_nodes.keys())
667
+
668
+ for identifier in added_node_identifiers:
669
+ new_node_info = new_nodes[identifier]
670
+ node_path = self.custom_nodes_path / new_node_info.name
671
+
672
+ if node_path.exists():
673
+ continue # Already present
674
+
675
+ # Install the node (skip dev nodes - user manages those)
676
+ if new_node_info.source != 'development':
677
+ logger.info(f"Installing '{new_node_info.name}' (rollback)")
678
+ try:
679
+ cache_path = self.node_lookup.download_to_cache(new_node_info)
680
+ if cache_path:
681
+ shutil.copytree(cache_path, node_path, dirs_exist_ok=True)
682
+ logger.info(f"Successfully installed '{new_node_info.name}'")
683
+ else:
684
+ logger.warning(f"Could not download '{new_node_info.name}'")
685
+ except Exception as e:
686
+ logger.warning(f"Failed to install '{new_node_info.name}': {e}")
687
+
688
+
689
+ def _get_existing_node_by_registry_id(self, registry_id: str) -> dict:
690
+ """Get existing node configuration by registry ID."""
691
+ existing_nodes = self.pyproject.nodes.get_existing()
692
+ for node_info in existing_nodes.values():
693
+ if hasattr(node_info, 'registry_id') and node_info.registry_id == registry_id:
694
+ return {
695
+ 'name': node_info.name,
696
+ 'registry_id': node_info.registry_id,
697
+ 'version': node_info.version,
698
+ 'repository': node_info.repository,
699
+ 'source': node_info.source
700
+ }
701
+ return {}
702
+
703
+ def _check_filesystem_conflict(
704
+ self,
705
+ node_name: str,
706
+ expected_repo_url: str | None = None
707
+ ) -> tuple[bool, str, NodeConflictContext | None]:
708
+ """Check if node directory exists and might conflict.
709
+
710
+ Args:
711
+ node_name: Name of the node directory
712
+ expected_repo_url: Expected repository URL (for comparison)
713
+
714
+ Returns:
715
+ (has_conflict, conflict_message, context)
716
+ """
717
+ node_path = self.custom_nodes_path / node_name
718
+
719
+ if not node_path.exists():
720
+ return False, "", None
721
+
722
+ # Check if it's a git repo
723
+ git_dir = node_path / '.git'
724
+ if not git_dir.exists():
725
+ context = NodeConflictContext(
726
+ conflict_type='directory_exists_non_git',
727
+ node_name=node_name,
728
+ filesystem_path=str(node_path),
729
+ suggested_actions=[
730
+ NodeAction(
731
+ action_type='add_node_dev',
732
+ node_name=node_name,
733
+ description="Track existing directory as development node"
734
+ ),
735
+ NodeAction(
736
+ action_type='add_node_force',
737
+ node_identifier='<identifier>',
738
+ description="Force replace existing directory"
739
+ )
740
+ ]
741
+ )
742
+ msg = f"Directory '{node_name}' already exists in custom_nodes/"
743
+ return True, msg, context
744
+
745
+ # Get remote URL
746
+ from ..utils.git import git_remote_get_url
747
+ local_remote = git_remote_get_url(node_path)
748
+
749
+ if not local_remote:
750
+ context = NodeConflictContext(
751
+ conflict_type='directory_exists_no_remote',
752
+ node_name=node_name,
753
+ filesystem_path=str(node_path),
754
+ suggested_actions=[
755
+ NodeAction(
756
+ action_type='add_node_dev',
757
+ node_name=node_name,
758
+ description="Track local git repository as development node"
759
+ ),
760
+ NodeAction(
761
+ action_type='add_node_force',
762
+ node_identifier='<identifier>',
763
+ description="Replace with registry version"
764
+ )
765
+ ]
766
+ )
767
+ msg = f"Git repository '{node_name}' exists locally (no remote)"
768
+ return True, msg, context
769
+
770
+ # Compare URLs if we have expected URL
771
+ if expected_repo_url:
772
+ if self._same_repository(local_remote, expected_repo_url):
773
+ context = NodeConflictContext(
774
+ conflict_type='same_repo_exists',
775
+ node_name=node_name,
776
+ local_remote_url=local_remote,
777
+ expected_remote_url=expected_repo_url,
778
+ suggested_actions=[
779
+ NodeAction(
780
+ action_type='add_node_dev',
781
+ node_name=node_name,
782
+ description="Track existing git clone as development node"
783
+ ),
784
+ NodeAction(
785
+ action_type='add_node_force',
786
+ node_identifier='<identifier>',
787
+ description="Re-download from registry (replaces local)"
788
+ )
789
+ ]
790
+ )
791
+ msg = f"Git clone of '{node_name}' already exists"
792
+ return True, msg, context
793
+ else:
794
+ context = NodeConflictContext(
795
+ conflict_type='different_repo_exists',
796
+ node_name=node_name,
797
+ local_remote_url=local_remote,
798
+ expected_remote_url=expected_repo_url,
799
+ suggested_actions=[
800
+ NodeAction(
801
+ action_type='rename_directory',
802
+ directory_name=node_name,
803
+ new_name=f"{node_name}-fork",
804
+ description="Rename your fork to avoid conflict"
805
+ ),
806
+ NodeAction(
807
+ action_type='add_node_force',
808
+ node_identifier='<identifier>',
809
+ description="Replace with registry version (deletes yours)"
810
+ )
811
+ ]
812
+ )
813
+ msg = f"Repository conflict for '{node_name}'"
814
+ return True, msg, context
815
+
816
+ # Have git repo but no expected URL to compare
817
+ context = NodeConflictContext(
818
+ conflict_type='directory_exists_no_remote',
819
+ node_name=node_name,
820
+ local_remote_url=local_remote,
821
+ suggested_actions=[
822
+ NodeAction(
823
+ action_type='add_node_dev',
824
+ node_name=node_name,
825
+ description="Track as development node"
826
+ ),
827
+ NodeAction(
828
+ action_type='add_node_force',
829
+ node_identifier='<identifier>',
830
+ description="Force replace"
831
+ )
832
+ ]
833
+ )
834
+ msg = f"Git repository '{node_name}' already exists"
835
+ return True, msg, context
836
+
837
+ @staticmethod
838
+ def _same_repository(url1: str, url2: str) -> bool:
839
+ """Check if two git URLs refer to the same repository.
840
+
841
+ Normalizes various URL formats for comparison.
842
+ """
843
+ normalized1 = normalize_github_url(url1).lower()
844
+ normalized2 = normalize_github_url(url2).lower()
845
+
846
+ return normalized1 == normalized2
847
+
848
+ def _add_development_node(self, identifier: str) -> NodeInfo:
849
+ """Add a development node - downloads if needed, then tracks."""
850
+ # Try to find existing directory (case-insensitive)
851
+ node_path = None
852
+ node_name: str | None = None
853
+
854
+ # Check if identifier is a simple name (not URL)
855
+ if not is_github_url(identifier):
856
+ # Look for existing directory
857
+ for item in self.custom_nodes_path.iterdir():
858
+ if item.is_dir() and item.name.lower() == identifier.lower():
859
+ node_path = item
860
+ node_name = item.name
861
+ logger.info(f"Found existing node directory: {node_name}")
862
+ break
863
+
864
+ # If not found locally, download it
865
+ if not node_path:
866
+ logger.info(f"Node not found locally, downloading: {identifier}")
867
+
868
+ # Get node info from lookup service
869
+ try:
870
+ node_info = self.node_lookup.get_node(identifier)
871
+ except CDNodeNotFoundError:
872
+ # Not in registry either - provide helpful error
873
+ if is_github_url(identifier):
874
+ raise CDNodeNotFoundError(
875
+ f"Cannot download from GitHub URL: {identifier}\n"
876
+ f"Ensure the URL is accessible and correctly formatted"
877
+ )
878
+ else:
879
+ raise CDNodeNotFoundError(
880
+ f"Node '{identifier}' not found in registry or filesystem.\n"
881
+ f"Provide a GitHub URL or ensure the directory exists in custom_nodes/"
882
+ )
883
+
884
+ node_name = node_info.name
885
+ node_path = self.custom_nodes_path / node_name
886
+
887
+ # Download to cache and copy to filesystem
888
+ logger.info(f"Downloading node '{node_name}' to {node_path}")
889
+ cache_path = self.node_lookup.download_to_cache(node_info)
890
+ if not cache_path:
891
+ raise CDEnvironmentError(f"Failed to download node '{node_name}'")
892
+ shutil.copytree(cache_path, node_path, dirs_exist_ok=True)
893
+
894
+ # At this point node_name and node_path must be set
895
+ assert node_name is not None, "node_name should be set by now"
896
+ assert node_path is not None, "node_path should be set by now"
897
+
898
+ # Check for duplicate tracking
899
+ existing = self._find_node_by_name(node_name)
900
+ if existing:
901
+ existing_id, existing_node = existing
902
+ if existing_node.version == 'dev':
903
+ logger.info(f"Development node '{node_name}' already tracked")
904
+ return existing_node
905
+ else:
906
+ context = NodeConflictContext(
907
+ conflict_type='already_tracked',
908
+ node_name=node_name,
909
+ existing_identifier=existing_id,
910
+ is_development=False,
911
+ suggested_actions=[
912
+ NodeAction(
913
+ action_type='remove_node',
914
+ node_identifier=existing_id,
915
+ description="Remove existing regular node first"
916
+ )
917
+ ]
918
+ )
919
+ raise CDNodeConflictError(
920
+ f"Node '{node_name}' already tracked as regular node (identifier: '{existing_id}')",
921
+ context=context
922
+ )
923
+
924
+ # Scan for requirements
925
+ requirements = self.node_lookup.scan_requirements(node_path)
926
+
927
+ # Create as development node
928
+ node_info = NodeInfo(name=node_name, version='dev', source='development')
929
+ node_package = NodePackage(node_info=node_info, requirements=requirements)
930
+
931
+ # Add to pyproject
932
+ self.add_node_package(node_package)
933
+
934
+ logger.info(f"Successfully added development node: {node_name}")
935
+ return node_info
936
+
937
+ def update_node(
938
+ self,
939
+ identifier: str,
940
+ confirmation_strategy: ConfirmationStrategy | None = None,
941
+ no_test: bool = False
942
+ ) -> UpdateResult:
943
+ """Update a node based on its source type.
944
+
945
+ Args:
946
+ identifier: Node identifier or name
947
+ confirmation_strategy: Strategy for confirming updates (None = auto-confirm)
948
+ no_test: Skip resolution testing (dev nodes only)
949
+
950
+ Returns:
951
+ UpdateResult with details of what changed
952
+
953
+ Raises:
954
+ CDNodeNotFoundError: If node not found
955
+ CDEnvironmentError: If node cannot be updated
956
+ """
957
+ # Default to auto-confirm if no strategy provided
958
+ if confirmation_strategy is None:
959
+ confirmation_strategy = AutoConfirmStrategy()
960
+
961
+ # Get current node info
962
+ nodes = self.pyproject.nodes.get_existing()
963
+ node_info = None
964
+ actual_identifier = None
965
+
966
+ # Try direct identifier lookup first
967
+ if identifier in nodes:
968
+ node_info = nodes[identifier]
969
+ actual_identifier = identifier
970
+ else:
971
+ # Try name-based lookup
972
+ found = self._find_node_by_name(identifier)
973
+ if found:
974
+ actual_identifier, node_info = found
975
+
976
+ if not node_info or not actual_identifier:
977
+ raise CDNodeNotFoundError(f"Node '{identifier}' not found")
978
+
979
+ # Dispatch based on source type
980
+ if node_info.source == 'development':
981
+ return self._update_development_node(actual_identifier, node_info, no_test)
982
+ elif node_info.source == 'registry':
983
+ return self._update_registry_node(actual_identifier, node_info, confirmation_strategy, no_test)
984
+ elif node_info.source == 'git':
985
+ return self._update_git_node(actual_identifier, node_info, confirmation_strategy, no_test)
986
+ else:
987
+ raise CDEnvironmentError(f"Unknown node source: {node_info.source}")
988
+
989
+ def _update_development_node(self, identifier: str, node_info: NodeInfo, no_test: bool) -> UpdateResult:
990
+ """Update dev node by re-scanning requirements."""
991
+ result = UpdateResult(node_name=node_info.name, source='development')
992
+
993
+ # Scan current requirements
994
+ node_path = self.custom_nodes_path / node_info.name
995
+ if not node_path.exists():
996
+ raise CDNodeNotFoundError(f"Dev node directory not found: {node_path}")
997
+
998
+ current_reqs = self.node_lookup.scan_requirements(node_path)
999
+
1000
+ # Get stored requirements from dependency group
1001
+ group_name = self.pyproject.nodes.generate_group_name(node_info, identifier)
1002
+ stored_groups = self.pyproject.dependencies.get_groups()
1003
+ stored_reqs = stored_groups.get(group_name, [])
1004
+
1005
+ # Normalize for comparison (compare package names only)
1006
+ current_names = {parse_dependency_string(r)[0] for r in current_reqs}
1007
+ stored_names = {parse_dependency_string(r)[0] for r in stored_reqs}
1008
+
1009
+ added = current_names - stored_names
1010
+ removed = stored_names - current_names
1011
+
1012
+ if not added and not removed:
1013
+ result.message = "No requirement changes detected"
1014
+ return result
1015
+
1016
+ # Update requirements
1017
+ existing_sources = self.pyproject.uv_config.get_source_names()
1018
+
1019
+ if current_reqs:
1020
+ self.uv.add_requirements_with_sources(
1021
+ current_reqs, group=group_name, no_sync=True, raw=True
1022
+ )
1023
+ else:
1024
+ # No requirements - remove group
1025
+ self.pyproject.dependencies.remove_group(group_name)
1026
+
1027
+ # Detect new sources
1028
+ new_sources = self.pyproject.uv_config.get_source_names() - existing_sources
1029
+ if new_sources:
1030
+ node_info.dependency_sources = sorted(new_sources)
1031
+ self.pyproject.nodes.add(node_info, identifier)
1032
+
1033
+ # Test resolution if requested
1034
+ if not no_test:
1035
+ resolution_result = self.resolution_tester.test_resolution(self.pyproject.path)
1036
+ if not resolution_result.success:
1037
+ self._raise_dependency_conflict(node_info.name, resolution_result)
1038
+
1039
+ result.requirements_added = list(added)
1040
+ result.requirements_removed = list(removed)
1041
+ result.changed = True
1042
+ result.message = f"Updated requirements: +{len(added)} -{len(removed)}"
1043
+
1044
+ # Sync Python environment to apply requirement changes (quiet - users see our high-level messages)
1045
+ self.uv.sync_project(quiet=True, all_groups=True)
1046
+
1047
+ logger.info(f"Updated dev node '{node_info.name}': {result.message}")
1048
+ return result
1049
+
1050
+ def _update_registry_node(
1051
+ self,
1052
+ identifier: str,
1053
+ node_info: NodeInfo,
1054
+ confirmation_strategy: ConfirmationStrategy,
1055
+ no_test: bool
1056
+ ) -> UpdateResult:
1057
+ """Update registry node to latest version with atomic rollback on failure."""
1058
+ result = UpdateResult(node_name=node_info.name, source='registry')
1059
+
1060
+ if not node_info.registry_id:
1061
+ raise CDEnvironmentError(f"Node '{node_info.name}' has no registry_id")
1062
+
1063
+ # Query registry for latest version
1064
+ try:
1065
+ registry_node = self.node_lookup.registry_client.get_node(node_info.registry_id)
1066
+ except Exception as e:
1067
+ result.message = f"Failed to check for updates: {e}"
1068
+ return result
1069
+
1070
+ if not registry_node or not registry_node.latest_version:
1071
+ result.message = "No updates available (registry unavailable)"
1072
+ return result
1073
+
1074
+ latest_version = registry_node.latest_version.version
1075
+ current_version = node_info.version or "unknown"
1076
+
1077
+ if latest_version == current_version:
1078
+ result.message = f"Already at latest version ({current_version})"
1079
+ return result
1080
+
1081
+ # Confirm update using strategy
1082
+ if not confirmation_strategy.confirm_update(node_info.name, current_version, latest_version):
1083
+ result.message = "Update cancelled by user"
1084
+ return result
1085
+
1086
+ # === ATOMIC UPDATE WITH ROLLBACK ===
1087
+ # Preserve old node by disabling it instead of removing
1088
+ node_path = self.custom_nodes_path / node_info.name
1089
+ disabled_path = self.custom_nodes_path / f"{node_info.name}.disabled"
1090
+ pyproject_snapshot = self.pyproject.snapshot()
1091
+
1092
+ try:
1093
+ # STEP 1: Disable old node (rename to .disabled)
1094
+ if node_path.exists():
1095
+ if disabled_path.exists():
1096
+ # Clean up any existing .disabled from previous failed update
1097
+ shutil.rmtree(disabled_path)
1098
+ shutil.move(node_path, disabled_path)
1099
+ logger.debug(f"Disabled old version of '{node_info.name}'")
1100
+
1101
+ # STEP 2: Remove old node from tracking
1102
+ self.pyproject.nodes.remove(identifier)
1103
+ self.uv.sync_project(quiet=True, all_groups=True)
1104
+
1105
+ # STEP 3: Get complete version data with downloadUrl from install endpoint
1106
+ complete_version = self.node_lookup.registry_client.install_node(
1107
+ node_info.registry_id,
1108
+ latest_version
1109
+ )
1110
+
1111
+ if complete_version:
1112
+ # Replace incomplete version data with complete version
1113
+ registry_node.latest_version = complete_version
1114
+
1115
+ # Create fresh node info from API response with complete data
1116
+ fresh_node_info = NodeInfo.from_registry_node(registry_node)
1117
+
1118
+ # STEP 4: Install the new version
1119
+ self._install_node_from_info(fresh_node_info, no_test=no_test)
1120
+
1121
+ # STEP 5: Success - delete old disabled version
1122
+ if disabled_path.exists():
1123
+ shutil.rmtree(disabled_path)
1124
+ logger.debug(f"Deleted old version of '{node_info.name}'")
1125
+
1126
+ except Exception as e:
1127
+ # === ROLLBACK ===
1128
+ logger.warning(f"Update failed for '{node_info.name}', rolling back...")
1129
+
1130
+ # 1. Restore pyproject.toml
1131
+ try:
1132
+ self.pyproject.restore(pyproject_snapshot)
1133
+ logger.debug("Restored pyproject.toml to pre-update state")
1134
+ except Exception as restore_err:
1135
+ logger.error(f"Failed to restore pyproject.toml: {restore_err}")
1136
+
1137
+ # 2. Remove failed new installation
1138
+ if node_path.exists():
1139
+ try:
1140
+ shutil.rmtree(node_path)
1141
+ logger.debug(f"Removed failed installation of '{node_info.name}'")
1142
+ except Exception as cleanup_err:
1143
+ logger.error(f"Failed to clean up new installation: {cleanup_err}")
1144
+
1145
+ # 3. Restore old version from .disabled
1146
+ if disabled_path.exists():
1147
+ try:
1148
+ shutil.move(disabled_path, node_path)
1149
+ logger.info(f"Restored old version of '{node_info.name}'")
1150
+ except Exception as restore_err:
1151
+ logger.error(f"Failed to restore old version: {restore_err}")
1152
+
1153
+ # 4. Sync environment to restore old dependencies
1154
+ try:
1155
+ self.uv.sync_project(quiet=True, all_groups=True)
1156
+ except Exception:
1157
+ pass # Best effort
1158
+
1159
+ # Re-raise original error
1160
+ raise CDEnvironmentError(f"Failed to update node '{node_info.name}': {e}") from e
1161
+
1162
+ result.old_version = current_version
1163
+ result.new_version = latest_version
1164
+ result.changed = True
1165
+ result.message = f"Updated from {current_version} → {latest_version}"
1166
+
1167
+ logger.info(f"Updated registry node '{node_info.name}': {result.message}")
1168
+ return result
1169
+
1170
+ def _update_git_node(
1171
+ self,
1172
+ identifier: str,
1173
+ node_info: NodeInfo,
1174
+ confirmation_strategy: ConfirmationStrategy,
1175
+ no_test: bool
1176
+ ) -> UpdateResult:
1177
+ """Update git node to latest commit with atomic rollback on failure."""
1178
+ result = UpdateResult(node_name=node_info.name, source='git')
1179
+
1180
+ if not node_info.repository:
1181
+ raise CDEnvironmentError(f"Node '{node_info.name}' has no repository URL")
1182
+
1183
+ # Query GitHub for latest commit
1184
+ try:
1185
+ repo_info = self.node_lookup.github_client.get_repository_info(node_info.repository)
1186
+ except Exception as e:
1187
+ result.message = f"Failed to check for updates: {e}"
1188
+ return result
1189
+
1190
+ if not repo_info:
1191
+ result.message = "Failed to get repository information"
1192
+ return result
1193
+
1194
+ latest_commit = repo_info.latest_commit
1195
+ current_commit = node_info.version or "unknown"
1196
+
1197
+ # Format for display
1198
+ current_display = current_commit[:8] if current_commit != "unknown" else "unknown"
1199
+ latest_display = latest_commit[:8] if latest_commit else "unknown"
1200
+
1201
+ if latest_commit == current_commit:
1202
+ result.message = f"Already at latest commit ({current_display})"
1203
+ return result
1204
+
1205
+ # Confirm update using strategy (pass formatted versions for display)
1206
+ if not confirmation_strategy.confirm_update(node_info.name, current_display, latest_display):
1207
+ result.message = "Update cancelled by user"
1208
+ return result
1209
+
1210
+ # === ATOMIC UPDATE WITH ROLLBACK ===
1211
+ node_path = self.custom_nodes_path / node_info.name
1212
+ disabled_path = self.custom_nodes_path / f"{node_info.name}.disabled"
1213
+ pyproject_snapshot = self.pyproject.snapshot()
1214
+
1215
+ try:
1216
+ # STEP 1: Disable old node (rename to .disabled)
1217
+ if node_path.exists():
1218
+ if disabled_path.exists():
1219
+ shutil.rmtree(disabled_path)
1220
+ shutil.move(node_path, disabled_path)
1221
+ logger.debug(f"Disabled old version of '{node_info.name}'")
1222
+
1223
+ # STEP 2: Remove old node from tracking
1224
+ self.pyproject.nodes.remove(identifier)
1225
+ self.uv.sync_project(quiet=True, all_groups=True)
1226
+
1227
+ # STEP 3: Create fresh node info from GitHub API response
1228
+ fresh_node_info = NodeInfo(
1229
+ name=repo_info.name,
1230
+ repository=repo_info.clone_url,
1231
+ source="git",
1232
+ version=repo_info.latest_commit
1233
+ )
1234
+
1235
+ # STEP 4: Install the new version
1236
+ self._install_node_from_info(fresh_node_info, no_test=no_test)
1237
+
1238
+ # STEP 5: Success - delete old disabled version
1239
+ if disabled_path.exists():
1240
+ shutil.rmtree(disabled_path)
1241
+ logger.debug(f"Deleted old version of '{node_info.name}'")
1242
+
1243
+ except Exception as e:
1244
+ # === ROLLBACK ===
1245
+ logger.warning(f"Update failed for '{node_info.name}', rolling back...")
1246
+
1247
+ # 1. Restore pyproject.toml
1248
+ try:
1249
+ self.pyproject.restore(pyproject_snapshot)
1250
+ logger.debug("Restored pyproject.toml to pre-update state")
1251
+ except Exception as restore_err:
1252
+ logger.error(f"Failed to restore pyproject.toml: {restore_err}")
1253
+
1254
+ # 2. Remove failed new installation
1255
+ if node_path.exists():
1256
+ try:
1257
+ shutil.rmtree(node_path)
1258
+ logger.debug(f"Removed failed installation of '{node_info.name}'")
1259
+ except Exception as cleanup_err:
1260
+ logger.error(f"Failed to clean up new installation: {cleanup_err}")
1261
+
1262
+ # 3. Restore old version from .disabled
1263
+ if disabled_path.exists():
1264
+ try:
1265
+ shutil.move(disabled_path, node_path)
1266
+ logger.info(f"Restored old version of '{node_info.name}'")
1267
+ except Exception as restore_err:
1268
+ logger.error(f"Failed to restore old version: {restore_err}")
1269
+
1270
+ # 4. Sync environment to restore old dependencies
1271
+ try:
1272
+ self.uv.sync_project(quiet=True, all_groups=True)
1273
+ except Exception:
1274
+ pass # Best effort
1275
+
1276
+ # Re-raise original error
1277
+ raise CDEnvironmentError(f"Failed to update node '{node_info.name}': {e}") from e
1278
+
1279
+ result.old_version = current_display
1280
+ result.new_version = latest_display
1281
+ result.changed = True
1282
+ result.message = f"Updated to latest commit ({latest_display})"
1283
+
1284
+ logger.info(f"Updated git node '{node_info.name}': {result.message}")
1285
+ return result
1286
+
1287
+ def check_development_node_drift(self) -> dict[str, tuple[set[str], set[str]]]:
1288
+ """Check if dev nodes have requirements drift.
1289
+
1290
+ Returns:
1291
+ Dict mapping node_name -> (added_deps, removed_deps)
1292
+ """
1293
+ drift = {}
1294
+ nodes = self.pyproject.nodes.get_existing()
1295
+
1296
+ for identifier, node_info in nodes.items():
1297
+ if node_info.source != 'development':
1298
+ continue
1299
+
1300
+ node_path = self.custom_nodes_path / node_info.name
1301
+ if not node_path.exists():
1302
+ continue
1303
+
1304
+ # Scan current requirements
1305
+ current_reqs = self.node_lookup.scan_requirements(node_path)
1306
+
1307
+ # Get stored requirements from dependency group
1308
+ group_name = self.pyproject.nodes.generate_group_name(node_info, identifier)
1309
+ stored_groups = self.pyproject.dependencies.get_groups()
1310
+ stored_reqs = stored_groups.get(group_name, [])
1311
+
1312
+ # Compare package names
1313
+ current_names = {parse_dependency_string(r)[0] for r in current_reqs}
1314
+ stored_names = {parse_dependency_string(r)[0] for r in stored_reqs}
1315
+
1316
+ added = current_names - stored_names
1317
+ removed = stored_names - current_names
1318
+
1319
+ if added or removed:
1320
+ drift[node_info.name] = (added, removed)
1321
+
1322
+ return drift
1323
+
1324
+ def _test_requirements_in_isolation(self, requirements: list[str]):
1325
+ """Test requirements in isolation without modifying pyproject.toml.
1326
+
1327
+ Uses the resolution tester to check if requirements are compatible
1328
+ with the current environment without actually modifying it.
1329
+
1330
+ Args:
1331
+ requirements: List of requirement strings to test
1332
+
1333
+ Returns:
1334
+ ResolutionResult with success status and any conflicts
1335
+ """
1336
+ # Use test_with_additions which creates a temp copy of pyproject.toml
1337
+ # and tests the dependencies in isolation
1338
+ return self.resolution_tester.test_with_additions(
1339
+ base_pyproject=self.pyproject.path,
1340
+ additional_deps=requirements,
1341
+ group_name=None # Test as main dependencies for broadest compatibility check
1342
+ )
1343
+
1344
+ def _raise_dependency_conflict(self, node_name: str, test_result) -> None:
1345
+ """Raise enhanced dependency conflict error with actionable suggestions.
1346
+
1347
+ Args:
1348
+ node_name: Name of the node being installed
1349
+ test_result: ResolutionResult from dependency testing
1350
+ """
1351
+ # Extract conflicting package pairs
1352
+ conflict_pairs = extract_conflicting_packages(test_result.stderr)
1353
+
1354
+ # Build simple, honest suggestions
1355
+ suggestions = [
1356
+ NodeAction(
1357
+ action_type='skip_node',
1358
+ description=f"Skip installing '{node_name}'"
1359
+ ),
1360
+ NodeAction(
1361
+ action_type='add_constraint',
1362
+ description="Add version constraint to override (see --verbose for details)"
1363
+ )
1364
+ ]
1365
+
1366
+ # Create enhanced context
1367
+ context = DependencyConflictContext(
1368
+ node_name=node_name,
1369
+ conflicting_packages=conflict_pairs,
1370
+ conflict_descriptions=test_result.conflicts,
1371
+ raw_stderr=test_result.stderr,
1372
+ suggested_actions=suggestions
1373
+ )
1374
+
1375
+ raise CDDependencyConflictError(
1376
+ f"Cannot add '{node_name}' due to dependency conflicts",
1377
+ context=context
1378
+ )