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.
- comfygit_core/analyzers/custom_node_scanner.py +109 -0
- comfygit_core/analyzers/git_change_parser.py +156 -0
- comfygit_core/analyzers/model_scanner.py +318 -0
- comfygit_core/analyzers/node_classifier.py +58 -0
- comfygit_core/analyzers/node_git_analyzer.py +77 -0
- comfygit_core/analyzers/status_scanner.py +362 -0
- comfygit_core/analyzers/workflow_dependency_parser.py +143 -0
- comfygit_core/caching/__init__.py +16 -0
- comfygit_core/caching/api_cache.py +210 -0
- comfygit_core/caching/base.py +212 -0
- comfygit_core/caching/comfyui_cache.py +100 -0
- comfygit_core/caching/custom_node_cache.py +320 -0
- comfygit_core/caching/workflow_cache.py +797 -0
- comfygit_core/clients/__init__.py +4 -0
- comfygit_core/clients/civitai_client.py +412 -0
- comfygit_core/clients/github_client.py +349 -0
- comfygit_core/clients/registry_client.py +230 -0
- comfygit_core/configs/comfyui_builtin_nodes.py +1614 -0
- comfygit_core/configs/comfyui_models.py +62 -0
- comfygit_core/configs/model_config.py +151 -0
- comfygit_core/constants.py +82 -0
- comfygit_core/core/environment.py +1635 -0
- comfygit_core/core/workspace.py +898 -0
- comfygit_core/factories/environment_factory.py +419 -0
- comfygit_core/factories/uv_factory.py +61 -0
- comfygit_core/factories/workspace_factory.py +109 -0
- comfygit_core/infrastructure/sqlite_manager.py +156 -0
- comfygit_core/integrations/__init__.py +7 -0
- comfygit_core/integrations/uv_command.py +318 -0
- comfygit_core/logging/logging_config.py +15 -0
- comfygit_core/managers/environment_git_orchestrator.py +316 -0
- comfygit_core/managers/environment_model_manager.py +296 -0
- comfygit_core/managers/export_import_manager.py +116 -0
- comfygit_core/managers/git_manager.py +667 -0
- comfygit_core/managers/model_download_manager.py +252 -0
- comfygit_core/managers/model_symlink_manager.py +166 -0
- comfygit_core/managers/node_manager.py +1378 -0
- comfygit_core/managers/pyproject_manager.py +1321 -0
- comfygit_core/managers/user_content_symlink_manager.py +436 -0
- comfygit_core/managers/uv_project_manager.py +569 -0
- comfygit_core/managers/workflow_manager.py +1944 -0
- comfygit_core/models/civitai.py +432 -0
- comfygit_core/models/commit.py +18 -0
- comfygit_core/models/environment.py +293 -0
- comfygit_core/models/exceptions.py +378 -0
- comfygit_core/models/manifest.py +132 -0
- comfygit_core/models/node_mapping.py +201 -0
- comfygit_core/models/protocols.py +248 -0
- comfygit_core/models/registry.py +63 -0
- comfygit_core/models/shared.py +356 -0
- comfygit_core/models/sync.py +42 -0
- comfygit_core/models/system.py +204 -0
- comfygit_core/models/workflow.py +914 -0
- comfygit_core/models/workspace_config.py +71 -0
- comfygit_core/py.typed +0 -0
- comfygit_core/repositories/migrate_paths.py +49 -0
- comfygit_core/repositories/model_repository.py +958 -0
- comfygit_core/repositories/node_mappings_repository.py +246 -0
- comfygit_core/repositories/workflow_repository.py +57 -0
- comfygit_core/repositories/workspace_config_repository.py +121 -0
- comfygit_core/resolvers/global_node_resolver.py +459 -0
- comfygit_core/resolvers/model_resolver.py +250 -0
- comfygit_core/services/import_analyzer.py +218 -0
- comfygit_core/services/model_downloader.py +422 -0
- comfygit_core/services/node_lookup_service.py +251 -0
- comfygit_core/services/registry_data_manager.py +161 -0
- comfygit_core/strategies/__init__.py +4 -0
- comfygit_core/strategies/auto.py +72 -0
- comfygit_core/strategies/confirmation.py +69 -0
- comfygit_core/utils/comfyui_ops.py +125 -0
- comfygit_core/utils/common.py +164 -0
- comfygit_core/utils/conflict_parser.py +232 -0
- comfygit_core/utils/dependency_parser.py +231 -0
- comfygit_core/utils/download.py +216 -0
- comfygit_core/utils/environment_cleanup.py +111 -0
- comfygit_core/utils/filesystem.py +178 -0
- comfygit_core/utils/git.py +1184 -0
- comfygit_core/utils/input_signature.py +145 -0
- comfygit_core/utils/model_categories.py +52 -0
- comfygit_core/utils/pytorch.py +71 -0
- comfygit_core/utils/requirements.py +211 -0
- comfygit_core/utils/retry.py +242 -0
- comfygit_core/utils/symlink_utils.py +119 -0
- comfygit_core/utils/system_detector.py +258 -0
- comfygit_core/utils/uuid.py +28 -0
- comfygit_core/utils/uv_error_handler.py +158 -0
- comfygit_core/utils/version.py +73 -0
- comfygit_core/utils/workflow_hash.py +90 -0
- comfygit_core/validation/resolution_tester.py +297 -0
- comfygit_core-0.2.0.dist-info/METADATA +939 -0
- comfygit_core-0.2.0.dist-info/RECORD +93 -0
- comfygit_core-0.2.0.dist-info/WHEEL +4 -0
- 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
|
+
)
|