CmdForge/src/cmdforge/cli/project_commands.py

603 lines
20 KiB
Python

"""Project management commands."""
import sys
from pathlib import Path
from ..manifest import (
load_manifest, save_manifest, create_manifest, find_manifest,
MANIFEST_FILENAME
)
from ..resolver import (
find_tool, install_from_registry, ToolSpec
)
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:
print("No cmdforge.yaml found in current project.")
print("Create one with: cmdforge init")
return 1
print(f"Project: {manifest.name} v{manifest.version}")
print()
if not manifest.dependencies:
print("No dependencies defined.")
print("Add one with: cmdforge add <owner/name>")
return 0
print(f"Dependencies ({len(manifest.dependencies)}):")
print()
for dep in manifest.dependencies:
# Check if installed
installed = find_tool(dep.name)
status = "[installed]" if installed else "[not installed]"
print(f" {dep.name}")
print(f" Version: {dep.version}")
print(f" Status: {status}")
print()
if manifest.overrides:
print("Overrides:")
for name, override in manifest.overrides.items():
if override.provider:
print(f" {name}: provider={override.provider}")
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 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
from ..system_deps import prompt_install_missing
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 to install.")
return 0
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 = []
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
resolved = install_from_registry(spec, version=install_version)
print(f"v{node.resolved_version or 'latest'}")
installed += 1
if resolved.tool.system_dependencies:
print()
tool_ref = f"{resolved.owner}/{resolved.tool.name}" if resolved.owner else resolved.tool.name
prompt_install_missing(resolved.tool.system_dependencies, tool_ref)
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)))
# 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
from ..system_deps import prompt_install_missing
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"Installing {pkg.name}@{pkg.version}...", end=" ", flush=True)
# Use correct API: install_from_registry(spec, version)
resolved = install_from_registry(pkg.name, version=pkg.version)
print("OK")
installed += 1
if resolved.tool.system_dependencies:
print()
tool_ref = f"{resolved.owner}/{resolved.tool.name}" if resolved.owner else resolved.tool.name
prompt_install_missing(resolved.tool.system_dependencies, tool_ref)
except RegistryError as e:
print(f"FAILED: {e.message}")
failed.append((pkg.name, e.message))
except Exception as e:
print(f"FAILED: {e}")
failed.append((pkg.name, str(e)))
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 cmd_add(args):
"""Add a tool to project dependencies."""
from ..registry_client import RegistryError
tool_spec = args.tool
version = args.version or "*"
# Find or create manifest
manifest_path = find_manifest()
if manifest_path:
manifest = load_manifest(manifest_path)
else:
# Create in current directory
manifest = create_manifest(name=Path.cwd().name)
manifest_path = Path.cwd() / MANIFEST_FILENAME
# Parse tool spec
parsed = ToolSpec.parse(tool_spec)
full_name = parsed.full_name
# Check if already installed locally before trying registry
local_tool = find_tool(tool_spec)
# Add dependency
manifest.add_dependency(full_name, version)
# Save
save_manifest(manifest, manifest_path)
print(f"Added {full_name}@{version} to {manifest_path.name}")
# Install if requested
if not args.no_install:
# If already installed locally, no need to fetch from registry
if local_tool:
source_label = "locally" if local_tool.source == "local" else "globally"
version_info = f"@{local_tool.version}" if local_tool.version else ""
print(f"Already installed {source_label}: {local_tool.full_name}{version_info}")
else:
print(f"Installing {full_name}...")
try:
resolved = install_from_registry(tool_spec, version if version != "*" else None)
print(f"Installed: {resolved.full_name}@{resolved.version}")
except RegistryError as e:
if e.code == "TOOL_NOT_FOUND":
print(f"Not found in registry: {full_name}", file=sys.stderr)
print(f"Dependency added - install manually or create the tool locally.", file=sys.stderr)
elif e.code == "VERSION_NOT_FOUND":
print(f"No published version of '{full_name}' found in registry.", file=sys.stderr)
print(f"Dependency added - publish the tool or install manually.", file=sys.stderr)
elif e.code == "CONNECTION_ERROR":
print(f"Could not connect to registry.", file=sys.stderr)
print("Run 'cmdforge install' to try again later.", file=sys.stderr)
else:
print(f"Install failed: {e.message}", file=sys.stderr)
print("Run 'cmdforge install' to try again.", file=sys.stderr)
except Exception as e:
print(f"Install failed: {e}", file=sys.stderr)
print("Run 'cmdforge install' to try again.", file=sys.stderr)
return 0
def cmd_remove(args):
"""Remove a tool from project dependencies."""
tool_spec = args.tool
# Find manifest
manifest_path = find_manifest()
if not manifest_path:
print("No cmdforge.yaml found in current project.")
print("Nothing to remove.")
return 1
manifest = load_manifest(manifest_path)
# Parse tool spec to get the name
parsed = ToolSpec.parse(tool_spec)
tool_name = parsed.full_name
# Remove from manifest
if manifest.remove_dependency(tool_name):
save_manifest(manifest, manifest_path)
print(f"Removed {tool_name} from {manifest_path.name}")
else:
# Try without owner prefix
if manifest.remove_dependency(parsed.name):
save_manifest(manifest, manifest_path)
print(f"Removed {parsed.name} from {manifest_path.name}")
else:
print(f"Dependency '{tool_name}' not found in {manifest_path.name}")
return 1
return 0
def cmd_init(args):
"""Initialize a new cmdforge.yaml."""
manifest_path = Path.cwd() / MANIFEST_FILENAME
if manifest_path.exists() and not args.force:
print(f"{MANIFEST_FILENAME} already exists. Use --force to overwrite.")
return 1
# Get project name
default_name = Path.cwd().name
if args.name:
name = args.name
else:
try:
name = input(f"Project name [{default_name}]: ").strip() or default_name
except (EOFError, KeyboardInterrupt):
print()
name = default_name
# Get version
if args.version:
version = args.version
else:
try:
version = input("Version [1.0.0]: ").strip() or "1.0.0"
except (EOFError, KeyboardInterrupt):
print()
version = "1.0.0"
# Create manifest
manifest = create_manifest(name=name, version=version)
save_manifest(manifest, manifest_path)
print(f"Created {MANIFEST_FILENAME}")
print()
print("Add dependencies with: cmdforge add <owner/name>")
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