603 lines
20 KiB
Python
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
|