diff --git a/TASKS.md b/TASKS.md index 09f6bcb..c4291b5 100644 --- a/TASKS.md +++ b/TASKS.md @@ -14,7 +14,7 @@ Status is one of: `todo`, `in-progress`, `blocked`, `review`, `done`. | T-002 | Fix Windows Ninja test-target path parsing | backend | Maintenance | review | none | | T-003 | `ebuild package` looks for the unsuffixed binary on Windows (`_build/app` rather than `_build/app.exe`) | backend | Maintenance | review | none | | T-004 | `_report_footprint` (the flash/RAM report `ebuild build` prints) looks for the unsuffixed binary on Windows, and fails silently rather than logging why | backend | Maintenance | review | none | -| T-005 | Move `executable_output_path()` out of the Ninja-specific backend into a backend-neutral module (`ebuild/build/layout.py`), re-exported from `ninja_backend` for compatibility | backend | Maintenance | todo | none | +| T-005 | Move `executable_output_path()` out of the Ninja-specific backend into a backend-neutral module (`ebuild/build/layout.py`), re-exported from `ninja_backend` for compatibility | backend | Maintenance | review | none | ### Evidence (self-reported by implementer; pending independent review per `.ai/reviewer.md` — "if you implemented it, you do not approve it") @@ -46,9 +46,23 @@ Status is one of: `todo`, `in-progress`, `blocked`, `review`, `done`. process cwd's own `eos.yaml`/`board.yaml`, if any, cannot change what it measures; confirmed to fail against the pre-fix lookup (no report emitted) and pass against the fix. -- **Suite result** (single run, both changes present, this Windows host): - **560 passed, 6 skipped, exit code 0**. Supersedes any other count quoted - for T-003 or T-004 elsewhere in this repo or in PR #110's description. +- **T-005**: `executable_output_path()` now lives in + `ebuild/build/layout.py`; `ninja_backend` re-exports the same function for + compatibility, and CLI consumers/tests use the backend-neutral owner. The + focused suite (`tests/unit/test_package_efw.py`, + `tests/unit/test_golden_path_commands.py`, `tests/unit/test_footprint.py`, + and `tests/unit/test_ninja_backend.py`) reports **119 passed, 1 skipped**; + the compatibility test asserts both import paths are identical and the + Windows suffix path is exercised. +- **Suite result**: the earlier **560 passed, 6 skipped, exit code 0** record + predates the current branch and must not be used as its validation result. + On the current PR head, the full suite on Windows CPython 3.14 reports + **669 passed, 6 skipped, 9 failed**; all nine failures are in + `tests/unit/test_index_sync.py` because `PackageRecipe.to_dict` is missing. + The independent Linux review run selected a different platform-sensitive + set (**670 passed, 4 skipped, 10 failed**), including one build-directory + test. These failures reproduce outside T-005 and are not hidden by this + change. ## Completed diff --git a/ebuild/build/layout.py b/ebuild/build/layout.py new file mode 100644 index 0000000..68a780d --- /dev/null +++ b/ebuild/build/layout.py @@ -0,0 +1,42 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""Backend-neutral paths for generated build outputs.""" + +from __future__ import annotations + +import sys +from pathlib import Path + + +def _exe_suffix() -> str: + """Return the executable suffix used by the host platform. + + Compiler drivers on Windows append ``.exe`` when ``-o`` names no + extension. Keeping that platform detail here prevents consumers from + rebuilding an output path that does not name the binary on disk. + """ + return ".exe" if sys.platform == "win32" else "" + + +def executable_output_path(build_dir: Path, target_name: str) -> Path: + """Return the linked binary path for an executable or test target. + + Consumers must use this helper rather than rebuilding ``build_dir / + target_name`` independently: on Windows, dropping the compiler-added + suffix makes the consumer look for a binary the build never produced. + + Args: + build_dir: Directory containing the generated build files and outputs. + target_name: Name of the executable or test target. + + Returns: + The target path, including ``.exe`` on Windows. + + Example: + >>> from pathlib import Path + >>> executable_output_path(Path("_build"), "hello").name in ( + ... "hello", "hello.exe") + True + """ + return Path(build_dir) / (target_name + _exe_suffix()) diff --git a/ebuild/build/ninja_backend.py b/ebuild/build/ninja_backend.py index de417bd..ef27380 100644 --- a/ebuild/build/ninja_backend.py +++ b/ebuild/build/ninja_backend.py @@ -14,6 +14,8 @@ from pathlib import Path from typing import Dict, List, Optional +from ebuild.build.layout import executable_output_path + @dataclass class PackagePaths: @@ -30,41 +32,6 @@ class PackagePaths: "-fno-pie", "-fno-PIE"} -def _exe_suffix() -> str: - """The extension the compiler driver gives an executable. - - gcc on Windows appends .exe when -o names no extension, so an edge - declaring "app" produced "app.exe" on disk: ninja never saw its own - output, treated the target as dirty and relinked on every build. - """ - return ".exe" if sys.platform == "win32" else "" - - -def executable_output_path(build_dir: Path, target_name: str) -> Path: - """Return the linked binary path NinjaBackend emits for *target_name*. - - Args: - build_dir: Directory that contains ``build.ninja`` and the linked - outputs. - target_name: The ``name`` of an ``executable`` or ``test`` target. - - Returns: - ``build_dir / target_name`` on POSIX, or that path with ``.exe`` - appended on Windows -- the same path the Ninja edge in - ``_write_ninja`` already names via ``_exe_suffix()``. A consumer - that rebuilds this path independently instead of calling this - function can silently drop the suffix and go looking for a binary - the edge never produced. - - Example: - >>> from pathlib import Path - >>> executable_output_path(Path("_build"), "hello").name in ( - ... "hello", "hello.exe") - True - """ - return Path(build_dir) / (target_name + _exe_suffix()) - - def _shared_flag() -> str: """The flag that makes the compiler driver emit a shared object. diff --git a/ebuild/cli/commands.py b/ebuild/cli/commands.py index de8928c..a32a76c 100644 --- a/ebuild/cli/commands.py +++ b/ebuild/cli/commands.py @@ -1,2930 +1,2930 @@ -# SPDX-License-Identifier: MIT -# Copyright (c) 2026 EoS Project - -"""CLI commands for ebuild using Click. - -Provides build, clean, configure, info, install, add, list-packages, -pipeline, and hardware analysis commands. -""" - -from __future__ import annotations - -import glob -import os -import re -import shutil -import subprocess -import threading -import sys -from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING - -if TYPE_CHECKING: - from ebuild.eos_ai.eos_hw_analyzer import HardwareProfile - -import click -import yaml - -from ebuild import __version__ -from ebuild.build.ninja_backend import ( - NinjaBackend, - PackagePaths, - executable_output_path, -) -from ebuild.build.toolchain import resolve_toolchain -from ebuild.cli.integration import register_commands as _register_integration_commands -from ebuild.cli.logger import Logger -from ebuild.core.config import ConfigError, load_config, ProjectConfig -from ebuild.core.graph import CycleError, DependencyGraph, build_dependency_graph -from ebuild.core.scheduler import run_graph -from ebuild.packages.builder import BuildError, PackageBuilder -from ebuild.packages.cache import PackageCache -from ebuild.packages.fetcher import FetchError, PackageFetcher -from ebuild.packages.lockfile import Lockfile -from ebuild.packages.recipe import RecipeError -from ebuild.packages.registry import create_registry, find_recipe_dirs -from ebuild.packages.resolver import PackageResolver, ResolveError - - -pass_logger = click.make_pass_decorator(Logger, ensure=True) - -# Canonical recipe search path discovery -_find_recipe_dirs = find_recipe_dirs - - - -def _install_packages( - cfg: ProjectConfig, - build_dir: Path, - log: Logger, - verbose: bool = False, - jobs: int = 1, -) -> Dict[str, PackagePaths]: - """Resolve, fetch, build, and return PackagePaths for all declared packages. - - Args: - jobs: Maximum packages to build concurrently. 1 (the default) preserves - the sequential build order exactly. - - Returns a dict mapping package name to PackagePaths for use by NinjaBackend. - """ - if not cfg.packages: - return {} - - log.step("Resolving packages...") - - recipe_dirs = _find_recipe_dirs(cfg.source_dir) - if not recipe_dirs: - log.warning("No recipe directories found. Create a 'recipes/' directory.") - return {} - - registry = create_registry(*recipe_dirs) - log.debug(f"Registry: {registry.package_count} recipes from {[str(p) for p in registry.search_paths]}") - - resolver = PackageResolver(registry) - requested = [{"name": p.name, "version": p.version} for p in cfg.packages] - resolved = resolver.resolve(requested) - - log.info(f"Packages to install: {', '.join(r.name + ' v' + r.version for r in resolved)}") - - # Lockfile - lock_path = cfg.source_dir / Lockfile.FILENAME - lockfile = Lockfile(lock_path) - - # Cache and fetcher - pkg_cache_dir = build_dir / "packages" - cache = PackageCache(pkg_cache_dir) - fetcher = PackageFetcher(pkg_cache_dir / "_downloads") - - # Build each package, honouring dependency order. Independent packages run - # concurrently when jobs > 1. - builder = PackageBuilder(cache, verbose=verbose) - install_dirs: Dict[str, Path] = {} - by_name = {r.name: r for r in resolved} - - graph = DependencyGraph() - for recipe in resolved: - graph.add_node(recipe.name) - for recipe in resolved: - for dep in recipe.dependencies: - if dep in by_name: - graph.add_edge(recipe.name, dep) - - dirs_lock = threading.Lock() - log_lock = threading.Lock() - - def build_one(name: str) -> Path: - recipe = by_name[name] - - if cache.is_built(recipe): - with dirs_lock: - install_dirs[name] = cache.install_dir(recipe) - with log_lock: - log.info(f" {recipe.name} v{recipe.version} — cached ✓") - return install_dirs[name] - - with log_lock: - log.step(f" Fetching {recipe.name} v{recipe.version}...") - fetcher.fetch(recipe, cache.src_dir(recipe)) - - with log_lock: - log.step(f" Building {recipe.name} v{recipe.version}...") - - dep_dirs = [] - for dep in recipe.dependencies: - with dirs_lock: - dep_dir = install_dirs.get(dep) - if dep_dir is None: - raise BuildError( - f"Dependency '{dep}' of '{recipe.name}' was not built. " - "Check that all recipes are available." - ) - dep_dirs.append(dep_dir) - - install_dir = builder.build(recipe, dep_install_dirs=dep_dirs) - with dirs_lock: - install_dirs[name] = install_dir - with log_lock: - log.success(f" {recipe.name} v{recipe.version} — built ✓") - return install_dir - - def note_skipped(name: str, _cause: BaseException) -> None: - with log_lock: - log.warning(f" {name} — skipped (a dependency failed)") - - if jobs > 1: - log.debug(f"Building packages with up to {jobs} concurrent jobs") - - run_graph(graph, build_one, jobs=jobs, on_skip=note_skipped) - - # Update lockfile - lockfile.lock(resolved) - lockfile.save() - log.debug(f"Lockfile written: {lock_path}") - - # Build PackagePaths for ninja - package_paths: Dict[str, PackagePaths] = {} - for recipe in resolved: - idir = install_dirs.get(recipe.name) - if idir: - inc = idir / "include" - lib = idir / "lib" - libs = _detect_libraries(lib, recipe.name) - package_paths[recipe.name] = PackagePaths( - include_dirs=[inc] if inc.exists() else [], - lib_dirs=[lib] if lib.exists() else [], - libraries=libs, - ) - - return package_paths - - -def _workspace_repo_paths() -> Dict[str, PackagePaths]: - """Include paths for the eos and eboot repos that `ebuild setup` cloned. - - A scaffolded project includes , but the generated build.yaml - carried no path to the headers, so every template failed with - "fatal error: eos/hal.h: No such file or directory" on the first build. - - These are resolved at build time from the cache rather than written into - build.yaml as absolute paths: the path is a fact about this machine, and - build.yaml is a file the developer commits. - - Returns an empty mapping when the cache is absent, so the error a developer - sees stays the missing header rather than a stack trace, and `ebuild setup` - remains the fix. - """ - from ebuild.deps import EBUILD_REPOS_DIR - - paths: Dict[str, PackagePaths] = {} - for name in ("eos", "eboot"): - root = Path(EBUILD_REPOS_DIR) / name - if not root.is_dir(): - continue - # Headers sit at two depths: kernel/include, hal/include ... and - # services/crypto/include, services/ota/include. Both are needed -- - # and live only in the deeper set. - include_dirs = sorted( - {p for pattern in ("include", "*/include", "*/*/include") - for p in root.glob(pattern) if p.is_dir()} - ) - if include_dirs: - lib_dirs, libraries = _cached_repo_libraries(root) - paths[name] = PackagePaths( - include_dirs=include_dirs, - lib_dirs=lib_dirs, - libraries=libraries, - ) - return paths - - -# Where `ebuild` puts the CMake build tree for a cached repo. Kept inside the -# clone so `ebuild setup` remains the only thing that owns ~/.ebuild/repos. -_REPO_BUILD_DIRNAME = "_ebuild" - - -def _cached_repo_libraries(root: Path) -> Tuple[List[Path], List[str]]: - """Static libraries a cached repo offers to projects that `use` it. - - Headers alone are not enough: a scaffolded project compiles against - and then fails at the link step with undefined references. - The repo is a CMake project with no install() rules, so there is nothing - to point a -L at until it has been built once. Build it on demand and - cache the result; subsequent builds reuse the tree. - - Returns ([], []) when the repo cannot be built here — a missing cmake, a - repo that is not a CMake project — so the developer still gets a link - error naming the symbol rather than a stack trace from ebuild. - """ - if not (root / "CMakeLists.txt").is_file(): - return [], [] - - build_dir = root / _REPO_BUILD_DIRNAME - archives = sorted(build_dir.rglob("*.a")) if build_dir.is_dir() else [] - - if not archives: - if shutil.which("cmake") is None: - return [], [] - try: - subprocess.run( - ["cmake", "-S", str(root), "-B", str(build_dir)], - check=True, capture_output=True, timeout=600, - ) - subprocess.run( - ["cmake", "--build", str(build_dir), "-j", str(os.cpu_count() or 1)], - check=True, capture_output=True, timeout=1800, - ) - except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError): - return [], [] - archives = sorted(build_dir.rglob("*.a")) - - if not archives: - return [], [] - - # -L one directory per archive location; -l the archive basenames with - # the lib prefix and .a suffix stripped, which is what the linker wants. - lib_dirs = sorted({a.parent for a in archives}) - libraries = [a.stem[3:] for a in archives if a.stem.startswith("lib")] - return lib_dirs, libraries - - -def _detect_libraries(lib_dir: Path, pkg_name: str) -> List[str]: - """Detect installed library names from a lib/ directory.""" - if not lib_dir.exists(): - return [pkg_name] - - libs = [] - for f in sorted(lib_dir.iterdir()): - name = f.name - if name.startswith("lib") and (name.endswith(".a") or name.endswith(".so")): - lib_name = name[3:] # strip "lib" - if lib_name.endswith(".a"): - lib_name = lib_name[:-2] - elif lib_name.endswith(".so"): - lib_name = lib_name[:-3] - if lib_name and lib_name not in libs: - libs.append(lib_name) - - return libs if libs else [pkg_name] - - -def _resolve_build_dir(build_dir: str, cfg: ProjectConfig) -> Path: - """Anchor a relative ``--build-dir`` to the project, not the cwd. - - ``build.yaml`` describes the project, so ``_build`` means "beside - build.yaml" -- which is what the committed examples show - (``examples/hello_world/_build/``), what README and demo.md walk - through, and what the generated files already assume: ninja is invoked - with ``cwd=cfg.source_dir``, and compile_commands.json records - ``directory`` as the source directory with build-dir-relative outputs. - - Only the Python side disagreed. It created and reported the build - directory relative to the *process* cwd, so the two bases coincided - exactly when the cwd was the project directory -- the documented golden - path, and the only case the examples exercise. With ``--config`` naming - a project elsewhere they diverged: `build` wrote build.ninja where the - ninja it then launched could not open it, and `configure` reported - success having written it somewhere a later build would not look. - - The result is absolute. A path relative to the project would still be - re-interpreted by ninja, which runs in ``cfg.source_dir``: a relative - ``--config myproj/build.yaml`` yields ``myproj/_build``, and ninja - would then look for ``myproj/myproj/_build/build.ninja``. Absolute is - the only form that means the same thing to the process creating the - directory and to the ninja that reads what was written into it, and it - keeps the generated build.ninja independent of the cwd it was - generated from. - """ - path = Path(build_dir) - if not path.is_absolute(): - path = cfg.source_dir / path - return path.resolve() - - -def _shown(path: Path) -> str: - """*path* as the user would type it: relative to the cwd when it is under it. - - Build directories are resolved to absolute paths so that ninja and the - process agree on them, but printing an absolute path for the ordinary - in-project build would replace the "_build/build.ninja" that demo.md - documents with a machine-specific one. - """ - try: - return str(path.relative_to(Path.cwd())) - except ValueError: - return str(path) - - -def _resolve_backend_request( - cfg: ProjectConfig, - backend_override: Optional[str], - source_dir: Path, - log: Logger, -) -> Tuple[str, Dict[str, Any]]: - """Resolve the effective backend and backend-specific config.""" - resolved_backend = backend_override or cfg.backend - backend_config = dict(cfg.backend_config) - - if resolved_backend == "auto": - from ebuild.build.dispatch import detect_backend - - resolved_backend = detect_backend(source_dir) - log.info(f"Auto-detected backend: {resolved_backend}") - - # A build.yaml that declares its own targets is a statement that - # ebuild builds this project. detect_backend() only inspects the - # filesystem, so a Makefile kept for `make flash` -- or a - # CMakeLists.txt belonging to one subcomponent -- used to outrank - # that statement: the dispatcher ran the external tool, the - # declared targets were never built, and the build still reported - # success. - # - # Only auto-detection is overridden. An explicit `backend:` in - # build.yaml or --backend on the command line still wins, which - # is how a project keeps both a target list and an external - # build. - if resolved_backend != "ninja" and cfg.targets: - log.info( - f"build.yaml declares {len(cfg.targets)} target(s), so " - f"the ninja backend is used instead of the detected " - f"{resolved_backend}. To build with {resolved_backend}, " - f"set 'backend: {resolved_backend}' in build.yaml or " - f"pass --backend {resolved_backend}." - ) - resolved_backend = "ninja" - - return resolved_backend, backend_config - - -# The project-local file that records which board this checkout targets. -_EOS_PROJECT_CONFIG = "eos.yaml" - - -def _record_board_selection(board: str, log: Logger) -> None: - """Persist ``--board`` into eos.yaml under ``system.board``. - - The golden path is `configure --board` then a bare `build`, so the choice - has to outlive the configure process. It is written to eos.yaml rather - than build.yaml because the board is a property of the system being - targeted, which is what eos.yaml already describes. - """ - path = Path(_EOS_PROJECT_CONFIG) - if not path.is_file(): - log.error( - f"No {_EOS_PROJECT_CONFIG} here, so there is nothing to record the " - f"board against. Run this from a project directory created by " - f"'ebuild new'." - ) - raise SystemExit(1) - - import yaml - - try: - data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} - except yaml.YAMLError as e: - log.error(f"{_EOS_PROJECT_CONFIG} is not valid YAML: {e}") - raise SystemExit(1) - - system = data.setdefault("system", {}) - previous = system.get("board") - system["board"] = board - path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8") - - if previous and previous != board: - log.info(f"Board: {previous} -> {board}") - else: - log.info(f"Board: {board}") - - -def _selected_board(default: str = "generic") -> str: - """The board this project targets, from its eos.yaml. - - Read rather than passed in: `ebuild build` takes no --board of its own in - the documented walk, so the value has to survive from `ebuild new` or - `ebuild configure`. - """ - path = Path("eos.yaml") - if not path.is_file(): - return default - try: - import yaml - data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} - except Exception: - return default - return (data.get("system") or {}).get("board") or default - - -def _build_summary(cfg: "ProjectConfig", compiler, package_paths, log: Logger) -> None: - """The per-component summary the MLP walk ends with. - - A build that prints only "Build completed successfully" leaves the - developer to infer what was actually in it. The interesting case is a - package that resolved to nothing: the build still succeeds, the feature is - simply absent, and nothing said so. - """ - board = _selected_board(default="") - rows = [ - ("toolchain", getattr(compiler, "cc", "") or "cc", True), - ("board configuration", board or "host (no board recorded)", True), - ] - - declared = [p.name for p in getattr(cfg, "packages", []) or []] - for name in declared: - paths = (package_paths or {}).get(name) - # A package with no resolved include or library directory contributed - # nothing to this build, whatever build.yaml says. - resolved = bool(paths and (paths.include_dirs or paths.lib_dirs)) - rows.append((name, "" if resolved else "declared, nothing resolved", - resolved)) - - for target in cfg.targets: - if target.target_type in ("executable", "test"): - rows.append((target.name, target.target_type, True)) - - width = max(len(n) for n, _d, _ok in rows) - log.info("") - log.info("EmbeddedOS Build") - for name, detail, ok in rows: - mark = "OK " if ok else "MISS" - log.info(f" {mark} {name.ljust(width)}" + (f" {detail}" if detail else "")) - - missing = [n for n, _d, ok in rows if not ok] - if missing: - log.warning( - f"{len(missing)} declared package(s) resolved to nothing: " - + ", ".join(missing) - + ". The build succeeded without them." - ) - - -def _report_footprint(cfg: "ProjectConfig", build_path: Path, log: Logger) -> None: - """Print how much of the board the build just used. - - The MLP walk ends with a build that says `Flash: 384 KB / RAM: 72 KB`. A - developer who has to run `size` themselves and remember which columns to - add is not being told; they are being left to find out. - - Never fatal. A footprint that cannot be measured -- no binutils, a cross - toolchain whose `size` is not installed -- is a missing convenience, and - failing a successful build over it would be worse than the silence it - replaces. - """ - from ebuild.build.footprint import ( - FootprintError, board_capacity, find_size_tool, format_report, - measure, over_budget, - ) - - binaries = [t for t in cfg.targets if t.target_type == "executable"] - if not binaries: - return - - artifact = executable_output_path(build_path, binaries[0].name) - if not artifact.is_file(): - log.debug(f"no artifact at {artifact}; skipping footprint") - return - - prefix = getattr(cfg.toolchain, "target", None) or "host" - tool = find_size_tool(prefix) - if tool is None: - log.debug(f"no size tool for toolchain {prefix!r}; skipping footprint") - return - - try: - fp = measure(artifact, tool) - except FootprintError as exc: - log.debug(f"footprint unavailable: {exc}") - return - - board = _selected_board(default="") - flash_cap, ram_cap = board_capacity(board or None, _board_config()) - log.info("") - for line in format_report(fp, flash_cap, ram_cap).splitlines(): - log.info(line) - - exceeded = over_budget(fp, flash_cap, ram_cap) - if exceeded: - # Not a build failure: the image linked. It will not fit on the board, - # which the developer needs to hear now rather than from a device that - # will not boot. - log.warning(f"{exceeded} -- this image will not fit.") - else: - log.info("Ready to flash.") - - -def _board_config() -> Optional[Dict[str, Any]]: - """The project's own board description, if it ships one. - - A project that states its part's real capacity should not be measured - against the reference part for its family. - """ - path = Path("board.yaml") - if not path.is_file(): - return None - try: - import yaml - data = yaml.safe_load(path.read_text(encoding="utf-8")) - except Exception: - return None - return data if isinstance(data, dict) else None - - -def _configure_ninja_backend( - cfg: ProjectConfig, - build_path: Path, - log: Logger, - *, - suggest_build: bool = True, -) -> None: - """Generate native ebuild Ninja files for configure-only workflows. - - The package paths are merged exactly as `ebuild build` merges them. If - they were not, `configure` and `build` would each write a different - build.ninja to the same path, and a developer who ran `configure` and then - invoked ninja directly would build without the cached-repo include and - library paths. - """ - log.step("Resolving toolchain...") - compiler = resolve_toolchain(cfg.toolchain) - - package_paths = {**_workspace_repo_paths(), - **_install_packages(cfg, build_path, log, verbose=log.verbose)} - - log.step(f"Generating build.ninja in {_shown(build_path)}/...") - ninja_backend = NinjaBackend(cfg, build_path, compiler, package_paths=package_paths) - ninja_backend.generate() - - log.success(f"Generated {_shown(build_path / 'build.ninja')}") - log.success(f"Generated {_shown(build_path / 'compile_commands.json')}") - if suggest_build: - log.info("Run 'ebuild build' to compile.") - - -def _configure_external_backend( - cfg: ProjectConfig, - resolved_backend: str, - backend_config: Dict[str, Any], - build_path: Path, - log: Logger, -) -> None: - """Run configure behavior for dispatcher-backed build systems.""" - from ebuild.build.dispatch import BackendDispatcher - - no_configure_backends = {"cargo", "make", "kbuild"} - - log.step(f"Using {resolved_backend} backend...") - if resolved_backend in no_configure_backends: - log.info(f"No separate configure step for {resolved_backend}.") - return - - dispatcher = BackendDispatcher(cfg.source_dir, build_path) - log.step(f"Configuring ({resolved_backend})...") - dispatcher.configure( - backend=resolved_backend, - config=backend_config, - ) - log.success(f"Configuration completed successfully ({resolved_backend}).") - - -def _format_subprocess_failure(exc: subprocess.CalledProcessError) -> str: - """Format a subprocess failure for user-facing CLI output.""" - cmd = exc.cmd - if isinstance(cmd, (list, tuple)): - cmd_str = " ".join(str(part) for part in cmd) - else: - cmd_str = str(cmd) - - return f"Command failed (exit code {exc.returncode}): {cmd_str}" - - -def _format_missing_tool(exc: FileNotFoundError) -> str: - """Format missing executable/path errors for user-facing CLI output.""" - if exc.filename: - return f"Required tool or file not found: {exc.filename}" - return str(exc) - - -# ═══════════════════════════════════════════════════════════════ -# Pipeline helper — shared by `pipeline` and `build --board` -# ═══════════════════════════════════════════════════════════════ - -def _run_pipeline_steps( - board: str, - hardware: Optional[str], - build_dir: Path, - log: Logger, -) -> Tuple[Any, Dict[str, Path], Dict[str, Path]]: - """Run the full pipeline: analyze -> generate configs -> generate eboot -> generate SDK. - - Returns (profile, config_outputs, boot_outputs). - """ - from ebuild.eos_ai.eos_hw_analyzer import EosHardwareAnalyzer - from ebuild.eos_ai.eos_config_generator import EosConfigGenerator - from ebuild.eos_ai.eos_boot_integrator import EosBootIntegrator - from ebuild.sdk_generator import generate_sdk_from_profile - - configs_dir = build_dir / "configs" - sdk_dir = build_dir / "sdk" - configs_dir.mkdir(parents=True, exist_ok=True) - sdk_dir.mkdir(parents=True, exist_ok=True) - - # Step 1: Analyze hardware - log.step("[1/6] Analyzing hardware...") - analyzer = EosHardwareAnalyzer() - - if hardware: - hw_path = Path(hardware) - if not hw_path.exists(): - raise FileNotFoundError("Hardware file not found: " + hardware) - log.info(" Reading hardware design: " + str(hw_path)) - profile = analyzer.interpret_file(str(hw_path)) - else: - log.info(" Using board name: " + board) - profile = analyzer.interpret_text(board) - - # Override MCU from --board if the profile didn't detect one - if board and (not profile.mcu or profile.mcu.lower() != board.lower()): - mcu_info = analyzer.MCU_DATABASE.get(board.lower()) - if mcu_info: - profile.mcu = board.upper() - profile.arch = mcu_info["arch"] - profile.core = mcu_info["core"] - profile.vendor = mcu_info["vendor"] - profile.mcu_family = mcu_info["family"] - - log.info(" MCU: " + profile.mcu + " (" + profile.core + ")") - log.info(" Arch: " + profile.arch) - log.info(" Peripherals: " + str(len(profile.peripherals)) + " detected") - - # Step 2: Generate configs (board.yaml, boot.yaml, build.yaml, eos_product_config.h) - log.step("[2/6] Generating configs...") - config_gen = EosConfigGenerator(str(configs_dir)) - config_outputs = config_gen.generate_all(profile) - for name, path in config_outputs.items(): - log.success(" " + name + ": " + str(path)) - - # Step 3: Generate eboot integration (flash layout, linker, pack script, cmake defs) - log.step("[3/6] Generating eboot integration files...") - integrator = EosBootIntegrator(str(configs_dir)) - boot_outputs = integrator.generate_from_boot_yaml(str(config_outputs["boot"])) - for name, path in boot_outputs.items(): - log.success(" " + name + ": " + str(path)) - - # Step 4: Generate SDK (toolchain.cmake, environment-setup, eboot target config) - # Drive the SDK from the detected profile, not just the board name — otherwise - # any MCU absent from the small target table silently regressed to an x86_64 SDK. - log.step("[4/6] Generating SDK...") - _, toolchain_ok, eboot_board = generate_sdk_from_profile(profile, str(sdk_dir), target=board) - if not toolchain_ok: - raise RuntimeError("no cross-toolchain ships for " + board + "; the SDK fell back " - "to the host x86_64 compiler. Run `ebuild sdk --list`.") - if eboot_board is None: - log.warning(" no eBoot board for " + board + ": eboot/eboot_board.cmake carries a " - "FATAL_ERROR; a build that needs the board will fail by name.") - else: - log.success(" SDK generated in " + str(sdk_dir)) - - # Step 5: Copy generated headers to build include path - log.step("[5/6] Copying headers to build include path...") - include_dir = build_dir / "include" / "generated" - include_dir.mkdir(parents=True, exist_ok=True) - - for header_name in ["eos_product_config.h", "eboot_flash_layout.h"]: - src = configs_dir / header_name - if src.exists(): - dst = include_dir / header_name - shutil.copy2(str(src), str(dst)) - log.info(" " + header_name + " -> " + str(dst)) - - return profile, config_outputs, boot_outputs - - -def _run_cmake_build(profile, board, source_dir, build_dir, log): - """Run cmake configure + build with EOS_ENABLE_* defines injected.""" - from ebuild.build.dispatch import BackendDispatcher - - enables = profile.get_eos_enables() - cmake_defines = {} - cmake_defines["EOS_BOARD"] = board.lower() - cmake_defines["EOS_ARCH"] = profile.arch or "arm" - cmake_defines["EOS_CORE"] = profile.core or "cortex-m4" - - for flag, val in enables.items(): - cmake_defines[flag] = "ON" if val else "OFF" - - # Point cmake to generated config headers - gen_include = build_dir / "include" / "generated" - if gen_include.exists(): - cmake_defines["EOS_GENERATED_INCLUDE_DIR"] = gen_include.as_posix() - - # Point to eboot cmake defs if present - eboot_cmake = build_dir / "configs" / "eboot_config.cmake" - if eboot_cmake.exists(): - cmake_defines["EBOOT_CONFIG_FILE"] = eboot_cmake.as_posix() - - log.step("[6/6] Building with cmake...") - log.info(" Defines: " + str(len(cmake_defines)) + " cmake variables") - - dispatcher = BackendDispatcher(source_dir, build_dir) - - log.step(" Configuring (cmake)...") - dispatcher.configure(backend="cmake", config={"defines": cmake_defines}) - - log.step(" Building (cmake)...") - dispatcher.build(backend="cmake", config={}) - - -def _run_pack_image(build_dir, log): - """Run pack_image.sh if it exists and firmware output is present.""" - pack_script = build_dir / "configs" / "pack_image.sh" - if not pack_script.exists(): - return - - firmware_candidates = list(build_dir.glob("*.bin")) + list(build_dir.glob("*.elf")) - if not firmware_candidates: - log.info("No firmware binary found -- skipping image packing.") - return - - firmware = firmware_candidates[0] - log.step("Packing firmware image: " + firmware.name + "...") - - if os.name == "nt": - log.info(" Pack script is a bash script -- skipping on Windows.") - log.info(" Run manually: bash " + str(pack_script) + " " + str(firmware)) - else: - try: - subprocess.run( - ["bash", str(pack_script), str(firmware)], - check=True, - cwd=str(build_dir), - ) - log.success(" Firmware image packed.") - except subprocess.CalledProcessError as e: - log.warning(" Pack script failed: " + str(e)) - - -def _get_target_class(board): - """Look up the target class (mcu, sbc, soc, pc, virtual, devboard) for a board.""" - from ebuild.sdk_generator import TARGET_ARCH - info = TARGET_ARCH.get(board.lower()) - if info: - return info.get("class", "mcu") - return "mcu" - - -def _generate_image(board, build_dir, log): - """Generate a testable image based on target class. - - MCU targets: handled by _run_pack_image() (firmware .bin). - Linux-class targets: assemble rootfs + create tar.gz disk image. - """ - from ebuild.system.rootfs import RootfsBuilder - from ebuild.system.image import ImageBuilder - - target_class = _get_target_class(board) - - if target_class == "mcu": - _run_pack_image(build_dir, log) - return None - - # Linux-class target: assemble rootfs + create disk image - log.step("[7/7] Generating system image...") - - # Assemble rootfs skeleton - log.info(" Assembling rootfs...") - rootfs_builder = RootfsBuilder(build_dir) - rootfs_dir = rootfs_builder.assemble( - init_system="busybox", - hostname="eos-" + board.lower(), - ) - log.success(" Rootfs assembled: " + str(rootfs_dir)) - - # Copy built libraries into rootfs - lib_dest = rootfs_dir / "usr" / "lib" / "eos" - lib_dest.mkdir(parents=True, exist_ok=True) - lib_count = 0 - for lib_file in build_dir.glob("*.a"): - shutil.copy2(str(lib_file), str(lib_dest / lib_file.name)) - lib_count += 1 - # Also check subdirectories for libraries - for lib_file in build_dir.rglob("*.a"): - dest = lib_dest / lib_file.name - if not dest.exists(): - shutil.copy2(str(lib_file), str(dest)) - lib_count += 1 - if lib_count > 0: - log.info(" Installed " + str(lib_count) + " libraries into rootfs") - - # Copy generated headers into rootfs - gen_include = build_dir / "include" / "generated" - if gen_include.exists(): - inc_dest = rootfs_dir / "usr" / "include" / "eos" - inc_dest.mkdir(parents=True, exist_ok=True) - header_count = 0 - for header in gen_include.glob("*.h"): - shutil.copy2(str(header), str(inc_dest / header.name)) - header_count += 1 - if header_count > 0: - log.info(" Installed " + str(header_count) + " headers into rootfs") - - # Copy SDK info into rootfs - sdk_dir = build_dir / "sdk" - if sdk_dir.exists(): - sdk_dest = rootfs_dir / "opt" / "eos-sdk" - sdk_dest.mkdir(parents=True, exist_ok=True) - for item in sdk_dir.rglob("*"): - if item.is_file(): - rel = item.relative_to(sdk_dir) - dest = sdk_dest / rel - dest.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(str(item), str(dest)) - - # Create disk image (tar.gz — cross-platform, always works) - log.info(" Creating disk image...") - imager = ImageBuilder(build_dir, log=log) - image_path = imager.create( - rootfs_dir=rootfs_dir, - image_format="tar", - label="eos-" + board.lower(), - ) - log.success(" Image created: " + str(image_path)) - - # Report image size - if image_path.exists(): - size_kb = image_path.stat().st_size // 1024 - if size_kb > 1024: - log.info(" Size: " + str(size_kb // 1024) + " MB") - else: - log.info(" Size: " + str(size_kb) + " KB") - - return image_path - - -@click.group() -@click.version_option(version=__version__, prog_name="ebuild") -@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.""" - ctx.ensure_object(dict) - ctx.obj = Logger(verbose=verbose) - - -@cli.command() -@click.option( - "--config", "config_path", - default="build.yaml", - type=click.Path(exists=False), - help="Path to the build configuration file.", -) -@click.option( - "--build-dir", - default="_build", - type=click.Path(), - help="Build output directory.", -) -@click.option( - "--backend", - default=None, - type=click.Choice(["auto", "cmake", "make", "meson", "cargo", "ninja", "kbuild"]), - help="Force a specific build backend.", -) -@click.option( - "--board", - default=None, - help="Target board name (e.g., stm32f4, nrf52). Triggers full pipeline before build.", -) -@click.option( - "--hardware", - default=None, - type=click.Path(exists=True), - help="Hardware design file (.kicad_sch, .sch, .csv). Used with --board for analysis.", -) -@click.option( - "-j", - "--jobs", - default=1, - type=click.IntRange(min=1), - help=( - "Number of packages to build concurrently (default 1). Independent " - "packages are built in parallel; dependency order is always honoured. " - "Each package's own build may already run parallel compile jobs, so " - "large values can oversubscribe the machine." - ), -) -@click.pass_obj -def build(log: Logger, config_path: str, build_dir: str, backend: Optional[str], - board: Optional[str], hardware: Optional[str], jobs: int = 1) -> None: - """Parse config, detect backend, and build the project. - - When --board is provided, runs the full pipeline (analyze -> generate -> - build) before the normal cmake build. Generated configs are stored in - _build/configs/ and EOS_ENABLE_* defines are passed to cmake automatically. - """ - log.header("ebuild — Build") - - build_path = Path(build_dir) - - try: - # Pipeline mode: --board triggers full analyze -> generate -> build - if board: - log.info("Board pipeline mode: " + board) - - profile, config_outputs, boot_outputs = _run_pipeline_steps( - board=board, - hardware=hardware, - build_dir=build_path, - log=log, - ) - - source_dir = Path(".") - if (source_dir / "CMakeLists.txt").exists(): - _run_cmake_build(profile, board, source_dir, build_path, log) - else: - log.info("No CMakeLists.txt found -- pipeline steps complete (no cmake build).") - - _generate_image(board, build_path, log) - - log.success("Build completed successfully (pipeline mode).") - return - - # Normal mode: standard config-based build - log.step("Loading configuration...") - cfg = load_config(config_path) - log.info(f"Project: {cfg.name} v{cfg.version}") - - build_path = _resolve_build_dir(build_dir, cfg) - resolved_backend, backend_config = _resolve_backend_request( - cfg=cfg, - backend_override=backend, - source_dir=cfg.source_dir, - log=log, - ) - - # Route: external build systems (cmake, make, meson, cargo, kbuild) - # go through the dispatcher. ebuild's own ninja backend handles - # projects with targets defined in build.yaml. - if resolved_backend != "ninja" or not cfg.targets: - from ebuild.build.dispatch import BackendDispatcher - - log.step(f"Using {resolved_backend} backend...") - dispatcher = BackendDispatcher(cfg.source_dir, build_path) - - # Tier 2+3: configure first - from ebuild.build.dispatch import TIER_1 - if resolved_backend not in TIER_1: - log.step(f"Configuring ({resolved_backend})...") - dispatcher.configure( - backend=resolved_backend, - config=backend_config, - ) - - # Build - log.step(f"Building ({resolved_backend})...") - dispatcher.build( - backend=resolved_backend, - config=backend_config, - ) - - log.success(f"Build completed successfully ({resolved_backend}).") - return - - # ebuild's own Ninja backend path (build.yaml with targets) - log.step("Resolving dependency graph...") - graph = build_dependency_graph(cfg.targets) - build_order = graph.topological_sort() - log.debug(f"Build order: {' → '.join(build_order)}") - - log.step("Resolving toolchain...") - compiler = resolve_toolchain(cfg.toolchain) - log.debug(f"Compiler: {compiler.cc}") - - # Install packages if any are declared - package_paths = {**_workspace_repo_paths(), - **_install_packages(cfg, build_path, log, verbose=log.verbose, jobs=jobs)} - - log.step(f"Generating build.ninja in {_shown(build_path)}/...") - ninja_backend = NinjaBackend(cfg, build_path, compiler, package_paths=package_paths) - ninja_backend.generate() - log.success(f"Generated {_shown(build_path / 'build.ninja')}") - log.success(f"Generated {_shown(build_path / 'compile_commands.json')}") - - log.step("Invoking ninja...") - ninja_cmd = [sys.executable, "-m", "ninja", "-f", str(build_path / "build.ninja")] - if log.verbose: - ninja_cmd.append("-v") - - result = subprocess.run(ninja_cmd, capture_output=not log.verbose, cwd=str(cfg.source_dir)) - if result.returncode != 0: - # ninja reports compiler diagnostics on stdout, not stderr, so a - # failure surfaced only through stderr says nothing about what broke. - # Replay both streams verbatim rather than through log.error(), which - # would prefix a multi-line diagnostic with a single "[error]" tag. - if not log.verbose: - if result.stdout: - sys.stdout.write(result.stdout.decode(errors="replace")) - sys.stdout.flush() - if result.stderr: - sys.stderr.write(result.stderr.decode(errors="replace")) - sys.stderr.flush() - log.error("Build failed.") - raise SystemExit(1) - - log.success("Build completed successfully.") - _build_summary(cfg, compiler, package_paths, log) - _report_footprint(cfg, build_path, log) - - except FileNotFoundError as e: - log.error(_format_missing_tool(e)) - raise SystemExit(1) - except subprocess.CalledProcessError as e: - log.error(_format_subprocess_failure(e)) - raise SystemExit(1) - except (ConfigError, RecipeError) as e: - log.error(f"Configuration error: {e}") - raise SystemExit(1) - except CycleError as e: - log.error(f"Dependency error: {e}") - raise SystemExit(1) - except (ResolveError, FetchError, BuildError) as e: - log.error(f"Package error: {e}") - raise SystemExit(1) - except RuntimeError as e: - log.error(str(e)) - raise SystemExit(1) - - -@cli.command() -@click.option( - "--board", - required=True, - help="Target board name (e.g., stm32f4, nrf52, stm32h7).", -) -@click.option( - "--hardware", - default=None, - type=click.Path(exists=True), - help="Hardware design file (.kicad_sch, .sch, .csv) for schematic analysis.", -) -@click.option( - "--build-dir", - default="_build", - type=click.Path(), - help="Build output directory.", -) -@click.option( - "--skip-build", - is_flag=True, - default=False, - help="Only generate configs and SDK -- skip cmake build.", -) -@click.pass_obj -def pipeline(log: Logger, board: str, hardware: Optional[str], - build_dir: str, skip_build: bool) -> None: - """Run the full end-to-end build pipeline for a target board. - - Chains: analyze hardware -> generate configs -> generate eboot integration -> - generate SDK -> cmake build -> pack firmware image. - - Examples:\n - ebuild pipeline --board stm32f4\n - ebuild pipeline --board stm32f4 --hardware board.kicad_sch\n - ebuild pipeline --board nrf52 --skip-build - """ - log.header("ebuild — Full Pipeline") - - build_path = Path(build_dir) - - try: - profile, config_outputs, boot_outputs = _run_pipeline_steps( - board=board, - hardware=hardware, - build_dir=build_path, - log=log, - ) - - if skip_build: - log.info("--skip-build: skipping cmake build and image generation.") - else: - source_dir = Path(".") - if (source_dir / "CMakeLists.txt").exists(): - _run_cmake_build(profile, board, source_dir, build_path, log) - else: - log.info("No CMakeLists.txt found -- skipping cmake build step.") - - _generate_image(board, build_path, log) - - # Summary - log.header("Pipeline Summary") - configs_dir = build_path / "configs" - sdk_dir = build_path / "sdk" - images_dir = build_path / "images" - rootfs_dir = build_path / "rootfs" - if configs_dir.exists(): - config_files = list(configs_dir.iterdir()) - log.info(" Configs: " + str(len(config_files)) + " files in " + str(configs_dir)) - for f in sorted(config_files): - log.info(" " + f.name) - if sdk_dir.exists(): - sdk_subdirs = [d for d in sdk_dir.iterdir() if d.is_dir()] - log.info(" SDK: " + str(len(sdk_subdirs)) + " target(s) in " + str(sdk_dir)) - if images_dir.exists(): - image_files = list(images_dir.iterdir()) - log.info(" Images: " + str(len(image_files)) + " file(s) in " + str(images_dir)) - for f in sorted(image_files): - size_kb = f.stat().st_size // 1024 - size_str = str(size_kb // 1024) + " MB" if size_kb > 1024 else str(size_kb) + " KB" - log.info(" " + f.name + " (" + size_str + ")") - if rootfs_dir.exists(): - rootfs_dirs = [d for d in rootfs_dir.iterdir() if d.is_dir()] - log.info(" Rootfs: " + str(len(rootfs_dirs)) + " directories in " + str(rootfs_dir)) - - log.success("Pipeline completed successfully.") - - except FileNotFoundError as e: - log.error(str(e)) - raise SystemExit(1) - except SystemExit: - raise - except Exception as e: - log.error("Pipeline failed: " + str(e)) - raise SystemExit(1) - - -@cli.command() -@click.option( - "--build-dir", - default="_build", - type=click.Path(), - help="Build output directory to remove.", -) -@click.pass_obj -def clean(log: Logger, build_dir: str) -> None: - """Remove the build output directory.""" - log.header("ebuild — Clean") - build_path = Path(build_dir) - - if build_path.exists(): - shutil.rmtree(build_path) - log.success(f"Removed {build_path}/") - else: - log.info(f"Nothing to clean — {build_path}/ does not exist.") - - -@cli.command() -@click.option( - "--config", "config_path", - default="build.yaml", - type=click.Path(exists=False), - help="Path to the build configuration file.", -) -@click.option( - "--build-dir", - default="_build", - type=click.Path(), - help="Build output directory.", -) -@click.option( - "--backend", - default=None, - type=click.Choice(["auto", "cmake", "make", "meson", "cargo", "ninja", "kbuild"]), - help="Force a specific build backend.", -) -@click.option( - "--board", - default=None, - help="Target board name (e.g., stm32f4, nrf52). Recorded in the project " - "config so later `ebuild build` / `flash` / `monitor` use it.", -) -@click.pass_obj -def configure(log: Logger, config_path: str, build_dir: str, backend: Optional[str], - board: Optional[str]) -> None: - """Generate build files without building.""" - log.header("ebuild — Configure") - - try: - if board: - _record_board_selection(board, log) - - log.step("Loading configuration...") - cfg = load_config(config_path) - log.info(f"Project: {cfg.name} v{cfg.version}") - - build_path = _resolve_build_dir(build_dir, cfg) - resolved_backend, backend_config = _resolve_backend_request( - cfg=cfg, - backend_override=backend, - source_dir=cfg.source_dir, - log=log, - ) - - if resolved_backend == "ninja": - _configure_ninja_backend(cfg, build_path, log) - return - - _configure_external_backend( - cfg=cfg, - resolved_backend=resolved_backend, - backend_config=backend_config, - build_path=build_path, - log=log, - ) - - except FileNotFoundError as e: - log.error(_format_missing_tool(e)) - raise SystemExit(1) - except subprocess.CalledProcessError as e: - log.error(_format_subprocess_failure(e)) - raise SystemExit(1) - except (ConfigError, RecipeError) as e: - log.error(f"Configuration error: {e}") - raise SystemExit(1) - except (CycleError, ResolveError, FetchError, BuildError) as e: - log.error(f"Error: {e}") - raise SystemExit(1) - - -@cli.command() -@click.option( - "--config", "config_path", - default="build.yaml", - type=click.Path(exists=False), - help="Path to the build configuration file.", -) -@click.pass_obj -def info(log: Logger, config_path: str) -> None: - """Show project info, targets, packages, and dependency graph.""" - log.header("ebuild — Project Info") - - try: - cfg = load_config(config_path) - - log.info(f"Project : {cfg.name}") - log.info(f"Version : {cfg.version}") - log.info(f"Source : {cfg.source_dir.resolve()}") - - if cfg.toolchain: - tc = cfg.toolchain - log.info(f"Compiler: {tc.compiler} (arch: {tc.arch})") - if tc.prefix: - log.info(f"Prefix : {tc.prefix}") - else: - log.info("Compiler: gcc (native)") - - if cfg.packages: - log.header("Packages") - for p in cfg.packages: - ver = f" v{p.version}" if p.version else "" - log.step(f"{p.name}{ver}") - - log.header("Targets") - for t in cfg.targets: - deps = f" depends=[{', '.join(t.depends)}]" if t.depends else "" - uses = f" uses=[{', '.join(t.uses)}]" if t.uses else "" - log.step(f"{t.name} ({t.target_type}){deps}{uses}") - if t.sources: - log.debug(f" sources: {t.sources}") - if t.cflags: - log.debug(f" cflags : {t.cflags}") - if t.ldflags: - log.debug(f" ldflags: {t.ldflags}") - - graph = build_dependency_graph(cfg.targets) - build_order = graph.topological_sort() - log.header("Build Order") - for i, name in enumerate(build_order, 1): - log.step(f"{i}. {name}") - - except FileNotFoundError as e: - log.error(str(e)) - raise SystemExit(1) - except ConfigError as e: - log.error(f"Configuration error: {e}") - raise SystemExit(1) - except CycleError as e: - log.error(f"Dependency error: {e}") - raise SystemExit(1) - - -@cli.command() -@click.option( - "--config", "config_path", - default="build.yaml", - type=click.Path(exists=False), - help="Path to the build configuration file.", -) -@click.option( - "--build-dir", - default="_build", - type=click.Path(), - help="Build output directory.", -) -@click.pass_obj -def install(log: Logger, config_path: str, build_dir: str) -> None: - """Resolve, fetch, and build all declared packages.""" - log.header("ebuild — Install Packages") - - try: - cfg = load_config(config_path) - log.info(f"Project: {cfg.name} v{cfg.version}") - - if not cfg.packages: - log.info("No packages declared in build.yaml.") - return - - build_path = _resolve_build_dir(build_dir, cfg) - _install_packages(cfg, build_path, log, verbose=log.verbose) - log.success("All packages installed successfully.") - - except FileNotFoundError as e: - log.error(str(e)) - raise SystemExit(1) - except (ConfigError, RecipeError) as e: - log.error(f"Configuration error: {e}") - raise SystemExit(1) - except (ResolveError, FetchError, BuildError) as e: - log.error(f"Package error: {e}") - raise SystemExit(1) - - -def _no_recipe_message(name: str, registry) -> str: - """Say what is available, and what the developer probably meant. - - "No recipe found" on its own leaves them guessing at the spelling, at - whether the package exists under another name, and at where recipes even - come from. - """ - import difflib - - try: - available = sorted({r.name for r in registry.list_packages()}) - except Exception: - available = [] - - lines = [f"No recipe for '{name}'."] - close = difflib.get_close_matches(name, available, n=3, cutoff=0.6) - if close: - lines.append(" Did you mean: " + ", ".join(close) + "?") - if available: - lines.append(" Available: " + ", ".join(available)) - else: - lines.append(" No recipes are visible from here — is this a project " - "directory with a recipes/ folder?") - lines.append(f" To add it anyway: ebuild add {name} --force") - return "\n".join(lines) - - -@cli.command("add") -@click.argument("package_name") -@click.option("--version", "pkg_version", default=None, help="Package version to add.") -@click.option( - "--config", "config_path", - default="build.yaml", - type=click.Path(exists=False), - help="Path to the build configuration file.", -) -@click.option( - "--force", is_flag=True, default=False, - help="Add a package with no recipe. It will not resolve until one exists.", -) -@click.pass_obj -def add_package(log: Logger, package_name: str, pkg_version: Optional[str], - config_path: str, force: bool) -> None: - """Add a package dependency to build.yaml.""" - log.header("ebuild — Add Package") - - config_path_obj = Path(config_path) - if not config_path_obj.exists(): - log.error(f"Config file not found: {config_path}") - raise SystemExit(1) - - # Verify the package exists in registry - recipe_dirs = _find_recipe_dirs(config_path_obj.parent) - if recipe_dirs: - registry = create_registry(*recipe_dirs) - recipe = registry.get(package_name, pkg_version) - if recipe: - log.info(f"Found recipe: {recipe.name} v{recipe.version}") - if pkg_version is None: - pkg_version = recipe.version - elif not force: - # Writing an entry that cannot resolve trades one clear error now - # for a confusing one at build time, in a file the developer has - # since committed. - log.error(_no_recipe_message(package_name, registry)) - raise SystemExit(1) - else: - log.warning( - f"No recipe found for '{package_name}' — added because " - f"--force was given. It will not resolve until a recipe exists." - ) - - # Load and update config - with open(config_path_obj, "r", encoding="utf-8") as f: - raw = yaml.safe_load(f) - - if "packages" not in raw: - raw["packages"] = [] - - # Check for duplicates - for p in raw["packages"]: - if isinstance(p, dict) and p.get("name") == package_name: - log.info(f"Package '{package_name}' already in build.yaml.") - return - - entry: Dict[str, str] = {"name": package_name} - if pkg_version: - entry["version"] = pkg_version - - raw["packages"].append(entry) - - with open(config_path_obj, "w", encoding="utf-8") as f: - yaml.dump(raw, f, default_flow_style=False, sort_keys=False) - - log.success(f"Added {package_name}" + (f" v{pkg_version}" if pkg_version else "") + f" to {config_path}") - - -@cli.command() -@click.option( - "--config", "config_path", - default="build.yaml", - type=click.Path(exists=False), - help="Path to the build configuration file.", -) -@click.option( - "--build-dir", - default="_build", - type=click.Path(), - help="Build output directory.", -) -@click.option( - "--format", "img_format", - default="tar", - type=click.Choice(["raw", "qcow2", "tar", "ext4", "squashfs"]), - help="Output image format.", -) -@click.option( - "--size", "size_mb", - default=256, - type=int, - help="Image size in MB (for raw/ext4).", -) -@click.pass_obj -def system(log: Logger, config_path: str, build_dir: str, img_format: str, size_mb: int) -> None: - """Build a complete Linux system image (rootfs + kernel + image).""" - log.header("ebuild — System Image Build") - - try: - from ebuild.system.rootfs import RootfsBuilder - from ebuild.system.image import ImageBuilder - - build_path = Path(build_dir) - - log.step("Assembling root filesystem...") - rootfs = RootfsBuilder(build_path) - rootfs_dir = rootfs.assemble(init_system="busybox", hostname="eos") - log.success(f"Rootfs assembled: {rootfs_dir}") - - log.step(f"Creating {img_format} image...") - imager = ImageBuilder(build_path, log=log) - image_path = imager.create( - rootfs_dir=rootfs_dir, - image_format=img_format, - image_size_mb=size_mb, - ) - log.success(f"Image created: {image_path}") - - except Exception as e: - log.error(f"System build failed: {e}") - raise SystemExit(1) - - -@cli.command() -@click.option( - "--config", "config_path", - default="build.yaml", - type=click.Path(exists=False), - help="Path to the build configuration file.", -) -@click.option( - "--build-dir", - default="_build", - type=click.Path(), - help="Build output directory.", -) -@click.option( - "--rtos", - default="generic", - type=click.Choice(["zephyr", "freertos", "nuttx", "generic"]), - help="Target RTOS.", -) -@click.option( - "--board", - default="generic", - help="Target board name.", -) -@click.pass_obj -def firmware(log: Logger, config_path: str, build_dir: str, rtos: str, board: str) -> None: - """Build RTOS firmware for an embedded target.""" - log.header("ebuild — Firmware Build") - - try: - from ebuild.firmware.firmware import FirmwareBuilder - - cfg = load_config(config_path) - log.info(f"Project: {cfg.name} v{cfg.version}") - - build_path = Path(build_dir) - builder = FirmwareBuilder(build_path, log=log) - - log.step(f"Building {rtos} firmware for {board}...") - output = builder.build( - source_dir=cfg.source_dir, - rtos=rtos, - board=board, - ) - log.success(f"Firmware built: {output}") - - except FileNotFoundError as e: - log.error(str(e)) - raise SystemExit(1) - except Exception as e: - log.error(f"Firmware build failed: {e}") - raise SystemExit(1) - - -@cli.command() -@click.argument("image", type=click.Path(exists=True)) -@click.option("--tool", default="openocd", - type=click.Choice(["openocd", "pyocd", "nrfjprog", "esptool", "stflash"]), - help="Flash tool to use.") -@click.option("--target", default="stm32f4", help="Target MCU/board.") -@click.option("--address", default="0x08000000", help="Flash base address (hex).") -@click.option("--reset-after", is_flag=True, default=False, help="Reset target after flashing.") -@click.pass_obj -def flash(log: Logger, image: str, tool: str, target: str, address: str, - reset_after: bool) -> None: - """Flash a firmware image to the target device. - - Supports OpenOCD, pyOCD, nrfjprog, esptool, and st-flash. - - Examples: - - ebuild flash firmware.bin --tool openocd --target stm32f4 - - ebuild flash app.bin --tool nrfjprog - - ebuild flash firmware.bin --tool esptool --address 0x10000 - - ebuild flash firmware.bin --tool pyocd --target nrf52840 --reset-after - """ - log.header("ebuild — Flash") - - try: - from ebuild.firmware.flash import flash as do_flash, reset as do_reset, FlashError - - image_path = Path(image) - addr = int(address, 0) - - log.step(f"Flashing {image_path.name} to {target} via {tool}...") - log.info(f" Address: {hex(addr)}") - - do_flash(image_path, tool=tool, target=target, address=addr) - log.success(f"Flash complete: {image_path.name}") - - if reset_after: - log.step("Resetting target...") - do_reset(tool=tool, target=target) - log.success("Target reset.") - - except FlashError as e: - log.error(str(e)) - raise SystemExit(1) - except Exception as e: - log.error(f"Flash failed: {e}") - raise SystemExit(1) - - -@cli.command("list-packages") -@click.option( - "--config", "config_path", - default="build.yaml", - type=click.Path(exists=False), - help="Path to the build configuration file.", -) -@click.pass_obj -def list_packages(log: Logger, config_path: str) -> None: - """List available package recipes and project packages.""" - log.header("ebuild — Package Registry") - - config_path_obj = Path(config_path) - project_dir = config_path_obj.parent if config_path_obj.exists() else Path(".") - - recipe_dirs = _find_recipe_dirs(project_dir) - if not recipe_dirs: - log.warning("No recipe directories found.") - return - - registry = create_registry(*recipe_dirs) - packages = registry.list_packages() - - if not packages: - log.info("No recipes found.") - return - - 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 "" - log.step(f"{recipe.name} v{recipe.version} [{recipe.build_system}]{deps}{desc}") - - # Show project packages if config exists - if config_path_obj.exists(): - try: - cfg = load_config(config_path_obj) - if cfg.packages: - log.header("Project Packages") - for p in cfg.packages: - ver = f" v{p.version}" if p.version else " (latest)" - status = "✓ recipe found" if registry.has(p.name, p.version) else "✗ no recipe" - log.step(f"{p.name}{ver} — {status}") - except (ConfigError, FileNotFoundError): - 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_filter=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}'. Add recipes to './recipes/' or run 'ebuild update-index --url '.") - else: - log.info("No packages found. Add recipes to './recipes/' or run 'ebuild update-index --url '.") - 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.""" - import json - from ebuild.packages.index_sync import IndexSyncManager, IndexSyncError - - log.header("ebuild — Update Package Index") - sync_mgr = IndexSyncManager() - - prev_sha256 = None - if sync_mgr.meta_json.is_file(): - try: - with open(sync_mgr.meta_json, "r", encoding="utf-8") as mf: - prev_sha256 = json.load(mf).get("sha256") - except Exception: - prev_sha256 = None - - try: - res = sync_mgr.sync(url=index_url, force=force, offline=offline) - msg = res.message - is_fallback = getattr(res, "is_fallback", False) - current_sha256 = getattr(res, "sha256", None) - pruned_count = getattr(res, "pruned", 0) - if not current_sha256 and sync_mgr.meta_json.is_file(): - try: - with open(sync_mgr.meta_json, "r", encoding="utf-8") as mf: - current_sha256 = json.load(mf).get("sha256") - except Exception: - current_sha256 = None - - if is_fallback and not offline: - log.warning(msg) - if current_sha256: - log.info(f"Index SHA-256 digest: {current_sha256}") - log.info(f"Index cache located at: {sync_mgr.index_dir}") - raise SystemExit(1) - log.success(msg) - if current_sha256: - log.info(f"Index SHA-256 digest: {current_sha256}") - if prev_sha256 and prev_sha256 != current_sha256: - log.info(f"Index updated (previous digest: {prev_sha256})") - if pruned_count > 0: - log.info(f"Pruned {pruned_count} stale cached recipe(s)") - 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).") -@click.option("--output-dir", default="_generated", help="Output directory for generated configs.") -@click.option("--eos-schemas", default=None, help="Path to eos/schemas/ for hardware vocabulary.") -@click.option("--llm", "use_llm", is_flag=True, default=False, help="Enable LLM-enhanced analysis (Ollama local or OPENAI_API_KEY).") -@click.pass_obj -def analyze(log: Logger, input_text: Optional[str], input_file: Optional[str], - output_dir: str, eos_schemas: Optional[str], use_llm: bool) -> None: - """Analyze hardware design and generate eos + eboot + ebuild configs. - - Accepts text description, KiCad schematic (.kicad_sch), Eagle schematic (.sch), - BOM CSV (.csv), or any text/YAML file. Auto-detects format by file extension. - - Generates board.yaml, boot.yaml, build.yaml, and eos_product_config.h. - - Examples: - - ebuild analyze "nRF52840 BLE sensor with I2C and SPI flash" - - ebuild analyze --file design.kicad_sch - - ebuild analyze --file design.sch - - ebuild analyze --file bom.csv - - ebuild analyze "STM32H7 with CAN Ethernet" --llm - """ - log.header("ebuild — Hardware Analysis") - - try: - from ebuild.eos_ai.eos_hw_analyzer import EosHardwareAnalyzer - from ebuild.eos_ai.eos_config_generator import EosConfigGenerator - from ebuild.eos_ai.eos_validator import EosConfigValidator - from ebuild.eos_ai.eos_boot_integrator import EosBootIntegrator - - interpreter = EosHardwareAnalyzer(eos_schemas_path=eos_schemas) - - if input_file: - path = Path(input_file) - log.step(f"Reading hardware design: {path}") - profile = interpreter.interpret_file(str(path)) - elif input_text: - log.step("Analyzing text description...") - profile = interpreter.interpret_text(input_text) - else: - log.error("Provide hardware description text or --file ") - raise SystemExit(1) - - log.info(f"MCU: {profile.mcu or '(unknown)'} ({profile.core})") - log.info(f"Arch: {profile.arch or '(unknown)'}") - log.info(f"Peripherals: {len(profile.peripherals)} detected") - for p in profile.peripherals: - extra = "" - if p.config.get("i2c_addr"): - extra = f" (I2C addr: {p.config['i2c_addr']})" - log.info(f" - {p.peripheral_type}: {p.name}{extra}") - log.info(f"Confidence: {profile.confidence:.0%}") - - # Optional LLM-enhanced analysis - if use_llm: - log.step("Running LLM-enhanced analysis...") - llm_info = interpreter.llm_client.get_provider_info() - log.info(f" Provider: {llm_info}") - if interpreter.llm_client.is_available(): - profile = interpreter.analyze_with_llm(profile) - log.success(" LLM analysis complete") - else: - log.warning(" No LLM available. Install Ollama or set OPENAI_API_KEY.") - - log.step("Generating configs...") - generator = EosConfigGenerator(output_dir) - outputs = generator.generate_all(profile) - - for name, path in outputs.items(): - log.success(f" {name}: {path}") - - log.step("Validating generated configs...") - validator = EosConfigValidator() - result = validator.validate_all(output_dir) - log.info(result.summary()) - - log.step("Generating eboot integration files...") - integrator = EosBootIntegrator(output_dir) - boot_outputs = integrator.generate_from_boot_yaml(str(outputs["boot"])) - for name, path in boot_outputs.items(): - log.success(f" {name}: {path}") - - prompt = interpreter.generate_prompt(profile) - prompt_path = Path(output_dir) / "llm_prompt.txt" - prompt_path.write_text(prompt) - log.info(f"LLM prompt saved: {prompt_path}") - - log.success("Analysis complete.") - - except Exception as e: - log.error(f"Analysis failed: {e}") - raise SystemExit(1) - - -@cli.command("generate-project") -@click.option("--text", "input_text", default=None, help="Hardware description text.") -@click.option("--file", "input_file", type=click.Path(exists=True), help="Hardware design file (YAML, KiCad, BOM).") -@click.option("--config", "config_yaml", type=click.Path(exists=True), help="Existing board.yaml from ebuild analyze.") -@click.option("--eos-repo", type=click.Path(exists=True), default=None, help="Path to local eos repo. Auto-clones from GitHub if omitted.") -@click.option("--eboot-repo", type=click.Path(exists=True), default=None, help="Path to local eboot repo. Auto-clones from GitHub if omitted.") -@click.option("--eos-url", default=None, help="Git URL for eos repo (overrides default GitHub URL).") -@click.option("--eboot-url", default=None, help="Git URL for eboot repo (overrides default GitHub URL).") -@click.option("--clone-dir", default=None, type=click.Path(), help="Directory to clone repos into. Uses temp dir if omitted.") -@click.option("--output", default="_project", help="Output directory (copy mode).") -@click.option("--mode", type=click.Choice(["copy", "branch"]), default="copy", help="Output mode.") -@click.option("--branch", default=None, help="Git branch name (branch mode only).") -@click.option("--eos-schemas", default=None, help="Path to eos/schemas/ for hardware vocabulary.") -@click.pass_obj -def generate_project( - log: Logger, - input_text: Optional[str], - input_file: Optional[str], - config_yaml: Optional[str], - eos_repo: Optional[str], - eboot_repo: Optional[str], - eos_url: Optional[str], - eboot_url: Optional[str], - clone_dir: Optional[str], - output: str, - mode: str, - branch: Optional[str], - eos_schemas: Optional[str], -) -> None: - """Generate a stripped-down eos/eboot project for specific hardware. - - Analyzes hardware requirements and prunes the full eos and eboot - repositories to only the modules needed for the target hardware. - Auto-clones eos and eboot from GitHub when local repo paths are not given. - - Examples: - - # Auto-clone from GitHub — no local repos needed: - ebuild generate-project --text "nRF52 BLE sensor with I2C and SPI" \\ - --output customer-ble-sensor - - # With local repos: - ebuild generate-project --text "nRF52 BLE sensor with I2C and SPI" \\ - --eos-repo ../eos --eboot-repo ../eboot --output customer-ble-sensor - - # From existing hardware analysis: - ebuild generate-project --config _generated/board.yaml \\ - --output gateway-project - - # Custom GitHub fork: - ebuild generate-project --text "STM32H7 industrial controller" \\ - --eos-url https://github.com/myorg/eos.git \\ - --eboot-url https://github.com/myorg/eboot.git \\ - --output industrial-project - - # Branch mode on local repos: - ebuild generate-project --config _generated/board.yaml \\ - --eos-repo ../eos --eboot-repo ../eboot \\ - --mode branch --branch customer/ble-sensor - """ - log.header("ebuild — Project Generator") - - try: - from ebuild.eos_ai.eos_hw_analyzer import EosHardwareAnalyzer - from ebuild.eos_ai.eos_project_generator import EosProjectGenerator - - # Step 1: Obtain a HardwareProfile - if config_yaml: - log.step(f"Loading hardware profile from {config_yaml}...") - profile = _load_profile_from_board_yaml(config_yaml) - elif input_file: - log.step(f"Analyzing hardware design: {input_file}...") - analyzer = EosHardwareAnalyzer(eos_schemas_path=eos_schemas) - path = Path(input_file) - if path.suffix == ".kicad_sch": - profile = analyzer.interpret_kicad(str(path)) - else: - content = path.read_text(encoding="utf-8", errors="replace") - if "," in content and len(content.split("\n")) > 2: - profile = analyzer.interpret_bom(content) - else: - profile = analyzer.interpret_text(content) - elif input_text: - log.step("Analyzing text description...") - analyzer = EosHardwareAnalyzer(eos_schemas_path=eos_schemas) - profile = analyzer.interpret_text(input_text) - else: - log.error("Provide hardware description via --text, --file, or --config.") - raise SystemExit(1) - - log.info(f"MCU: {profile.mcu or '(unknown)'} ({profile.core})") - log.info(f"Arch: {profile.arch or '(unknown)'}") - log.info(f"Peripherals: {len(profile.peripherals)} detected") - - # Step 2: Create generator and auto-clone repos if needed - generator = EosProjectGenerator( - eos_repo=eos_repo, - eboot_repo=eboot_repo, - eos_url=eos_url, - eboot_url=eboot_url, - ) - - if not eos_repo or not eboot_repo: - log.step("Cloning repos from GitHub (repos not provided locally)...") - generator.ensure_repos( - need_eos=(eos_repo is None), - need_eboot=(eboot_repo is None), - clone_dir=clone_dir, - ) - if generator.eos_repo and not eos_repo: - log.info(f" eos cloned to: {generator.eos_repo}") - if generator.eboot_repo and not eboot_repo: - log.info(f" eboot cloned to: {generator.eboot_repo}") - - manifest = generator.resolve_manifest(profile) - log.info(f"eos modules: {len(manifest.eos_dirs)} dirs, product={manifest.eos_product}") - log.info(f"eboot modules: {len(manifest.eboot_files)} core files, board={manifest.eboot_board}") - if manifest.eos_toolchain: - log.info(f"eos toolchain: {manifest.eos_toolchain}") - if manifest.eos_examples: - log.info(f"eos examples: {', '.join(manifest.eos_examples)}") - log.info(f"eos extras: {', '.join(manifest.eos_extras)}") - log.info(f"eboot extras: {', '.join(manifest.eboot_extras)}") - - log.step(f"Generating project ({mode} mode)...") - outputs = generator.generate( - profile=profile, - output=output, - mode=mode, - branch=branch, - ) - - for name, path in outputs.items(): - log.success(f" {name}: {path}") - - log.success("Project generation complete.") - - except SystemExit: - raise - except Exception as e: - log.error(f"Project generation failed: {e}") - raise SystemExit(1) - - -def _load_profile_from_board_yaml(board_yaml_path: str) -> "HardwareProfile": - """Load a HardwareProfile from a board.yaml produced by ``ebuild analyze``.""" - from ebuild.eos_ai.eos_hw_analyzer import ( - HardwareProfile, - PeripheralInfo, - ) - - path = Path(board_yaml_path) - data = yaml.safe_load(path.read_text()) - board = data.get("board", data) - - profile = HardwareProfile( - mcu=board.get("mcu", ""), - mcu_family=board.get("family", ""), - arch=board.get("arch", ""), - core=board.get("core", ""), - vendor=board.get("vendor", ""), - clock_hz=board.get("clock_hz", 0), - flash_size=board.get("memory", {}).get("flash", 0), - ram_size=board.get("memory", {}).get("ram", 0), - features=board.get("features", []), - ) - - for p in board.get("peripherals", []): - profile.peripherals.append(PeripheralInfo( - name=p.get("name", ""), - peripheral_type=p.get("type", ""), - bus=p.get("bus", ""), - )) - - return profile - - -@cli.command("new") -@click.argument("project_name") -@click.option( - "--template", "template_name", - default="bare-metal", - type=click.Choice(["bare-metal", "ble-sensor", "rtos-app", "linux-app", "secure-boot", "safety-critical"]), - help="Project template to use.", -) -@click.option( - "--board", "board_name", - default="generic", - help="Target board name (e.g., nrf52, stm32h7, rpi4, generic).", -) -@click.option( - "--output-dir", - default=None, - type=click.Path(), - help="Parent directory for the new project. Defaults to current directory.", -) -@click.pass_obj -def new(log: Logger, project_name: str, template_name: str, board_name: str, - output_dir: Optional[str]) -> None: - """Scaffold a new EoS project from a template. - - Creates a ready-to-build project directory with src/main.c, build.yaml, - eos.yaml, and README.md pre-configured for the selected template and board. - - Examples: - - ebuild new my-sensor --template ble-sensor --board nrf52 - - ebuild new my-controller --template rtos-app --board stm32h7 - - ebuild new my-app --template bare-metal - - ebuild new my-gateway --template linux-app --board rpi4 - """ - log.header("ebuild — New Project") - - # Resolve template directory - templates_dir = Path(__file__).resolve().parent.parent.parent / "templates" - template_dir = templates_dir / template_name - - if not template_dir.is_dir(): - log.error(f"Template '{template_name}' not found at {templates_dir}") - log.info(f"Available templates: {', '.join(t.name for t in templates_dir.iterdir() if t.is_dir())}") - raise SystemExit(1) - - # Resolve output directory - parent = Path(output_dir) if output_dir else Path(".") - project_dir = parent / project_name - - if project_dir.exists(): - log.error(f"Directory already exists: {project_dir}") - raise SystemExit(1) - - # Board → arch/toolchain mapping - board_map = { - "nrf52": {"arch": "arm", "core": "cortex-m4f", "toolchain": "arm-none-eabi", "vendor": "nordic"}, - "nrf52840": {"arch": "arm", "core": "cortex-m4f", "toolchain": "arm-none-eabi", "vendor": "nordic"}, - "stm32h7": {"arch": "arm", "core": "cortex-m7", "toolchain": "arm-none-eabi", "vendor": "st"}, - "stm32f4": {"arch": "arm", "core": "cortex-m4f", "toolchain": "arm-none-eabi", "vendor": "st"}, - "rpi4": {"arch": "arm64", "core": "cortex-a72", "toolchain": "aarch64-linux-gnu", "vendor": "broadcom"}, - "esp32": {"arch": "xtensa", "core": "lx6", "toolchain": "xtensa-esp32-elf", "vendor": "espressif"}, - "rp2040": {"arch": "arm", "core": "cortex-m0+", "toolchain": "arm-none-eabi", "vendor": "raspberrypi"}, - "tms570": {"arch": "arm", "core": "cortex-r5f", "toolchain": "arm-none-eabi", "vendor": "ti"}, - "am64x": {"arch": "hybrid", "core": "cortex-a53+r5f", "toolchain": "aarch64-linux-gnu", "vendor": "ti"}, - "generic": {"arch": "host", "core": "host", "toolchain": "host", "vendor": "generic"}, - } - board_info = board_map.get(board_name, board_map["generic"]) - - log.step(f"Creating project '{project_name}' from '{template_name}' template...") - log.info(f"Board: {board_name} (arch={board_info['arch']}, core={board_info['core']})") - - # Create project directory structure - src_dir = project_dir / "src" - src_dir.mkdir(parents=True) - - # Template variable substitution - replacements = { - "{{PROJECT_NAME}}": project_name, - "{{BOARD_NAME}}": board_name, - "{{ARCH}}": board_info["arch"], - "{{CORE}}": board_info["core"], - "{{TOOLCHAIN}}": board_info["toolchain"], - "{{VENDOR}}": board_info["vendor"], - "{{TEMPLATE}}": template_name, - } - - # Copy and process template files - file_mapping = { - "main.c.template": src_dir / "main.c", - "build.yaml.template": project_dir / "build.yaml", - "eos.yaml.template": project_dir / "eos.yaml", - "README.md.template": project_dir / "README.md", - } - - for template_file, output_path in file_mapping.items(): - src_path = template_dir / template_file - if not src_path.exists(): - log.warning(f"Template file missing: {template_file}") - continue - - content = src_path.read_text(encoding="utf-8") - for key, val in replacements.items(): - content = content.replace(key, val) - - output_path.write_text(content, encoding="utf-8") - log.success(f" {output_path.relative_to(parent)}") - - log.success(f"\nProject created: {project_dir}") - log.info("\nNext steps:") - log.info(f" cd {project_name}") - log.info(" ebuild build") - - -@cli.command("generate-boot") -@click.argument("boot_yaml", type=click.Path(exists=True)) -@click.option("--output-dir", default="_generated", help="Output directory.") -@click.pass_obj -def generate_boot(log: Logger, boot_yaml: str, output_dir: str) -> None: - """Generate eboot C headers, linker scripts, and pack scripts from boot.yaml.""" - log.header("ebuild — eboot Config Generation") - - try: - from ebuild.eos_ai.eos_boot_integrator import EosBootIntegrator - from ebuild.eos_ai.eos_validator import EosConfigValidator - - log.step(f"Validating {boot_yaml}...") - validator = EosConfigValidator() - result = validator.validate_boot(boot_yaml) - log.info(result.summary()) - - if not result.valid: - log.error("Boot config validation failed. Fix errors before generating.") - raise SystemExit(1) - - log.step("Generating eboot build inputs...") - integrator = EosBootIntegrator(output_dir) - outputs = integrator.generate_from_boot_yaml(boot_yaml) - - for name, path in outputs.items(): - log.success(f" {name}: {path}") - - log.success("eboot configs generated successfully.") - - except SystemExit: - raise - except Exception as e: - log.error(f"Generation failed: {e}") - raise SystemExit(1) - - -# ═══════════════════════════════════════════════════════════════ -# Dependency management commands -# ═══════════════════════════════════════════════════════════════ - -@cli.command() -@click.option("--eos-url", default=None, help="Git URL for eos repo (overrides default).") -@click.option("--eboot-url", default=None, help="Git URL for eboot repo (overrides default).") -@click.option("--eos-branch", default=None, help="Branch/tag for eos repo.") -@click.option("--eboot-branch", default=None, help="Branch/tag for eboot repo.") -@click.option("--eos-path", default=None, type=click.Path(exists=True), help="Link to local eos repo (no clone).") -@click.option("--eboot-path", default=None, type=click.Path(exists=True), help="Link to local eboot repo (no clone).") -@click.pass_obj -def setup( - log: Logger, - eos_url: Optional[str], - eboot_url: Optional[str], - eos_branch: Optional[str], - eboot_branch: Optional[str], - eos_path: Optional[str], - eboot_path: Optional[str], -) -> None: - """Clone eos + eboot repos to the local cache (~/.ebuild/repos/). - - On first run this clones both repos with default settings. - Use flags to override URLs, branches, or link to local repos. - - Examples: - - ebuild setup - - ebuild setup --eos-url https://github.com/myfork/eos.git - - ebuild setup --eboot-branch v0.2.0 - - ebuild setup --eos-path /path/to/local/eos - """ - from ebuild.deps.manager import DepsManager - - log.header("ebuild — Setup") - mgr = DepsManager() - - try: - log.step("Setting up eos...") - eos_dir = mgr.setup("eos", url=eos_url, branch=eos_branch, path=eos_path) - log.success(f" eos: {eos_dir}") - - log.step("Setting up eboot...") - eboot_dir = mgr.setup("eboot", url=eboot_url, branch=eboot_branch, path=eboot_path) - log.success(f" eboot: {eboot_dir}") - - log.success("Setup complete. Repos are ready.") - except Exception as e: - log.error(f"Setup failed: {e}") - raise SystemExit(1) - - -@cli.group() -@click.pass_context -def repos(ctx: click.Context) -> None: - """Manage cached eos/eboot repositories.""" - pass - - -@repos.command("status") -@click.pass_obj -def repos_status(log: Logger) -> None: - """Show all repos, URLs, branches, and paths.""" - from ebuild.deps.manager import DepsManager - - log.header("ebuild — Repo Status") - mgr = DepsManager() - entries = mgr.status() - - for info in entries: - log.step(f"{info['name']}") - log.info(f" URL: {info['url']}") - log.info(f" Branch: {info['branch']}") - if info.get("config_path"): - log.info(f" Linked: {info['config_path']}") - if info.get("cached"): - log.info(f" Cached: {info['cache_location']}") - log.info(f" Git: {info.get('git_branch', '?')} @ {info.get('git_commit', '?')}") - else: - log.info(" Cached: no") - - -@repos.command("update") -@click.argument("repo_name", required=False, default=None) -@click.pass_obj -def repos_update(log: Logger, repo_name: Optional[str]) -> None: - """Git pull latest for one or all repos.""" - from ebuild.deps.manager import DepsManager - - log.header("ebuild — Repo Update") - mgr = DepsManager() - results = mgr.update(repo_name) - - for name, result in results.items(): - if "updated" in result: - log.success(f" {name}: {result}") - elif "failed" in result: - log.error(f" {name}: {result}") - else: - log.info(f" {name}: {result}") - - -@repos.command("set-url") -@click.argument("repo_name") -@click.argument("url") -@click.pass_obj -def repos_set_url(log: Logger, repo_name: str, url: str) -> None: - """Change the git URL for a repo.""" - from ebuild.deps.manager import DepsManager - - mgr = DepsManager() - mgr.set_url(repo_name, url) - log.success(f"Set {repo_name} URL to {url}") - - -@repos.command("set-branch") -@click.argument("repo_name") -@click.argument("branch") -@click.pass_obj -def repos_set_branch(log: Logger, repo_name: str, branch: str) -> None: - """Change the branch/tag for a repo.""" - from ebuild.deps.manager import DepsManager - - mgr = DepsManager() - mgr.set_branch(repo_name, branch) - log.success(f"Set {repo_name} branch to {branch}") - - -@repos.command("link") -@click.argument("repo_name") -@click.argument("local_path", type=click.Path(exists=True)) -@click.pass_obj -def repos_link(log: Logger, repo_name: str, local_path: str) -> None: - """Link a repo to a local directory (no clone).""" - from ebuild.deps.manager import DepsManager - - mgr = DepsManager() - mgr.link(repo_name, local_path) - log.success(f"Linked {repo_name} → {Path(local_path).resolve()}") - - -@repos.command("unlink") -@click.argument("repo_name") -@click.pass_obj -def repos_unlink(log: Logger, repo_name: str) -> None: - """Remove local path override, reverting to cache.""" - from ebuild.deps.manager import DepsManager - - mgr = DepsManager() - mgr.unlink(repo_name) - log.success(f"Unlinked {repo_name} — will use cached clone.") - - -# ═══════════════════════════════════════════════════════════════ -# Board generation command -# ═══════════════════════════════════════════════════════════════ - -@cli.command("generate-board") -@click.option("--mcu", default=None, help="MCU name (e.g., stm32f407, nrf52840).") -@click.option("--from-kicad", "kicad_file", default=None, type=click.Path(exists=True), help="KiCad schematic (.kicad_sch).") -@click.option("--from-eagle", "eagle_file", default=None, type=click.Path(exists=True), help="Eagle schematic (.sch).") -@click.option("--from-bom", "bom_file", default=None, type=click.Path(exists=True), help="BOM CSV file.") -@click.option("--describe", "description", default=None, help="Text description of hardware.") -@click.option("--product", default=None, help="Product profile for auto-config (e.g., ble-sensor, gateway).") -@click.option("--output", "output_dir", default="_generated", help="Output directory for generated configs.") -@click.option("--eos-schemas", default=None, help="Path to eos/schemas/ for hardware vocabulary.") -@click.pass_obj -def generate_board( - log: Logger, - mcu: Optional[str], - kicad_file: Optional[str], - eagle_file: Optional[str], - bom_file: Optional[str], - description: Optional[str], - product: Optional[str], - output_dir: str, - eos_schemas: Optional[str], -) -> None: - """Generate board/boot/build YAML configs from hardware inputs. - - Accepts an MCU name, KiCad schematic, Eagle schematic, BOM CSV, - or text description. Generates board.yaml, boot.yaml, build.yaml, - eos_product_config.h, and eboot integration files. - - Examples: - - ebuild generate-board --mcu stm32f407 --output ./config/ - - ebuild generate-board --from-kicad design.kicad_sch --output ./config/ - - ebuild generate-board --from-eagle design.sch --output ./config/ - - ebuild generate-board --from-bom parts.csv --output ./config/ - - ebuild generate-board --describe "STM32H743 with CAN, SPI flash" --output ./config/ - - ebuild generate-board --mcu nrf52840 --product ble-sensor --output ./config/ - """ - log.header("ebuild — Board Config Generator") - - try: - from ebuild.eos_ai.eos_hw_analyzer import EosHardwareAnalyzer - from ebuild.eos_ai.eos_config_generator import EosConfigGenerator - from ebuild.eos_ai.eos_validator import EosConfigValidator - from ebuild.eos_ai.eos_boot_integrator import EosBootIntegrator - - analyzer = EosHardwareAnalyzer(eos_schemas_path=eos_schemas) - - # Determine input source - if kicad_file: - log.step(f"Analyzing KiCad schematic: {kicad_file}") - profile = analyzer.interpret_kicad(kicad_file) - elif eagle_file: - log.step(f"Analyzing Eagle schematic: {eagle_file}") - profile = analyzer.interpret_file(eagle_file) - elif bom_file: - log.step(f"Analyzing BOM: {bom_file}") - content = Path(bom_file).read_text(encoding="utf-8", errors="replace") - profile = analyzer.interpret_bom(content) - elif description: - log.step("Analyzing text description...") - profile = analyzer.interpret_text(description) - elif mcu: - log.step(f"Generating config for MCU: {mcu}") - profile = analyzer.interpret_text(mcu) - else: - log.error("Provide --mcu, --from-kicad, --from-eagle, --from-bom, or --describe.") - raise SystemExit(1) - - # Override MCU if explicitly provided alongside another input - if mcu and profile.mcu != mcu: - profile.mcu = mcu - - log.info(f"MCU: {profile.mcu or '(unknown)'} ({profile.core})") - log.info(f"Arch: {profile.arch or '(unknown)'}") - log.info(f"Peripherals: {len(profile.peripherals)} detected") - for p in profile.peripherals: - log.info(f" - {p.peripheral_type}: {p.name}") - - # Generate configs - log.step("Generating board/boot/build configs...") - gen = EosConfigGenerator(output_dir) - outputs = gen.generate_all(profile) - - for name, path in outputs.items(): - log.success(f" {name}: {path}") - - # Validate - log.step("Validating generated configs...") - validator = EosConfigValidator() - val_result = validator.validate_all(output_dir) - log.info(val_result.summary()) - - # Generate eboot integration files - log.step("Generating eboot integration files...") - integrator = EosBootIntegrator(output_dir) - boot_outputs = integrator.generate_from_boot_yaml(str(outputs["boot"])) - for name, path in boot_outputs.items(): - log.success(f" {name}: {path}") - - log.success("Board config generation complete.") - - except SystemExit: - raise - except Exception as e: - log.error(f"Board generation failed: {e}") - raise SystemExit(1) - - -@cli.command() -@click.option( - "--config", - "config_path", - default="build.yaml", - type=click.Path(), - help="Path to the build configuration file.", -) -@click.option( - "--build-dir", - default="_build", - type=click.Path(), - help="Build output directory.", -) -@click.option( - "--filter", - "name_filter", - default=None, - help="Only run tests whose name contains this substring.", -) -@click.pass_obj -def test(log: Logger, config_path: str, build_dir: str, - name_filter: Optional[str]) -> None: - """Build and run the project's tests. - - Step six of the golden path. Delegates to whichever runner the project - already uses -- ctest for a CMake tree, `cargo test`, `meson test`, or - `make test` -- rather than imposing a test framework on the project. - """ - log.header("ebuild — Test") - - build_path = Path(build_dir) - - try: - log.step("Loading configuration...") - cfg = load_config(config_path) - log.info(f"Project: {cfg.name} v{cfg.version}") - except FileNotFoundError: - log.error( - f"No {config_path} here. Run this from a project directory, or " - f"pass --config." - ) - raise SystemExit(1) - except (ConfigError, RecipeError) as e: - log.error(f"Configuration error: {e}") - raise SystemExit(1) - - native = [t for t in cfg.targets if t.target_type == "test"] - if native: - _run_native_tests(cfg, native, build_path, log, name_filter) - return - - runner = _resolve_test_runner(cfg.source_dir, build_path, name_filter) - if runner is None: - log.error( - "No test runner found for this project.\n" - " ebuild test drives the project's own runner. Add one of:\n" - " - CMake with enable_testing() + add_test() -> ctest\n" - " - a 'test' target in the Makefile -> make test\n" - " - Cargo.toml -> cargo test\n" - " - meson.build -> meson test" - ) - raise SystemExit(1) - - name, argv, cwd = runner - log.step(f"Running tests with {name}...") - log.info(" ".join(argv)) - - try: - # 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 " - f"run here." - ) - 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) - - # 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.") - - -@cli.command() -@click.option("--config", "config_path", default="build.yaml", - type=click.Path(), help="Path to the build configuration file.") -@click.option("--build-dir", default="_build", type=click.Path(), - help="Build output directory.") -@click.option("--output", "output_path", default=None, type=click.Path(), - help="Destination .efw path. Defaults to .efw.") -@click.option("--load", "load_addr", default=None, - help="Load address, e.g. 0x08000000.") -@click.option("--entry", "entry_addr", default=None, - help="Entry address, e.g. 0x08000100.") -@click.pass_obj -def package(log: Logger, config_path: str, build_dir: str, - output_path: Optional[str], load_addr: Optional[str], - entry_addr: Optional[str]) -> None: - """Assemble the built artifact into an eFirmware `.efw` image. - - The step §29's development-to-device flow puts between eBuild and the - device. eFirmware implements the format and ships `efwtool`; this drives - it, so a developer does not have to know the tool exists. - """ - from ebuild.build.firmware_image import ( - FirmwareImageError, find_efwtool, missing_tool_message, pack, verify, - ) - from ebuild.deps import EBUILD_REPOS_DIR - - log.header("ebuild — Package") - - try: - cfg = load_config(config_path) - except FileNotFoundError: - log.error(f"No {config_path} here. Run this from a project directory.") - raise SystemExit(1) - except (ConfigError, RecipeError) as e: - log.error(f"Configuration error: {e}") - raise SystemExit(1) - - binaries = [t for t in cfg.targets if t.target_type == "executable"] - if not binaries: - log.error("No executable target in build.yaml — nothing to package.") - raise SystemExit(1) - - artifact = executable_output_path(Path(build_dir), binaries[0].name) - if not artifact.is_file(): - log.error(f"No built artifact at {artifact}. Run 'ebuild build' first.") - raise SystemExit(1) - - efwtool = find_efwtool(Path(EBUILD_REPOS_DIR)) - if efwtool is None: - log.error(missing_tool_message(Path(EBUILD_REPOS_DIR))) - raise SystemExit(1) - - output = Path(output_path or f"{cfg.name}.efw") - log.step(f"Packing {artifact.name} -> {output}") - try: - pack(efwtool, artifact, output, version=cfg.version or "0.0.0", - load_addr=load_addr, entry_addr=entry_addr) - verdict = verify(efwtool, output) - except FirmwareImageError as exc: - log.error(str(exc)) - raise SystemExit(1) - - log.success(f"{output} ({output.stat().st_size} bytes)") - for line in verdict.splitlines(): - log.info(f" {line}") - log.info("") - log.info(f"Inspect it with: {efwtool} inspect {output}") - -@cli.command() -@click.option("--json", "as_json", is_flag=True, - help="Emit the checks as JSON, for CI.") -@click.pass_obj -def doctor(log: Logger, as_json: bool) -> None: - """Diagnose the build environment in one command. - - Reports what is installed, what is missing, and what each missing piece - would cost. Read-only: it names the fix rather than applying it. - - Exits non-zero only for problems that actually stop a build, so a - host-only machine with no cross toolchain still passes. - """ - from ebuild.system.doctor import exit_code, format_report, run_all - - checks = run_all() - - if as_json: - import json as _json - click.echo(_json.dumps( - [{"name": c.name, "status": c.status, - "detail": c.detail, "fix": c.fix} for c in checks], - indent=2, - )) - raise SystemExit(exit_code(checks)) - - log.header("ebuild — Environment") - for line in format_report(checks).splitlines(): - click.echo(line) - raise SystemExit(exit_code(checks)) - - -def _run_native_tests( - cfg: "ProjectConfig", - targets: List[Any], - build_path: Path, - log: Logger, - name_filter: Optional[str], -) -> None: - """Build and run the project's own ``test`` targets. - - A scaffolded project has no CMake tree and no Makefile, so there is no - external runner to delegate to. The test binaries are ordinary ebuild - targets; build them the same way `ebuild build` does, then run each one - and treat a non-zero exit as a failure. - """ - selected = [t for t in targets if not name_filter or name_filter in t.name] - if not selected: - log.error(f"No test target matches --filter {name_filter!r}.") - raise SystemExit(1) - - log.step("Building test targets...") - _configure_ninja_backend(cfg, build_path, log, suggest_build=False) - - from ebuild.build.dispatch import ninja_command - - # Ninja addresses targets by their output path, and `ebuild build` drives - # it with -f from the project root, so the same form is used here. The - # path must include the platform suffix: on Windows the edge is - # ``.exe``, and asking ninja to build ```` is an unknown - # target. - argv = ( - ninja_command() - + ["-f", str(build_path / "build.ninja")] - + [str(executable_output_path(build_path, t.name)) for t in selected] - ) - result = subprocess.run(argv) - if result.returncode != 0: - log.error("Test targets failed to build.") - raise SystemExit(result.returncode) - - failures: List[str] = [] - for target in selected: - binary = executable_output_path(build_path, target.name) - if not binary.is_file(): - log.error(f"{target.name}: built, but no binary at {binary}") - failures.append(target.name) - continue - - log.step(f"Running {target.name}...") - run = subprocess.run([str(binary)]) - if run.returncode == 0: - log.success(f" {target.name}: passed") - else: - log.error(f" {target.name}: exited {run.returncode}") - failures.append(target.name) - - if failures: - log.error(f"{len(failures)} of {len(selected)} test targets failed: " - + ", ".join(failures)) - raise SystemExit(1) - - log.success(f"All {len(selected)} test targets 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 _resolve_test_runner( - source_dir: Path, - build_dir: Path, - name_filter: Optional[str], -) -> Optional[Tuple[str, List[str], Path]]: - """Pick the test runner this project already uses. - - Returns ``(display_name, argv, cwd)``, or None when the project declares - no tests. Ordered so that an explicit CMake test registry wins over a - generic `make test` target in the same tree. - """ - if (build_dir / "CTestTestfile.cmake").is_file(): - argv = ["ctest", "--output-on-failure"] - if name_filter: - argv += ["-R", name_filter] - return "ctest", argv, build_dir - - if (source_dir / "Cargo.toml").is_file(): - argv = ["cargo", "test"] - if name_filter: - argv += [name_filter] - return "cargo test", argv, source_dir - - if (build_dir / "meson-info").is_dir(): - argv = ["meson", "test", "-C", str(build_dir)] - if name_filter: - argv += ["--suite", name_filter] - return "meson test", argv, source_dir - - makefile = next( - (source_dir / n for n in ("Makefile", "makefile", "GNUmakefile") - if (source_dir / n).is_file()), - None, - ) - if makefile is not None: - text = makefile.read_text(encoding="utf-8", errors="replace") - if re.search(r"^test\s*:", text, re.MULTILINE): - return "make test", ["make", "-C", str(source_dir), "test"], source_dir - - return None - - -@cli.command() -@click.option("--port", default=None, - help="Serial device (e.g. /dev/ttyUSB0). Auto-detected if omitted.") -@click.option("--baud", default=115200, type=int, help="Baud rate.") -@click.pass_obj -def monitor(log: Logger, port: Optional[str], baud: int) -> None: - """Attach a serial monitor to the target device. - - Step eight of the golden path -- the step that shows a developer their - first firmware run actually produced output. - """ - log.header("ebuild — Monitor") - log.info(f"Board: {_selected_board()}") - - if port is None: - candidates = _serial_ports() - if not candidates: - log.error( - "No serial device found.\n" - " Looked for /dev/ttyUSB*, /dev/ttyACM*, /dev/tty.usb*.\n" - " Connect the board, or name the device with --port." - ) - raise SystemExit(1) - if len(candidates) > 1: - log.error( - "More than one serial device is connected, so ebuild will not " - "guess which one is the board:\n" - + "\n".join(f" {c}" for c in candidates) - + "\n Choose one with --port." - ) - raise SystemExit(1) - port = candidates[0] - log.info(f"Auto-detected {port}") - - log.step(f"Opening {port} at {baud} baud... (Ctrl-C to exit)") - - try: - import serial # type: ignore[import-untyped] - except ImportError: - log.error( - "pyserial is not installed, so the monitor cannot open the port.\n" - " Install it with: pip install pyserial" - ) - raise SystemExit(1) - - try: - with serial.Serial(port, baud, timeout=0.2) as conn: - while True: - chunk = conn.read(4096) - if chunk: - sys.stdout.write(chunk.decode("utf-8", errors="replace")) - sys.stdout.flush() - except KeyboardInterrupt: - log.info("") - log.success("Monitor closed.") - except Exception as e: - log.error(f"Serial error on {port}: {e}") - raise SystemExit(1) - - -def _serial_ports() -> List[str]: - """Serial devices that look like an attached development board.""" - found: List[str] = [] - for pattern in ("/dev/ttyUSB*", "/dev/ttyACM*", "/dev/tty.usb*"): - found.extend(sorted(glob.glob(pattern))) - return found - - -# ═════════════════════════════════════════════════════════════ -# Integration commands -# ═════════════════════════════════════════════════════════════ -# `integration`, `qemu`, `sdk`, `package` and `models` live in -# ebuild/cli/integration.py and are attached to the group by -# register_commands(). That call used to live only in ebuild/__main__.py, -# which runs for `python -m ebuild` and not for the `ebuild` console script -# that pyproject.toml's [project.scripts] installs on PATH. The five -# commands were therefore missing from the entry point that every user and -# every doc actually invokes. Registering here attaches them to the group -# itself, so both entry points -- and anything that imports `cli` -- see -# the same CLI. -_register_integration_commands(cli) +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""CLI commands for ebuild using Click. + +Provides build, clean, configure, info, install, add, list-packages, +pipeline, and hardware analysis commands. +""" + +from __future__ import annotations + +import glob +import os +import re +import shutil +import subprocess +import threading +import sys +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from ebuild.eos_ai.eos_hw_analyzer import HardwareProfile + +import click +import yaml + +from ebuild import __version__ +from ebuild.build.layout import executable_output_path +from ebuild.build.ninja_backend import ( + NinjaBackend, + PackagePaths, +) +from ebuild.build.toolchain import resolve_toolchain +from ebuild.cli.integration import register_commands as _register_integration_commands +from ebuild.cli.logger import Logger +from ebuild.core.config import ConfigError, load_config, ProjectConfig +from ebuild.core.graph import CycleError, DependencyGraph, build_dependency_graph +from ebuild.core.scheduler import run_graph +from ebuild.packages.builder import BuildError, PackageBuilder +from ebuild.packages.cache import PackageCache +from ebuild.packages.fetcher import FetchError, PackageFetcher +from ebuild.packages.lockfile import Lockfile +from ebuild.packages.recipe import RecipeError +from ebuild.packages.registry import create_registry, find_recipe_dirs +from ebuild.packages.resolver import PackageResolver, ResolveError + + +pass_logger = click.make_pass_decorator(Logger, ensure=True) + +# Canonical recipe search path discovery +_find_recipe_dirs = find_recipe_dirs + + + +def _install_packages( + cfg: ProjectConfig, + build_dir: Path, + log: Logger, + verbose: bool = False, + jobs: int = 1, +) -> Dict[str, PackagePaths]: + """Resolve, fetch, build, and return PackagePaths for all declared packages. + + Args: + jobs: Maximum packages to build concurrently. 1 (the default) preserves + the sequential build order exactly. + + Returns a dict mapping package name to PackagePaths for use by NinjaBackend. + """ + if not cfg.packages: + return {} + + log.step("Resolving packages...") + + recipe_dirs = _find_recipe_dirs(cfg.source_dir) + if not recipe_dirs: + log.warning("No recipe directories found. Create a 'recipes/' directory.") + return {} + + registry = create_registry(*recipe_dirs) + log.debug(f"Registry: {registry.package_count} recipes from {[str(p) for p in registry.search_paths]}") + + resolver = PackageResolver(registry) + requested = [{"name": p.name, "version": p.version} for p in cfg.packages] + resolved = resolver.resolve(requested) + + log.info(f"Packages to install: {', '.join(r.name + ' v' + r.version for r in resolved)}") + + # Lockfile + lock_path = cfg.source_dir / Lockfile.FILENAME + lockfile = Lockfile(lock_path) + + # Cache and fetcher + pkg_cache_dir = build_dir / "packages" + cache = PackageCache(pkg_cache_dir) + fetcher = PackageFetcher(pkg_cache_dir / "_downloads") + + # Build each package, honouring dependency order. Independent packages run + # concurrently when jobs > 1. + builder = PackageBuilder(cache, verbose=verbose) + install_dirs: Dict[str, Path] = {} + by_name = {r.name: r for r in resolved} + + graph = DependencyGraph() + for recipe in resolved: + graph.add_node(recipe.name) + for recipe in resolved: + for dep in recipe.dependencies: + if dep in by_name: + graph.add_edge(recipe.name, dep) + + dirs_lock = threading.Lock() + log_lock = threading.Lock() + + def build_one(name: str) -> Path: + recipe = by_name[name] + + if cache.is_built(recipe): + with dirs_lock: + install_dirs[name] = cache.install_dir(recipe) + with log_lock: + log.info(f" {recipe.name} v{recipe.version} — cached ✓") + return install_dirs[name] + + with log_lock: + log.step(f" Fetching {recipe.name} v{recipe.version}...") + fetcher.fetch(recipe, cache.src_dir(recipe)) + + with log_lock: + log.step(f" Building {recipe.name} v{recipe.version}...") + + dep_dirs = [] + for dep in recipe.dependencies: + with dirs_lock: + dep_dir = install_dirs.get(dep) + if dep_dir is None: + raise BuildError( + f"Dependency '{dep}' of '{recipe.name}' was not built. " + "Check that all recipes are available." + ) + dep_dirs.append(dep_dir) + + install_dir = builder.build(recipe, dep_install_dirs=dep_dirs) + with dirs_lock: + install_dirs[name] = install_dir + with log_lock: + log.success(f" {recipe.name} v{recipe.version} — built ✓") + return install_dir + + def note_skipped(name: str, _cause: BaseException) -> None: + with log_lock: + log.warning(f" {name} — skipped (a dependency failed)") + + if jobs > 1: + log.debug(f"Building packages with up to {jobs} concurrent jobs") + + run_graph(graph, build_one, jobs=jobs, on_skip=note_skipped) + + # Update lockfile + lockfile.lock(resolved) + lockfile.save() + log.debug(f"Lockfile written: {lock_path}") + + # Build PackagePaths for ninja + package_paths: Dict[str, PackagePaths] = {} + for recipe in resolved: + idir = install_dirs.get(recipe.name) + if idir: + inc = idir / "include" + lib = idir / "lib" + libs = _detect_libraries(lib, recipe.name) + package_paths[recipe.name] = PackagePaths( + include_dirs=[inc] if inc.exists() else [], + lib_dirs=[lib] if lib.exists() else [], + libraries=libs, + ) + + return package_paths + + +def _workspace_repo_paths() -> Dict[str, PackagePaths]: + """Include paths for the eos and eboot repos that `ebuild setup` cloned. + + A scaffolded project includes , but the generated build.yaml + carried no path to the headers, so every template failed with + "fatal error: eos/hal.h: No such file or directory" on the first build. + + These are resolved at build time from the cache rather than written into + build.yaml as absolute paths: the path is a fact about this machine, and + build.yaml is a file the developer commits. + + Returns an empty mapping when the cache is absent, so the error a developer + sees stays the missing header rather than a stack trace, and `ebuild setup` + remains the fix. + """ + from ebuild.deps import EBUILD_REPOS_DIR + + paths: Dict[str, PackagePaths] = {} + for name in ("eos", "eboot"): + root = Path(EBUILD_REPOS_DIR) / name + if not root.is_dir(): + continue + # Headers sit at two depths: kernel/include, hal/include ... and + # services/crypto/include, services/ota/include. Both are needed -- + # and live only in the deeper set. + include_dirs = sorted( + {p for pattern in ("include", "*/include", "*/*/include") + for p in root.glob(pattern) if p.is_dir()} + ) + if include_dirs: + lib_dirs, libraries = _cached_repo_libraries(root) + paths[name] = PackagePaths( + include_dirs=include_dirs, + lib_dirs=lib_dirs, + libraries=libraries, + ) + return paths + + +# Where `ebuild` puts the CMake build tree for a cached repo. Kept inside the +# clone so `ebuild setup` remains the only thing that owns ~/.ebuild/repos. +_REPO_BUILD_DIRNAME = "_ebuild" + + +def _cached_repo_libraries(root: Path) -> Tuple[List[Path], List[str]]: + """Static libraries a cached repo offers to projects that `use` it. + + Headers alone are not enough: a scaffolded project compiles against + and then fails at the link step with undefined references. + The repo is a CMake project with no install() rules, so there is nothing + to point a -L at until it has been built once. Build it on demand and + cache the result; subsequent builds reuse the tree. + + Returns ([], []) when the repo cannot be built here — a missing cmake, a + repo that is not a CMake project — so the developer still gets a link + error naming the symbol rather than a stack trace from ebuild. + """ + if not (root / "CMakeLists.txt").is_file(): + return [], [] + + build_dir = root / _REPO_BUILD_DIRNAME + archives = sorted(build_dir.rglob("*.a")) if build_dir.is_dir() else [] + + if not archives: + if shutil.which("cmake") is None: + return [], [] + try: + subprocess.run( + ["cmake", "-S", str(root), "-B", str(build_dir)], + check=True, capture_output=True, timeout=600, + ) + subprocess.run( + ["cmake", "--build", str(build_dir), "-j", str(os.cpu_count() or 1)], + check=True, capture_output=True, timeout=1800, + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError): + return [], [] + archives = sorted(build_dir.rglob("*.a")) + + if not archives: + return [], [] + + # -L one directory per archive location; -l the archive basenames with + # the lib prefix and .a suffix stripped, which is what the linker wants. + lib_dirs = sorted({a.parent for a in archives}) + libraries = [a.stem[3:] for a in archives if a.stem.startswith("lib")] + return lib_dirs, libraries + + +def _detect_libraries(lib_dir: Path, pkg_name: str) -> List[str]: + """Detect installed library names from a lib/ directory.""" + if not lib_dir.exists(): + return [pkg_name] + + libs = [] + for f in sorted(lib_dir.iterdir()): + name = f.name + if name.startswith("lib") and (name.endswith(".a") or name.endswith(".so")): + lib_name = name[3:] # strip "lib" + if lib_name.endswith(".a"): + lib_name = lib_name[:-2] + elif lib_name.endswith(".so"): + lib_name = lib_name[:-3] + if lib_name and lib_name not in libs: + libs.append(lib_name) + + return libs if libs else [pkg_name] + + +def _resolve_build_dir(build_dir: str, cfg: ProjectConfig) -> Path: + """Anchor a relative ``--build-dir`` to the project, not the cwd. + + ``build.yaml`` describes the project, so ``_build`` means "beside + build.yaml" -- which is what the committed examples show + (``examples/hello_world/_build/``), what README and demo.md walk + through, and what the generated files already assume: ninja is invoked + with ``cwd=cfg.source_dir``, and compile_commands.json records + ``directory`` as the source directory with build-dir-relative outputs. + + Only the Python side disagreed. It created and reported the build + directory relative to the *process* cwd, so the two bases coincided + exactly when the cwd was the project directory -- the documented golden + path, and the only case the examples exercise. With ``--config`` naming + a project elsewhere they diverged: `build` wrote build.ninja where the + ninja it then launched could not open it, and `configure` reported + success having written it somewhere a later build would not look. + + The result is absolute. A path relative to the project would still be + re-interpreted by ninja, which runs in ``cfg.source_dir``: a relative + ``--config myproj/build.yaml`` yields ``myproj/_build``, and ninja + would then look for ``myproj/myproj/_build/build.ninja``. Absolute is + the only form that means the same thing to the process creating the + directory and to the ninja that reads what was written into it, and it + keeps the generated build.ninja independent of the cwd it was + generated from. + """ + path = Path(build_dir) + if not path.is_absolute(): + path = cfg.source_dir / path + return path.resolve() + + +def _shown(path: Path) -> str: + """*path* as the user would type it: relative to the cwd when it is under it. + + Build directories are resolved to absolute paths so that ninja and the + process agree on them, but printing an absolute path for the ordinary + in-project build would replace the "_build/build.ninja" that demo.md + documents with a machine-specific one. + """ + try: + return str(path.relative_to(Path.cwd())) + except ValueError: + return str(path) + + +def _resolve_backend_request( + cfg: ProjectConfig, + backend_override: Optional[str], + source_dir: Path, + log: Logger, +) -> Tuple[str, Dict[str, Any]]: + """Resolve the effective backend and backend-specific config.""" + resolved_backend = backend_override or cfg.backend + backend_config = dict(cfg.backend_config) + + if resolved_backend == "auto": + from ebuild.build.dispatch import detect_backend + + resolved_backend = detect_backend(source_dir) + log.info(f"Auto-detected backend: {resolved_backend}") + + # A build.yaml that declares its own targets is a statement that + # ebuild builds this project. detect_backend() only inspects the + # filesystem, so a Makefile kept for `make flash` -- or a + # CMakeLists.txt belonging to one subcomponent -- used to outrank + # that statement: the dispatcher ran the external tool, the + # declared targets were never built, and the build still reported + # success. + # + # Only auto-detection is overridden. An explicit `backend:` in + # build.yaml or --backend on the command line still wins, which + # is how a project keeps both a target list and an external + # build. + if resolved_backend != "ninja" and cfg.targets: + log.info( + f"build.yaml declares {len(cfg.targets)} target(s), so " + f"the ninja backend is used instead of the detected " + f"{resolved_backend}. To build with {resolved_backend}, " + f"set 'backend: {resolved_backend}' in build.yaml or " + f"pass --backend {resolved_backend}." + ) + resolved_backend = "ninja" + + return resolved_backend, backend_config + + +# The project-local file that records which board this checkout targets. +_EOS_PROJECT_CONFIG = "eos.yaml" + + +def _record_board_selection(board: str, log: Logger) -> None: + """Persist ``--board`` into eos.yaml under ``system.board``. + + The golden path is `configure --board` then a bare `build`, so the choice + has to outlive the configure process. It is written to eos.yaml rather + than build.yaml because the board is a property of the system being + targeted, which is what eos.yaml already describes. + """ + path = Path(_EOS_PROJECT_CONFIG) + if not path.is_file(): + log.error( + f"No {_EOS_PROJECT_CONFIG} here, so there is nothing to record the " + f"board against. Run this from a project directory created by " + f"'ebuild new'." + ) + raise SystemExit(1) + + import yaml + + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except yaml.YAMLError as e: + log.error(f"{_EOS_PROJECT_CONFIG} is not valid YAML: {e}") + raise SystemExit(1) + + system = data.setdefault("system", {}) + previous = system.get("board") + system["board"] = board + path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8") + + if previous and previous != board: + log.info(f"Board: {previous} -> {board}") + else: + log.info(f"Board: {board}") + + +def _selected_board(default: str = "generic") -> str: + """The board this project targets, from its eos.yaml. + + Read rather than passed in: `ebuild build` takes no --board of its own in + the documented walk, so the value has to survive from `ebuild new` or + `ebuild configure`. + """ + path = Path("eos.yaml") + if not path.is_file(): + return default + try: + import yaml + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except Exception: + return default + return (data.get("system") or {}).get("board") or default + + +def _build_summary(cfg: "ProjectConfig", compiler, package_paths, log: Logger) -> None: + """The per-component summary the MLP walk ends with. + + A build that prints only "Build completed successfully" leaves the + developer to infer what was actually in it. The interesting case is a + package that resolved to nothing: the build still succeeds, the feature is + simply absent, and nothing said so. + """ + board = _selected_board(default="") + rows = [ + ("toolchain", getattr(compiler, "cc", "") or "cc", True), + ("board configuration", board or "host (no board recorded)", True), + ] + + declared = [p.name for p in getattr(cfg, "packages", []) or []] + for name in declared: + paths = (package_paths or {}).get(name) + # A package with no resolved include or library directory contributed + # nothing to this build, whatever build.yaml says. + resolved = bool(paths and (paths.include_dirs or paths.lib_dirs)) + rows.append((name, "" if resolved else "declared, nothing resolved", + resolved)) + + for target in cfg.targets: + if target.target_type in ("executable", "test"): + rows.append((target.name, target.target_type, True)) + + width = max(len(n) for n, _d, _ok in rows) + log.info("") + log.info("EmbeddedOS Build") + for name, detail, ok in rows: + mark = "OK " if ok else "MISS" + log.info(f" {mark} {name.ljust(width)}" + (f" {detail}" if detail else "")) + + missing = [n for n, _d, ok in rows if not ok] + if missing: + log.warning( + f"{len(missing)} declared package(s) resolved to nothing: " + + ", ".join(missing) + + ". The build succeeded without them." + ) + + +def _report_footprint(cfg: "ProjectConfig", build_path: Path, log: Logger) -> None: + """Print how much of the board the build just used. + + The MLP walk ends with a build that says `Flash: 384 KB / RAM: 72 KB`. A + developer who has to run `size` themselves and remember which columns to + add is not being told; they are being left to find out. + + Never fatal. A footprint that cannot be measured -- no binutils, a cross + toolchain whose `size` is not installed -- is a missing convenience, and + failing a successful build over it would be worse than the silence it + replaces. + """ + from ebuild.build.footprint import ( + FootprintError, board_capacity, find_size_tool, format_report, + measure, over_budget, + ) + + binaries = [t for t in cfg.targets if t.target_type == "executable"] + if not binaries: + return + + artifact = executable_output_path(build_path, binaries[0].name) + if not artifact.is_file(): + log.debug(f"no artifact at {artifact}; skipping footprint") + return + + prefix = getattr(cfg.toolchain, "target", None) or "host" + tool = find_size_tool(prefix) + if tool is None: + log.debug(f"no size tool for toolchain {prefix!r}; skipping footprint") + return + + try: + fp = measure(artifact, tool) + except FootprintError as exc: + log.debug(f"footprint unavailable: {exc}") + return + + board = _selected_board(default="") + flash_cap, ram_cap = board_capacity(board or None, _board_config()) + log.info("") + for line in format_report(fp, flash_cap, ram_cap).splitlines(): + log.info(line) + + exceeded = over_budget(fp, flash_cap, ram_cap) + if exceeded: + # Not a build failure: the image linked. It will not fit on the board, + # which the developer needs to hear now rather than from a device that + # will not boot. + log.warning(f"{exceeded} -- this image will not fit.") + else: + log.info("Ready to flash.") + + +def _board_config() -> Optional[Dict[str, Any]]: + """The project's own board description, if it ships one. + + A project that states its part's real capacity should not be measured + against the reference part for its family. + """ + path = Path("board.yaml") + if not path.is_file(): + return None + try: + import yaml + data = yaml.safe_load(path.read_text(encoding="utf-8")) + except Exception: + return None + return data if isinstance(data, dict) else None + + +def _configure_ninja_backend( + cfg: ProjectConfig, + build_path: Path, + log: Logger, + *, + suggest_build: bool = True, +) -> None: + """Generate native ebuild Ninja files for configure-only workflows. + + The package paths are merged exactly as `ebuild build` merges them. If + they were not, `configure` and `build` would each write a different + build.ninja to the same path, and a developer who ran `configure` and then + invoked ninja directly would build without the cached-repo include and + library paths. + """ + log.step("Resolving toolchain...") + compiler = resolve_toolchain(cfg.toolchain) + + package_paths = {**_workspace_repo_paths(), + **_install_packages(cfg, build_path, log, verbose=log.verbose)} + + log.step(f"Generating build.ninja in {_shown(build_path)}/...") + ninja_backend = NinjaBackend(cfg, build_path, compiler, package_paths=package_paths) + ninja_backend.generate() + + log.success(f"Generated {_shown(build_path / 'build.ninja')}") + log.success(f"Generated {_shown(build_path / 'compile_commands.json')}") + if suggest_build: + log.info("Run 'ebuild build' to compile.") + + +def _configure_external_backend( + cfg: ProjectConfig, + resolved_backend: str, + backend_config: Dict[str, Any], + build_path: Path, + log: Logger, +) -> None: + """Run configure behavior for dispatcher-backed build systems.""" + from ebuild.build.dispatch import BackendDispatcher + + no_configure_backends = {"cargo", "make", "kbuild"} + + log.step(f"Using {resolved_backend} backend...") + if resolved_backend in no_configure_backends: + log.info(f"No separate configure step for {resolved_backend}.") + return + + dispatcher = BackendDispatcher(cfg.source_dir, build_path) + log.step(f"Configuring ({resolved_backend})...") + dispatcher.configure( + backend=resolved_backend, + config=backend_config, + ) + log.success(f"Configuration completed successfully ({resolved_backend}).") + + +def _format_subprocess_failure(exc: subprocess.CalledProcessError) -> str: + """Format a subprocess failure for user-facing CLI output.""" + cmd = exc.cmd + if isinstance(cmd, (list, tuple)): + cmd_str = " ".join(str(part) for part in cmd) + else: + cmd_str = str(cmd) + + return f"Command failed (exit code {exc.returncode}): {cmd_str}" + + +def _format_missing_tool(exc: FileNotFoundError) -> str: + """Format missing executable/path errors for user-facing CLI output.""" + if exc.filename: + return f"Required tool or file not found: {exc.filename}" + return str(exc) + + +# ═══════════════════════════════════════════════════════════════ +# Pipeline helper — shared by `pipeline` and `build --board` +# ═══════════════════════════════════════════════════════════════ + +def _run_pipeline_steps( + board: str, + hardware: Optional[str], + build_dir: Path, + log: Logger, +) -> Tuple[Any, Dict[str, Path], Dict[str, Path]]: + """Run the full pipeline: analyze -> generate configs -> generate eboot -> generate SDK. + + Returns (profile, config_outputs, boot_outputs). + """ + from ebuild.eos_ai.eos_hw_analyzer import EosHardwareAnalyzer + from ebuild.eos_ai.eos_config_generator import EosConfigGenerator + from ebuild.eos_ai.eos_boot_integrator import EosBootIntegrator + from ebuild.sdk_generator import generate_sdk_from_profile + + configs_dir = build_dir / "configs" + sdk_dir = build_dir / "sdk" + configs_dir.mkdir(parents=True, exist_ok=True) + sdk_dir.mkdir(parents=True, exist_ok=True) + + # Step 1: Analyze hardware + log.step("[1/6] Analyzing hardware...") + analyzer = EosHardwareAnalyzer() + + if hardware: + hw_path = Path(hardware) + if not hw_path.exists(): + raise FileNotFoundError("Hardware file not found: " + hardware) + log.info(" Reading hardware design: " + str(hw_path)) + profile = analyzer.interpret_file(str(hw_path)) + else: + log.info(" Using board name: " + board) + profile = analyzer.interpret_text(board) + + # Override MCU from --board if the profile didn't detect one + if board and (not profile.mcu or profile.mcu.lower() != board.lower()): + mcu_info = analyzer.MCU_DATABASE.get(board.lower()) + if mcu_info: + profile.mcu = board.upper() + profile.arch = mcu_info["arch"] + profile.core = mcu_info["core"] + profile.vendor = mcu_info["vendor"] + profile.mcu_family = mcu_info["family"] + + log.info(" MCU: " + profile.mcu + " (" + profile.core + ")") + log.info(" Arch: " + profile.arch) + log.info(" Peripherals: " + str(len(profile.peripherals)) + " detected") + + # Step 2: Generate configs (board.yaml, boot.yaml, build.yaml, eos_product_config.h) + log.step("[2/6] Generating configs...") + config_gen = EosConfigGenerator(str(configs_dir)) + config_outputs = config_gen.generate_all(profile) + for name, path in config_outputs.items(): + log.success(" " + name + ": " + str(path)) + + # Step 3: Generate eboot integration (flash layout, linker, pack script, cmake defs) + log.step("[3/6] Generating eboot integration files...") + integrator = EosBootIntegrator(str(configs_dir)) + boot_outputs = integrator.generate_from_boot_yaml(str(config_outputs["boot"])) + for name, path in boot_outputs.items(): + log.success(" " + name + ": " + str(path)) + + # Step 4: Generate SDK (toolchain.cmake, environment-setup, eboot target config) + # Drive the SDK from the detected profile, not just the board name — otherwise + # any MCU absent from the small target table silently regressed to an x86_64 SDK. + log.step("[4/6] Generating SDK...") + _, toolchain_ok, eboot_board = generate_sdk_from_profile(profile, str(sdk_dir), target=board) + if not toolchain_ok: + raise RuntimeError("no cross-toolchain ships for " + board + "; the SDK fell back " + "to the host x86_64 compiler. Run `ebuild sdk --list`.") + if eboot_board is None: + log.warning(" no eBoot board for " + board + ": eboot/eboot_board.cmake carries a " + "FATAL_ERROR; a build that needs the board will fail by name.") + else: + log.success(" SDK generated in " + str(sdk_dir)) + + # Step 5: Copy generated headers to build include path + log.step("[5/6] Copying headers to build include path...") + include_dir = build_dir / "include" / "generated" + include_dir.mkdir(parents=True, exist_ok=True) + + for header_name in ["eos_product_config.h", "eboot_flash_layout.h"]: + src = configs_dir / header_name + if src.exists(): + dst = include_dir / header_name + shutil.copy2(str(src), str(dst)) + log.info(" " + header_name + " -> " + str(dst)) + + return profile, config_outputs, boot_outputs + + +def _run_cmake_build(profile, board, source_dir, build_dir, log): + """Run cmake configure + build with EOS_ENABLE_* defines injected.""" + from ebuild.build.dispatch import BackendDispatcher + + enables = profile.get_eos_enables() + cmake_defines = {} + cmake_defines["EOS_BOARD"] = board.lower() + cmake_defines["EOS_ARCH"] = profile.arch or "arm" + cmake_defines["EOS_CORE"] = profile.core or "cortex-m4" + + for flag, val in enables.items(): + cmake_defines[flag] = "ON" if val else "OFF" + + # Point cmake to generated config headers + gen_include = build_dir / "include" / "generated" + if gen_include.exists(): + cmake_defines["EOS_GENERATED_INCLUDE_DIR"] = gen_include.as_posix() + + # Point to eboot cmake defs if present + eboot_cmake = build_dir / "configs" / "eboot_config.cmake" + if eboot_cmake.exists(): + cmake_defines["EBOOT_CONFIG_FILE"] = eboot_cmake.as_posix() + + log.step("[6/6] Building with cmake...") + log.info(" Defines: " + str(len(cmake_defines)) + " cmake variables") + + dispatcher = BackendDispatcher(source_dir, build_dir) + + log.step(" Configuring (cmake)...") + dispatcher.configure(backend="cmake", config={"defines": cmake_defines}) + + log.step(" Building (cmake)...") + dispatcher.build(backend="cmake", config={}) + + +def _run_pack_image(build_dir, log): + """Run pack_image.sh if it exists and firmware output is present.""" + pack_script = build_dir / "configs" / "pack_image.sh" + if not pack_script.exists(): + return + + firmware_candidates = list(build_dir.glob("*.bin")) + list(build_dir.glob("*.elf")) + if not firmware_candidates: + log.info("No firmware binary found -- skipping image packing.") + return + + firmware = firmware_candidates[0] + log.step("Packing firmware image: " + firmware.name + "...") + + if os.name == "nt": + log.info(" Pack script is a bash script -- skipping on Windows.") + log.info(" Run manually: bash " + str(pack_script) + " " + str(firmware)) + else: + try: + subprocess.run( + ["bash", str(pack_script), str(firmware)], + check=True, + cwd=str(build_dir), + ) + log.success(" Firmware image packed.") + except subprocess.CalledProcessError as e: + log.warning(" Pack script failed: " + str(e)) + + +def _get_target_class(board): + """Look up the target class (mcu, sbc, soc, pc, virtual, devboard) for a board.""" + from ebuild.sdk_generator import TARGET_ARCH + info = TARGET_ARCH.get(board.lower()) + if info: + return info.get("class", "mcu") + return "mcu" + + +def _generate_image(board, build_dir, log): + """Generate a testable image based on target class. + + MCU targets: handled by _run_pack_image() (firmware .bin). + Linux-class targets: assemble rootfs + create tar.gz disk image. + """ + from ebuild.system.rootfs import RootfsBuilder + from ebuild.system.image import ImageBuilder + + target_class = _get_target_class(board) + + if target_class == "mcu": + _run_pack_image(build_dir, log) + return None + + # Linux-class target: assemble rootfs + create disk image + log.step("[7/7] Generating system image...") + + # Assemble rootfs skeleton + log.info(" Assembling rootfs...") + rootfs_builder = RootfsBuilder(build_dir) + rootfs_dir = rootfs_builder.assemble( + init_system="busybox", + hostname="eos-" + board.lower(), + ) + log.success(" Rootfs assembled: " + str(rootfs_dir)) + + # Copy built libraries into rootfs + lib_dest = rootfs_dir / "usr" / "lib" / "eos" + lib_dest.mkdir(parents=True, exist_ok=True) + lib_count = 0 + for lib_file in build_dir.glob("*.a"): + shutil.copy2(str(lib_file), str(lib_dest / lib_file.name)) + lib_count += 1 + # Also check subdirectories for libraries + for lib_file in build_dir.rglob("*.a"): + dest = lib_dest / lib_file.name + if not dest.exists(): + shutil.copy2(str(lib_file), str(dest)) + lib_count += 1 + if lib_count > 0: + log.info(" Installed " + str(lib_count) + " libraries into rootfs") + + # Copy generated headers into rootfs + gen_include = build_dir / "include" / "generated" + if gen_include.exists(): + inc_dest = rootfs_dir / "usr" / "include" / "eos" + inc_dest.mkdir(parents=True, exist_ok=True) + header_count = 0 + for header in gen_include.glob("*.h"): + shutil.copy2(str(header), str(inc_dest / header.name)) + header_count += 1 + if header_count > 0: + log.info(" Installed " + str(header_count) + " headers into rootfs") + + # Copy SDK info into rootfs + sdk_dir = build_dir / "sdk" + if sdk_dir.exists(): + sdk_dest = rootfs_dir / "opt" / "eos-sdk" + sdk_dest.mkdir(parents=True, exist_ok=True) + for item in sdk_dir.rglob("*"): + if item.is_file(): + rel = item.relative_to(sdk_dir) + dest = sdk_dest / rel + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(str(item), str(dest)) + + # Create disk image (tar.gz — cross-platform, always works) + log.info(" Creating disk image...") + imager = ImageBuilder(build_dir, log=log) + image_path = imager.create( + rootfs_dir=rootfs_dir, + image_format="tar", + label="eos-" + board.lower(), + ) + log.success(" Image created: " + str(image_path)) + + # Report image size + if image_path.exists(): + size_kb = image_path.stat().st_size // 1024 + if size_kb > 1024: + log.info(" Size: " + str(size_kb // 1024) + " MB") + else: + log.info(" Size: " + str(size_kb) + " KB") + + return image_path + + +@click.group() +@click.version_option(version=__version__, prog_name="ebuild") +@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.""" + ctx.ensure_object(dict) + ctx.obj = Logger(verbose=verbose) + + +@cli.command() +@click.option( + "--config", "config_path", + default="build.yaml", + type=click.Path(exists=False), + help="Path to the build configuration file.", +) +@click.option( + "--build-dir", + default="_build", + type=click.Path(), + help="Build output directory.", +) +@click.option( + "--backend", + default=None, + type=click.Choice(["auto", "cmake", "make", "meson", "cargo", "ninja", "kbuild"]), + help="Force a specific build backend.", +) +@click.option( + "--board", + default=None, + help="Target board name (e.g., stm32f4, nrf52). Triggers full pipeline before build.", +) +@click.option( + "--hardware", + default=None, + type=click.Path(exists=True), + help="Hardware design file (.kicad_sch, .sch, .csv). Used with --board for analysis.", +) +@click.option( + "-j", + "--jobs", + default=1, + type=click.IntRange(min=1), + help=( + "Number of packages to build concurrently (default 1). Independent " + "packages are built in parallel; dependency order is always honoured. " + "Each package's own build may already run parallel compile jobs, so " + "large values can oversubscribe the machine." + ), +) +@click.pass_obj +def build(log: Logger, config_path: str, build_dir: str, backend: Optional[str], + board: Optional[str], hardware: Optional[str], jobs: int = 1) -> None: + """Parse config, detect backend, and build the project. + + When --board is provided, runs the full pipeline (analyze -> generate -> + build) before the normal cmake build. Generated configs are stored in + _build/configs/ and EOS_ENABLE_* defines are passed to cmake automatically. + """ + log.header("ebuild — Build") + + build_path = Path(build_dir) + + try: + # Pipeline mode: --board triggers full analyze -> generate -> build + if board: + log.info("Board pipeline mode: " + board) + + profile, config_outputs, boot_outputs = _run_pipeline_steps( + board=board, + hardware=hardware, + build_dir=build_path, + log=log, + ) + + source_dir = Path(".") + if (source_dir / "CMakeLists.txt").exists(): + _run_cmake_build(profile, board, source_dir, build_path, log) + else: + log.info("No CMakeLists.txt found -- pipeline steps complete (no cmake build).") + + _generate_image(board, build_path, log) + + log.success("Build completed successfully (pipeline mode).") + return + + # Normal mode: standard config-based build + log.step("Loading configuration...") + cfg = load_config(config_path) + log.info(f"Project: {cfg.name} v{cfg.version}") + + build_path = _resolve_build_dir(build_dir, cfg) + resolved_backend, backend_config = _resolve_backend_request( + cfg=cfg, + backend_override=backend, + source_dir=cfg.source_dir, + log=log, + ) + + # Route: external build systems (cmake, make, meson, cargo, kbuild) + # go through the dispatcher. ebuild's own ninja backend handles + # projects with targets defined in build.yaml. + if resolved_backend != "ninja" or not cfg.targets: + from ebuild.build.dispatch import BackendDispatcher + + log.step(f"Using {resolved_backend} backend...") + dispatcher = BackendDispatcher(cfg.source_dir, build_path) + + # Tier 2+3: configure first + from ebuild.build.dispatch import TIER_1 + if resolved_backend not in TIER_1: + log.step(f"Configuring ({resolved_backend})...") + dispatcher.configure( + backend=resolved_backend, + config=backend_config, + ) + + # Build + log.step(f"Building ({resolved_backend})...") + dispatcher.build( + backend=resolved_backend, + config=backend_config, + ) + + log.success(f"Build completed successfully ({resolved_backend}).") + return + + # ebuild's own Ninja backend path (build.yaml with targets) + log.step("Resolving dependency graph...") + graph = build_dependency_graph(cfg.targets) + build_order = graph.topological_sort() + log.debug(f"Build order: {' → '.join(build_order)}") + + log.step("Resolving toolchain...") + compiler = resolve_toolchain(cfg.toolchain) + log.debug(f"Compiler: {compiler.cc}") + + # Install packages if any are declared + package_paths = {**_workspace_repo_paths(), + **_install_packages(cfg, build_path, log, verbose=log.verbose, jobs=jobs)} + + log.step(f"Generating build.ninja in {_shown(build_path)}/...") + ninja_backend = NinjaBackend(cfg, build_path, compiler, package_paths=package_paths) + ninja_backend.generate() + log.success(f"Generated {_shown(build_path / 'build.ninja')}") + log.success(f"Generated {_shown(build_path / 'compile_commands.json')}") + + log.step("Invoking ninja...") + ninja_cmd = [sys.executable, "-m", "ninja", "-f", str(build_path / "build.ninja")] + if log.verbose: + ninja_cmd.append("-v") + + result = subprocess.run(ninja_cmd, capture_output=not log.verbose, cwd=str(cfg.source_dir)) + if result.returncode != 0: + # ninja reports compiler diagnostics on stdout, not stderr, so a + # failure surfaced only through stderr says nothing about what broke. + # Replay both streams verbatim rather than through log.error(), which + # would prefix a multi-line diagnostic with a single "[error]" tag. + if not log.verbose: + if result.stdout: + sys.stdout.write(result.stdout.decode(errors="replace")) + sys.stdout.flush() + if result.stderr: + sys.stderr.write(result.stderr.decode(errors="replace")) + sys.stderr.flush() + log.error("Build failed.") + raise SystemExit(1) + + log.success("Build completed successfully.") + _build_summary(cfg, compiler, package_paths, log) + _report_footprint(cfg, build_path, log) + + except FileNotFoundError as e: + log.error(_format_missing_tool(e)) + raise SystemExit(1) + except subprocess.CalledProcessError as e: + log.error(_format_subprocess_failure(e)) + raise SystemExit(1) + except (ConfigError, RecipeError) as e: + log.error(f"Configuration error: {e}") + raise SystemExit(1) + except CycleError as e: + log.error(f"Dependency error: {e}") + raise SystemExit(1) + except (ResolveError, FetchError, BuildError) as e: + log.error(f"Package error: {e}") + raise SystemExit(1) + except RuntimeError as e: + log.error(str(e)) + raise SystemExit(1) + + +@cli.command() +@click.option( + "--board", + required=True, + help="Target board name (e.g., stm32f4, nrf52, stm32h7).", +) +@click.option( + "--hardware", + default=None, + type=click.Path(exists=True), + help="Hardware design file (.kicad_sch, .sch, .csv) for schematic analysis.", +) +@click.option( + "--build-dir", + default="_build", + type=click.Path(), + help="Build output directory.", +) +@click.option( + "--skip-build", + is_flag=True, + default=False, + help="Only generate configs and SDK -- skip cmake build.", +) +@click.pass_obj +def pipeline(log: Logger, board: str, hardware: Optional[str], + build_dir: str, skip_build: bool) -> None: + """Run the full end-to-end build pipeline for a target board. + + Chains: analyze hardware -> generate configs -> generate eboot integration -> + generate SDK -> cmake build -> pack firmware image. + + Examples:\n + ebuild pipeline --board stm32f4\n + ebuild pipeline --board stm32f4 --hardware board.kicad_sch\n + ebuild pipeline --board nrf52 --skip-build + """ + log.header("ebuild — Full Pipeline") + + build_path = Path(build_dir) + + try: + profile, config_outputs, boot_outputs = _run_pipeline_steps( + board=board, + hardware=hardware, + build_dir=build_path, + log=log, + ) + + if skip_build: + log.info("--skip-build: skipping cmake build and image generation.") + else: + source_dir = Path(".") + if (source_dir / "CMakeLists.txt").exists(): + _run_cmake_build(profile, board, source_dir, build_path, log) + else: + log.info("No CMakeLists.txt found -- skipping cmake build step.") + + _generate_image(board, build_path, log) + + # Summary + log.header("Pipeline Summary") + configs_dir = build_path / "configs" + sdk_dir = build_path / "sdk" + images_dir = build_path / "images" + rootfs_dir = build_path / "rootfs" + if configs_dir.exists(): + config_files = list(configs_dir.iterdir()) + log.info(" Configs: " + str(len(config_files)) + " files in " + str(configs_dir)) + for f in sorted(config_files): + log.info(" " + f.name) + if sdk_dir.exists(): + sdk_subdirs = [d for d in sdk_dir.iterdir() if d.is_dir()] + log.info(" SDK: " + str(len(sdk_subdirs)) + " target(s) in " + str(sdk_dir)) + if images_dir.exists(): + image_files = list(images_dir.iterdir()) + log.info(" Images: " + str(len(image_files)) + " file(s) in " + str(images_dir)) + for f in sorted(image_files): + size_kb = f.stat().st_size // 1024 + size_str = str(size_kb // 1024) + " MB" if size_kb > 1024 else str(size_kb) + " KB" + log.info(" " + f.name + " (" + size_str + ")") + if rootfs_dir.exists(): + rootfs_dirs = [d for d in rootfs_dir.iterdir() if d.is_dir()] + log.info(" Rootfs: " + str(len(rootfs_dirs)) + " directories in " + str(rootfs_dir)) + + log.success("Pipeline completed successfully.") + + except FileNotFoundError as e: + log.error(str(e)) + raise SystemExit(1) + except SystemExit: + raise + except Exception as e: + log.error("Pipeline failed: " + str(e)) + raise SystemExit(1) + + +@cli.command() +@click.option( + "--build-dir", + default="_build", + type=click.Path(), + help="Build output directory to remove.", +) +@click.pass_obj +def clean(log: Logger, build_dir: str) -> None: + """Remove the build output directory.""" + log.header("ebuild — Clean") + build_path = Path(build_dir) + + if build_path.exists(): + shutil.rmtree(build_path) + log.success(f"Removed {build_path}/") + else: + log.info(f"Nothing to clean — {build_path}/ does not exist.") + + +@cli.command() +@click.option( + "--config", "config_path", + default="build.yaml", + type=click.Path(exists=False), + help="Path to the build configuration file.", +) +@click.option( + "--build-dir", + default="_build", + type=click.Path(), + help="Build output directory.", +) +@click.option( + "--backend", + default=None, + type=click.Choice(["auto", "cmake", "make", "meson", "cargo", "ninja", "kbuild"]), + help="Force a specific build backend.", +) +@click.option( + "--board", + default=None, + help="Target board name (e.g., stm32f4, nrf52). Recorded in the project " + "config so later `ebuild build` / `flash` / `monitor` use it.", +) +@click.pass_obj +def configure(log: Logger, config_path: str, build_dir: str, backend: Optional[str], + board: Optional[str]) -> None: + """Generate build files without building.""" + log.header("ebuild — Configure") + + try: + if board: + _record_board_selection(board, log) + + log.step("Loading configuration...") + cfg = load_config(config_path) + log.info(f"Project: {cfg.name} v{cfg.version}") + + build_path = _resolve_build_dir(build_dir, cfg) + resolved_backend, backend_config = _resolve_backend_request( + cfg=cfg, + backend_override=backend, + source_dir=cfg.source_dir, + log=log, + ) + + if resolved_backend == "ninja": + _configure_ninja_backend(cfg, build_path, log) + return + + _configure_external_backend( + cfg=cfg, + resolved_backend=resolved_backend, + backend_config=backend_config, + build_path=build_path, + log=log, + ) + + except FileNotFoundError as e: + log.error(_format_missing_tool(e)) + raise SystemExit(1) + except subprocess.CalledProcessError as e: + log.error(_format_subprocess_failure(e)) + raise SystemExit(1) + except (ConfigError, RecipeError) as e: + log.error(f"Configuration error: {e}") + raise SystemExit(1) + except (CycleError, ResolveError, FetchError, BuildError) as e: + log.error(f"Error: {e}") + raise SystemExit(1) + + +@cli.command() +@click.option( + "--config", "config_path", + default="build.yaml", + type=click.Path(exists=False), + help="Path to the build configuration file.", +) +@click.pass_obj +def info(log: Logger, config_path: str) -> None: + """Show project info, targets, packages, and dependency graph.""" + log.header("ebuild — Project Info") + + try: + cfg = load_config(config_path) + + log.info(f"Project : {cfg.name}") + log.info(f"Version : {cfg.version}") + log.info(f"Source : {cfg.source_dir.resolve()}") + + if cfg.toolchain: + tc = cfg.toolchain + log.info(f"Compiler: {tc.compiler} (arch: {tc.arch})") + if tc.prefix: + log.info(f"Prefix : {tc.prefix}") + else: + log.info("Compiler: gcc (native)") + + if cfg.packages: + log.header("Packages") + for p in cfg.packages: + ver = f" v{p.version}" if p.version else "" + log.step(f"{p.name}{ver}") + + log.header("Targets") + for t in cfg.targets: + deps = f" depends=[{', '.join(t.depends)}]" if t.depends else "" + uses = f" uses=[{', '.join(t.uses)}]" if t.uses else "" + log.step(f"{t.name} ({t.target_type}){deps}{uses}") + if t.sources: + log.debug(f" sources: {t.sources}") + if t.cflags: + log.debug(f" cflags : {t.cflags}") + if t.ldflags: + log.debug(f" ldflags: {t.ldflags}") + + graph = build_dependency_graph(cfg.targets) + build_order = graph.topological_sort() + log.header("Build Order") + for i, name in enumerate(build_order, 1): + log.step(f"{i}. {name}") + + except FileNotFoundError as e: + log.error(str(e)) + raise SystemExit(1) + except ConfigError as e: + log.error(f"Configuration error: {e}") + raise SystemExit(1) + except CycleError as e: + log.error(f"Dependency error: {e}") + raise SystemExit(1) + + +@cli.command() +@click.option( + "--config", "config_path", + default="build.yaml", + type=click.Path(exists=False), + help="Path to the build configuration file.", +) +@click.option( + "--build-dir", + default="_build", + type=click.Path(), + help="Build output directory.", +) +@click.pass_obj +def install(log: Logger, config_path: str, build_dir: str) -> None: + """Resolve, fetch, and build all declared packages.""" + log.header("ebuild — Install Packages") + + try: + cfg = load_config(config_path) + log.info(f"Project: {cfg.name} v{cfg.version}") + + if not cfg.packages: + log.info("No packages declared in build.yaml.") + return + + build_path = _resolve_build_dir(build_dir, cfg) + _install_packages(cfg, build_path, log, verbose=log.verbose) + log.success("All packages installed successfully.") + + except FileNotFoundError as e: + log.error(str(e)) + raise SystemExit(1) + except (ConfigError, RecipeError) as e: + log.error(f"Configuration error: {e}") + raise SystemExit(1) + except (ResolveError, FetchError, BuildError) as e: + log.error(f"Package error: {e}") + raise SystemExit(1) + + +def _no_recipe_message(name: str, registry) -> str: + """Say what is available, and what the developer probably meant. + + "No recipe found" on its own leaves them guessing at the spelling, at + whether the package exists under another name, and at where recipes even + come from. + """ + import difflib + + try: + available = sorted({r.name for r in registry.list_packages()}) + except Exception: + available = [] + + lines = [f"No recipe for '{name}'."] + close = difflib.get_close_matches(name, available, n=3, cutoff=0.6) + if close: + lines.append(" Did you mean: " + ", ".join(close) + "?") + if available: + lines.append(" Available: " + ", ".join(available)) + else: + lines.append(" No recipes are visible from here — is this a project " + "directory with a recipes/ folder?") + lines.append(f" To add it anyway: ebuild add {name} --force") + return "\n".join(lines) + + +@cli.command("add") +@click.argument("package_name") +@click.option("--version", "pkg_version", default=None, help="Package version to add.") +@click.option( + "--config", "config_path", + default="build.yaml", + type=click.Path(exists=False), + help="Path to the build configuration file.", +) +@click.option( + "--force", is_flag=True, default=False, + help="Add a package with no recipe. It will not resolve until one exists.", +) +@click.pass_obj +def add_package(log: Logger, package_name: str, pkg_version: Optional[str], + config_path: str, force: bool) -> None: + """Add a package dependency to build.yaml.""" + log.header("ebuild — Add Package") + + config_path_obj = Path(config_path) + if not config_path_obj.exists(): + log.error(f"Config file not found: {config_path}") + raise SystemExit(1) + + # Verify the package exists in registry + recipe_dirs = _find_recipe_dirs(config_path_obj.parent) + if recipe_dirs: + registry = create_registry(*recipe_dirs) + recipe = registry.get(package_name, pkg_version) + if recipe: + log.info(f"Found recipe: {recipe.name} v{recipe.version}") + if pkg_version is None: + pkg_version = recipe.version + elif not force: + # Writing an entry that cannot resolve trades one clear error now + # for a confusing one at build time, in a file the developer has + # since committed. + log.error(_no_recipe_message(package_name, registry)) + raise SystemExit(1) + else: + log.warning( + f"No recipe found for '{package_name}' — added because " + f"--force was given. It will not resolve until a recipe exists." + ) + + # Load and update config + with open(config_path_obj, "r", encoding="utf-8") as f: + raw = yaml.safe_load(f) + + if "packages" not in raw: + raw["packages"] = [] + + # Check for duplicates + for p in raw["packages"]: + if isinstance(p, dict) and p.get("name") == package_name: + log.info(f"Package '{package_name}' already in build.yaml.") + return + + entry: Dict[str, str] = {"name": package_name} + if pkg_version: + entry["version"] = pkg_version + + raw["packages"].append(entry) + + with open(config_path_obj, "w", encoding="utf-8") as f: + yaml.dump(raw, f, default_flow_style=False, sort_keys=False) + + log.success(f"Added {package_name}" + (f" v{pkg_version}" if pkg_version else "") + f" to {config_path}") + + +@cli.command() +@click.option( + "--config", "config_path", + default="build.yaml", + type=click.Path(exists=False), + help="Path to the build configuration file.", +) +@click.option( + "--build-dir", + default="_build", + type=click.Path(), + help="Build output directory.", +) +@click.option( + "--format", "img_format", + default="tar", + type=click.Choice(["raw", "qcow2", "tar", "ext4", "squashfs"]), + help="Output image format.", +) +@click.option( + "--size", "size_mb", + default=256, + type=int, + help="Image size in MB (for raw/ext4).", +) +@click.pass_obj +def system(log: Logger, config_path: str, build_dir: str, img_format: str, size_mb: int) -> None: + """Build a complete Linux system image (rootfs + kernel + image).""" + log.header("ebuild — System Image Build") + + try: + from ebuild.system.rootfs import RootfsBuilder + from ebuild.system.image import ImageBuilder + + build_path = Path(build_dir) + + log.step("Assembling root filesystem...") + rootfs = RootfsBuilder(build_path) + rootfs_dir = rootfs.assemble(init_system="busybox", hostname="eos") + log.success(f"Rootfs assembled: {rootfs_dir}") + + log.step(f"Creating {img_format} image...") + imager = ImageBuilder(build_path, log=log) + image_path = imager.create( + rootfs_dir=rootfs_dir, + image_format=img_format, + image_size_mb=size_mb, + ) + log.success(f"Image created: {image_path}") + + except Exception as e: + log.error(f"System build failed: {e}") + raise SystemExit(1) + + +@cli.command() +@click.option( + "--config", "config_path", + default="build.yaml", + type=click.Path(exists=False), + help="Path to the build configuration file.", +) +@click.option( + "--build-dir", + default="_build", + type=click.Path(), + help="Build output directory.", +) +@click.option( + "--rtos", + default="generic", + type=click.Choice(["zephyr", "freertos", "nuttx", "generic"]), + help="Target RTOS.", +) +@click.option( + "--board", + default="generic", + help="Target board name.", +) +@click.pass_obj +def firmware(log: Logger, config_path: str, build_dir: str, rtos: str, board: str) -> None: + """Build RTOS firmware for an embedded target.""" + log.header("ebuild — Firmware Build") + + try: + from ebuild.firmware.firmware import FirmwareBuilder + + cfg = load_config(config_path) + log.info(f"Project: {cfg.name} v{cfg.version}") + + build_path = Path(build_dir) + builder = FirmwareBuilder(build_path, log=log) + + log.step(f"Building {rtos} firmware for {board}...") + output = builder.build( + source_dir=cfg.source_dir, + rtos=rtos, + board=board, + ) + log.success(f"Firmware built: {output}") + + except FileNotFoundError as e: + log.error(str(e)) + raise SystemExit(1) + except Exception as e: + log.error(f"Firmware build failed: {e}") + raise SystemExit(1) + + +@cli.command() +@click.argument("image", type=click.Path(exists=True)) +@click.option("--tool", default="openocd", + type=click.Choice(["openocd", "pyocd", "nrfjprog", "esptool", "stflash"]), + help="Flash tool to use.") +@click.option("--target", default="stm32f4", help="Target MCU/board.") +@click.option("--address", default="0x08000000", help="Flash base address (hex).") +@click.option("--reset-after", is_flag=True, default=False, help="Reset target after flashing.") +@click.pass_obj +def flash(log: Logger, image: str, tool: str, target: str, address: str, + reset_after: bool) -> None: + """Flash a firmware image to the target device. + + Supports OpenOCD, pyOCD, nrfjprog, esptool, and st-flash. + + Examples: + + ebuild flash firmware.bin --tool openocd --target stm32f4 + + ebuild flash app.bin --tool nrfjprog + + ebuild flash firmware.bin --tool esptool --address 0x10000 + + ebuild flash firmware.bin --tool pyocd --target nrf52840 --reset-after + """ + log.header("ebuild — Flash") + + try: + from ebuild.firmware.flash import flash as do_flash, reset as do_reset, FlashError + + image_path = Path(image) + addr = int(address, 0) + + log.step(f"Flashing {image_path.name} to {target} via {tool}...") + log.info(f" Address: {hex(addr)}") + + do_flash(image_path, tool=tool, target=target, address=addr) + log.success(f"Flash complete: {image_path.name}") + + if reset_after: + log.step("Resetting target...") + do_reset(tool=tool, target=target) + log.success("Target reset.") + + except FlashError as e: + log.error(str(e)) + raise SystemExit(1) + except Exception as e: + log.error(f"Flash failed: {e}") + raise SystemExit(1) + + +@cli.command("list-packages") +@click.option( + "--config", "config_path", + default="build.yaml", + type=click.Path(exists=False), + help="Path to the build configuration file.", +) +@click.pass_obj +def list_packages(log: Logger, config_path: str) -> None: + """List available package recipes and project packages.""" + log.header("ebuild — Package Registry") + + config_path_obj = Path(config_path) + project_dir = config_path_obj.parent if config_path_obj.exists() else Path(".") + + recipe_dirs = _find_recipe_dirs(project_dir) + if not recipe_dirs: + log.warning("No recipe directories found.") + return + + registry = create_registry(*recipe_dirs) + packages = registry.list_packages() + + if not packages: + log.info("No recipes found.") + return + + 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 "" + log.step(f"{recipe.name} v{recipe.version} [{recipe.build_system}]{deps}{desc}") + + # Show project packages if config exists + if config_path_obj.exists(): + try: + cfg = load_config(config_path_obj) + if cfg.packages: + log.header("Project Packages") + for p in cfg.packages: + ver = f" v{p.version}" if p.version else " (latest)" + status = "✓ recipe found" if registry.has(p.name, p.version) else "✗ no recipe" + log.step(f"{p.name}{ver} — {status}") + except (ConfigError, FileNotFoundError): + 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_filter=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}'. Add recipes to './recipes/' or run 'ebuild update-index --url '.") + else: + log.info("No packages found. Add recipes to './recipes/' or run 'ebuild update-index --url '.") + 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.""" + import json + from ebuild.packages.index_sync import IndexSyncManager, IndexSyncError + + log.header("ebuild — Update Package Index") + sync_mgr = IndexSyncManager() + + prev_sha256 = None + if sync_mgr.meta_json.is_file(): + try: + with open(sync_mgr.meta_json, "r", encoding="utf-8") as mf: + prev_sha256 = json.load(mf).get("sha256") + except Exception: + prev_sha256 = None + + try: + res = sync_mgr.sync(url=index_url, force=force, offline=offline) + msg = res.message + is_fallback = getattr(res, "is_fallback", False) + current_sha256 = getattr(res, "sha256", None) + pruned_count = getattr(res, "pruned", 0) + if not current_sha256 and sync_mgr.meta_json.is_file(): + try: + with open(sync_mgr.meta_json, "r", encoding="utf-8") as mf: + current_sha256 = json.load(mf).get("sha256") + except Exception: + current_sha256 = None + + if is_fallback and not offline: + log.warning(msg) + if current_sha256: + log.info(f"Index SHA-256 digest: {current_sha256}") + log.info(f"Index cache located at: {sync_mgr.index_dir}") + raise SystemExit(1) + log.success(msg) + if current_sha256: + log.info(f"Index SHA-256 digest: {current_sha256}") + if prev_sha256 and prev_sha256 != current_sha256: + log.info(f"Index updated (previous digest: {prev_sha256})") + if pruned_count > 0: + log.info(f"Pruned {pruned_count} stale cached recipe(s)") + 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).") +@click.option("--output-dir", default="_generated", help="Output directory for generated configs.") +@click.option("--eos-schemas", default=None, help="Path to eos/schemas/ for hardware vocabulary.") +@click.option("--llm", "use_llm", is_flag=True, default=False, help="Enable LLM-enhanced analysis (Ollama local or OPENAI_API_KEY).") +@click.pass_obj +def analyze(log: Logger, input_text: Optional[str], input_file: Optional[str], + output_dir: str, eos_schemas: Optional[str], use_llm: bool) -> None: + """Analyze hardware design and generate eos + eboot + ebuild configs. + + Accepts text description, KiCad schematic (.kicad_sch), Eagle schematic (.sch), + BOM CSV (.csv), or any text/YAML file. Auto-detects format by file extension. + + Generates board.yaml, boot.yaml, build.yaml, and eos_product_config.h. + + Examples: + + ebuild analyze "nRF52840 BLE sensor with I2C and SPI flash" + + ebuild analyze --file design.kicad_sch + + ebuild analyze --file design.sch + + ebuild analyze --file bom.csv + + ebuild analyze "STM32H7 with CAN Ethernet" --llm + """ + log.header("ebuild — Hardware Analysis") + + try: + from ebuild.eos_ai.eos_hw_analyzer import EosHardwareAnalyzer + from ebuild.eos_ai.eos_config_generator import EosConfigGenerator + from ebuild.eos_ai.eos_validator import EosConfigValidator + from ebuild.eos_ai.eos_boot_integrator import EosBootIntegrator + + interpreter = EosHardwareAnalyzer(eos_schemas_path=eos_schemas) + + if input_file: + path = Path(input_file) + log.step(f"Reading hardware design: {path}") + profile = interpreter.interpret_file(str(path)) + elif input_text: + log.step("Analyzing text description...") + profile = interpreter.interpret_text(input_text) + else: + log.error("Provide hardware description text or --file ") + raise SystemExit(1) + + log.info(f"MCU: {profile.mcu or '(unknown)'} ({profile.core})") + log.info(f"Arch: {profile.arch or '(unknown)'}") + log.info(f"Peripherals: {len(profile.peripherals)} detected") + for p in profile.peripherals: + extra = "" + if p.config.get("i2c_addr"): + extra = f" (I2C addr: {p.config['i2c_addr']})" + log.info(f" - {p.peripheral_type}: {p.name}{extra}") + log.info(f"Confidence: {profile.confidence:.0%}") + + # Optional LLM-enhanced analysis + if use_llm: + log.step("Running LLM-enhanced analysis...") + llm_info = interpreter.llm_client.get_provider_info() + log.info(f" Provider: {llm_info}") + if interpreter.llm_client.is_available(): + profile = interpreter.analyze_with_llm(profile) + log.success(" LLM analysis complete") + else: + log.warning(" No LLM available. Install Ollama or set OPENAI_API_KEY.") + + log.step("Generating configs...") + generator = EosConfigGenerator(output_dir) + outputs = generator.generate_all(profile) + + for name, path in outputs.items(): + log.success(f" {name}: {path}") + + log.step("Validating generated configs...") + validator = EosConfigValidator() + result = validator.validate_all(output_dir) + log.info(result.summary()) + + log.step("Generating eboot integration files...") + integrator = EosBootIntegrator(output_dir) + boot_outputs = integrator.generate_from_boot_yaml(str(outputs["boot"])) + for name, path in boot_outputs.items(): + log.success(f" {name}: {path}") + + prompt = interpreter.generate_prompt(profile) + prompt_path = Path(output_dir) / "llm_prompt.txt" + prompt_path.write_text(prompt) + log.info(f"LLM prompt saved: {prompt_path}") + + log.success("Analysis complete.") + + except Exception as e: + log.error(f"Analysis failed: {e}") + raise SystemExit(1) + + +@cli.command("generate-project") +@click.option("--text", "input_text", default=None, help="Hardware description text.") +@click.option("--file", "input_file", type=click.Path(exists=True), help="Hardware design file (YAML, KiCad, BOM).") +@click.option("--config", "config_yaml", type=click.Path(exists=True), help="Existing board.yaml from ebuild analyze.") +@click.option("--eos-repo", type=click.Path(exists=True), default=None, help="Path to local eos repo. Auto-clones from GitHub if omitted.") +@click.option("--eboot-repo", type=click.Path(exists=True), default=None, help="Path to local eboot repo. Auto-clones from GitHub if omitted.") +@click.option("--eos-url", default=None, help="Git URL for eos repo (overrides default GitHub URL).") +@click.option("--eboot-url", default=None, help="Git URL for eboot repo (overrides default GitHub URL).") +@click.option("--clone-dir", default=None, type=click.Path(), help="Directory to clone repos into. Uses temp dir if omitted.") +@click.option("--output", default="_project", help="Output directory (copy mode).") +@click.option("--mode", type=click.Choice(["copy", "branch"]), default="copy", help="Output mode.") +@click.option("--branch", default=None, help="Git branch name (branch mode only).") +@click.option("--eos-schemas", default=None, help="Path to eos/schemas/ for hardware vocabulary.") +@click.pass_obj +def generate_project( + log: Logger, + input_text: Optional[str], + input_file: Optional[str], + config_yaml: Optional[str], + eos_repo: Optional[str], + eboot_repo: Optional[str], + eos_url: Optional[str], + eboot_url: Optional[str], + clone_dir: Optional[str], + output: str, + mode: str, + branch: Optional[str], + eos_schemas: Optional[str], +) -> None: + """Generate a stripped-down eos/eboot project for specific hardware. + + Analyzes hardware requirements and prunes the full eos and eboot + repositories to only the modules needed for the target hardware. + Auto-clones eos and eboot from GitHub when local repo paths are not given. + + Examples: + + # Auto-clone from GitHub — no local repos needed: + ebuild generate-project --text "nRF52 BLE sensor with I2C and SPI" \\ + --output customer-ble-sensor + + # With local repos: + ebuild generate-project --text "nRF52 BLE sensor with I2C and SPI" \\ + --eos-repo ../eos --eboot-repo ../eboot --output customer-ble-sensor + + # From existing hardware analysis: + ebuild generate-project --config _generated/board.yaml \\ + --output gateway-project + + # Custom GitHub fork: + ebuild generate-project --text "STM32H7 industrial controller" \\ + --eos-url https://github.com/myorg/eos.git \\ + --eboot-url https://github.com/myorg/eboot.git \\ + --output industrial-project + + # Branch mode on local repos: + ebuild generate-project --config _generated/board.yaml \\ + --eos-repo ../eos --eboot-repo ../eboot \\ + --mode branch --branch customer/ble-sensor + """ + log.header("ebuild — Project Generator") + + try: + from ebuild.eos_ai.eos_hw_analyzer import EosHardwareAnalyzer + from ebuild.eos_ai.eos_project_generator import EosProjectGenerator + + # Step 1: Obtain a HardwareProfile + if config_yaml: + log.step(f"Loading hardware profile from {config_yaml}...") + profile = _load_profile_from_board_yaml(config_yaml) + elif input_file: + log.step(f"Analyzing hardware design: {input_file}...") + analyzer = EosHardwareAnalyzer(eos_schemas_path=eos_schemas) + path = Path(input_file) + if path.suffix == ".kicad_sch": + profile = analyzer.interpret_kicad(str(path)) + else: + content = path.read_text(encoding="utf-8", errors="replace") + if "," in content and len(content.split("\n")) > 2: + profile = analyzer.interpret_bom(content) + else: + profile = analyzer.interpret_text(content) + elif input_text: + log.step("Analyzing text description...") + analyzer = EosHardwareAnalyzer(eos_schemas_path=eos_schemas) + profile = analyzer.interpret_text(input_text) + else: + log.error("Provide hardware description via --text, --file, or --config.") + raise SystemExit(1) + + log.info(f"MCU: {profile.mcu or '(unknown)'} ({profile.core})") + log.info(f"Arch: {profile.arch or '(unknown)'}") + log.info(f"Peripherals: {len(profile.peripherals)} detected") + + # Step 2: Create generator and auto-clone repos if needed + generator = EosProjectGenerator( + eos_repo=eos_repo, + eboot_repo=eboot_repo, + eos_url=eos_url, + eboot_url=eboot_url, + ) + + if not eos_repo or not eboot_repo: + log.step("Cloning repos from GitHub (repos not provided locally)...") + generator.ensure_repos( + need_eos=(eos_repo is None), + need_eboot=(eboot_repo is None), + clone_dir=clone_dir, + ) + if generator.eos_repo and not eos_repo: + log.info(f" eos cloned to: {generator.eos_repo}") + if generator.eboot_repo and not eboot_repo: + log.info(f" eboot cloned to: {generator.eboot_repo}") + + manifest = generator.resolve_manifest(profile) + log.info(f"eos modules: {len(manifest.eos_dirs)} dirs, product={manifest.eos_product}") + log.info(f"eboot modules: {len(manifest.eboot_files)} core files, board={manifest.eboot_board}") + if manifest.eos_toolchain: + log.info(f"eos toolchain: {manifest.eos_toolchain}") + if manifest.eos_examples: + log.info(f"eos examples: {', '.join(manifest.eos_examples)}") + log.info(f"eos extras: {', '.join(manifest.eos_extras)}") + log.info(f"eboot extras: {', '.join(manifest.eboot_extras)}") + + log.step(f"Generating project ({mode} mode)...") + outputs = generator.generate( + profile=profile, + output=output, + mode=mode, + branch=branch, + ) + + for name, path in outputs.items(): + log.success(f" {name}: {path}") + + log.success("Project generation complete.") + + except SystemExit: + raise + except Exception as e: + log.error(f"Project generation failed: {e}") + raise SystemExit(1) + + +def _load_profile_from_board_yaml(board_yaml_path: str) -> "HardwareProfile": + """Load a HardwareProfile from a board.yaml produced by ``ebuild analyze``.""" + from ebuild.eos_ai.eos_hw_analyzer import ( + HardwareProfile, + PeripheralInfo, + ) + + path = Path(board_yaml_path) + data = yaml.safe_load(path.read_text()) + board = data.get("board", data) + + profile = HardwareProfile( + mcu=board.get("mcu", ""), + mcu_family=board.get("family", ""), + arch=board.get("arch", ""), + core=board.get("core", ""), + vendor=board.get("vendor", ""), + clock_hz=board.get("clock_hz", 0), + flash_size=board.get("memory", {}).get("flash", 0), + ram_size=board.get("memory", {}).get("ram", 0), + features=board.get("features", []), + ) + + for p in board.get("peripherals", []): + profile.peripherals.append(PeripheralInfo( + name=p.get("name", ""), + peripheral_type=p.get("type", ""), + bus=p.get("bus", ""), + )) + + return profile + + +@cli.command("new") +@click.argument("project_name") +@click.option( + "--template", "template_name", + default="bare-metal", + type=click.Choice(["bare-metal", "ble-sensor", "rtos-app", "linux-app", "secure-boot", "safety-critical"]), + help="Project template to use.", +) +@click.option( + "--board", "board_name", + default="generic", + help="Target board name (e.g., nrf52, stm32h7, rpi4, generic).", +) +@click.option( + "--output-dir", + default=None, + type=click.Path(), + help="Parent directory for the new project. Defaults to current directory.", +) +@click.pass_obj +def new(log: Logger, project_name: str, template_name: str, board_name: str, + output_dir: Optional[str]) -> None: + """Scaffold a new EoS project from a template. + + Creates a ready-to-build project directory with src/main.c, build.yaml, + eos.yaml, and README.md pre-configured for the selected template and board. + + Examples: + + ebuild new my-sensor --template ble-sensor --board nrf52 + + ebuild new my-controller --template rtos-app --board stm32h7 + + ebuild new my-app --template bare-metal + + ebuild new my-gateway --template linux-app --board rpi4 + """ + log.header("ebuild — New Project") + + # Resolve template directory + templates_dir = Path(__file__).resolve().parent.parent.parent / "templates" + template_dir = templates_dir / template_name + + if not template_dir.is_dir(): + log.error(f"Template '{template_name}' not found at {templates_dir}") + log.info(f"Available templates: {', '.join(t.name for t in templates_dir.iterdir() if t.is_dir())}") + raise SystemExit(1) + + # Resolve output directory + parent = Path(output_dir) if output_dir else Path(".") + project_dir = parent / project_name + + if project_dir.exists(): + log.error(f"Directory already exists: {project_dir}") + raise SystemExit(1) + + # Board → arch/toolchain mapping + board_map = { + "nrf52": {"arch": "arm", "core": "cortex-m4f", "toolchain": "arm-none-eabi", "vendor": "nordic"}, + "nrf52840": {"arch": "arm", "core": "cortex-m4f", "toolchain": "arm-none-eabi", "vendor": "nordic"}, + "stm32h7": {"arch": "arm", "core": "cortex-m7", "toolchain": "arm-none-eabi", "vendor": "st"}, + "stm32f4": {"arch": "arm", "core": "cortex-m4f", "toolchain": "arm-none-eabi", "vendor": "st"}, + "rpi4": {"arch": "arm64", "core": "cortex-a72", "toolchain": "aarch64-linux-gnu", "vendor": "broadcom"}, + "esp32": {"arch": "xtensa", "core": "lx6", "toolchain": "xtensa-esp32-elf", "vendor": "espressif"}, + "rp2040": {"arch": "arm", "core": "cortex-m0+", "toolchain": "arm-none-eabi", "vendor": "raspberrypi"}, + "tms570": {"arch": "arm", "core": "cortex-r5f", "toolchain": "arm-none-eabi", "vendor": "ti"}, + "am64x": {"arch": "hybrid", "core": "cortex-a53+r5f", "toolchain": "aarch64-linux-gnu", "vendor": "ti"}, + "generic": {"arch": "host", "core": "host", "toolchain": "host", "vendor": "generic"}, + } + board_info = board_map.get(board_name, board_map["generic"]) + + log.step(f"Creating project '{project_name}' from '{template_name}' template...") + log.info(f"Board: {board_name} (arch={board_info['arch']}, core={board_info['core']})") + + # Create project directory structure + src_dir = project_dir / "src" + src_dir.mkdir(parents=True) + + # Template variable substitution + replacements = { + "{{PROJECT_NAME}}": project_name, + "{{BOARD_NAME}}": board_name, + "{{ARCH}}": board_info["arch"], + "{{CORE}}": board_info["core"], + "{{TOOLCHAIN}}": board_info["toolchain"], + "{{VENDOR}}": board_info["vendor"], + "{{TEMPLATE}}": template_name, + } + + # Copy and process template files + file_mapping = { + "main.c.template": src_dir / "main.c", + "build.yaml.template": project_dir / "build.yaml", + "eos.yaml.template": project_dir / "eos.yaml", + "README.md.template": project_dir / "README.md", + } + + for template_file, output_path in file_mapping.items(): + src_path = template_dir / template_file + if not src_path.exists(): + log.warning(f"Template file missing: {template_file}") + continue + + content = src_path.read_text(encoding="utf-8") + for key, val in replacements.items(): + content = content.replace(key, val) + + output_path.write_text(content, encoding="utf-8") + log.success(f" {output_path.relative_to(parent)}") + + log.success(f"\nProject created: {project_dir}") + log.info("\nNext steps:") + log.info(f" cd {project_name}") + log.info(" ebuild build") + + +@cli.command("generate-boot") +@click.argument("boot_yaml", type=click.Path(exists=True)) +@click.option("--output-dir", default="_generated", help="Output directory.") +@click.pass_obj +def generate_boot(log: Logger, boot_yaml: str, output_dir: str) -> None: + """Generate eboot C headers, linker scripts, and pack scripts from boot.yaml.""" + log.header("ebuild — eboot Config Generation") + + try: + from ebuild.eos_ai.eos_boot_integrator import EosBootIntegrator + from ebuild.eos_ai.eos_validator import EosConfigValidator + + log.step(f"Validating {boot_yaml}...") + validator = EosConfigValidator() + result = validator.validate_boot(boot_yaml) + log.info(result.summary()) + + if not result.valid: + log.error("Boot config validation failed. Fix errors before generating.") + raise SystemExit(1) + + log.step("Generating eboot build inputs...") + integrator = EosBootIntegrator(output_dir) + outputs = integrator.generate_from_boot_yaml(boot_yaml) + + for name, path in outputs.items(): + log.success(f" {name}: {path}") + + log.success("eboot configs generated successfully.") + + except SystemExit: + raise + except Exception as e: + log.error(f"Generation failed: {e}") + raise SystemExit(1) + + +# ═══════════════════════════════════════════════════════════════ +# Dependency management commands +# ═══════════════════════════════════════════════════════════════ + +@cli.command() +@click.option("--eos-url", default=None, help="Git URL for eos repo (overrides default).") +@click.option("--eboot-url", default=None, help="Git URL for eboot repo (overrides default).") +@click.option("--eos-branch", default=None, help="Branch/tag for eos repo.") +@click.option("--eboot-branch", default=None, help="Branch/tag for eboot repo.") +@click.option("--eos-path", default=None, type=click.Path(exists=True), help="Link to local eos repo (no clone).") +@click.option("--eboot-path", default=None, type=click.Path(exists=True), help="Link to local eboot repo (no clone).") +@click.pass_obj +def setup( + log: Logger, + eos_url: Optional[str], + eboot_url: Optional[str], + eos_branch: Optional[str], + eboot_branch: Optional[str], + eos_path: Optional[str], + eboot_path: Optional[str], +) -> None: + """Clone eos + eboot repos to the local cache (~/.ebuild/repos/). + + On first run this clones both repos with default settings. + Use flags to override URLs, branches, or link to local repos. + + Examples: + + ebuild setup + + ebuild setup --eos-url https://github.com/myfork/eos.git + + ebuild setup --eboot-branch v0.2.0 + + ebuild setup --eos-path /path/to/local/eos + """ + from ebuild.deps.manager import DepsManager + + log.header("ebuild — Setup") + mgr = DepsManager() + + try: + log.step("Setting up eos...") + eos_dir = mgr.setup("eos", url=eos_url, branch=eos_branch, path=eos_path) + log.success(f" eos: {eos_dir}") + + log.step("Setting up eboot...") + eboot_dir = mgr.setup("eboot", url=eboot_url, branch=eboot_branch, path=eboot_path) + log.success(f" eboot: {eboot_dir}") + + log.success("Setup complete. Repos are ready.") + except Exception as e: + log.error(f"Setup failed: {e}") + raise SystemExit(1) + + +@cli.group() +@click.pass_context +def repos(ctx: click.Context) -> None: + """Manage cached eos/eboot repositories.""" + pass + + +@repos.command("status") +@click.pass_obj +def repos_status(log: Logger) -> None: + """Show all repos, URLs, branches, and paths.""" + from ebuild.deps.manager import DepsManager + + log.header("ebuild — Repo Status") + mgr = DepsManager() + entries = mgr.status() + + for info in entries: + log.step(f"{info['name']}") + log.info(f" URL: {info['url']}") + log.info(f" Branch: {info['branch']}") + if info.get("config_path"): + log.info(f" Linked: {info['config_path']}") + if info.get("cached"): + log.info(f" Cached: {info['cache_location']}") + log.info(f" Git: {info.get('git_branch', '?')} @ {info.get('git_commit', '?')}") + else: + log.info(" Cached: no") + + +@repos.command("update") +@click.argument("repo_name", required=False, default=None) +@click.pass_obj +def repos_update(log: Logger, repo_name: Optional[str]) -> None: + """Git pull latest for one or all repos.""" + from ebuild.deps.manager import DepsManager + + log.header("ebuild — Repo Update") + mgr = DepsManager() + results = mgr.update(repo_name) + + for name, result in results.items(): + if "updated" in result: + log.success(f" {name}: {result}") + elif "failed" in result: + log.error(f" {name}: {result}") + else: + log.info(f" {name}: {result}") + + +@repos.command("set-url") +@click.argument("repo_name") +@click.argument("url") +@click.pass_obj +def repos_set_url(log: Logger, repo_name: str, url: str) -> None: + """Change the git URL for a repo.""" + from ebuild.deps.manager import DepsManager + + mgr = DepsManager() + mgr.set_url(repo_name, url) + log.success(f"Set {repo_name} URL to {url}") + + +@repos.command("set-branch") +@click.argument("repo_name") +@click.argument("branch") +@click.pass_obj +def repos_set_branch(log: Logger, repo_name: str, branch: str) -> None: + """Change the branch/tag for a repo.""" + from ebuild.deps.manager import DepsManager + + mgr = DepsManager() + mgr.set_branch(repo_name, branch) + log.success(f"Set {repo_name} branch to {branch}") + + +@repos.command("link") +@click.argument("repo_name") +@click.argument("local_path", type=click.Path(exists=True)) +@click.pass_obj +def repos_link(log: Logger, repo_name: str, local_path: str) -> None: + """Link a repo to a local directory (no clone).""" + from ebuild.deps.manager import DepsManager + + mgr = DepsManager() + mgr.link(repo_name, local_path) + log.success(f"Linked {repo_name} → {Path(local_path).resolve()}") + + +@repos.command("unlink") +@click.argument("repo_name") +@click.pass_obj +def repos_unlink(log: Logger, repo_name: str) -> None: + """Remove local path override, reverting to cache.""" + from ebuild.deps.manager import DepsManager + + mgr = DepsManager() + mgr.unlink(repo_name) + log.success(f"Unlinked {repo_name} — will use cached clone.") + + +# ═══════════════════════════════════════════════════════════════ +# Board generation command +# ═══════════════════════════════════════════════════════════════ + +@cli.command("generate-board") +@click.option("--mcu", default=None, help="MCU name (e.g., stm32f407, nrf52840).") +@click.option("--from-kicad", "kicad_file", default=None, type=click.Path(exists=True), help="KiCad schematic (.kicad_sch).") +@click.option("--from-eagle", "eagle_file", default=None, type=click.Path(exists=True), help="Eagle schematic (.sch).") +@click.option("--from-bom", "bom_file", default=None, type=click.Path(exists=True), help="BOM CSV file.") +@click.option("--describe", "description", default=None, help="Text description of hardware.") +@click.option("--product", default=None, help="Product profile for auto-config (e.g., ble-sensor, gateway).") +@click.option("--output", "output_dir", default="_generated", help="Output directory for generated configs.") +@click.option("--eos-schemas", default=None, help="Path to eos/schemas/ for hardware vocabulary.") +@click.pass_obj +def generate_board( + log: Logger, + mcu: Optional[str], + kicad_file: Optional[str], + eagle_file: Optional[str], + bom_file: Optional[str], + description: Optional[str], + product: Optional[str], + output_dir: str, + eos_schemas: Optional[str], +) -> None: + """Generate board/boot/build YAML configs from hardware inputs. + + Accepts an MCU name, KiCad schematic, Eagle schematic, BOM CSV, + or text description. Generates board.yaml, boot.yaml, build.yaml, + eos_product_config.h, and eboot integration files. + + Examples: + + ebuild generate-board --mcu stm32f407 --output ./config/ + + ebuild generate-board --from-kicad design.kicad_sch --output ./config/ + + ebuild generate-board --from-eagle design.sch --output ./config/ + + ebuild generate-board --from-bom parts.csv --output ./config/ + + ebuild generate-board --describe "STM32H743 with CAN, SPI flash" --output ./config/ + + ebuild generate-board --mcu nrf52840 --product ble-sensor --output ./config/ + """ + log.header("ebuild — Board Config Generator") + + try: + from ebuild.eos_ai.eos_hw_analyzer import EosHardwareAnalyzer + from ebuild.eos_ai.eos_config_generator import EosConfigGenerator + from ebuild.eos_ai.eos_validator import EosConfigValidator + from ebuild.eos_ai.eos_boot_integrator import EosBootIntegrator + + analyzer = EosHardwareAnalyzer(eos_schemas_path=eos_schemas) + + # Determine input source + if kicad_file: + log.step(f"Analyzing KiCad schematic: {kicad_file}") + profile = analyzer.interpret_kicad(kicad_file) + elif eagle_file: + log.step(f"Analyzing Eagle schematic: {eagle_file}") + profile = analyzer.interpret_file(eagle_file) + elif bom_file: + log.step(f"Analyzing BOM: {bom_file}") + content = Path(bom_file).read_text(encoding="utf-8", errors="replace") + profile = analyzer.interpret_bom(content) + elif description: + log.step("Analyzing text description...") + profile = analyzer.interpret_text(description) + elif mcu: + log.step(f"Generating config for MCU: {mcu}") + profile = analyzer.interpret_text(mcu) + else: + log.error("Provide --mcu, --from-kicad, --from-eagle, --from-bom, or --describe.") + raise SystemExit(1) + + # Override MCU if explicitly provided alongside another input + if mcu and profile.mcu != mcu: + profile.mcu = mcu + + log.info(f"MCU: {profile.mcu or '(unknown)'} ({profile.core})") + log.info(f"Arch: {profile.arch or '(unknown)'}") + log.info(f"Peripherals: {len(profile.peripherals)} detected") + for p in profile.peripherals: + log.info(f" - {p.peripheral_type}: {p.name}") + + # Generate configs + log.step("Generating board/boot/build configs...") + gen = EosConfigGenerator(output_dir) + outputs = gen.generate_all(profile) + + for name, path in outputs.items(): + log.success(f" {name}: {path}") + + # Validate + log.step("Validating generated configs...") + validator = EosConfigValidator() + val_result = validator.validate_all(output_dir) + log.info(val_result.summary()) + + # Generate eboot integration files + log.step("Generating eboot integration files...") + integrator = EosBootIntegrator(output_dir) + boot_outputs = integrator.generate_from_boot_yaml(str(outputs["boot"])) + for name, path in boot_outputs.items(): + log.success(f" {name}: {path}") + + log.success("Board config generation complete.") + + except SystemExit: + raise + except Exception as e: + log.error(f"Board generation failed: {e}") + raise SystemExit(1) + + +@cli.command() +@click.option( + "--config", + "config_path", + default="build.yaml", + type=click.Path(), + help="Path to the build configuration file.", +) +@click.option( + "--build-dir", + default="_build", + type=click.Path(), + help="Build output directory.", +) +@click.option( + "--filter", + "name_filter", + default=None, + help="Only run tests whose name contains this substring.", +) +@click.pass_obj +def test(log: Logger, config_path: str, build_dir: str, + name_filter: Optional[str]) -> None: + """Build and run the project's tests. + + Step six of the golden path. Delegates to whichever runner the project + already uses -- ctest for a CMake tree, `cargo test`, `meson test`, or + `make test` -- rather than imposing a test framework on the project. + """ + log.header("ebuild — Test") + + build_path = Path(build_dir) + + try: + log.step("Loading configuration...") + cfg = load_config(config_path) + log.info(f"Project: {cfg.name} v{cfg.version}") + except FileNotFoundError: + log.error( + f"No {config_path} here. Run this from a project directory, or " + f"pass --config." + ) + raise SystemExit(1) + except (ConfigError, RecipeError) as e: + log.error(f"Configuration error: {e}") + raise SystemExit(1) + + native = [t for t in cfg.targets if t.target_type == "test"] + if native: + _run_native_tests(cfg, native, build_path, log, name_filter) + return + + runner = _resolve_test_runner(cfg.source_dir, build_path, name_filter) + if runner is None: + log.error( + "No test runner found for this project.\n" + " ebuild test drives the project's own runner. Add one of:\n" + " - CMake with enable_testing() + add_test() -> ctest\n" + " - a 'test' target in the Makefile -> make test\n" + " - Cargo.toml -> cargo test\n" + " - meson.build -> meson test" + ) + raise SystemExit(1) + + name, argv, cwd = runner + log.step(f"Running tests with {name}...") + log.info(" ".join(argv)) + + try: + # 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 " + f"run here." + ) + 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) + + # 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.") + + +@cli.command() +@click.option("--config", "config_path", default="build.yaml", + type=click.Path(), help="Path to the build configuration file.") +@click.option("--build-dir", default="_build", type=click.Path(), + help="Build output directory.") +@click.option("--output", "output_path", default=None, type=click.Path(), + help="Destination .efw path. Defaults to .efw.") +@click.option("--load", "load_addr", default=None, + help="Load address, e.g. 0x08000000.") +@click.option("--entry", "entry_addr", default=None, + help="Entry address, e.g. 0x08000100.") +@click.pass_obj +def package(log: Logger, config_path: str, build_dir: str, + output_path: Optional[str], load_addr: Optional[str], + entry_addr: Optional[str]) -> None: + """Assemble the built artifact into an eFirmware `.efw` image. + + The step §29's development-to-device flow puts between eBuild and the + device. eFirmware implements the format and ships `efwtool`; this drives + it, so a developer does not have to know the tool exists. + """ + from ebuild.build.firmware_image import ( + FirmwareImageError, find_efwtool, missing_tool_message, pack, verify, + ) + from ebuild.deps import EBUILD_REPOS_DIR + + log.header("ebuild — Package") + + try: + cfg = load_config(config_path) + except FileNotFoundError: + log.error(f"No {config_path} here. Run this from a project directory.") + raise SystemExit(1) + except (ConfigError, RecipeError) as e: + log.error(f"Configuration error: {e}") + raise SystemExit(1) + + binaries = [t for t in cfg.targets if t.target_type == "executable"] + if not binaries: + log.error("No executable target in build.yaml — nothing to package.") + raise SystemExit(1) + + artifact = executable_output_path(Path(build_dir), binaries[0].name) + if not artifact.is_file(): + log.error(f"No built artifact at {artifact}. Run 'ebuild build' first.") + raise SystemExit(1) + + efwtool = find_efwtool(Path(EBUILD_REPOS_DIR)) + if efwtool is None: + log.error(missing_tool_message(Path(EBUILD_REPOS_DIR))) + raise SystemExit(1) + + output = Path(output_path or f"{cfg.name}.efw") + log.step(f"Packing {artifact.name} -> {output}") + try: + pack(efwtool, artifact, output, version=cfg.version or "0.0.0", + load_addr=load_addr, entry_addr=entry_addr) + verdict = verify(efwtool, output) + except FirmwareImageError as exc: + log.error(str(exc)) + raise SystemExit(1) + + log.success(f"{output} ({output.stat().st_size} bytes)") + for line in verdict.splitlines(): + log.info(f" {line}") + log.info("") + log.info(f"Inspect it with: {efwtool} inspect {output}") + +@cli.command() +@click.option("--json", "as_json", is_flag=True, + help="Emit the checks as JSON, for CI.") +@click.pass_obj +def doctor(log: Logger, as_json: bool) -> None: + """Diagnose the build environment in one command. + + Reports what is installed, what is missing, and what each missing piece + would cost. Read-only: it names the fix rather than applying it. + + Exits non-zero only for problems that actually stop a build, so a + host-only machine with no cross toolchain still passes. + """ + from ebuild.system.doctor import exit_code, format_report, run_all + + checks = run_all() + + if as_json: + import json as _json + click.echo(_json.dumps( + [{"name": c.name, "status": c.status, + "detail": c.detail, "fix": c.fix} for c in checks], + indent=2, + )) + raise SystemExit(exit_code(checks)) + + log.header("ebuild — Environment") + for line in format_report(checks).splitlines(): + click.echo(line) + raise SystemExit(exit_code(checks)) + + +def _run_native_tests( + cfg: "ProjectConfig", + targets: List[Any], + build_path: Path, + log: Logger, + name_filter: Optional[str], +) -> None: + """Build and run the project's own ``test`` targets. + + A scaffolded project has no CMake tree and no Makefile, so there is no + external runner to delegate to. The test binaries are ordinary ebuild + targets; build them the same way `ebuild build` does, then run each one + and treat a non-zero exit as a failure. + """ + selected = [t for t in targets if not name_filter or name_filter in t.name] + if not selected: + log.error(f"No test target matches --filter {name_filter!r}.") + raise SystemExit(1) + + log.step("Building test targets...") + _configure_ninja_backend(cfg, build_path, log, suggest_build=False) + + from ebuild.build.dispatch import ninja_command + + # Ninja addresses targets by their output path, and `ebuild build` drives + # it with -f from the project root, so the same form is used here. The + # path must include the platform suffix: on Windows the edge is + # ``.exe``, and asking ninja to build ```` is an unknown + # target. + argv = ( + ninja_command() + + ["-f", str(build_path / "build.ninja")] + + [str(executable_output_path(build_path, t.name)) for t in selected] + ) + result = subprocess.run(argv) + if result.returncode != 0: + log.error("Test targets failed to build.") + raise SystemExit(result.returncode) + + failures: List[str] = [] + for target in selected: + binary = executable_output_path(build_path, target.name) + if not binary.is_file(): + log.error(f"{target.name}: built, but no binary at {binary}") + failures.append(target.name) + continue + + log.step(f"Running {target.name}...") + run = subprocess.run([str(binary)]) + if run.returncode == 0: + log.success(f" {target.name}: passed") + else: + log.error(f" {target.name}: exited {run.returncode}") + failures.append(target.name) + + if failures: + log.error(f"{len(failures)} of {len(selected)} test targets failed: " + + ", ".join(failures)) + raise SystemExit(1) + + log.success(f"All {len(selected)} test targets 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 _resolve_test_runner( + source_dir: Path, + build_dir: Path, + name_filter: Optional[str], +) -> Optional[Tuple[str, List[str], Path]]: + """Pick the test runner this project already uses. + + Returns ``(display_name, argv, cwd)``, or None when the project declares + no tests. Ordered so that an explicit CMake test registry wins over a + generic `make test` target in the same tree. + """ + if (build_dir / "CTestTestfile.cmake").is_file(): + argv = ["ctest", "--output-on-failure"] + if name_filter: + argv += ["-R", name_filter] + return "ctest", argv, build_dir + + if (source_dir / "Cargo.toml").is_file(): + argv = ["cargo", "test"] + if name_filter: + argv += [name_filter] + return "cargo test", argv, source_dir + + if (build_dir / "meson-info").is_dir(): + argv = ["meson", "test", "-C", str(build_dir)] + if name_filter: + argv += ["--suite", name_filter] + return "meson test", argv, source_dir + + makefile = next( + (source_dir / n for n in ("Makefile", "makefile", "GNUmakefile") + if (source_dir / n).is_file()), + None, + ) + if makefile is not None: + text = makefile.read_text(encoding="utf-8", errors="replace") + if re.search(r"^test\s*:", text, re.MULTILINE): + return "make test", ["make", "-C", str(source_dir), "test"], source_dir + + return None + + +@cli.command() +@click.option("--port", default=None, + help="Serial device (e.g. /dev/ttyUSB0). Auto-detected if omitted.") +@click.option("--baud", default=115200, type=int, help="Baud rate.") +@click.pass_obj +def monitor(log: Logger, port: Optional[str], baud: int) -> None: + """Attach a serial monitor to the target device. + + Step eight of the golden path -- the step that shows a developer their + first firmware run actually produced output. + """ + log.header("ebuild — Monitor") + log.info(f"Board: {_selected_board()}") + + if port is None: + candidates = _serial_ports() + if not candidates: + log.error( + "No serial device found.\n" + " Looked for /dev/ttyUSB*, /dev/ttyACM*, /dev/tty.usb*.\n" + " Connect the board, or name the device with --port." + ) + raise SystemExit(1) + if len(candidates) > 1: + log.error( + "More than one serial device is connected, so ebuild will not " + "guess which one is the board:\n" + + "\n".join(f" {c}" for c in candidates) + + "\n Choose one with --port." + ) + raise SystemExit(1) + port = candidates[0] + log.info(f"Auto-detected {port}") + + log.step(f"Opening {port} at {baud} baud... (Ctrl-C to exit)") + + try: + import serial # type: ignore[import-untyped] + except ImportError: + log.error( + "pyserial is not installed, so the monitor cannot open the port.\n" + " Install it with: pip install pyserial" + ) + raise SystemExit(1) + + try: + with serial.Serial(port, baud, timeout=0.2) as conn: + while True: + chunk = conn.read(4096) + if chunk: + sys.stdout.write(chunk.decode("utf-8", errors="replace")) + sys.stdout.flush() + except KeyboardInterrupt: + log.info("") + log.success("Monitor closed.") + except Exception as e: + log.error(f"Serial error on {port}: {e}") + raise SystemExit(1) + + +def _serial_ports() -> List[str]: + """Serial devices that look like an attached development board.""" + found: List[str] = [] + for pattern in ("/dev/ttyUSB*", "/dev/ttyACM*", "/dev/tty.usb*"): + found.extend(sorted(glob.glob(pattern))) + return found + + +# ═════════════════════════════════════════════════════════════ +# Integration commands +# ═════════════════════════════════════════════════════════════ +# `integration`, `qemu`, `sdk`, `package` and `models` live in +# ebuild/cli/integration.py and are attached to the group by +# register_commands(). That call used to live only in ebuild/__main__.py, +# which runs for `python -m ebuild` and not for the `ebuild` console script +# that pyproject.toml's [project.scripts] installs on PATH. The five +# commands were therefore missing from the entry point that every user and +# every doc actually invokes. Registering here attaches them to the group +# itself, so both entry points -- and anything that imports `cli` -- see +# the same CLI. +_register_integration_commands(cli) diff --git a/tests/unit/test_footprint.py b/tests/unit/test_footprint.py index c926816..f542f6d 100644 --- a/tests/unit/test_footprint.py +++ b/tests/unit/test_footprint.py @@ -245,12 +245,12 @@ def test_looks_up_the_windows_suffixed_artifact(self, tmp_path, monkeypatch): """ from types import SimpleNamespace - from ebuild.build import ninja_backend + from ebuild.build import layout from ebuild.cli import commands from ebuild.core.config import ProjectConfig, TargetConfig monkeypatch.chdir(tmp_path) - monkeypatch.setattr(ninja_backend, "_exe_suffix", lambda: ".exe") + monkeypatch.setattr(layout, "_exe_suffix", lambda: ".exe") monkeypatch.setattr( "ebuild.build.footprint.find_size_tool", lambda prefix: "/usr/bin/size") diff --git a/tests/unit/test_golden_path_commands.py b/tests/unit/test_golden_path_commands.py index 6231196..951ac26 100644 --- a/tests/unit/test_golden_path_commands.py +++ b/tests/unit/test_golden_path_commands.py @@ -182,12 +182,12 @@ def test_native_runner_asks_ninja_for_the_linked_binary(self, tmp_path, monkeypa """ from types import SimpleNamespace - from ebuild.build import ninja_backend + from ebuild.build import layout from ebuild.build.ninja_backend import NinjaBackend from ebuild.cli import commands from ebuild.core.config import ProjectConfig - monkeypatch.setattr(ninja_backend, "_exe_suffix", lambda: ".exe") + monkeypatch.setattr(layout, "_exe_suffix", lambda: ".exe") cfg = ProjectConfig( name="p", version="1", source_dir=tmp_path, diff --git a/tests/unit/test_ninja_backend.py b/tests/unit/test_ninja_backend.py index c0dd009..3db661d 100644 --- a/tests/unit/test_ninja_backend.py +++ b/tests/unit/test_ninja_backend.py @@ -13,6 +13,7 @@ import pytest +from ebuild.build import layout from ebuild.build.ninja_backend import NinjaBackend, escape_ninja_path from ebuild.build.toolchain import ResolvedToolchain from ebuild.core.config import ProjectConfig, TargetConfig @@ -22,6 +23,13 @@ def _toolchain(): return SimpleNamespace(cc="cc", cxx="c++", ar="ar") +def test_executable_path_is_reexported_for_backend_compatibility(): + """Existing Ninja imports must resolve to the neutral layout helper.""" + from ebuild.build.ninja_backend import executable_output_path as legacy_path + + assert legacy_path is layout.executable_output_path + + class TestNinjaBackendSharedLibrary(unittest.TestCase): """A shared_library target must link with the platform's shared-object flag and get the same -L/-l wiring as executables. Previously it used the diff --git a/tests/unit/test_package_efw.py b/tests/unit/test_package_efw.py index c2eb8ee..2beed72 100644 --- a/tests/unit/test_package_efw.py +++ b/tests/unit/test_package_efw.py @@ -30,7 +30,7 @@ missing_tool_message, pack, ) -from ebuild.build.ninja_backend import executable_output_path +from ebuild.build.layout import executable_output_path from ebuild.cli.commands import cli @@ -273,8 +273,8 @@ def test_it_finds_the_windows_suffixed_artifact(self, tmp_path, monkeypatch): against the pre-fix code, which looked for the unsuffixed name -- on any host the suite runs on. """ - from ebuild.build import ninja_backend - monkeypatch.setattr(ninja_backend, "_exe_suffix", lambda: ".exe") + from ebuild.build import layout + monkeypatch.setattr(layout, "_exe_suffix", lambda: ".exe") tool = _efwtool_that_packs(tmp_path) monkeypatch.chdir(self._project(tmp_path))