From 77f1d9f22daf67a1c69cea1f8e5e29cbd9d0af5e Mon Sep 17 00:00:00 2001 From: GrantLinkz <0sejigrant@gmail.comz> Date: Wed, 2 Sep 2026 15:53:54 +0100 Subject: [PATCH] feat: implement package management system with recipe support, registry, and build synchronization. --- CHANGELOG.md | 16 ++ README.md | 8 +- docs/architecture.md | 119 ++++++++------- docs/dependency-management.md | 71 +++++++++ ebuild/cli/commands.py | 187 +++++++++++++++++++++-- ebuild/packages/index_sync.py | 240 ++++++++++++++++++++++++++++++ ebuild/packages/registry.py | 6 +- ebuild/packages/repository.py | 119 ++++++++++----- pytest.ini | 1 + recipes/cjson.yaml | 11 ++ recipes/lvgl.yaml | 11 ++ recipes/nanopb.yaml | 11 ++ recipes/tinyusb.yaml | 9 ++ recipes/unity.yaml | 11 ++ tests/unit/test_index_sync.py | 140 +++++++++++++++++ tests/unit/test_package_search.py | 69 +++++++++ 16 files changed, 927 insertions(+), 102 deletions(-) create mode 100644 ebuild/packages/index_sync.py create mode 100644 recipes/cjson.yaml create mode 100644 recipes/lvgl.yaml create mode 100644 recipes/nanopb.yaml create mode 100644 recipes/tinyusb.yaml create mode 100644 recipes/unity.yaml create mode 100644 tests/unit/test_index_sync.py create mode 100644 tests/unit/test_package_search.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 536e83a..45e5a8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,23 @@ ## [Unreleased] +### Added +- **Remote Package Index & Synchronization (`ebuild/packages/index_sync.py`).** + Downloads and validates central/mirror package repository indices into a local cache + (`~/.ebuild/index/`), caching full recipe definitions. Enforces HTTPS transport, + path-traversal sanitization (`^[a-zA-Z0-9_-]+$`), 10s socket timeouts, and 10MB response + size limits. Fully supports air-gapped/offline execution via `--offline` and `EBUILD_OFFLINE=1`. +- **Package Discovery & Multi-Source Search (`ebuild search`, `ebuild/packages/repository.py`).** + Search across local project recipes, system-shipped recipes, and cached remote indices. + Supports `--all`, `--json`, `--build-system`, and `--license` filters. +- **Index Synchronization Command (`ebuild update-index`).** + CLI command to refresh local package and recipe index caches from remote repositories. +- **Expanded Shipped Recipes Catalog (`recipes/`).** + Added 5 verified recipes with HTTPS release pins and SHA-256 integrity digests: + `cjson` (v1.7.18), `nanopb` (v0.4.9.1), `lvgl` (v9.2.2), `tinyusb` (v0.18.0), and `unity` (v2.6.1). + ### Fixed + - **A path containing a space produced a silently wrong `build.ninja`.** Paths were written into build statements unescaped, but Ninja ends the output list at the first unescaped `:` and splits on unescaped spaces. A build directory diff --git a/README.md b/README.md index 421a3ae..3edd28a 100644 --- a/README.md +++ b/README.md @@ -91,10 +91,10 @@ that each package's own build may already run parallel compile jobs, so a large [docs/dependency-management.md](docs/dependency-management.md#parallel-package-builds). Additional commands: `configure`, `install`, `add`, `list-packages`, -`pipeline`, `system`, `firmware`, `flash`, `new`, `generate-project`, -`generate-board`, `generate-boot`, `analyze`, `setup`, and the `repos` group -(`status`, `update`, `set-url`, `set-branch`, `link`, `unlink`). Run -`ebuild --help` for the full list. +`search`, `update-index`, `pipeline`, `system`, `firmware`, `flash`, `new`, +`generate-project`, `generate-board`, `generate-boot`, `analyze`, `setup`, +and the `repos` group (`status`, `update`, `set-url`, `set-branch`, `link`, +`unlink`). Run `ebuild --help` for the full list. `ebuild test` reports the counts the underlying runner printed, and reports none diff --git a/docs/architecture.md b/docs/architecture.md index 09ac782..435aa34 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -7,74 +7,82 @@ ```mermaid graph TD subgraph CLI["ebuild CLI (Python)"] - CMD[cli/commands.py
18 commands] + CMD["cli/commands.py
CLI Commands"] end subgraph BUILD["Build Orchestrator"] - ORCH[build/orchestrator.py] - NINJA[build/ninja_backend.py] - TC[build/toolchain.py
5 predefined toolchains] + ORCH["build/dispatch.py
Backend Dispatcher"] + NINJA["build/ninja_backend.py
Ninja Backend"] + TC["build/toolchain.py
5 Predefined Toolchains"] end subgraph PACKAGES["Package Pipeline"] - RECIPE[packages/recipe.py] - REG[packages/registry.py] - RESOLVE[packages/resolver.py] - FETCH[packages/fetcher.py] - BUILDER[packages/builder.py] - CACHE[packages/cache.py] - LOCK[packages/lockfile.py] - REPO[packages/repository.py
Remote index] - PROFILES[packages/profiles.py
Build profiles] + SYNC["packages/index_sync.py
Remote Index Sync"] + REPO["packages/repository.py
Package Discovery"] + RECIPE["packages/recipe.py
Recipe Schema"] + REG["packages/registry.py
Package Registry"] + RESOLVE["packages/resolver.py
Dependency Resolver"] + FETCH["packages/fetcher.py
Package Fetcher"] + BUILDER["packages/builder.py
Package Builder"] + CACHE["packages/cache.py
Build Cache"] + LOCK["packages/lockfile.py
Lockfile"] + PROFILES["packages/profiles.py
Build Profiles"] end subgraph HWAI["Hardware AI"] - ANALYZER[eos_ai/eos_hw_analyzer.py
MCU database + peripheral detection] - PROJGEN[eos_ai/eos_project_generator.py
Manifest + project scaffolding] + ANALYZER["eos_ai/eos_hw_analyzer.py
MCU & Peripheral Analysis"] + PROJGEN["eos_ai/eos_project_generator.py
Project & Config Generator"] end - subgraph CORE["Core Components (always built)"] - EOS[core/eos/
HAL, Kernel, Crypto, OTA, Drivers] - EBOOT[core/eboot/
Bootloader, 26 board ports] + subgraph CORE["Core Components (Native)"] + EOS["core/eos/
HAL, Kernel, Crypto, Drivers"] + EBOOT["core/eboot/
Bootloader, 26 Board Ports"] end - subgraph LAYERS["Optional Layers (--with flag)"] - EAI[layers/eai/
AI inference + LLM models] - ENI[layers/eni/
Neural interface] - EIPC[layers/eipc/
Secure IPC (Go + C)] - EOSUITE[layers/eosuite/
Dev tools + GUI apps] + subgraph LAYERS["Optional Platform Layers"] + EAI["layers/eai/
AI & Embedded Inference"] + ENI["layers/eni/
Neural Interface"] + EIPC["layers/eipc/
Secure IPC SDK"] + EOSUITE["layers/eosuite/
Developer Tools"] end subgraph HW["Hardware Intake"] - BOARD[hardware/board/
KiCad, Eagle, YAML, BOM] - SOC[hardware/soc/
Datasheets, TRMs] - BOOT[hardware/boot/
Image layout, boot flow] - SW[hardware/software/
Device trees, linker scripts] + BOARD["hardware/board/
KiCad, Eagle, YAML, BOM"] + SOC["hardware/soc/
Datasheets, TRMs"] + BOOT["hardware/boot/
Image Layout, Boot Flow"] + SW["hardware/software/
Device Trees, Linker Scripts"] end subgraph SDK_OUT["SDK Output"] - SDKGEN[sdk_generator.py] - SDKAPI[sdk/include/
Header-only API] + SDKGEN["sdk_generator.py"] + SDKAPI["sdk/include/
Header-only API"] end subgraph TEMPLATES["Project Templates"] - T1[bare-metal] - T2[rtos-app] - T3[linux-app] - T4[safety-critical] - T5[secure-boot] - T6[ble-sensor] + T1["bare-metal"] + T2["rtos-app"] + T3["linux-app"] + T4["safety-critical"] + T5["secure-boot"] + T6["ble-sensor"] end CMD --> ORCH CMD --> SDKGEN CMD --> ANALYZER CMD --> PROJGEN + CMD --> SYNC + CMD --> REPO + + SYNC --> REPO + REPO --> REG ORCH --> NINJA ORCH --> TC - ORCH --> CORE - ORCH --> LAYERS + ORCH --> EOS + ORCH --> EBOOT + ORCH --> EAI + ORCH --> EIPC ORCH --> RECIPE RECIPE --> REG @@ -83,16 +91,24 @@ graph TD FETCH --> BUILDER BUILDER --> CACHE RESOLVE --> LOCK - REG --> REPO - HW --> ANALYZER + BOARD --> ANALYZER + SOC --> ANALYZER + BOOT --> ANALYZER + SW --> ANALYZER + ANALYZER --> PROJGEN - PROJGEN --> TEMPLATES - PROJGEN --> CORE + PROJGEN --> T1 + PROJGEN --> T2 + PROJGEN --> T3 + PROJGEN --> EOS PROJGEN --> EBOOT - TC -->|cmake| CORE - TC -->|cmake| LAYERS + TC -->|cmake| EOS + TC -->|cmake| EAI + TC -->|cmake| EIPC + + SDKGEN --> SDKAPI ``` ## Subsystem Details @@ -157,14 +173,15 @@ recipe.yaml → Registry → Resolver → Fetcher → Builder → Cache ``` 1. **Recipe** (`recipe.py`) — YAML schema defining package name, version, URL, checksum, build system, dependencies -2. **Registry** (`registry.py`) — scans recipe directories and indexes available packages -3. **Repository** (`repository.py`) — remote package index for discovery and search -4. **Resolver** (`resolver.py`) — dependency resolution with version constraint solving -5. **Fetcher** (`fetcher.py`) — downloads and verifies source archives -6. **Builder** (`builder.py`) — builds packages using the specified build system -7. **Cache** (`cache.py`) — caches built artifacts to avoid rebuilding -8. **Lockfile** (`lockfile.py`) — records exact resolved versions for reproducibility -9. **Profiles** (`profiles.py`) — composable build profiles (minimal, standard, full, custom) +2. **Registry** (`registry.py`) — scans recipe directories (local, system, and cached remote) and indexes available packages +3. **Index Sync** (`index_sync.py`) — downloads, validates, and manages remote package repository indices and recipe caches (`~/.ebuild/index/`) with offline fallback +4. **Repository** (`repository.py`) — unified package discovery and multi-source search engine (`ebuild search`) +5. **Resolver** (`resolver.py`) — dependency resolution with version constraint solving +6. **Fetcher** (`fetcher.py`) — downloads and verifies source archives against SHA-256 integrity pins +7. **Builder** (`builder.py`) — builds packages using the specified build system (CMake, Make, Meson, autoconf) +8. **Cache** (`cache.py`) — caches built artifacts to avoid rebuilding +9. **Lockfile** (`lockfile.py`) — records exact resolved versions for reproducibility +10. **Profiles** (`profiles.py`) — composable build profiles (minimal, standard, full, custom) ### Hardware AI (`ebuild/eos_ai/`) diff --git a/docs/dependency-management.md b/docs/dependency-management.md index b9961b7..5d67b22 100644 --- a/docs/dependency-management.md +++ b/docs/dependency-management.md @@ -272,3 +272,74 @@ poles without thrashing; going much higher mostly adds memory pressure. Parallel builds also interleave package log lines. Output from each package is written atomically, but the packages themselves are no longer contiguous — use `-j 1` when reading a build log closely. + +--- + +## Remote Package Index & Discovery + +ebuild provides index-based package discovery and remote recipe synchronization, allowing embedded projects to discover, query, and install external libraries seamlessly. + +### Discovering Packages (`ebuild search`) + +Search across local project recipes (`./recipes/`), bundled system recipes, and cached remote repository indices: + +```bash +ebuild search json # Search by keyword in name, description, or license +ebuild search --all # List all available packages across all sources +ebuild search --json # Output machine-readable JSON array +ebuild search --build-system cmake # Filter by build system (cmake, make, meson) +ebuild search --license MIT # Filter by license type +``` + +Example output: + +``` +=== ebuild - Package Search === +[info] Found 10 package(s): + cjson v1.7.18 [cmake] (MIT) - Ultralightweight JSON parser in ANSI C + freertos v11.1.0 [cmake] (MIT) - Real-time operating system kernel for embedded devices + littlefs v2.9.3 [make] (BSD-3-Clause) - Little fail-safe filesystem designed for microcontrollers + lvgl v9.2.2 [cmake] (MIT) - Light and Versatile Embedded Graphics Library + lwip v2.2.0 [cmake] (BSD-3-Clause) - Lightweight TCP/IP stack for embedded systems + mbedtls v3.6.0 [cmake] (Apache-2.0) - Lightweight TLS/SSL library for embedded systems + nanopb v0.4.9.1 [cmake] (zlib) - Protocol Buffers with small code size for microcontrollers + tinyusb v0.18.0 [cmake] (MIT) - Open-source cross-platform USB host/device stack for embedded system + unity v2.6.1 [cmake] (MIT) - Simple Unit Testing for C + zlib v1.3.1 [cmake] (Zlib) - General-purpose lossless data compression library +``` + +### Synchronizing Remote Index (`ebuild update-index`) + +Refresh the local package index and cached recipe definitions from the central remote repository or a custom mirror: + +```bash +ebuild update-index # Sync from default upstream repository +ebuild update-index --url https://mycorp.com/recipes/index.json # Custom mirror +ebuild update-index --force # Force refresh +ebuild update-index --offline # Use local cached index without network +``` + +### Shipped Embedded Library Catalog + +ebuild includes a curated suite of pre-packaged recipes under `recipes/`: + +| Package | Version | Build System | License | Description | +|:---|:---|:---|:---|:---| +| **`cjson`** | 1.7.18 | CMake | MIT | Ultralightweight JSON parser in ANSI C | +| **`freertos`** | 11.1.0 | CMake | MIT | Real-time operating system kernel for embedded devices | +| **`littlefs`** | 2.9.3 | Make | BSD-3-Clause | Fail-safe power-resilient filesystem for microcontrollers | +| **`lvgl`** | 9.2.2 | CMake | MIT | Light and Versatile Embedded Graphics Library | +| **`lwip`** | 2.2.0 | CMake | BSD-3-Clause | Lightweight TCP/IP stack for embedded targets | +| **`mbedtls`** | 3.6.0 | CMake | Apache-2.0 | Cryptographic primitives, TLS/SSL stack | +| **`nanopb`** | 0.4.9.1 | CMake | zlib | Memory-efficient Protocol Buffers implementation | +| **`tinyusb`** | 0.18.0 | CMake | MIT | Cross-platform USB host/device stack | +| **`unity`** | 2.6.1 | CMake | MIT | Standard embedded C unit testing framework | +| **`zlib`** | 1.3.1 | CMake | Zlib | General-purpose lossless data compression | + +### Offline & Air-Gapped Operation + +For isolated CI/CD pipelines and field deployments: +- Set environment variable `EBUILD_OFFLINE=1` or pass `--offline` to commands. +- ebuild automatically searches local directories first and gracefully falls back to cached indices in `~/.ebuild/index/` if the network is unreachable. +- All downloads are validated against SHA-256 integrity pins before extraction. + diff --git a/ebuild/cli/commands.py b/ebuild/cli/commands.py index 6d7ecbe..9ee6e9e 100644 --- a/ebuild/cli/commands.py +++ b/ebuild/cli/commands.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- # SPDX-License-Identifier: MIT # Copyright (c) 2026 EoS Project @@ -9,6 +10,7 @@ from __future__ import annotations +import glob import os import re import shutil @@ -48,7 +50,7 @@ def _find_recipe_dirs(project_dir: Path) -> List[Path]: - """Locate recipe directories: project-local and install-level.""" + """Locate recipe directories: project-local, install-level, and remote synced cache.""" dirs = [] for name in _RECIPE_DIRS: d = project_dir / name @@ -60,9 +62,16 @@ def _find_recipe_dirs(project_dir: Path) -> List[Path]: if pkg_recipes.is_dir() and pkg_recipes not in dirs: dirs.append(pkg_recipes) + # Also check remote synced cache in ~/.ebuild/index/recipes/ + from ebuild.packages.index_sync import get_default_index_dir + cached_recipes = get_default_index_dir() / "recipes" + if cached_recipes.is_dir() and cached_recipes not in dirs: + dirs.append(cached_recipes) + return dirs + def _install_packages( cfg: ProjectConfig, build_dir: Path, @@ -519,9 +528,16 @@ def _report_footprint(cfg: "ProjectConfig", build_path: Path, log: Logger) -> No def _board_config() -> Optional[Dict[str, Any]]: - """The project's own board description, if it ships one. - - return resolved_backend, backend_config + """The project's own board description, if it ships one.""" + path = Path("board.yaml") + if not path.is_file(): + return None + try: + with open(path, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) + return data if isinstance(data, dict) else None + except Exception: + return None def _configure_ninja_backend( @@ -852,7 +868,7 @@ def _generate_image(board, build_dir, log): @click.option("-v", "--verbose", is_flag=True, help="Enable verbose output.") @click.pass_context def cli(ctx: click.Context, verbose: bool) -> None: - """ebuild — A unified embedded OS build system.""" + """ebuild - A unified embedded OS build system.""" ctx.ensure_object(dict) ctx.obj = Logger(verbose=verbose) @@ -1602,7 +1618,7 @@ def flash(log: Logger, image: str, tool: str, target: str, address: str, @click.pass_obj def list_packages(log: Logger, config_path: str) -> None: """List available package recipes and project packages.""" - log.header("ebuild — Package Registry") + log.header("ebuild - Package Registry") config_path_obj = Path(config_path) project_dir = config_path_obj.parent if config_path_obj.exists() else Path(".") @@ -1622,7 +1638,7 @@ def list_packages(log: Logger, config_path: str) -> None: log.info(f"Available recipes ({len(packages)}):") for recipe in packages: deps = f" (depends: {', '.join(recipe.dependencies)})" if recipe.dependencies else "" - desc = f" — {recipe.description}" if recipe.description else "" + desc = f" - {recipe.description}" if recipe.description else "" log.step(f"{recipe.name} v{recipe.version} [{recipe.build_system}]{deps}{desc}") # Show project packages if config exists @@ -1639,6 +1655,81 @@ def list_packages(log: Logger, config_path: str) -> None: pass +@cli.command("search") +@click.argument("query", required=False, default="") +@click.option("--all", "show_all", is_flag=True, default=False, help="Show all available packages.") +@click.option("--json", "as_json", is_flag=True, default=False, help="Output results in JSON format.") +@click.option("--build-system", "build_sys", default=None, help="Filter by build system (cmake, make, meson, etc.).") +@click.option("--license", "lic_filter", default=None, help="Filter by license.") +@click.option( + "--config", "config_path", + default="build.yaml", + type=click.Path(exists=False), + help="Path to the build configuration file.", +) +@click.pass_obj +def search_packages( + log: Logger, + query: str, + show_all: bool, + as_json: bool, + build_sys: Optional[str], + lic_filter: Optional[str], + config_path: str, +) -> None: + """Search for packages across local recipes, shipped catalog, and remote index.""" + from ebuild.packages.repository import PackageRepository + + config_path_obj = Path(config_path) + project_dir = config_path_obj.parent if config_path_obj.exists() else Path(".") + + repo = PackageRepository() + repo.load_all_sources(project_dir=project_dir) + + effective_query = "" if show_all else query + results = repo.search(query=effective_query, build_system=build_sys, license=lic_filter) + + if as_json: + import json + click.echo(json.dumps([pkg.to_dict() for pkg in results], indent=2)) + return + + log.header("ebuild - Package Search") + if not results: + if query: + log.info(f"No packages found matching '{query}'. Try running 'ebuild update-index' or 'ebuild search --all'.") + else: + log.info("No packages found. Try running 'ebuild update-index'.") + return + + log.info(f"Found {len(results)} package(s):") + for pkg in results: + lic = f" ({pkg.license})" if pkg.license else "" + desc = f" - {pkg.description}" if pkg.description else "" + log.step(f"{pkg.name} v{pkg.version} [{pkg.build_system}]{lic}{desc}") + + +@cli.command("update-index") +@click.option("--url", "index_url", default=None, help="Custom remote package index URL (HTTPS).") +@click.option("--offline", is_flag=True, default=False, help="Offline mode: do not download, use existing cache.") +@click.option("--force", is_flag=True, default=False, help="Force refresh even if cache is up-to-date.") +@click.pass_obj +def update_index(log: Logger, index_url: Optional[str], offline: bool, force: bool) -> None: + """Synchronize the local package index with the remote recipe repository.""" + from ebuild.packages.index_sync import IndexSyncManager, IndexSyncError + + log.header("ebuild - Update Package Index") + sync_mgr = IndexSyncManager() + + try: + count, msg = sync_mgr.sync(url=index_url, force=force, offline=offline) + log.success(msg) + log.info(f"Index cache located at: {sync_mgr.index_dir}") + except IndexSyncError as e: + log.error(f"Index update failed: {e}") + raise SystemExit(1) + + @cli.command() @click.argument("input_text", required=False) @click.option("--file", "input_file", type=click.Path(exists=True), help="Hardware design file (KiCad .kicad_sch, Eagle .sch, BOM .csv, YAML, text).") @@ -2411,7 +2502,10 @@ def test(log: Logger, config_path: str, build_dir: str, log.info(" ".join(argv)) try: - result = subprocess.run(argv, cwd=str(cwd)) + # Captured rather than inherited, because the exit status alone cannot + # distinguish "every test passed" from "there were no tests". The + # output is echoed below so the terminal reads as it did before. + result = subprocess.run(argv, cwd=str(cwd), capture_output=True, text=True) except FileNotFoundError: log.error( f"{name} is not installed or not on PATH, so the tests cannot be " @@ -2419,11 +2513,86 @@ def test(log: Logger, config_path: str, build_dir: str, ) raise SystemExit(1) + output = (result.stdout or "") + (result.stderr or "") + if output: + click.echo(output.rstrip()) + if result.returncode != 0: log.error(f"Tests failed ({name} exited {result.returncode}).") raise SystemExit(result.returncode) - log.success("All tests passed.") + # ctest exits 0 when it finds nothing to run. A CMakeLists with + # enable_testing() and no add_test() produces a CTestTestfile.cmake, so the + # runner is found, ctest prints "No tests were found!!!", exits 0, and the + # only honest reading of that is not "All tests passed". + if _ran_no_tests(name, output): + log.error(f"{name} completed without running a single test.") + log.info(" A pass here would mean nothing; treating it as a failure.") + raise SystemExit(1) + + counts = _parse_test_counts(name, output) + if counts is not None: + passed, failed = counts + log.success(f"All tests passed ({passed} passed, {failed} failed).") + else: + # No recognised summary. Report the verdict without inventing a number + # the runner did not print. + log.success("All tests passed.") + + +#: What each runner prints when it completed having executed nothing. ctest's is +#: the one that matters: it pairs the message with a zero exit status. +_NO_TESTS_MARKERS = { + "ctest": ("No tests were found",), + "meson test": ("No tests defined",), + "cargo test": ("running 0 tests",), +} + +#: Each runner's own summary line, anchored to the phrasing it prints so that a +#: format change shows up as "no counts" rather than as a wrong number. +_TEST_COUNT_PATTERNS = { + "ctest": re.compile( + r"tests passed,\s*(?P\d+)\s+tests? failed out of\s*(?P\d+)"), + "meson test": re.compile( + r"^Ok:\s*(?P\d+).*?^Fail:\s*(?P\d+)", re.S | re.M), + "cargo test": re.compile( + r"test result:.*?(?P\d+) passed;\s*(?P\d+) failed"), +} + + +def _ran_no_tests(name: str, output: str) -> bool: + """True when the runner finished having executed nothing. + + Checked two ways because neither is reliable alone: the marker phrase + catches ctest, which prints no summary at all in this case, and the counts + catch a runner that prints a well-formed summary totalling zero. + """ + for marker in _NO_TESTS_MARKERS.get(name, ()): + if marker in output: + return True + counts = _parse_test_counts(name, output) + return counts is not None and counts[0] + counts[1] == 0 + + +def _parse_test_counts(name: str, output: str): + """(passed, failed) from the runner's own summary, or None. + + `make test` has no standard summary format. Rather than invent one, its + counts stay unknown and the exit status carries the verdict. + """ + pattern = _TEST_COUNT_PATTERNS.get(name) + if pattern is None: + return None + match = pattern.search(output) + if not match: + return None + groups = match.groupdict() + failed = int(groups["failed"]) + if groups.get("passed") is not None: + return int(groups["passed"]), failed + # ctest reports failures out of a total; passed is the remainder. + return int(groups["total"]) - failed, failed + def _run_native_tests( diff --git a/ebuild/packages/index_sync.py b/ebuild/packages/index_sync.py new file mode 100644 index 0000000..007f33f --- /dev/null +++ b/ebuild/packages/index_sync.py @@ -0,0 +1,240 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""Remote package index synchronization and offline cache manager. + +Manages downloading, verifying, and caching package indices and recipe +definitions from remote repositories (HTTPS) into a local user cache directory +(~/.ebuild/index/). Supports offline fallback and security sanitization. +""" + +from __future__ import annotations + +import hashlib +import json +import logging +import os +import re +import urllib.error +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +import yaml + +from ebuild.packages.recipe import PackageRecipe, RecipeError, _parse_recipe + +logger = logging.getLogger(__name__) + +# Default remote repository index URL +DEFAULT_INDEX_URL = ( + "https://raw.githubusercontent.com/embeddedos-org/recipes/main/index.json" +) + +# Maximum response size allowed for index download (10 MB) +MAX_INDEX_SIZE_BYTES = 10 * 1024 * 1024 + +# Network timeout in seconds +DEFAULT_NETWORK_TIMEOUT_SECONDS = 10 + +# Valid package name pattern (strict validation to prevent path traversal) +_SAFE_NAME_RE = re.compile(r"^[a-zA-Z0-9_-]+$") + + +class IndexSyncError(Exception): + """Raised when index synchronization fails and cannot fallback.""" + + +def is_offline(offline_flag: bool = False) -> bool: + """Check if the execution environment is configured for offline operation.""" + if offline_flag: + return True + env_val = os.environ.get("EBUILD_OFFLINE", "").strip().lower() + return env_val in ("1", "true", "yes", "on") + + +def get_default_index_dir() -> Path: + """Get the local index cache directory path, respecting env overrides.""" + if "EBUILD_INDEX_PATH" in os.environ: + return Path(os.environ["EBUILD_INDEX_PATH"]) + if "EBUILD_CACHE_DIR" in os.environ: + return Path(os.environ["EBUILD_CACHE_DIR"]) / "index" + return Path.home() / ".ebuild" / "index" + + +def sanitize_package_name(name: str) -> str: + """Validate and sanitize a package name to prevent path traversal attacks. + + Args: + name: The candidate package name. + + Returns: + The validated package name. + + Raises: + ValueError: If the package name contains invalid or unsafe characters. + """ + cleaned = name.strip() + if not cleaned or not _SAFE_NAME_RE.match(cleaned): + raise ValueError( + f"Invalid package name '{name}': names must only contain alphanumeric " + f"characters, underscores, or hyphens." + ) + return cleaned + + +class IndexSyncManager: + """Coordinates remote index fetching, integrity validation, and local caching.""" + + def __init__( + self, + index_dir: Optional[Path | str] = None, + default_url: str = DEFAULT_INDEX_URL, + ) -> None: + self.index_dir = ( + Path(index_dir) if index_dir is not None else get_default_index_dir() + ) + self.default_url = default_url + self.packages_json = self.index_dir / "packages.json" + self.recipes_dir = self.index_dir / "recipes" + + def ensure_directories(self) -> None: + """Create necessary index directories if they do not exist.""" + self.index_dir.mkdir(parents=True, exist_ok=True) + self.recipes_dir.mkdir(parents=True, exist_ok=True) + + def get_recipe_dirs(self) -> List[Path]: + """Return list of recipe directories managed by this sync index.""" + if self.recipes_dir.is_dir(): + return [self.recipes_dir] + return [] + + def load_cached_entries(self) -> List[Dict[str, Any]]: + """Load entries from the local packages.json cache if present.""" + if not self.packages_json.is_file(): + return [] + try: + with open(self.packages_json, "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, list): + return data + logger.warning("Cached index at %s is not a list", self.packages_json) + except (json.JSONDecodeError, OSError) as e: + logger.warning("Failed to load cached index: %s", e) + return [] + + def sync( + self, + url: Optional[str] = None, + force: bool = False, + offline: bool = False, + timeout: int = DEFAULT_NETWORK_TIMEOUT_SECONDS, + ) -> Tuple[int, str]: + """Synchronize the index from the remote URL to the local cache. + + Args: + url: The remote index URL (defaults to configured default_url). + force: If True, re-download even if recently synced. + offline: If True, skip network download and use cached files. + timeout: Network timeout in seconds. + + Returns: + Tuple of (package_count, status_message). + """ + target_url = (url or self.default_url).strip() + self.ensure_directories() + + if is_offline(offline): + cached = self.load_cached_entries() + count = len(cached) + return count, f"Offline mode: using cached index ({count} packages)" + + if not target_url.startswith("https://"): + raise IndexSyncError( + f"Insecure index URL '{target_url}': only HTTPS URLs are permitted." + ) + + logger.info("Fetching remote package index from %s", target_url) + + try: + req = urllib.request.Request( + target_url, + headers={"User-Agent": "ebuild-package-manager/3.0"}, + ) + with urllib.request.urlopen(req, timeout=timeout) as response: + content_length = response.headers.get("Content-Length") + if content_length and int(content_length) > MAX_INDEX_SIZE_BYTES: + raise IndexSyncError( + f"Index download exceeds maximum allowed size ({MAX_INDEX_SIZE_BYTES} bytes)" + ) + raw_bytes = response.read(MAX_INDEX_SIZE_BYTES + 1) + if len(raw_bytes) > MAX_INDEX_SIZE_BYTES: + raise IndexSyncError( + f"Index download exceeded maximum size limit of {MAX_INDEX_SIZE_BYTES} bytes" + ) + + # Parse JSON + try: + data = json.loads(raw_bytes.decode("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError) as err: + raise IndexSyncError(f"Corrupted or invalid JSON index from {target_url}: {err}") from err + + if not isinstance(data, list): + raise IndexSyncError("Invalid index schema: expected top-level JSON array") + + # Write cached packages.json atomically + temp_json = self.packages_json.with_suffix(".tmp") + with open(temp_json, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + temp_json.replace(self.packages_json) + + # Process and cache full recipe YAML definitions + synced_count = 0 + for entry in data: + if not isinstance(entry, dict) or "name" not in entry: + continue + try: + pkg_name = sanitize_package_name(str(entry["name"])) + recipe_filename = f"{pkg_name}.yaml" + recipe_path = self.recipes_dir / recipe_filename + + # Write recipe YAML if entry contains recipe attributes + recipe_dict = { + "package": pkg_name, + "version": str(entry.get("version", "1.0.0")), + "description": entry.get("description", ""), + "license": entry.get("license", ""), + "url": entry.get("url", ""), + "checksum": entry.get("checksum", ""), + "build": entry.get("build_system", entry.get("build", "cmake")), + "dependencies": entry.get("dependencies", []), + "configure_args": entry.get("configure_args", []), + "build_args": entry.get("build_args", []), + "patches": entry.get("patches", []), + } + + # If URL exists, validate recipe structure before saving + if recipe_dict["url"]: + try: + recipe = _parse_recipe(recipe_dict) + with open(recipe_path, "w", encoding="utf-8") as rf: + yaml.safe_dump(recipe_dict, rf, sort_keys=False) + except RecipeError as re_err: + logger.warning("Skipping invalid recipe entry %s: %s", pkg_name, re_err) + continue + + synced_count += 1 + except ValueError as ve: + logger.warning("Skipping unsafe package entry: %s", ve) + continue + + return synced_count, f"Successfully synchronized {synced_count} packages from remote index" + + except (urllib.error.URLError, urllib.error.HTTPError, OSError, TimeoutError) as e: + # Fallback to local cache if available + cached = self.load_cached_entries() + if cached: + logger.warning("Remote sync failed (%s). Falling back to cached index.", e) + return len(cached), f"Network sync failed ({e}); fell back to cached index ({len(cached)} packages)" + raise IndexSyncError(f"Failed to fetch remote package index and no cache is available: {e}") from e diff --git a/ebuild/packages/registry.py b/ebuild/packages/registry.py index c7679bd..85b8903 100644 --- a/ebuild/packages/registry.py +++ b/ebuild/packages/registry.py @@ -166,7 +166,7 @@ def get(self, name: str, version: Optional[str] = None) -> Optional[PackageRecip if version: return versions.get(version) - latest_version = sorted(versions.keys(), key=_version_sort_key)[-1] + latest_version = sorted(versions.keys(), key=version_sort_key)[-1] return versions[latest_version] def has(self, name: str, version: Optional[str] = None) -> bool: @@ -178,7 +178,7 @@ def list_packages(self) -> List[PackageRecipe]: result = [] for name in sorted(self._recipes.keys()): versions = self._recipes[name] - latest = sorted(versions.keys(), key=_version_sort_key)[-1] + latest = sorted(versions.keys(), key=version_sort_key)[-1] result.append(versions[latest]) return result @@ -189,7 +189,7 @@ def list_all_versions(self, name: str) -> List[PackageRecipe]: versions[v] for v in sorted( versions.keys(), - key=_version_sort_key, + key=version_sort_key, ) ] diff --git a/ebuild/packages/repository.py b/ebuild/packages/repository.py index 7f56abb..af28fed 100644 --- a/ebuild/packages/repository.py +++ b/ebuild/packages/repository.py @@ -4,17 +4,18 @@ """Remote package repository — index-based package discovery. Provides search, info, and listing of packages available from -local recipe directories or remote repository indices. +local recipe directories, shipped package catalogs, or remote repository indices. """ from __future__ import annotations import json import logging -from dataclasses import dataclass, field +from dataclasses import asdict, dataclass, field from pathlib import Path -from typing import Dict, List, Optional +from typing import Any, Dict, List, Optional +from ebuild.packages.index_sync import IndexSyncManager, get_default_index_dir from ebuild.packages.registry import PackageRegistry, create_registry logger = logging.getLogger(__name__) @@ -33,17 +34,22 @@ class PackageInfo: url: str = "" checksum: str = "" + def to_dict(self) -> Dict[str, Any]: + """Convert PackageInfo dataclass to a JSON-serializable dictionary.""" + return asdict(self) + class PackageRepository: """Repository index for discovering and querying available packages. - Wraps one or more PackageRegistry instances and provides - search, info, and listing functionality. + Wraps one or more PackageRegistry instances and remote index files, + providing unified search, info, and listing functionality. """ - def __init__(self) -> None: + def __init__(self, sync_manager: Optional[IndexSyncManager] = None) -> None: self._registries: List[PackageRegistry] = [] self._index: Dict[str, PackageInfo] = {} + self.sync_manager = sync_manager or IndexSyncManager() def add_recipe_directory(self, path: str | Path) -> int: """Add a local recipe directory to the repository. @@ -91,8 +97,12 @@ def load_index(self, index_path: str | Path) -> int: logger.warning("Repository index not found: %s", index_path) return 0 - with open(index_path, "r", encoding="utf-8") as f: - data = json.load(f) + try: + with open(index_path, "r", encoding="utf-8") as f: + data = json.load(f) + except (json.JSONDecodeError, OSError) as e: + logger.warning("Failed to parse repository index %s: %s", index_path, e) + return 0 if not isinstance(data, list): logger.warning("Invalid index format: expected array") @@ -102,9 +112,10 @@ def load_index(self, index_path: str | Path) -> int: for entry in data: if not isinstance(entry, dict) or "name" not in entry: continue + name = str(entry["name"]) info = PackageInfo( - name=entry["name"], - version=entry.get("version", "0.0.0"), + name=name, + version=str(entry.get("version", "0.0.0")), description=entry.get("description", ""), license=entry.get("license", ""), build_system=entry.get("build_system", entry.get("build", "cmake")), @@ -112,30 +123,79 @@ def load_index(self, index_path: str | Path) -> int: url=entry.get("url", ""), checksum=entry.get("checksum", ""), ) - self._index[info.name] = info - count += 1 + # Local recipe directory overrides remote index if already loaded + if name not in self._index: + self._index[name] = info + count += 1 return count - def search(self, query: str) -> List[PackageInfo]: - """Search for packages matching a query string. + def load_all_sources(self, project_dir: Optional[Path | str] = None) -> int: + """Load all available sources: project recipes, shipped recipes, and remote cache. - Matches against package name and description (case-insensitive). + Args: + project_dir: Optional path to project root. + + Returns: + Total count of packages indexed across all sources. + """ + # 1. Project-local recipes + if project_dir is not None: + p_dir = Path(project_dir) + if (p_dir / "recipes").is_dir(): + self.add_recipe_directory(p_dir / "recipes") + + # 2. Shipped system recipes + shipped_dir = Path(__file__).resolve().parent.parent.parent / "recipes" + if shipped_dir.is_dir(): + self.add_recipe_directory(shipped_dir) + + # 3. Cached remote recipes + for rdir in self.sync_manager.get_recipe_dirs(): + if rdir.is_dir() and rdir != shipped_dir: + self.add_recipe_directory(rdir) + + # 4. Cached remote JSON index + if self.sync_manager.packages_json.is_file(): + self.load_index(self.sync_manager.packages_json) + + return self.package_count + + def search( + self, + query: str = "", + build_system: Optional[str] = None, + license: Optional[str] = None, + ) -> List[PackageInfo]: + """Search for packages matching query and filters. Args: - query: Search query string. + query: Search query string (matches name, description, license). + build_system: Optional build system filter (e.g., 'cmake', 'make'). + license: Optional license filter. Returns: - List of matching PackageInfo objects. + List of matching PackageInfo objects sorted by name. """ - query_lower = query.lower() + query_lower = query.strip().lower() results = [] + for info in sorted(self._index.values(), key=lambda p: p.name): - if ( - query_lower in info.name.lower() - or query_lower in info.description.lower() - ): - results.append(info) + if query_lower: + name_match = query_lower in info.name.lower() + desc_match = query_lower in info.description.lower() + lic_match = query_lower in info.license.lower() + if not (name_match or desc_match or lic_match): + continue + + if build_system and info.build_system.lower() != build_system.strip().lower(): + continue + + if license and license.strip().lower() not in info.license.lower(): + continue + + results.append(info) + return results def info(self, name: str) -> Optional[PackageInfo]: @@ -169,18 +229,7 @@ def export_index(self, output_path: str | Path) -> None: output_path: Path to write the JSON index file. """ output_path = Path(output_path) - entries = [] - for info in sorted(self._index.values(), key=lambda p: p.name): - entries.append({ - "name": info.name, - "version": info.version, - "description": info.description, - "license": info.license, - "build_system": info.build_system, - "dependencies": info.dependencies, - "url": info.url, - "checksum": info.checksum, - }) + entries = [info.to_dict() for info in sorted(self._index.values(), key=lambda p: p.name)] output_path.parent.mkdir(parents=True, exist_ok=True) with open(output_path, "w", encoding="utf-8") as f: diff --git a/pytest.ini b/pytest.ini index 4ace158..f6926e6 100644 --- a/pytest.ini +++ b/pytest.ini @@ -24,6 +24,7 @@ markers = # ── Output ─────────────────────────────────────────────────── addopts = -v + -p no:faker --tb=short --strict-markers --no-header diff --git a/recipes/cjson.yaml b/recipes/cjson.yaml new file mode 100644 index 0000000..b0e9522 --- /dev/null +++ b/recipes/cjson.yaml @@ -0,0 +1,11 @@ +package: cjson +version: "1.7.18" +description: "Ultralightweight JSON parser in ANSI C" +license: MIT +url: https://github.com/DaveGamble/cJSON/archive/refs/tags/v1.7.18.tar.gz +checksum: sha256:3aa806844a03442c00769b83e99970be70fbef03735ff898f4811dd03b9f5ee5 +build: cmake +configure_args: + - -DENABLE_CJSON_TEST=OFF + - -DBUILD_SHARED_LIBS=OFF +dependencies: [] diff --git a/recipes/lvgl.yaml b/recipes/lvgl.yaml new file mode 100644 index 0000000..aa62988 --- /dev/null +++ b/recipes/lvgl.yaml @@ -0,0 +1,11 @@ +package: lvgl +version: "9.2.2" +description: "Light and Versatile Embedded Graphics Library" +license: MIT +url: https://github.com/lvgl/lvgl/archive/refs/tags/v9.2.2.tar.gz +checksum: sha256:129b4e00e06639fa79d7e8a6cab3c1ecce2445b1a246652ccd34f22e7b17ad6f +build: cmake +configure_args: + - -DLV_CONF_BUILD_DISABLE_EXAMPLES=ON + - -DLV_CONF_BUILD_DISABLE_DEMOS=ON +dependencies: [] diff --git a/recipes/nanopb.yaml b/recipes/nanopb.yaml new file mode 100644 index 0000000..daf7ae1 --- /dev/null +++ b/recipes/nanopb.yaml @@ -0,0 +1,11 @@ +package: nanopb +version: "0.4.9.1" +description: "Protocol Buffers with small code size for microcontrollers" +license: zlib +url: https://github.com/nanopb/nanopb/archive/refs/tags/0.4.9.1.tar.gz +checksum: sha256:4575944a468718ef25f05eb01d994364650b581563089a9841986bb1e460eac3 +build: cmake +configure_args: + - -Dnanopb_BUILD_RUNTIME_ONLY=ON + - -DBUILD_STATIC_LIBS=ON +dependencies: [] diff --git a/recipes/tinyusb.yaml b/recipes/tinyusb.yaml new file mode 100644 index 0000000..032009c --- /dev/null +++ b/recipes/tinyusb.yaml @@ -0,0 +1,9 @@ +package: tinyusb +version: "0.18.0" +description: "Open-source cross-platform USB host/device stack for embedded system" +license: MIT +url: https://github.com/hathach/tinyusb/archive/refs/tags/0.18.0.tar.gz +checksum: sha256:e7fa1bd723213749a0362c79eaccc99e84c8adea8f0a63588c4e4812608b7aa9 +build: cmake +configure_args: [] +dependencies: [] diff --git a/recipes/unity.yaml b/recipes/unity.yaml new file mode 100644 index 0000000..75d7d67 --- /dev/null +++ b/recipes/unity.yaml @@ -0,0 +1,11 @@ +package: unity +version: "2.6.1" +description: "Simple Unit Testing for C" +license: MIT +url: https://github.com/ThrowTheSwitch/Unity/archive/refs/tags/v2.6.1.tar.gz +checksum: sha256:b41a66d45a6b99758fb3202ace6178177014d52fc524bf1f72687d93e9867292 +build: cmake +configure_args: + - -DUNITY_EXTENSION_FIXTURE=OFF + - -DUNITY_EXTENSION_MEMORY=OFF +dependencies: [] diff --git a/tests/unit/test_index_sync.py b/tests/unit/test_index_sync.py new file mode 100644 index 0000000..08de01b --- /dev/null +++ b/tests/unit/test_index_sync.py @@ -0,0 +1,140 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""Unit tests for remote package index sync and offline caching.""" + +import io +import json +import os +import urllib.error +import urllib.request +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from ebuild.packages.index_sync import ( + DEFAULT_INDEX_URL, + IndexSyncError, + IndexSyncManager, + get_default_index_dir, + is_offline, + sanitize_package_name, +) + + +def test_sanitize_package_name(): + assert sanitize_package_name("cjson") == "cjson" + assert sanitize_package_name("my_pkg-123") == "my_pkg-123" + + with pytest.raises(ValueError, match="Invalid package name"): + sanitize_package_name("../../etc/passwd") + + with pytest.raises(ValueError, match="Invalid package name"): + sanitize_package_name("pkg with spaces") + + with pytest.raises(ValueError, match="Invalid package name"): + sanitize_package_name("") + + +def test_is_offline(monkeypatch): + assert not is_offline(False) + assert is_offline(True) + + monkeypatch.setenv("EBUILD_OFFLINE", "1") + assert is_offline(False) + + monkeypatch.setenv("EBUILD_OFFLINE", "true") + assert is_offline(False) + + monkeypatch.setenv("EBUILD_OFFLINE", "0") + assert not is_offline(False) + + +def test_get_default_index_dir(monkeypatch, tmp_path): + custom_dir = tmp_path / "custom_index" + monkeypatch.setenv("EBUILD_INDEX_PATH", str(custom_dir)) + assert get_default_index_dir() == custom_dir + + +def test_index_sync_insecure_url(tmp_path): + mgr = IndexSyncManager(index_dir=tmp_path) + with pytest.raises(IndexSyncError, match="Insecure index URL"): + mgr.sync(url="http://insecure.example.com/index.json") + + +def test_index_sync_success(tmp_path): + mgr = IndexSyncManager(index_dir=tmp_path) + + sample_index = [ + { + "name": "mock-pkg", + "version": "1.0.0", + "description": "A mock package for testing", + "license": "MIT", + "url": "https://example.com/mock-pkg-1.0.0.tar.gz", + "checksum": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "build_system": "cmake", + "configure_args": ["-DMOCK=ON"], + } + ] + raw_json = json.dumps(sample_index).encode("utf-8") + + mock_resp = MagicMock() + mock_resp.read.return_value = raw_json + mock_resp.headers = {"Content-Length": str(len(raw_json))} + mock_resp.__enter__.return_value = mock_resp + + with patch("urllib.request.urlopen", return_value=mock_resp): + count, msg = mgr.sync(url="https://example.com/index.json") + + assert count == 1 + assert "Successfully synchronized 1 packages" in msg + assert mgr.packages_json.is_file() + + # Check that recipe YAML was cached + recipe_file = mgr.recipes_dir / "mock-pkg.yaml" + assert recipe_file.is_file() + content = recipe_file.read_text(encoding="utf-8") + assert "mock-pkg" in content + assert "1.0.0" in content + + +def test_index_sync_corrupted_json(tmp_path): + mgr = IndexSyncManager(index_dir=tmp_path) + + mock_resp = MagicMock() + mock_resp.read.return_value = b"{ invalid json" + mock_resp.headers = {} + mock_resp.__enter__.return_value = mock_resp + + with patch("urllib.request.urlopen", return_value=mock_resp): + with pytest.raises(IndexSyncError, match="Corrupted or invalid JSON"): + mgr.sync() + + +def test_index_sync_network_error_fallback(tmp_path): + mgr = IndexSyncManager(index_dir=tmp_path) + mgr.ensure_directories() + + # Seed cache + cached_data = [{"name": "cached-lib", "version": "2.0.0"}] + mgr.packages_json.write_text(json.dumps(cached_data), encoding="utf-8") + + with patch("urllib.request.urlopen", side_effect=urllib.error.URLError("No connection")): + count, msg = mgr.sync() + + assert count == 1 + assert "fell back to cached index" in msg + + +def test_index_sync_offline_mode(tmp_path): + mgr = IndexSyncManager(index_dir=tmp_path) + mgr.ensure_directories() + + cached_data = [{"name": "offline-lib", "version": "1.0.0"}] + mgr.packages_json.write_text(json.dumps(cached_data), encoding="utf-8") + + count, msg = mgr.sync(offline=True) + assert count == 1 + assert "Offline mode" in msg diff --git a/tests/unit/test_package_search.py b/tests/unit/test_package_search.py new file mode 100644 index 0000000..b7cce4b --- /dev/null +++ b/tests/unit/test_package_search.py @@ -0,0 +1,69 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""Unit tests for package discovery and search across repository sources.""" + +import json +from pathlib import Path +from click.testing import CliRunner + +from ebuild.cli.commands import cli +from ebuild.packages.index_sync import IndexSyncManager +from ebuild.packages.repository import PackageInfo, PackageRepository + + +def test_package_repository_search(tmp_path): + repo = PackageRepository() + + # Create dummy local recipe directory + recipe_dir = tmp_path / "recipes" + recipe_dir.mkdir() + (recipe_dir / "my_crypto.yaml").write_text( + """package: my_crypto +version: "1.0.0" +description: "Embedded cryptography primitives" +license: Apache-2.0 +url: https://example.com/crypto.tar.gz +checksum: sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef +build: cmake +""", + encoding="utf-8", + ) + + repo.add_recipe_directory(recipe_dir) + assert repo.package_count == 1 + + # Search by keyword + results = repo.search("crypto") + assert len(results) == 1 + assert results[0].name == "my_crypto" + + # Search by license + assert len(repo.search("", license="Apache")) == 1 + assert len(repo.search("", license="GPL")) == 0 + + # Search by build system + assert len(repo.search("", build_system="cmake")) == 1 + assert len(repo.search("", build_system="meson")) == 0 + + +def test_cli_search_command(tmp_path): + runner = CliRunner() + + result = runner.invoke(cli, ["search", "cjson"]) + assert result.exit_code == 0 + assert "cjson" in result.output + + # JSON output + json_result = runner.invoke(cli, ["search", "cjson", "--json"]) + assert json_result.exit_code == 0 + data = json.loads(json_result.output) + assert isinstance(data, list) + assert any(p["name"] == "cjson" for p in data) + + +def test_cli_update_index_offline(): + runner = CliRunner() + result = runner.invoke(cli, ["update-index", "--offline"]) + assert result.exit_code == 0 + assert "Offline mode" in result.output