Initial upload: dependency freshness checker CLI tool
Some checks failed
CI / test (push) Has been cancelled
Some checks failed
CI / test (push) Has been cancelled
This commit is contained in:
211
src/depcheck/cli.py
Normal file
211
src/depcheck/cli.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""Main CLI module for depcheck."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
|
||||
from depcheck import __version__
|
||||
from depcheck.analyzers import CVEAnalyzer, load_cve_database
|
||||
from depcheck.config import Config
|
||||
from depcheck.models import ScanResult, Severity
|
||||
from depcheck.parsers import ParserRegistry, create_parser_registry
|
||||
from depcheck.reporters import JSONReporter, TerminalReporter
|
||||
|
||||
|
||||
@click.group()
|
||||
@click.version_option(version=__version__, prog_name="depcheck")
|
||||
@click.option("--config", type=click.Path(exists=True), help="Path to config file")
|
||||
@click.option("-v", "--verbose", is_flag=True, help="Enable verbose output")
|
||||
@click.option("-q", "--quiet", is_flag=True, help="Suppress non-essential output")
|
||||
@click.pass_context
|
||||
def main(ctx: click.Context, config: Optional[str], verbose: bool, quiet: bool) -> None:
|
||||
"""Dependency Freshness Checker - Monitor outdated dependencies across package managers."""
|
||||
ctx.ensure_object(dict)
|
||||
ctx.obj["verbose"] = verbose
|
||||
ctx.obj["quiet"] = quiet
|
||||
|
||||
|
||||
@main.command()
|
||||
@click.argument("path", type=click.Path(exists=True), default=".")
|
||||
@click.option("--json", "json_output", is_flag=True, help="Output in JSON format")
|
||||
@click.option("--ci", is_flag=True, help="CI/CD mode (non-interactive, proper exit codes)")
|
||||
@click.option(
|
||||
"--fail-level",
|
||||
type=click.Choice([s.value for s in Severity]),
|
||||
default=Severity.MEDIUM.value,
|
||||
help="Severity level that causes non-zero exit code",
|
||||
)
|
||||
@click.option(
|
||||
"--package-manager",
|
||||
type=click.Choice(["npm", "pip", "go", "cargo", "auto"]),
|
||||
default="auto",
|
||||
help="Package manager to use",
|
||||
)
|
||||
@click.option(
|
||||
"--exclude-dev",
|
||||
is_flag=True,
|
||||
help="Exclude dev dependencies",
|
||||
)
|
||||
@click.pass_context
|
||||
def scan(
|
||||
ctx: click.Context,
|
||||
path: str,
|
||||
json_output: bool,
|
||||
ci: bool,
|
||||
fail_level: str,
|
||||
package_manager: str,
|
||||
exclude_dev: bool,
|
||||
) -> None:
|
||||
"""Scan dependency files for outdated packages and vulnerabilities."""
|
||||
verbose = ctx.obj.get("verbose", False)
|
||||
quiet = ctx.obj.get("quiet", False)
|
||||
|
||||
config = Config(
|
||||
fail_level=Severity(fail_level),
|
||||
output_format="json" if json_output else "terminal",
|
||||
verbose=verbose,
|
||||
quiet=quiet,
|
||||
include_dev=not exclude_dev,
|
||||
)
|
||||
|
||||
scan_path = Path(path)
|
||||
|
||||
registry = create_parser_registry()
|
||||
cve_analyzer = CVEAnalyzer(load_cve_database())
|
||||
|
||||
if json_output or ci:
|
||||
reporter: JSONReporter | TerminalReporter = JSONReporter(config)
|
||||
else:
|
||||
reporter = TerminalReporter(config)
|
||||
|
||||
result = ScanResult()
|
||||
|
||||
try:
|
||||
if scan_path.is_file():
|
||||
result = _scan_file(scan_path, registry, cve_analyzer, config)
|
||||
else:
|
||||
result = _scan_directory(scan_path, registry, cve_analyzer, config)
|
||||
except Exception as e:
|
||||
if not quiet:
|
||||
reporter.print_error(f"Scan failed: {str(e)}")
|
||||
sys.exit(2)
|
||||
|
||||
if json_output:
|
||||
print(reporter.report(result))
|
||||
else:
|
||||
if result.scan_errors and verbose:
|
||||
for error in result.scan_errors:
|
||||
reporter.print_warning(error)
|
||||
reporter.report(result)
|
||||
|
||||
exit_code = reporter.get_exit_code(result) if ci else 0
|
||||
sys.exit(exit_code)
|
||||
|
||||
|
||||
def _scan_file(
|
||||
file_path: Path,
|
||||
registry: ParserRegistry,
|
||||
cve_analyzer: CVEAnalyzer,
|
||||
config: Config,
|
||||
) -> ScanResult:
|
||||
"""Scan a single dependency file."""
|
||||
result = ScanResult(source_file=str(file_path))
|
||||
|
||||
parser = registry.get_parser(file_path)
|
||||
if parser is None:
|
||||
result.scan_errors.append(f"No parser found for {file_path}")
|
||||
return result
|
||||
|
||||
dependencies = parser.parse(file_path)
|
||||
for dep in dependencies:
|
||||
if not config.include_dev and dep.category in ["devDependencies", "devDependencies"]:
|
||||
continue
|
||||
|
||||
vulnerabilities = cve_analyzer.analyze(dep)
|
||||
for vuln in vulnerabilities:
|
||||
result.vulnerabilities.append((dep, vuln))
|
||||
|
||||
result.dependencies = dependencies
|
||||
result.package_manager = parser.package_manager
|
||||
result.source_file = str(file_path)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _scan_directory(
|
||||
directory: Path,
|
||||
registry: ParserRegistry,
|
||||
cve_analyzer: CVEAnalyzer,
|
||||
config: Config,
|
||||
) -> ScanResult:
|
||||
"""Scan a directory for all supported dependency files."""
|
||||
result = ScanResult()
|
||||
|
||||
if package_manager := _get_package_manager_from_args(directory, registry):
|
||||
for pattern in _get_package_file_patterns(package_manager):
|
||||
for file_path in directory.rglob(pattern):
|
||||
if _should_skip_file(file_path, config):
|
||||
continue
|
||||
file_result = _scan_file(file_path, registry, cve_analyzer, config)
|
||||
result.dependencies.extend(file_result.dependencies)
|
||||
result.vulnerabilities.extend(file_result.vulnerabilities)
|
||||
result.scan_errors.extend(file_result.scan_errors)
|
||||
else:
|
||||
for file_path in _find_all_dependency_files(directory):
|
||||
if _should_skip_file(file_path, config):
|
||||
continue
|
||||
file_result = _scan_file(file_path, registry, cve_analyzer, config)
|
||||
result.dependencies.extend(file_result.dependencies)
|
||||
result.vulnerabilities.extend(file_result.vulnerabilities)
|
||||
result.scan_errors.extend(file_result.scan_errors)
|
||||
|
||||
if result.dependencies:
|
||||
result.package_manager = result.dependencies[0].package_manager
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _get_package_manager_from_args(directory: Path, registry: ParserRegistry) -> Optional[str]:
|
||||
"""Determine package manager from directory contents."""
|
||||
for parser in registry.get_all_parsers():
|
||||
for pattern in parser.get_file_patterns():
|
||||
if (directory / pattern).exists():
|
||||
return parser.package_manager.value
|
||||
return None
|
||||
|
||||
|
||||
def _get_package_file_patterns(package_manager: str) -> list[str]:
|
||||
"""Get file patterns for a package manager."""
|
||||
patterns = {
|
||||
"npm": ["package.json"],
|
||||
"pip": ["requirements.txt", "pyproject.toml"],
|
||||
"go": ["go.mod"],
|
||||
"cargo": ["Cargo.toml"],
|
||||
}
|
||||
return patterns.get(package_manager, [])
|
||||
|
||||
|
||||
def _find_all_dependency_files(directory: Path) -> list[Path]:
|
||||
"""Find all dependency files in a directory."""
|
||||
patterns = ["package.json", "requirements.txt", "pyproject.toml", "go.mod", "Cargo.toml"]
|
||||
files: list[Path] = []
|
||||
for pattern in patterns:
|
||||
files.extend(directory.rglob(pattern))
|
||||
return sorted(set(files))
|
||||
|
||||
|
||||
def _should_skip_file(file_path: Path, config: Config) -> bool:
|
||||
"""Check if a file should be skipped."""
|
||||
for pattern in config.ignore_patterns:
|
||||
if pattern in str(file_path):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@main.command()
|
||||
@click.pass_context
|
||||
def check_update(ctx: click.Context) -> None:
|
||||
"""Check if a new version of depcheck is available."""
|
||||
click.echo("Update check not implemented in offline mode.")
|
||||
Reference in New Issue
Block a user