"""Lock file for reproducible dependency installation. Provides cmdforge.lock file support for: - Recording exact versions of all dependencies (direct + transitive) - Reproducible installs across machines - Integrity verification via content hashes """ import hashlib from dataclasses import dataclass, field from datetime import datetime from pathlib import Path from typing import Dict, List, Optional import yaml @dataclass class LockedPackage: """A locked dependency with exact version and integrity.""" name: str # Qualified: owner/name version: str # Exact resolved version constraint: str # Original constraint integrity: str # sha256 hash (from registry config_hash) source: str # "registry", "local", "global" direct: bool # True if in manifest required_by: List[str] = field(default_factory=list) # Parent packages path: Optional[str] = None # Relative path for local tools content_hash: str = "" # Definition plus dependency identities dependency_hashes: Dict[str, str] = field(default_factory=dict) @property def owner(self) -> str: """Extract owner from qualified name.""" return self.name.split("/")[0] if "/" in self.name else "" @property def tool_name(self) -> str: """Extract tool name from qualified name.""" return self.name.split("/")[1] if "/" in self.name else self.name @dataclass class LockfileMetadata: """Metadata about when/how the lock file was generated.""" generated_at: str cmdforge_version: str manifest_hash: str platform: str python_version: str @dataclass class Lockfile: """Complete lock file representation.""" lockfile_version: int = 1 metadata: Optional[LockfileMetadata] = None packages: Dict[str, LockedPackage] = field(default_factory=dict) @classmethod def load(cls, path: Path = None) -> Optional["Lockfile"]: """Load lock file from disk. Args: path: Path to lock file (default: ./cmdforge.lock) Returns: Lockfile object, or None if not found """ path = path or Path("cmdforge.lock") if not path.exists(): return None try: with open(path) as f: data = yaml.safe_load(f) or {} return cls._from_dict(data) except Exception as e: print(f"Warning: Could not load lock file: {e}") return None def save(self, path: Path = None) -> None: """Save lock file to disk. Args: path: Path to save to (default: ./cmdforge.lock) """ path = path or Path("cmdforge.lock") with open(path, "w") as f: # Add header comment f.write("# cmdforge.lock\n") f.write("# Auto-generated by 'cmdforge lock' - do not edit manually\n") f.write("# To update: cmdforge lock --force\n\n") yaml.safe_dump( self._to_dict(), f, sort_keys=False, default_flow_style=False ) def get_package(self, name: str) -> Optional[LockedPackage]: """Get a locked package by qualified name.""" return self.packages.get(name) def is_stale(self, manifest_path: Path = None) -> bool: """Check if lock file is outdated relative to manifest. Note: This only checks if the manifest hash changed. It does NOT detect if local tools were edited after locking. Use verify_lockfile() for full integrity checking. Args: manifest_path: Path to manifest (default: ./cmdforge.yaml) Returns: True if lock file is stale """ manifest_path = manifest_path or Path("cmdforge.yaml") if not manifest_path.exists(): return True if not self.metadata: return True current_hash = compute_file_hash(manifest_path) return current_hash != self.metadata.manifest_hash @classmethod def _from_dict(cls, data: dict) -> "Lockfile": """Parse lock file from dict.""" metadata = None if "metadata" in data: m = data["metadata"] metadata = LockfileMetadata( generated_at=m.get("generated_at", ""), cmdforge_version=m.get("cmdforge_version", ""), manifest_hash=m.get("manifest_hash", ""), platform=m.get("platform", ""), python_version=m.get("python_version", "") ) packages = {} for name, pkg_data in data.get("packages", {}).items(): packages[name] = LockedPackage( name=name, version=pkg_data.get("version", ""), constraint=pkg_data.get("constraint", "*"), integrity=pkg_data.get("integrity", ""), source=pkg_data.get("source", "registry"), direct=pkg_data.get("direct", False), required_by=pkg_data.get("required_by", []), path=pkg_data.get("path"), content_hash=pkg_data.get("content_hash", ""), dependency_hashes=pkg_data.get("dependency_hashes", {}) ) return cls( lockfile_version=data.get("lockfile_version", 1), metadata=metadata, packages=packages ) def _to_dict(self) -> dict: """Convert to dict for YAML serialization.""" d = {"lockfile_version": self.lockfile_version} if self.metadata: d["metadata"] = { "generated_at": self.metadata.generated_at, "cmdforge_version": self.metadata.cmdforge_version, "manifest_hash": self.metadata.manifest_hash, "platform": self.metadata.platform, "python_version": self.metadata.python_version } d["packages"] = {} for name, pkg in self.packages.items(): pkg_dict = { "version": pkg.version, "constraint": pkg.constraint, "integrity": pkg.integrity, "source": pkg.source, "direct": pkg.direct } if pkg.required_by: pkg_dict["required_by"] = pkg.required_by if pkg.path: pkg_dict["path"] = pkg.path if pkg.content_hash: pkg_dict["content_hash"] = pkg.content_hash if pkg.dependency_hashes: pkg_dict["dependency_hashes"] = dict(sorted(pkg.dependency_hashes.items())) d["packages"][name] = pkg_dict return d def compute_file_hash(path: Path) -> str: """Compute SHA256 hash of raw file bytes. Used ONLY for manifest hash (to detect any change, including formatting). For tool config integrity, use hash_utils.compute_yaml_hash() instead. Args: path: Path to file Returns: Hash string in format "sha256:<64-char-hex>" """ sha256 = hashlib.sha256() with open(path, "rb") as f: for chunk in iter(lambda: f.read(8192), b""): sha256.update(chunk) return f"sha256:{sha256.hexdigest()}" def generate_lockfile( manifest: "Manifest", graph: "DependencyGraph", client: "RegistryClient" ) -> Lockfile: """ Generate a lock file from resolved dependencies. Args: manifest: Project manifest with constraints graph: Resolved dependency graph (from DependencyGraphBuilder) client: Registry client for fetching config_hash Returns: Complete Lockfile ready to save """ import platform import sys from . import __version__ # Compute manifest hash manifest_path = Path("cmdforge.yaml") manifest_hash = compute_file_hash(manifest_path) if manifest_path.exists() else "" metadata = LockfileMetadata( generated_at=datetime.now().astimezone().isoformat(), cmdforge_version=__version__, manifest_hash=manifest_hash, platform=platform.system().lower(), python_version=f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" ) lock = Lockfile(metadata=metadata) # Get direct dependency names for marking direct_names = {d.name for d in manifest.dependencies} # Process all nodes in the graph for qualified_name, node in graph.nodes.items(): if not node.is_resolved: continue integrity = _get_integrity_hash(node, client) pkg = LockedPackage( name=qualified_name, version=node.resolved_version or "", constraint=node.version_constraint or "*", integrity=integrity, source=node.source or "registry", direct=qualified_name in direct_names or _is_direct(qualified_name, direct_names), required_by=_find_parents(qualified_name, graph), path=str(node.path) if node.source == "local" and node.path else None ) lock.packages[qualified_name] = pkg from .integrity import compute_content_identity visiting = set() def identity_for(package_name: str) -> str: pkg = lock.packages.get(package_name) node = graph.nodes.get(package_name) if pkg is None or node is None or not pkg.integrity: return "" if pkg.content_hash: return pkg.content_hash if package_name in visiting: return "" visiting.add(package_name) dependencies = {} for child_name in sorted(node.children): child_hash = identity_for(child_name) if not child_hash: visiting.remove(package_name) return "" dependencies[child_name] = child_hash visiting.remove(package_name) pkg.dependency_hashes = dependencies pkg.content_hash = compute_content_identity(pkg.integrity, dependencies) return pkg.content_hash for package_name in sorted(lock.packages): identity_for(package_name) return lock def _get_integrity_hash(node: "DependencyNode", client: "RegistryClient") -> str: """Get integrity hash for a dependency node.""" if node.source in ("local", "global") and node.path: # For local/global tools, hash normalized config config_path = node.path / "config.yaml" if config_path.exists(): from .hash_utils import compute_yaml_hash return compute_yaml_hash(config_path.read_text()) return "" elif node.source == "registry": # For registry tools, get the config_hash from registry # This is the hash BEFORE any local modifications try: result = client.download_tool( node.owner, node.name, version=node.resolved_version, install=False # Don't count as install, just get config ) # Use the config_hash from registry response if hasattr(result, 'config_hash') and result.config_hash: # config_hash already includes "sha256:" prefix return result.config_hash # Fallback: hash the config_yaml content using normalized hashing if hasattr(result, 'config_yaml') and result.config_yaml: from .hash_utils import compute_yaml_hash return compute_yaml_hash(result.config_yaml) except Exception: return "" return "" def _is_direct(qualified_name: str, direct_names: set) -> bool: """Check if a qualified name matches any direct dependency.""" # Handle case where manifest has unqualified names tool_name = qualified_name.split("/")[1] if "/" in qualified_name else qualified_name return qualified_name in direct_names or tool_name in direct_names def _find_parents(qualified_name: str, graph: "DependencyGraph") -> List[str]: """Find packages that depend on this one.""" parents = [] for name, node in graph.nodes.items(): if qualified_name in node.children: parents.append(name) return parents def verify_lockfile( lock: Lockfile, client: "RegistryClient" ) -> List[str]: """ Verify installed tools match lock file. Args: lock: Lock file to verify against client: Registry client for hash verification Returns: List of verification errors (empty if all OK) """ from .resolver import ToolResolver, ToolNotFoundError errors = [] # Create resolver that doesn't use manifest (bypass version overrides) resolver = ToolResolver(auto_fetch=False) resolver.manifest = None for name, locked in lock.packages.items(): # Check tool exists try: resolved = resolver.resolve(name) except ToolNotFoundError: errors.append(f"{name}: not installed") continue except Exception: errors.append(f"{name}: not installed") continue # Check version matches resolved_version = resolved.version or "" locked_version = locked.version or "" if resolved_version and locked_version and resolved_version != locked_version: errors.append( f"{name}: version mismatch " f"(installed: {resolved_version}, locked: {locked_version})" ) # Check integrity against installed tool config if locked.integrity and resolved.path: try: config_path = resolved.path / "config.yaml" if config_path.exists(): from .hash_utils import compute_yaml_hash current_hash = compute_yaml_hash(config_path.read_text()) if current_hash != locked.integrity: errors.append( f"{name}: integrity mismatch " f"(installed tool differs from lock)" ) except Exception as e: errors.append(f"{name}: could not verify integrity ({e})") if locked.content_hash: from .integrity import compute_content_identity actual_dependencies = {} missing_dependencies = [] for dep_name, expected_hash in locked.dependency_hashes.items(): dep = lock.packages.get(dep_name) if dep is None or not dep.content_hash: missing_dependencies.append(dep_name) elif dep.content_hash != expected_hash: errors.append( f"{name}: dependency identity mismatch ({dep_name})" ) else: actual_dependencies[dep_name] = dep.content_hash if missing_dependencies: errors.append( f"{name}: unresolved integrity dependencies: " + ", ".join(missing_dependencies) ) elif locked.integrity: actual_identity = compute_content_identity( locked.integrity, actual_dependencies ) if actual_identity != locked.content_hash: errors.append(f"{name}: transitive content identity mismatch") return errors