Add transitive dependency resolution and lock file support
Implements two major features for project dependency management: Transitive Dependency Resolution: - DependencyGraph/DependencyNode for structured dependency tracking - DependencyGraphBuilder with recursive resolution and cycle detection - Topological sorting for correct install order (dependencies first) - Version conflict detection with provenance tracking - Full semver support: ^, ~, >=, <=, >, <, = constraints - New `cmdforge deps tree` command to visualize dependency tree - Install flags: --dry-run, --force, --verbose Lock File Support (cmdforge.lock): - Lockfile/LockedPackage/LockfileMetadata dataclasses - Records exact versions of all dependencies (direct + transitive) - Integrity verification via SHA256 hashes - `cmdforge lock` to generate/update lock file - `cmdforge verify` to check installed tools match lock - Install flags: --frozen, --strict-frozen, --ignore-lock 62 new tests for version, dependency_graph, and lockfile modules. Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
@@ -14,6 +14,10 @@ from ..resolver import (
|
||||
|
||||
def cmd_deps(args):
|
||||
"""Show project dependencies from cmdforge.yaml."""
|
||||
# If 'tree' subcommand, delegate to that
|
||||
if hasattr(args, 'deps_cmd') and args.deps_cmd == 'tree':
|
||||
return cmd_deps_tree(args)
|
||||
|
||||
manifest = load_manifest()
|
||||
|
||||
if manifest is None:
|
||||
@@ -51,9 +55,99 @@ def cmd_deps(args):
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_deps_tree(args):
|
||||
"""Show dependency tree with transitive dependencies."""
|
||||
from ..dependency_graph import DependencyGraphBuilder
|
||||
from ..resolver import ToolResolver
|
||||
|
||||
manifest = load_manifest()
|
||||
if manifest is None:
|
||||
print("No cmdforge.yaml found in current project.")
|
||||
print("Create one with: cmdforge init")
|
||||
return 1
|
||||
|
||||
if not manifest.dependencies:
|
||||
print("No dependencies in cmdforge.yaml")
|
||||
return 0
|
||||
|
||||
verbose = getattr(args, 'verbose', False)
|
||||
|
||||
resolver = ToolResolver(auto_fetch=False)
|
||||
builder = DependencyGraphBuilder(resolver=resolver, verbose=verbose)
|
||||
|
||||
print("Resolving dependencies...")
|
||||
graph = builder.build(manifest.dependencies)
|
||||
|
||||
def print_tree(qualified_name: str, indent: int = 0, seen: set = None):
|
||||
seen = seen or set()
|
||||
prefix = " " * indent
|
||||
node = graph.get_node(qualified_name)
|
||||
|
||||
if not node:
|
||||
print(f"{prefix}- {qualified_name} (not found)")
|
||||
return
|
||||
|
||||
# Status indicator
|
||||
if node.source in ("local", "global"):
|
||||
status = "installed"
|
||||
elif node.source == "registry" and node.path:
|
||||
status = "installed"
|
||||
elif node.source == "registry":
|
||||
status = "registry"
|
||||
else:
|
||||
status = "missing"
|
||||
|
||||
# Show resolved version if available, otherwise fall back to constraint
|
||||
if node.resolved_version:
|
||||
version = f"@{node.resolved_version}"
|
||||
elif node.version_constraint:
|
||||
version = f"@{node.version_constraint}"
|
||||
else:
|
||||
version = ""
|
||||
circular = " (circular)" if qualified_name in seen else ""
|
||||
|
||||
print(f"{prefix}- {node.qualified_name}{version} [{status}]{circular}")
|
||||
|
||||
if qualified_name not in seen:
|
||||
seen.add(qualified_name)
|
||||
for child in node.children:
|
||||
print_tree(child, indent + 1, seen.copy())
|
||||
|
||||
print()
|
||||
print("Dependency tree:")
|
||||
for root in graph.root_dependencies:
|
||||
print_tree(root)
|
||||
|
||||
if graph.conflicts:
|
||||
print()
|
||||
print("Version conflicts:")
|
||||
for tool, v1, from1, v2, from2 in graph.conflicts:
|
||||
print(f" {tool}: {v1} (from {from1}) vs {v2} (from {from2})")
|
||||
|
||||
if graph.cycles:
|
||||
print()
|
||||
print("Circular dependencies:")
|
||||
for cycle in graph.cycles:
|
||||
print(f" {' -> '.join(cycle)}")
|
||||
|
||||
# Summary
|
||||
print()
|
||||
total = len(graph.nodes)
|
||||
installed = sum(1 for n in graph.nodes.values() if n.source in ("local", "global") or (n.source == "registry" and n.path))
|
||||
to_install = sum(1 for n in graph.nodes.values() if n.source == "registry" and n.path is None)
|
||||
missing = sum(1 for n in graph.nodes.values() if not n.is_resolved)
|
||||
|
||||
print(f"Total: {total} tools ({installed} installed, {to_install} to install, {missing} missing)")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_install_deps(args):
|
||||
"""Install dependencies from cmdforge.yaml."""
|
||||
"""Install all dependencies from cmdforge.yaml with transitive resolution."""
|
||||
from ..dependency_graph import DependencyGraphBuilder
|
||||
from ..resolver import ToolResolver, install_from_registry, ToolNotFoundError
|
||||
from ..registry_client import RegistryError
|
||||
from ..lockfile import Lockfile
|
||||
|
||||
manifest = load_manifest()
|
||||
|
||||
@@ -66,46 +160,195 @@ def cmd_install_deps(args):
|
||||
print("No dependencies to install.")
|
||||
return 0
|
||||
|
||||
print(f"Installing dependencies for {manifest.name}...")
|
||||
verbose = getattr(args, 'verbose', False)
|
||||
dry_run = getattr(args, 'dry_run', False)
|
||||
force = getattr(args, 'force', False)
|
||||
frozen = getattr(args, 'frozen', False)
|
||||
strict_frozen = getattr(args, 'strict_frozen', False)
|
||||
ignore_lock = getattr(args, 'ignore_lock', False)
|
||||
|
||||
# Check for lock file
|
||||
lock = Lockfile.load()
|
||||
|
||||
if frozen or strict_frozen:
|
||||
# Frozen mode: require lock file
|
||||
if lock is None:
|
||||
print("Error: --frozen requires cmdforge.lock")
|
||||
print("Run 'cmdforge lock' first.")
|
||||
return 1
|
||||
if strict_frozen and lock.is_stale():
|
||||
print("Error: cmdforge.lock is stale (manifest changed)")
|
||||
print("Run 'cmdforge lock' to update, or drop --strict-frozen")
|
||||
return 1
|
||||
|
||||
if lock and not ignore_lock:
|
||||
return _install_from_lock(lock, args)
|
||||
|
||||
# Fall through to manifest-based installation
|
||||
# Build dependency graph
|
||||
resolver = ToolResolver(auto_fetch=False)
|
||||
builder = DependencyGraphBuilder(resolver=resolver, verbose=verbose)
|
||||
|
||||
print("Resolving dependencies...")
|
||||
graph = builder.build(manifest.dependencies)
|
||||
|
||||
# Check for issues
|
||||
if graph.has_cycles():
|
||||
print()
|
||||
print("Error: Circular dependencies detected:")
|
||||
for cycle in graph.cycles:
|
||||
print(f" {' -> '.join(cycle)}")
|
||||
return 1
|
||||
|
||||
if graph.has_conflicts():
|
||||
print()
|
||||
print("Warning: Version conflicts detected:")
|
||||
for tool, v1, from1, v2, from2 in graph.conflicts:
|
||||
print(f" {tool}: {v1} (from {from1}) vs {v2} (from {from2})")
|
||||
if not force:
|
||||
print()
|
||||
print("Use --force to install anyway (first constraint wins)")
|
||||
return 1
|
||||
unresolved = graph.get_unresolved()
|
||||
if unresolved:
|
||||
print()
|
||||
print("Error: Missing dependencies:")
|
||||
for node in unresolved:
|
||||
constraint = f"@{node.version_constraint}" if node.version_constraint else ""
|
||||
print(f" {node.qualified_name}{constraint}")
|
||||
return 1
|
||||
|
||||
# Determine what needs installation (in correct order)
|
||||
to_install = [
|
||||
node for node in graph.get_install_order()
|
||||
if node.source == "registry" and node.path is None
|
||||
]
|
||||
|
||||
if not to_install:
|
||||
print("All dependencies already installed.")
|
||||
return 0
|
||||
|
||||
if dry_run:
|
||||
print()
|
||||
print(f"Would install {len(to_install)} tool(s):")
|
||||
for node in to_install:
|
||||
version = node.resolved_version or "latest"
|
||||
print(f" {node.qualified_name}@{version}")
|
||||
return 0
|
||||
|
||||
print()
|
||||
print(f"Installing {len(to_install)} tool(s) in dependency order:")
|
||||
for node in to_install:
|
||||
version = node.resolved_version or "latest"
|
||||
print(f" {node.qualified_name}@{version}")
|
||||
|
||||
# Install in topological order (dependencies first)
|
||||
installed = 0
|
||||
failed = []
|
||||
installed = []
|
||||
|
||||
for i, dep in enumerate(manifest.dependencies, 1):
|
||||
print(f"[{i}/{len(manifest.dependencies)}] {dep.name}@{dep.version}")
|
||||
print()
|
||||
for node in to_install:
|
||||
try:
|
||||
print(f"Installing {node.qualified_name}...", end=" ", flush=True)
|
||||
spec = node.qualified_name
|
||||
install_version = node.resolved_version or node.version_constraint
|
||||
install_from_registry(spec, version=install_version)
|
||||
print(f"v{node.resolved_version or 'latest'}")
|
||||
installed += 1
|
||||
except RegistryError as e:
|
||||
print(f"FAILED: {e.message}")
|
||||
failed.append((node.qualified_name, e.message))
|
||||
except Exception as e:
|
||||
print(f"FAILED: {e}")
|
||||
failed.append((node.qualified_name, str(e)))
|
||||
|
||||
# Check if already installed
|
||||
existing = find_tool(dep.name)
|
||||
if existing:
|
||||
print(f" Already installed: {existing.full_name}")
|
||||
installed.append(dep.name)
|
||||
# Summary
|
||||
print()
|
||||
print(f"Installed: {installed}/{len(to_install)}")
|
||||
if failed:
|
||||
print("Failed:")
|
||||
for name, err in failed:
|
||||
print(f" {name}: {err}")
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _install_from_lock(lock: "Lockfile", args) -> int:
|
||||
"""Install exact versions from lock file."""
|
||||
from ..resolver import ToolResolver, ToolNotFoundError, install_from_registry
|
||||
from ..registry_client import RegistryError
|
||||
|
||||
verbose = getattr(args, 'verbose', False)
|
||||
dry_run = getattr(args, 'dry_run', False)
|
||||
force = getattr(args, 'force', False)
|
||||
|
||||
print("Installing from cmdforge.lock (reproducible)...")
|
||||
|
||||
if lock.is_stale() and not force:
|
||||
print("Warning: Lock file may be outdated (manifest changed)")
|
||||
print("Run 'cmdforge lock' to update, or use --force to continue")
|
||||
return 1
|
||||
|
||||
# Check what needs installing
|
||||
resolver = ToolResolver(auto_fetch=False)
|
||||
resolver.manifest = None
|
||||
to_install = []
|
||||
|
||||
for name, locked in lock.packages.items():
|
||||
try:
|
||||
resolved = resolver.resolve(name)
|
||||
# Check if version matches
|
||||
resolved_version = resolved.version or ""
|
||||
locked_version = locked.version or ""
|
||||
if resolved_version != locked_version:
|
||||
to_install.append(locked)
|
||||
except ToolNotFoundError:
|
||||
to_install.append(locked)
|
||||
except Exception:
|
||||
to_install.append(locked)
|
||||
|
||||
if not to_install:
|
||||
print("All locked packages already installed.")
|
||||
return 0
|
||||
|
||||
print()
|
||||
print(f"Installing {len(to_install)} package(s):")
|
||||
for pkg in to_install:
|
||||
print(f" {pkg.name}@{pkg.version}")
|
||||
|
||||
if dry_run:
|
||||
return 0
|
||||
|
||||
# Install exact versions
|
||||
installed = 0
|
||||
failed = []
|
||||
|
||||
print()
|
||||
for pkg in to_install:
|
||||
if pkg.source == "local":
|
||||
print(f"{pkg.name}: skipping (local)")
|
||||
continue
|
||||
|
||||
try:
|
||||
print(f" Downloading...")
|
||||
resolved = install_from_registry(dep.name, dep.version)
|
||||
print(f" Installed: {resolved.full_name}@{resolved.version}")
|
||||
installed.append(dep.name)
|
||||
print(f"Installing {pkg.name}@{pkg.version}...", end=" ", flush=True)
|
||||
# Use correct API: install_from_registry(spec, version)
|
||||
install_from_registry(pkg.name, version=pkg.version)
|
||||
print("OK")
|
||||
installed += 1
|
||||
except RegistryError as e:
|
||||
if e.code == "TOOL_NOT_FOUND":
|
||||
print(f" Not found in registry")
|
||||
elif e.code == "VERSION_NOT_FOUND" or e.code == "CONSTRAINT_UNSATISFIABLE":
|
||||
print(f" Version {dep.version} not available")
|
||||
elif e.code == "CONNECTION_ERROR":
|
||||
print(f" Connection failed (check network)")
|
||||
else:
|
||||
print(f" Failed: {e.message}")
|
||||
failed.append(dep.name)
|
||||
print(f"FAILED: {e.message}")
|
||||
failed.append((pkg.name, e.message))
|
||||
except Exception as e:
|
||||
print(f" Failed: {e}")
|
||||
failed.append(dep.name)
|
||||
print(f"FAILED: {e}")
|
||||
failed.append((pkg.name, str(e)))
|
||||
|
||||
print()
|
||||
print(f"Installed {len(installed)} tools")
|
||||
|
||||
print(f"Installed: {installed}/{len(to_install)}")
|
||||
if failed:
|
||||
print(f"Failed: {', '.join(failed)}")
|
||||
print("Failed:")
|
||||
for name, err in failed:
|
||||
print(f" {name}: {err}")
|
||||
return 1
|
||||
|
||||
return 0
|
||||
@@ -200,3 +443,105 @@ def cmd_init(args):
|
||||
print("Install them with: cmdforge install")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_lock(args):
|
||||
"""Generate or update cmdforge.lock file."""
|
||||
from ..lockfile import Lockfile, generate_lockfile
|
||||
from ..dependency_graph import DependencyGraphBuilder
|
||||
from ..resolver import ToolResolver
|
||||
from ..registry_client import get_client
|
||||
|
||||
manifest = load_manifest()
|
||||
if manifest is None:
|
||||
print("No cmdforge.yaml found. Run 'cmdforge init' first.")
|
||||
return 1
|
||||
|
||||
verbose = getattr(args, 'verbose', False)
|
||||
force = getattr(args, 'force', False)
|
||||
|
||||
# Check for existing lock
|
||||
existing_lock = Lockfile.load()
|
||||
if existing_lock and not force:
|
||||
if not existing_lock.is_stale():
|
||||
print("Lock file is up to date. Use --force to regenerate.")
|
||||
return 0
|
||||
print("Lock file is stale (manifest changed), regenerating...")
|
||||
|
||||
# Resolve all dependencies
|
||||
print("Resolving dependencies...")
|
||||
resolver = ToolResolver(auto_fetch=True) # Allow fetching for resolution
|
||||
builder = DependencyGraphBuilder(resolver=resolver, verbose=verbose)
|
||||
|
||||
try:
|
||||
graph = builder.build(manifest.dependencies)
|
||||
except Exception as e:
|
||||
print(f"Error resolving dependencies: {e}")
|
||||
return 1
|
||||
|
||||
if graph.has_cycles():
|
||||
print("Error: Circular dependencies detected")
|
||||
for cycle in graph.cycles:
|
||||
print(f" {' -> '.join(cycle)}")
|
||||
return 1
|
||||
|
||||
unresolved = graph.get_unresolved()
|
||||
if unresolved and not force:
|
||||
print("Error: Some dependencies could not be resolved:")
|
||||
for node in unresolved:
|
||||
constraint = f"@{node.version_constraint}" if node.version_constraint else ""
|
||||
print(f" {node.qualified_name}{constraint}")
|
||||
print("Fix missing deps or re-run with --force to lock what can be resolved.")
|
||||
return 1
|
||||
if unresolved and force:
|
||||
print("Warning: Some dependencies could not be resolved and will be skipped:")
|
||||
for node in unresolved:
|
||||
constraint = f"@{node.version_constraint}" if node.version_constraint else ""
|
||||
print(f" {node.qualified_name}{constraint}")
|
||||
|
||||
# Generate lock file
|
||||
print("Generating lock file...")
|
||||
client = get_client()
|
||||
lock = generate_lockfile(manifest, graph, client)
|
||||
lock.save()
|
||||
|
||||
# Summary
|
||||
direct = sum(1 for p in lock.packages.values() if p.direct)
|
||||
transitive = len(lock.packages) - direct
|
||||
print()
|
||||
print(f"Locked {len(lock.packages)} package(s):")
|
||||
print(f" Direct: {direct}")
|
||||
print(f" Transitive: {transitive}")
|
||||
print()
|
||||
print("Wrote cmdforge.lock")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_verify(args):
|
||||
"""Verify installed tools match lock file."""
|
||||
from ..lockfile import Lockfile, verify_lockfile
|
||||
from ..registry_client import get_client
|
||||
|
||||
lock = Lockfile.load()
|
||||
if lock is None:
|
||||
print("No cmdforge.lock found. Run 'cmdforge lock' first.")
|
||||
return 1
|
||||
|
||||
if lock.is_stale():
|
||||
print("Warning: Lock file may be outdated (manifest changed)")
|
||||
print()
|
||||
|
||||
print("Verifying lock file...")
|
||||
client = get_client()
|
||||
errors = verify_lockfile(lock, client)
|
||||
|
||||
if not errors:
|
||||
print(f"All {len(lock.packages)} packages verified OK")
|
||||
return 0
|
||||
|
||||
print()
|
||||
print(f"{len(errors)} verification error(s):")
|
||||
for error in errors:
|
||||
print(f" {error}")
|
||||
return 1
|
||||
|
||||
Reference in New Issue
Block a user