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