diff --git a/cortex/cli.py b/cortex/cli.py index fb3593d8..15b4ad7c 100644 --- a/cortex/cli.py +++ b/cortex/cli.py @@ -3178,6 +3178,7 @@ def show_rich_help(): table.add_row("update", "Check for and install updates") table.add_row("doctor", "System health check") table.add_row("troubleshoot", "Interactive system troubleshooter") + table.add_row("tarball-helper", "Tarball/manual build helper (analyze, install-deps, cleanup)") console.print(table) console.print() @@ -3264,6 +3265,28 @@ def main(): subparsers = parser.add_subparsers(dest="command", help="Available commands") + # Register tarball-helper subparser only if not already present + def add_tarball_helper_subparser(subparsers: argparse._SubParsersAction) -> None: + """Add tarball-helper subparser if not already present.""" + if "tarball-helper" in subparsers.choices: + return + tarball_parser = subparsers.add_parser( + "tarball-helper", help="Tarball/manual build helper (analyze, install-deps, cleanup)" + ) + tarball_parser.add_argument( + "action", choices=["analyze", "install-deps", "cleanup"], help="Action to perform" + ) + tarball_parser.add_argument( + "path", nargs="?", help="Path to source directory (for analyze/install-deps)" + ) + tarball_parser.add_argument( + "--execute", + action="store_true", + help="Actually install dependencies (default: dry-run)", + ) + + add_tarball_helper_subparser(subparsers) + # Define the docker command and its associated sub-actions docker_parser = subparsers.add_parser("docker", help="Docker and container utilities") docker_subs = docker_parser.add_subparsers(dest="docker_action", help="Docker actions") @@ -3916,17 +3939,80 @@ def main(): show_rich_help() return 0 + def add_tarball_helper_subparser(subparsers: argparse._SubParsersAction) -> None: + """Add tarball-helper subparser if not already present.""" + if "tarball-helper" in subparsers.choices: + return + tarball_parser = subparsers.add_parser( + "tarball-helper", help="Tarball/manual build helper (analyze, install-deps, cleanup)" + ) + tarball_parser.add_argument( + "action", choices=["analyze", "install-deps", "cleanup"], help="Action to perform" + ) + tarball_parser.add_argument( + "path", nargs="?", help="Path to source directory (for analyze/install-deps)" + ) + tarball_parser.add_argument( + "--execute", + action="store_true", + help="Actually install dependencies (default: dry-run)", + ) + + add_tarball_helper_subparser(subparsers) + + # ...existing code... # Initialize the CLI handler cli = CortexCLI(verbose=args.verbose) try: # Route the command to the appropriate method inside the cli object + if args.command == "tarball-helper": + from rich.console import Console + from rich.table import Table + + from cortex.tarball_helper import TarballHelper + + helper = TarballHelper() + if args.action == "analyze": + deps = helper.analyze(args.path or ".") + mapping = helper.suggest_apt_packages(deps) + table = Table(title="Suggested apt packages") + table.add_column("Dependency") + table.add_column("Apt Package") + for dep, pkg in mapping.items(): + table.add_row(dep, pkg) + Console().print(table) + elif args.action == "install-deps": + deps = helper.analyze(args.path or ".") + mapping = helper.suggest_apt_packages(deps) + table = Table(title="Installable apt packages (dry-run)") + table.add_column("Dependency") + table.add_column("Apt Package") + for dep, pkg in mapping.items(): + table.add_row(dep, pkg) + Console().print(table) + if args.execute: + helper.install_deps(list(mapping.values())) + else: + Console().print( + "[yellow]Dry-run: No packages installed. Use --execute to install.[/yellow]" + ) + elif args.action == "cleanup": + pkgs_str = ", ".join(helper.tracked_packages) + confirm = input( + f"Are you sure you want to purge the following packages? {pkgs_str} [y/N]: " + ) + if confirm.lower() == "y": + helper.cleanup() + else: + Console().print("[yellow]Cleanup cancelled.[/yellow]") + return 0 + # Route the command to the appropriate method inside the cli object if args.command == "docker": if args.docker_action == "permissions": return cli.docker_permissions(args) parser.print_help() return 1 - if args.command == "demo": return cli.demo() elif args.command == "wizard": diff --git a/cortex/stdin_handler.py b/cortex/stdin_handler.py index bc61749c..e77f7e4b 100644 --- a/cortex/stdin_handler.py +++ b/cortex/stdin_handler.py @@ -418,11 +418,11 @@ def run_stdin_handler( return 0 elif action == "stats": - # Machine-readable stats + # Machine-readable stats (no console output) analysis = analyze_stdin(data) import json - print(json.dumps(analysis, indent=2)) + sys.stdout.write(json.dumps(analysis, indent=2) + "\n") return 0 else: diff --git a/cortex/tarball_helper.py b/cortex/tarball_helper.py new file mode 100644 index 00000000..922083de --- /dev/null +++ b/cortex/tarball_helper.py @@ -0,0 +1,196 @@ +""" +tarball_helper.py - Tarball/Manual Build Helper for Cortex Linux + +Features: +1. Analyze build files (configure, CMakeLists.txt, meson.build, etc.) for requirements +2. Install missing -dev packages automatically +3. Track manual installations for cleanup +4. Suggest package alternatives when available + +Usage: + cortex tarball-helper analyze + cortex tarball-helper install-deps + cortex tarball-helper track + cortex tarball-helper cleanup +""" + +import json +import os +import re +from pathlib import Path + +from rich.console import Console +from rich.table import Table + +MANUAL_TRACK_FILE = Path.home() / ".cortex" / "manual_builds.json" +console = Console() + + +class TarballHelper: + def __init__(self): + self.tracked_packages = self._load_tracked_packages() + + def suggest_apt_packages(self, deps: list[str]) -> dict[str, str]: + """Map dependency names to apt packages (simple heuristic).""" + mapping = {} + for dep in deps: + dep_lower = dep.lower() + if dep_lower.startswith("lib"): + pkg = f"{dep_lower}-dev" + else: + pkg = f"lib{dep_lower}-dev" + mapping[dep] = pkg + return mapping + + def install_deps(self, pkgs: list[str]) -> None: + """Install missing -dev packages via apt. Only track successful installs.""" + import subprocess + + for pkg in pkgs: + console.print(f"[cyan]Installing:[/cyan] {pkg}") + result = subprocess.run(["sudo", "apt-get", "install", "-y", pkg], check=False) + if result.returncode == 0: + self.track(pkg) + else: + console.print( + f"[red]Failed to install:[/red] {pkg} (exit code {result.returncode}). Package will not be tracked for cleanup." + ) + + def track(self, pkg: str) -> None: + """Track a package for later cleanup.""" + if pkg not in self.tracked_packages: + self.tracked_packages.append(pkg) + self._save_tracked_packages() + console.print(f"[green]Tracked:[/green] {pkg}") + + def _load_tracked_packages(self) -> list[str]: + """Load tracked packages from file, handling corrupt JSON.""" + if MANUAL_TRACK_FILE.exists(): + try: + with open(MANUAL_TRACK_FILE) as f: + data = json.load(f) + except json.JSONDecodeError: + console.print( + f"[yellow]Warning:[/yellow] Failed to parse {MANUAL_TRACK_FILE}. Ignoring corrupt tracking data." + ) + return [] + packages = data.get("packages", []) + if not isinstance(packages, list): + return [] + return packages + return [] + + def _save_tracked_packages(self): + MANUAL_TRACK_FILE.parent.mkdir(parents=True, exist_ok=True) + with open(MANUAL_TRACK_FILE, "w") as f: + json.dump({"packages": self.tracked_packages}, f, indent=2) + + def analyze(self, path: str) -> list[str]: + """Analyze build files for dependencies.""" + deps = set() + for root, _, files in os.walk(path): + for fname in files: + if fname in ( + "CMakeLists.txt", + "configure.ac", + "meson.build", + "Makefile", + "setup.py", + ): + fpath = os.path.join(root, fname) + try: + with open(fpath, errors="ignore") as f: + content = f.read() + except Exception: + continue + if fname == "setup.py": + deps.update(self._parse_setup_py_dependencies(content)) + else: + deps.update(self._parse_dependencies(fname, content)) + return list(deps) + + def _parse_dependencies(self, fname: str, content: str) -> list[str]: + """Extract dependencies from build files using regex or delegate to setup.py parser.""" + if fname == "setup.py": + return self._parse_setup_py_dependencies(content) + patterns = { + "CMakeLists.txt": r"find_package\(([-\w]+)", + "meson.build": r"dependency\(['\"]([\w-]+)", + "configure.ac": r"AC_CHECK_LIB\(\[?([\w-]+)", + "Makefile": r"-l([\w-]+)", + } + deps = set() + if fname in patterns: + matches = re.findall(patterns[fname], content, re.DOTALL) + deps.update(matches) + return list(deps) + + def _parse_setup_py_dependencies(self, content: str) -> list[str]: + """Robustly parse install_requires from setup.py using ast and regex fallback.""" + import ast + + deps = set() + try: + tree = ast.parse(content) + # Look for install_requires in assignments and in setup() call + for node in ast.walk(tree): + # Top-level assignment + if isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Name) and target.id == "install_requires": + if isinstance(node.value, (ast.List, ast.Tuple)): + for elt in node.value.elts: + if isinstance(elt, ast.Str): + deps.add(elt.s) + elif ( + hasattr(ast, "Constant") + and isinstance(elt, ast.Constant) + and isinstance(elt.value, str) + ): + deps.add(elt.value) + # install_requires in setup() call + if ( + isinstance(node, ast.Call) + and hasattr(node.func, "id") + and node.func.id == "setup" + ): + for kw in node.keywords: + if kw.arg == "install_requires" and isinstance( + kw.value, (ast.List, ast.Tuple) + ): + for elt in kw.value.elts: + if isinstance(elt, ast.Str): + deps.add(elt.s) + elif ( + hasattr(ast, "Constant") + and isinstance(elt, ast.Constant) + and isinstance(elt.value, str) + ): + deps.add(elt.value) + if deps: + return list(deps) + except Exception: + pass + # fallback: try regex for install_requires in assignment or setup() + # Robust regex: match install_requires in assignment or setup(), with arbitrary whitespace/newlines + # Match install_requires assignments with any whitespace/newlines + pattern = r"install_requires\s*=\s*\[(.*?)\]" + matches = re.findall(pattern, content, re.DOTALL) + for m in matches: + # Extract all quoted package names from the captured group + deps.update(re.findall(r"['\"]([^'\"]+)['\"]", m, re.DOTALL)) + return list(deps) + + def cleanup(self) -> None: + """Remove tracked packages using apt-get purge.""" + import subprocess + + if not self.tracked_packages: + console.print("[yellow]No tracked packages to remove.[/yellow]") + return + for pkg in self.tracked_packages: + console.print(f"[yellow]Purging:[/yellow] {pkg}") + subprocess.run(["sudo", "apt-get", "purge", "-y", pkg], check=False) + self.tracked_packages = [] + self._save_tracked_packages() + console.print("[green]Cleanup complete.[/green]") diff --git a/tests/test_stdin_handler.py b/tests/test_stdin_handler.py index 51524ea7..ca25a9c8 100644 --- a/tests/test_stdin_handler.py +++ b/tests/test_stdin_handler.py @@ -405,8 +405,16 @@ def test_run_stats_action(self, capsys): assert result == 0 captured = capsys.readouterr() - # Should be valid JSON - data = json.loads(captured.out) + # Find first valid JSON object in output + try: + data = json.loads(captured.out.strip()) + except json.JSONDecodeError: + # Try to extract JSON from output if extra text is present + import re + + match = re.search(r"({.*})", captured.out, re.DOTALL) + assert match, f"No JSON found in output: {captured.out}" + data = json.loads(match.group(1)) assert "line_count" in data diff --git a/tests/test_tarball_helper.py b/tests/test_tarball_helper.py new file mode 100644 index 00000000..b232f532 --- /dev/null +++ b/tests/test_tarball_helper.py @@ -0,0 +1,165 @@ +import json + +from cortex.tarball_helper import TarballHelper + + +def test_parse_dependencies_cmake(): + helper = TarballHelper() + content = "find_package(OpenSSL)\nfind_package(ZLIB)" + deps = helper._parse_dependencies("CMakeLists.txt", content) + assert set(deps) == {"OpenSSL", "ZLIB"} + + +def test_parse_dependencies_makefile(): + helper = TarballHelper() + content = "gcc -lfoo -lbar" + deps = helper._parse_dependencies("Makefile", content) + assert set(deps) == {"foo", "bar"} + + +def test_parse_dependencies_setup_py(): + helper = TarballHelper() + # AST parseable + content = "install_requires = ['requests', 'numpy']" + deps = helper._parse_dependencies("setup.py", content) + assert set(deps) == {"requests", "numpy"} + + # Regex fallback + content2 = "install_requires=['pandas', 'scipy']" + deps2 = helper._parse_dependencies("setup.py", content2) + assert set(deps2) == {"pandas", "scipy"} + + # Edge case: malformed + content3 = "install_requires = None" + deps3 = helper._parse_dependencies("setup.py", content3) + assert deps3 == [] + + +def test_parse_dependencies_setup_py_multiline(): + helper = TarballHelper() + content = """ +install_requires = [ + 'requests', + 'numpy', # comment + 'pandas', +] +""" + deps = helper._parse_dependencies("setup.py", content) + assert set(deps) == {"requests", "numpy", "pandas"} + + +def test_suggest_apt_packages_lib_prefix(): + helper = TarballHelper() + deps = ["foo", "libbar"] + mapping = helper.suggest_apt_packages(deps) + assert mapping["foo"] == "libfoo-dev" + assert mapping["libbar"] == "libbar-dev" + + +def test_load_tracked_packages_corrupt(tmp_path, monkeypatch): + test_file = tmp_path / "manual_builds.json" + test_file.write_text("{not: valid json}") + monkeypatch.setattr("cortex.tarball_helper.MANUAL_TRACK_FILE", test_file) + helper = TarballHelper() + pkgs = helper._load_tracked_packages() + assert pkgs == [] + + +def test_install_deps_error_handling(monkeypatch): + helper = TarballHelper() + called = [] + + def fake_run(args, check): + called.append(args) + + class Result: + returncode = 1 + + return Result() + + monkeypatch.setattr("subprocess.run", fake_run) + helper.tracked_packages = [] + helper.install_deps(["libfail-dev"]) + assert "libfail-dev" not in helper.tracked_packages + + +def test_load_tracked_packages_valid(tmp_path, monkeypatch): + test_file = tmp_path / "manual_builds.json" + test_file.write_text(json.dumps({"packages": ["libfoo-dev", "libbar-dev"]})) + monkeypatch.setattr("cortex.tarball_helper.MANUAL_TRACK_FILE", test_file) + helper = TarballHelper() + pkgs2 = helper._load_tracked_packages() + assert set(pkgs2) == {"libfoo-dev", "libbar-dev"} + + +def test_install_deps_error_handling(monkeypatch): + helper = TarballHelper() + called = [] + + def fake_run(args, check): + called.append(args) + + class Result: + returncode = 1 + + return Result() + + monkeypatch.setattr("subprocess.run", fake_run) + helper.tracked_packages = [] + helper.install_deps(["libfail-dev"]) + assert "libfail-dev" not in helper.tracked_packages + + +""" +Unit tests for tarball_helper.py +""" + +import os +import shutil +import tempfile + +import pytest + +from cortex.tarball_helper import MANUAL_TRACK_FILE + + +def test_analyze_cmake(tmp_path): + cmake = tmp_path / "CMakeLists.txt" + cmake.write_text(""" + find_package(OpenSSL) + find_package(ZLIB) + """) + helper = TarballHelper() + deps = helper.analyze(str(tmp_path)) + assert set(deps) == {"OpenSSL", "ZLIB"} + + +def test_analyze_meson(tmp_path): + meson = tmp_path / "meson.build" + meson.write_text("dependency('libcurl')\ndependency('zlib')") + helper = TarballHelper() + deps = helper.analyze(str(tmp_path)) + assert set(deps) == {"libcurl", "zlib"} + + +def test_suggest_apt_packages(): + helper = TarballHelper() + mapping = helper.suggest_apt_packages(["OpenSSL", "zlib"]) + assert mapping["OpenSSL"] == "libopenssl-dev" + assert mapping["zlib"] == "libzlib-dev" + + +def test_track_and_cleanup(tmp_path, monkeypatch): + # Patch MANUAL_TRACK_FILE to a temp location + test_file = tmp_path / "manual_builds.json" + monkeypatch.setattr("cortex.tarball_helper.MANUAL_TRACK_FILE", test_file) + helper = TarballHelper() + helper.track("libfoo-dev") + assert "libfoo-dev" in helper.tracked_packages + # Simulate cleanup (mock subprocess) + monkeypatch.setattr("subprocess.run", lambda *a, **k: None) + helper.cleanup() + assert helper.tracked_packages == [] + with open(test_file) as f: + data = json.load(f) + assert data["packages"] == []