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