diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6c6c262..c665a6c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,15 @@ on: pull_request: branches: [master, main] +# Every other workflow in this repo declares a concurrency group; ci.yml, +# the heaviest one, did not. Pushing twice to a PR left the earlier run +# queued, and both competed for the same scarce windows/macos runners -- +# three superseded runs sat ahead of the current one for over an hour. +# cancel-in-progress because a superseded commit's result is not wanted. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: # ── Test Matrix ─────────────────────────────────────────────────────────── test: @@ -17,7 +26,12 @@ jobs: strategy: matrix: python-version: ["3.10", "3.11", "3.12"] - os: [ubuntu-22.04, macos-13, windows-2022] + # macos-latest, not macos-13: the macos-13 image is retired, so jobs + # requesting it queue forever and never get a runner. Across four runs + # on this branch the ubuntu-22.04 and windows-2022 jobs all started and + # finished while the three macos-13 jobs sat queued for over two hours. + # Every other workflow in this repo already uses macos-latest. + os: [ubuntu-22.04, macos-latest, windows-2022] fail-fast: false steps: @@ -42,8 +56,12 @@ jobs: run: ruff check . --select=E,F,W --ignore=E501 continue-on-error: true + # --exclude: layers/eosuite/ vendors its own tests/ package, so a bare + # `mypy .` sees two modules named "tests" and bails with "Duplicate + # module named 'tests'" before checking anything. continue-on-error hid + # that the type check was doing no work at all. - name: Type check (mypy) - run: mypy . --ignore-missing-imports --no-strict-optional + run: mypy . --ignore-missing-imports --no-strict-optional --exclude '^layers/' continue-on-error: true # Runs the whole tests/ tree. The previous steps ran only tests/unit/ @@ -57,7 +75,13 @@ jobs: # coverage policy already lives in codecov.yml; whether to also # enforce it here is a maintainer decision, so this change leaves # both of those numbers alone. + # + # shell: bash — the matrix includes windows-2022, where the default + # shell is PowerShell and the backslash line continuations below are a + # syntax error ("Missing expression after unary operator '--'"), so the + # Windows leg of this job failed before pytest ever started. - name: Run test suite + shell: bash run: | python -m pytest tests/ -v --tb=short \ --cov=ebuild --cov-report=xml --cov-report=term-missing \ diff --git a/.gitignore b/.gitignore index d8451ed0..549c42d5 100644 --- a/.gitignore +++ b/.gitignore @@ -66,6 +66,4 @@ Desktop.ini build-*/ node_modules/ target/ - -# ebuild build output (the default --build-dir) _build/ diff --git a/core/eos/core/src/graph.c b/core/eos/core/src/graph.c index 72f94aa3..5fac4d05 100644 --- a/core/eos/core/src/graph.c +++ b/core/eos/core/src/graph.c @@ -21,6 +21,7 @@ EosResult eos_graph_add_node(EosGraph *g, const char *name, EosNodeType type, int id = g->node_count; EosNode *n = &g->nodes[id]; strncpy(n->name, name, EOS_MAX_NAME - 1); + n->name[EOS_MAX_NAME-1] = '\0'; n->type = type; n->build_type = build_type; n->status = EOS_NODE_PENDING; diff --git a/core/eos/tests/test_graph.c b/core/eos/tests/test_graph.c index 40a16bc9..a93afc9b 100644 --- a/core/eos/tests/test_graph.c +++ b/core/eos/tests/test_graph.c @@ -52,7 +52,46 @@ static void test_graph_add_nodes(void) { ASSERT(g.node_count == 3, "graph has 3 nodes"); } +static void test_graph_max_length_node_name(void) { + printf("test_graph_max_length_node_name:\n"); + EosGraph g; + eos_graph_init(&g); + + char name[EOS_MAX_NAME]; + + /* Create a name that fills the buffer except for the null terminator. */ + memset(name, 'A', EOS_MAX_NAME - 1); + name[EOS_MAX_NAME - 1] = '\0'; + + /* + * Fill the destination with non-zero data so the test can detect + * a missing null terminator even though eos_graph_init() zeroes + * the graph initially. + */ + memset(g.nodes[0].name, 'X', EOS_MAX_NAME); + + int id; + EosResult r = eos_graph_add_node( + &g, + name, + EOS_NODE_PACKAGE, + EOS_BUILD_CMAKE, + &id + ); + + ASSERT(r == EOS_OK, "maximum-length node name can be added"); + + ASSERT( + g.nodes[id].name[EOS_MAX_NAME - 1] == '\0', + "node name is null terminated" + ); + + ASSERT( + eos_graph_find_node(&g, name) == id, + "maximum-length node name can be found" + ); +} static void test_graph_find_node(void) { printf("test_graph_find_node:\n"); EosGraph g; @@ -168,6 +207,7 @@ int main(void) { test_graph_init(); test_graph_add_nodes(); test_graph_find_node(); + test_graph_max_length_node_name(); test_topological_sort_linear(); test_topological_sort_diamond(); test_cycle_detection(); diff --git a/ebuild/build/dispatch.py b/ebuild/build/dispatch.py index 3cf77d5b..cfc640f1 100644 --- a/ebuild/build/dispatch.py +++ b/ebuild/build/dispatch.py @@ -25,6 +25,30 @@ ALL_BACKENDS = {"cmake", "make", "meson", "cargo", "kbuild", "ninja"} +#: Backends this dispatcher actually drives. "ninja" is ebuild's own backend -- +#: the CLI invokes NinjaBackend directly and never routes it through here. +DISPATCHED_BACKENDS = {"cmake", "make", "meson", "cargo", "kbuild"} + + +class UnknownBackendError(ValueError, RuntimeError): + """Raised when a backend reaches the dispatcher that it cannot drive. + + Subclasses both ValueError and RuntimeError: callers treat an unrecognized + backend name as a bad argument, while the CLI treats a backend it failed to + route (notably "ninja") as a routing failure. Silently doing nothing here is + what made `ebuild build` report "Build completed successfully" without ever + running a compiler. + """ + + +def _unknown_backend(backend: str, step: str) -> UnknownBackendError: + return UnknownBackendError( + f"Unknown build backend '{backend}'. " + f"Supported backends: {', '.join(sorted(DISPATCHED_BACKENDS))}. " + "ebuild's own 'ninja' backend is invoked directly by the CLI and is " + f"not dispatched here, so it cannot be {step} through BackendDispatcher." + ) + def ninja_command(): """Return the argv prefix that runs ninja on this machine. @@ -117,7 +141,7 @@ def configure( dry_run: If True, log commands instead of executing them. Raises: - ValueError: If the backend is not recognized. + UnknownBackendError: If the backend is not one this dispatcher drives. """ config = config or {} self.build_dir.mkdir(parents=True, exist_ok=True) @@ -142,12 +166,7 @@ def configure( pass # No separate configure step else: - raise RuntimeError( - f"BackendDispatcher cannot configure backend '{backend}'. " - "This dispatcher only handles cmake, meson, and cargo " - "(make/kbuild need no configure step). ebuild's own ninja " - "backend is generated and invoked by the CLI, not here." - ) + raise _unknown_backend(backend, "configured") def build( self, @@ -164,7 +183,7 @@ def build( dry_run: If True, log commands instead of executing them. Raises: - ValueError: If the backend is not recognized. + UnknownBackendError: If the backend is not one this dispatcher drives. """ config = config or {} @@ -195,12 +214,7 @@ def build( _run_or_log(cmd, dry_run) else: - raise RuntimeError( - f"BackendDispatcher cannot build backend '{backend}'. " - "Supported here: cargo, cmake, kbuild, make, meson. ebuild's own " - "ninja backend is generated and invoked by the CLI, not through " - "this dispatcher." - ) + raise _unknown_backend(backend, "built") def clean( self, @@ -215,9 +229,7 @@ def clean( dry_run: If True, log commands instead of executing them. Raises: - RuntimeError: If the backend is not recognized. configure() and - build() raise the same type for the same condition, so a - caller can guard all three with one ``except``. + UnknownBackendError: If the backend is not one this dispatcher drives. """ if backend == "cmake": _run_or_log( @@ -251,7 +263,4 @@ def clean( check=False, ) else: - raise RuntimeError( - f"Unknown build backend '{backend}'. " - f"Supported backends: {', '.join(sorted(ALL_BACKENDS))}" - ) + raise _unknown_backend(backend, "cleaned") diff --git a/ebuild/build/firmware_image.py b/ebuild/build/firmware_image.py new file mode 100644 index 00000000..79aa0128 --- /dev/null +++ b/ebuild/build/firmware_image.py @@ -0,0 +1,149 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""Assemble an eFirmware `.efw` image from a built artifact. + +§29's development-to-device flow ends: + + eBuild -> {EoS, eBoot, application} -> eFirmware artifact -> {EoSim, hardware} + +Every piece of that existed except the arrow into eFirmware. The +`embeddedos-org/eFirmware` repository implements the image format and ships +`efwtool` to pack, inspect and verify one; nothing in ebuild referenced it, so +a developer had to know the tool existed, build it themselves, and run it by +hand. + +This drives `efwtool` rather than re-implementing the header in Python. The +header is a packed C struct with a magic, a version, a size and a SHA-256, and +a second implementation of it in another language is a second thing to keep in +step — the exact failure this repository has been repairing all week. If the +format changes, the tool changes with it and this keeps working. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path +from typing import List, Optional + +#: Where `ebuild setup` caches the sibling repositories. +_EFW_REPO = "efirmware" + +#: Built once and reused, inside the clone, so `ebuild setup` stays the only +#: thing that owns ~/.ebuild/repos. +_BUILD_DIRNAME = "_ebuild" + + +class FirmwareImageError(RuntimeError): + """Raised when an image cannot be assembled.""" + + +def find_efwtool(repos_dir: Path) -> Optional[Path]: + """Locate `efwtool`, building the cached eFirmware checkout if needed. + + Returns None when the checkout is absent or cannot be built, so the caller + can say what to run rather than failing with a stack trace. + """ + on_path = shutil.which("efwtool") + if on_path: + return Path(on_path) + + root = Path(repos_dir) / _EFW_REPO + if not (root / "CMakeLists.txt").is_file(): + return None + + build_dir = root / _BUILD_DIRNAME + existing = _first_efwtool(build_dir) + if existing: + return existing + + if shutil.which("cmake") is None: + return None + 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 None + return _first_efwtool(build_dir) + + +def _first_efwtool(build_dir: Path) -> Optional[Path]: + if not build_dir.is_dir(): + return None + for candidate in sorted(build_dir.rglob("efwtool")): + if candidate.is_file() and os.access(candidate, os.X_OK): + return candidate + return None + + +def pack(efwtool: Path, payload: Path, output: Path, *, + version: str = "0.0.0", + load_addr: Optional[str] = None, + entry_addr: Optional[str] = None) -> None: + """Wrap ``payload`` in an eFirmware header, writing ``output``.""" + if not payload.is_file(): + raise FirmwareImageError(f"no artifact to package at {payload}") + + argv: List[str] = [str(efwtool), "pack", str(payload), str(output), + "--version", version] + if load_addr: + argv += ["--load", load_addr] + if entry_addr: + argv += ["--entry", entry_addr] + + proc = _run(argv, f"packing {payload.name}") + if not output.is_file(): + raise FirmwareImageError( + f"efwtool reported success but wrote no image at {output}: " + f"{proc.stdout.strip()[:200]}" + ) + + +def verify(efwtool: Path, image: Path) -> str: + """Re-read the image through efwtool. Returns its verdict line. + + Packing and verifying with the same tool does not prove the format is + right, but it does prove the file on disk parses — which catches a + truncated write, a wrong path, and a payload that never got copied. + """ + return _run([str(efwtool), "verify", str(image)], + f"verifying {image.name}").stdout.strip() + + +def inspect(efwtool: Path, image: Path) -> str: + return _run([str(efwtool), "inspect", str(image)], + f"inspecting {image.name}").stdout.strip() + + +def _run(argv: List[str], what: str) -> subprocess.CompletedProcess: + try: + proc = subprocess.run(argv, capture_output=True, text=True, timeout=300) + except (OSError, subprocess.TimeoutExpired) as exc: + raise FirmwareImageError(f"{what} failed: {exc}") from exc + if proc.returncode != 0: + raise FirmwareImageError( + f"{what} failed: {(proc.stderr or proc.stdout).strip()[:300]}" + ) + return proc + + +def missing_tool_message(repos_dir: Path) -> str: + """What to tell a developer who has no efwtool.""" + root = Path(repos_dir) / _EFW_REPO + if not root.is_dir(): + return ( + "eFirmware is not in the local cache, so no .efw image can be " + "assembled.\n" + " Run 'ebuild setup' to fetch it, or put efwtool on PATH." + ) + return ( + f"eFirmware is cached at {root} but efwtool could not be built.\n" + " Build it by hand with:\n" + f" cmake -S {root} -B {root / _BUILD_DIRNAME} && " + f"cmake --build {root / _BUILD_DIRNAME}" + ) diff --git a/ebuild/build/ninja_backend.py b/ebuild/build/ninja_backend.py index 1282545e..795c7624 100644 --- a/ebuild/build/ninja_backend.py +++ b/ebuild/build/ninja_backend.py @@ -31,12 +31,23 @@ class PackagePaths: "-fno-pie", "-fno-PIE"} -def _shared_flag() -> str: - """The compiler flag that produces a shared object on this platform. +def _ninja_path(path) -> str: + """Escape *path* for use in a Ninja build statement. - macOS links dynamic libraries with -dynamiclib; ELF platforms use -shared. + Ninja splits build statements on unescaped spaces and colons, so a Windows + absolute path writes a drive letter that Ninja reads as the output/rule + separator: + + build C:\\...\\main.o: cc main.c + ^ "expected build command name" + + `$` is escaped first so the escapes introduced below are not re-escaped. + Only build statements need this; variable values (cflags, ldflags) are read + to end of line and must not be escaped, or the flags reach the compiler + mangled. """ - return "-dynamiclib" if sys.platform == "darwin" else "-shared" + text = str(path) + return text.replace("$", "$$").replace(":", "$:").replace(" ", "$ ") class NinjaBackend: @@ -114,19 +125,20 @@ def _resolve_target_cflags(self, target) -> List[str]: return cflags def _object_path(self, target, src: str) -> Path: - """Object file for one source within one target. - - Namespaced by target name: a source shared by two targets must produce - two distinct objects. Ninja rejects two edges writing the same output, - and the targets may compile it with different cflags. - - The source's directory structure is flattened into the filename rather - than mirrored beneath the build directory. Mirroring lets a source from - outside the project -- ``../shared/util.c`` -- place its object outside - the build directory too, where ``clean`` will not find it. + """Object file path for *src* as compiled by *target*. + + Object paths are namespaced by target name. Two targets may legitimately + list the same source: a library and a test binary sharing a helper, or + one source built twice with different defines. Each needs its own + object, because each compiles with its own cflags. Keying only on the + source made both targets claim one output, which ninja rejects with + "multiple rules generate ...". + + Example: + >>> backend._object_path(target, "src/main.c") # target.name == "app" + PosixPath('_build/obj/app/src/main.o') """ - flat = re.sub(r"[^A-Za-z0-9_.-]", "_", str(src).replace("\\", "/")) - return self.build_dir / "obj" / target.name / f"{flat}.o" + return (self.build_dir / "obj" / target.name / src).with_suffix(".o") def _write_ninja(self) -> None: """Write the build.ninja file.""" @@ -147,10 +159,6 @@ def _write_ninja(self) -> None: " command = $cc $ldflags $in -o $out $libs", " description = LINK $out", "", - "rule link_shared", - f" command = $cc {_shared_flag()} $ldflags $in -o $out $libs", - " description = LINK_SHARED $out", - "", "rule ar_rule", " command = $ar rcs $out $in", " description = AR $out", @@ -167,7 +175,7 @@ def _write_ninja(self) -> None: obj = str(self._object_path(target, src)) obj_files.append(obj) lines.append( - f"build {obj}: cc {src}" + f"build {_ninja_path(obj)}: cc {_ninja_path(src)}" ) if cflags: lines.append(f" cflags = {' '.join(cflags)}") @@ -194,7 +202,8 @@ def _write_ninja(self) -> None: link_inputs = obj_files + dep_archives out = str(self.build_dir / target.name) lines.append( - f"build {out}: link {' '.join(link_inputs)}" + f"build {_ninja_path(out)}: link " + f"{' '.join(_ninja_path(i) for i in link_inputs)}" ) if ldflags: lines.append(f" ldflags = {' '.join(ldflags)}") @@ -212,7 +221,10 @@ def _write_ninja(self) -> None: out = str(self.build_dir / f"lib{target.name}{ext}") if target.target_type == "static_library": - lines.append(f"build {out}: ar_rule {' '.join(obj_files)}") + lines.append( + f"build {_ninja_path(out)}: ar_rule " + f"{' '.join(_ninja_path(o) for o in obj_files)}" + ) else: # Shared libraries need the same -L/-l wiring executables # get, which the rule preamble alone does not supply. The @@ -228,7 +240,10 @@ def _write_ninja(self) -> None: for lib in pkg.libraries: libs.append(f"-l{lib}") - lines.append(f"build {out}: link_shared {' '.join(obj_files)}") + lines.append( + f"build {_ninja_path(out)}: link " + f"{' '.join(_ninja_path(o) for o in obj_files)}" + ) if ldflags: lines.append(f" ldflags = {' '.join(ldflags)}") if libs: diff --git a/ebuild/cli/commands.py b/ebuild/cli/commands.py index 8696e216..4366ac3c 100644 --- a/ebuild/cli/commands.py +++ b/ebuild/cli/commands.py @@ -1,2277 +1,2254 @@ -# 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 -from ebuild.build.toolchain import resolve_toolchain -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 -from ebuild.packages.resolver import PackageResolver, ResolveError - - -pass_logger = click.make_pass_decorator(Logger, ensure=True) - -# Default recipe search paths (relative to project root) -_RECIPE_DIRS = ["recipes"] - - -def _find_recipe_dirs(project_dir: Path) -> List[Path]: - """Locate recipe directories: project-local and install-level.""" - dirs = [] - for name in _RECIPE_DIRS: - d = project_dir / name - if d.is_dir(): - dirs.append(d) - - # Also check ebuild install location - pkg_recipes = Path(__file__).resolve().parent.parent.parent / "recipes" - if pkg_recipes.is_dir() and pkg_recipes not in dirs: - dirs.append(pkg_recipes) - - return 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_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}") - - return resolved_backend, backend_config - - -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 _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 = build_path / binaries[0].name - if not artifact.is_file(): - 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 {build_path}/...") - ninja_backend = NinjaBackend(cfg, build_path, compiler, package_paths=package_paths) - ninja_backend.generate() - - log.success(f"Generated {build_path / 'build.ninja'}") - log.success(f"Generated {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) - - -# ═══════════════════════════════════════════════════════════════ -# Board selection — shared by `configure --board` and `monitor` -# --------------------------------------------------------------------------- - -# 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 recorded by ``configure --board``, or ``default``.""" - path = Path(_EOS_PROJECT_CONFIG) - 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 - - -# --------------------------------------------------------------------------- -# 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 - - 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) - log.step("[4/6] Generating SDK...") - target_name = board.lower() - generate_sdk(target_name, str(sdk_dir)) - 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 = Path(build_dir) - 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 {build_path}/...") - ninja_backend = NinjaBackend(cfg, build_path, compiler, package_paths=package_paths) - ninja_backend.generate() - log.success(f"Generated {build_path / 'build.ninja'}") - log.success(f"Generated {build_path / 'compile_commands.json'}") - - log.step("Invoking ninja...") - from ebuild.build.dispatch import ninja_command - ninja_cmd = ninja_command() + ["-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.") - _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 = Path(build_dir) - 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 = Path(build_dir) - _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) - - -@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.pass_obj -def add_package(log: Logger, package_name: str, pkg_version: Optional[str], config_path: str) -> 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 - else: - log.warning(f"No recipe found for '{package_name}' — adding anyway.") - - # 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() -@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) - # A scaffolded project ships a runnable test so that `ebuild test`, step - # six of the golden path, passes on a fresh checkout instead of reporting - # that the project declares no tests. - tests_dir = project_dir / "tests" - tests_dir.mkdir(parents=True, exist_ok=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", - "test_main.c.template": tests_dir / "test_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("--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)) +# 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 os +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 +from ebuild.build.toolchain import resolve_toolchain +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 +from ebuild.packages.resolver import PackageResolver, ResolveError + + +pass_logger = click.make_pass_decorator(Logger, ensure=True) + +# Default recipe search paths (relative to project root) +_RECIPE_DIRS = ["recipes"] + + +def _find_recipe_dirs(project_dir: Path) -> List[Path]: + """Locate recipe directories: project-local and install-level.""" + dirs = [] + for name in _RECIPE_DIRS: + d = project_dir / name + if d.is_dir(): + dirs.append(d) + + # Also check ebuild install location + pkg_recipes = Path(__file__).resolve().parent.parent.parent / "recipes" + if pkg_recipes.is_dir() and pkg_recipes not in dirs: + dirs.append(pkg_recipes) + + return 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 _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_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}") + + return resolved_backend, backend_config + + +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 = build_path / binaries[0].name + if not artifact.is_file(): + 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, +) -> None: + """Generate native ebuild Ninja files for configure-only workflows.""" + log.step("Resolving toolchain...") + compiler = resolve_toolchain(cfg.toolchain) + + package_paths = _install_packages(cfg, build_path, log, verbose=log.verbose) + + log.step(f"Generating build.ninja in {build_path}/...") + ninja_backend = NinjaBackend(cfg, build_path, compiler, package_paths=package_paths) + ninja_backend.generate() + + log.success(f"Generated {build_path / 'build.ninja'}") + log.success(f"Generated {build_path / 'compile_commands.json'}") + 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 + + 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) + log.step("[4/6] Generating SDK...") + target_name = board.lower() + generate_sdk(target_name, str(sdk_dir)) + 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 = Path(build_dir) + 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 = _install_packages(cfg, build_path, log, verbose=log.verbose, jobs=jobs) + + log.step(f"Generating build.ninja in {build_path}/...") + ninja_backend = NinjaBackend(cfg, build_path, compiler, package_paths=package_paths) + ninja_backend.generate() + log.success(f"Generated {build_path / 'build.ninja'}") + log.success(f"Generated {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.pass_obj +def configure(log: Logger, config_path: str, build_dir: str, backend: Optional[str]) -> None: + """Generate build files without building.""" + log.header("ebuild — Configure") + + try: + log.step("Loading configuration...") + cfg = load_config(config_path) + log.info(f"Project: {cfg.name} v{cfg.version}") + + build_path = Path(build_dir) + 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 = Path(build_dir) + _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() +@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("--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)) + + +@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 = 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}") diff --git a/ebuild/core/config.py b/ebuild/core/config.py index 6f95475e..5e398091 100644 --- a/ebuild/core/config.py +++ b/ebuild/core/config.py @@ -305,18 +305,25 @@ def load_config(config_path: str | Path) -> ProjectConfig: # --- packages section (optional, Phase 2) --- packages: List[PackageDep] = [] - raw_packages = raw.get("packages", []) - - if isinstance(raw_packages, list): - for p in raw_packages: - if isinstance(p, dict): - pkg_name = p.get("name", "") - pkg_version = p.get("version") - - if pkg_name: - packages.append( - PackageDep(name=pkg_name, version=pkg_version) - ) + if "packages" in raw: + raw_packages = raw["packages"] + if not isinstance(raw_packages, list): + raise ConfigError( + "'packages' must be a list of package definitions." + ) + for pkg in raw_packages: + if not isinstance(pkg, dict): + raise ConfigError( + "Invalid package definition: expected a YAML mapping, " + f"got {type(pkg).__name__}." + ) + pkg_name = pkg.get("name", "") + pkg_version = pkg.get("version") + if not pkg_name: + raise ConfigError("Package definition must have a 'name' field.") + if pkg_version is not None and not isinstance(pkg_version, str): + pkg_version = str(pkg_version) + packages.append(PackageDep(name=pkg_name, version=pkg_version)) return ProjectConfig( name=project_name, diff --git a/ebuild/deps/__init__.py b/ebuild/deps/__init__.py index 900da51c..c5f72196 100644 --- a/ebuild/deps/__init__.py +++ b/ebuild/deps/__init__.py @@ -1,47 +1,55 @@ -# SPDX-License-Identifier: MIT -# Copyright (c) 2026 EoS Project - -"""ebuild.deps — Dependency management for eos/eboot repositories. - -Auto-creates the ``~/.ebuild/`` directory structure on first import and -provides the default configuration template for ``config.yaml``. -""" - -from __future__ import annotations - -from pathlib import Path - -# Default location for ebuild's persistent state -EBUILD_HOME = Path.home() / ".ebuild" -EBUILD_REPOS_DIR = EBUILD_HOME / "repos" -EBUILD_CONFIG_PATH = EBUILD_HOME / "config.yaml" - -# Default repo URLs (same as eos_project_generator.py) -DEFAULT_EOS_REPO_URL = "https://github.com/embeddedos-org/eos.git" -DEFAULT_EBOOT_REPO_URL = "https://github.com/embeddedos-org/eBoot.git" - -DEFAULT_CONFIG = { - "repos": { - "eos": { - "url": DEFAULT_EOS_REPO_URL, - "branch": "master", - "path": None, - }, - "eboot": { - "url": DEFAULT_EBOOT_REPO_URL, - "branch": "master", - "path": None, - }, - }, - "cache_dir": str(EBUILD_REPOS_DIR), -} - - -def ensure_ebuild_home() -> Path: - """Create ``~/.ebuild/`` and ``~/.ebuild/repos/`` if they don't exist. - - Returns the ``~/.ebuild/`` path. - """ - EBUILD_HOME.mkdir(parents=True, exist_ok=True) - EBUILD_REPOS_DIR.mkdir(parents=True, exist_ok=True) - return EBUILD_HOME +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""ebuild.deps — Dependency management for eos/eboot repositories. + +Auto-creates the ``~/.ebuild/`` directory structure on first import and +provides the default configuration template for ``config.yaml``. +""" + +from __future__ import annotations + +from pathlib import Path + +# Default location for ebuild's persistent state +EBUILD_HOME = Path.home() / ".ebuild" +EBUILD_REPOS_DIR = EBUILD_HOME / "repos" +EBUILD_CONFIG_PATH = EBUILD_HOME / "config.yaml" + +# Default repo URLs (same as eos_project_generator.py) +DEFAULT_EOS_REPO_URL = "https://github.com/embeddedos-org/eos.git" +DEFAULT_EBOOT_REPO_URL = "https://github.com/embeddedos-org/eBoot.git" +DEFAULT_EFIRMWARE_REPO_URL = "https://github.com/embeddedos-org/eFirmware.git" + +DEFAULT_CONFIG = { + "repos": { + "eos": { + "url": DEFAULT_EOS_REPO_URL, + "branch": "main", + "path": None, + }, + "eboot": { + "url": DEFAULT_EBOOT_REPO_URL, + "branch": "main", + "path": None, + }, + # §29 ends the development-to-device flow at an eFirmware artifact. + # `ebuild package` needs efwtool from this checkout to produce one. + "efirmware": { + "url": DEFAULT_EFIRMWARE_REPO_URL, + "branch": "master", + "path": None, + }, + }, + "cache_dir": str(EBUILD_REPOS_DIR), +} + + +def ensure_ebuild_home() -> Path: + """Create ``~/.ebuild/`` and ``~/.ebuild/repos/`` if they don't exist. + + Returns the ``~/.ebuild/`` path. + """ + EBUILD_HOME.mkdir(parents=True, exist_ok=True) + EBUILD_REPOS_DIR.mkdir(parents=True, exist_ok=True) + return EBUILD_HOME diff --git a/ebuild/deps/manager.py b/ebuild/deps/manager.py index 851c186c..7d220c6d 100644 --- a/ebuild/deps/manager.py +++ b/ebuild/deps/manager.py @@ -14,7 +14,7 @@ import subprocess import warnings from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple import yaml @@ -30,6 +30,12 @@ # Known repo names KNOWN_REPOS = ("eos", "eboot") +# Sibling directory names. GitHub and local checkouts often use eBoot. +SIBLING_DIR_NAMES: Dict[str, Tuple[str, ...]] = { + "eos": ("eos",), + "eboot": ("eboot", "eBoot"), +} + # Environment variable names for path overrides ENV_PATH_VARS = { "eos": "EBUILD_EOS_PATH", @@ -46,7 +52,7 @@ class DepsManager: 2. Environment variables ``EBUILD_EOS_PATH`` / ``EBUILD_EBOOT_PATH`` 3. ``~/.ebuild/config.yaml`` custom ``path:`` override 4. ``~/.ebuild/repos//`` (cached git clone) - 5. Sibling directory ``..//`` (workspace layout) + 5. Sibling directory ``..//`` (workspace layout; ``eboot`` also tries ``eBoot``) 6. Embedded ``core//`` (legacy fallback — prints deprecation warning) """ @@ -204,11 +210,13 @@ def get_repo_path( if cached.is_dir(): return cached - # 5. Sibling directory + # 5. Sibling directory (try documented aliases; Linux is case-sensitive) if project_dir: - sibling = project_dir.parent / repo_name - if sibling.is_dir(): - return sibling + names = SIBLING_DIR_NAMES.get(repo_name, (repo_name,)) + for name in names: + sibling = project_dir.parent / name + if sibling.is_dir(): + return sibling # 6. Legacy embedded core// (deprecation warning) if project_dir: diff --git a/ebuild/packages/fetcher.py b/ebuild/packages/fetcher.py index 7c81b370..f04e8f93 100644 --- a/ebuild/packages/fetcher.py +++ b/ebuild/packages/fetcher.py @@ -47,6 +47,16 @@ def fetch(self, recipe: PackageRecipe, extract_to: str | Path) -> Path: Raises: FetchError: If download or verification fails. """ + # PackageRecipe.validate() rejects a recipe without a checksum, but + # fetch() is reachable with a hand-built recipe too, so refuse here as + # well rather than falling through to an unverified extract. + if not recipe.checksum: + raise FetchError( + f"Refusing to fetch {recipe.name} v{recipe.version}: the recipe " + f"carries no checksum, so there is nothing to verify the " + f"download against." + ) + archive_path = self._download(recipe) if recipe.checksum: try: @@ -66,10 +76,10 @@ def _download(self, recipe: PackageRecipe) -> Path: """Download the source archive if not already cached.""" if not recipe.url: raise FetchError(f"No URL specified for package {recipe.name}") - if not recipe.url.startswith(("http://", "https://")): + if not recipe.url.startswith("https://"): raise FetchError( f"Invalid URL scheme for {recipe.name}: {recipe.url} " - f"(only http:// and https:// are allowed)" + f"(only https:// is allowed)" ) archive_path = self._archive_path(recipe) diff --git a/ebuild/packages/recipe.py b/ebuild/packages/recipe.py index ab8e5cbf..71bf2c2e 100644 --- a/ebuild/packages/recipe.py +++ b/ebuild/packages/recipe.py @@ -13,6 +13,8 @@ from pathlib import Path from typing import Any, Dict, List, Optional +import re + import yaml @@ -39,6 +41,9 @@ class PackageRecipe: VALID_BUILD_SYSTEMS = ("cmake", "autoconf", "make", "meson", "custom") + #: A bare SHA-256 digest, with or without the "sha256:" prefix. + _SHA256_RE = re.compile(r"^(?:sha256:)?[0-9a-fA-F]{64}$") + @property def slug(self) -> str: """Unique identifier: name-version.""" @@ -52,6 +57,31 @@ def validate(self) -> None: raise RecipeError(f"Package '{self.name}' must have a 'version' field.") if not self.url: raise RecipeError(f"Package '{self.name}' must have a 'url' field.") + + # A recipe with a checksum is a pin: a URL plus the digest of exactly + # what should be at it. The digest is not required here -- a recipe is + # also used to model packages that are never downloaded -- but a + # checksum that is present has to be a real one. "sha256:placeholder" + # parsed fine and then failed every single fetch with a mismatch, which + # is how two shipped recipes stayed unfetchable. PackageFetcher.fetch() + # separately refuses to download anything with no checksum at all. + if self.checksum and not self._SHA256_RE.match(self.checksum): + raise RecipeError( + f"Package '{self.name}': checksum '{self.checksum}' is not a " + f"sha256 digest. Expected 64 hex characters, optionally " + f"prefixed with 'sha256:'. Placeholder values are rejected — " + f"they turn every fetch of this package into a checksum " + f"mismatch." + ) + + # Plaintext HTTP defeats the pin's purpose in the common case where a + # recipe is edited without recomputing the digest, and it leaks what is + # being built. Every shipped recipe already uses https. + if self.url.startswith("http://"): + raise RecipeError( + f"Package '{self.name}': plaintext http:// is not accepted for " + f"'{self.url}'. Use https://." + ) if self.build_system not in self.VALID_BUILD_SYSTEMS: raise RecipeError( f"Package '{self.name}': invalid build system '{self.build_system}'. " diff --git a/recipes/freertos.yaml b/recipes/freertos.yaml index b2c7179e..2e9fa3a3 100644 --- a/recipes/freertos.yaml +++ b/recipes/freertos.yaml @@ -3,7 +3,7 @@ version: "11.1.0" description: "Real-time operating system kernel for embedded devices" license: MIT url: https://github.com/FreeRTOS/FreeRTOS-Kernel/releases/download/V11.1.0/FreeRTOS-KernelV11.1.0.zip -checksum: sha256:e36e5a2fcef99b83e8adbb8f8d5e4181a42c9ff0b604dc6fa7aad2a2ef0e3140 +checksum: sha256:eebd58aa71a623c9381f25f77b708c0ed14ef995a8913e2460fe9f286bb271eb build: cmake configure_args: - -DFREERTOS_HEAP=4 diff --git a/recipes/littlefs.yaml b/recipes/littlefs.yaml index 1fb5feee..bbbd456f 100644 --- a/recipes/littlefs.yaml +++ b/recipes/littlefs.yaml @@ -3,7 +3,7 @@ version: "2.9.3" description: "Little fail-safe filesystem designed for microcontrollers" license: BSD-3-Clause url: https://github.com/littlefs-project/littlefs/archive/refs/tags/v2.9.3.tar.gz -checksum: sha256:placeholder +checksum: sha256:9cf2e7db673ea27d967a54cdafe8f55a7ffe27c63a2070ff7424fadd559cad67 build: make build_args: - CC=$(CROSS_COMPILE)gcc diff --git a/recipes/lwip.yaml b/recipes/lwip.yaml index 0fd90202..106a6a4b 100644 --- a/recipes/lwip.yaml +++ b/recipes/lwip.yaml @@ -3,7 +3,7 @@ version: "2.2.0" description: "Lightweight TCP/IP stack for embedded systems" license: BSD-3-Clause url: https://download.savannah.nongnu.org/releases/lwip/lwip-2.2.0.zip -checksum: sha256:placeholder +checksum: sha256:c79255f6cb550eaa07d6e90d859b8c1abe81658115ae8175e74b67ac22c7ed87 build: cmake configure_args: - -DLWIP_DIR=${SOURCE_DIR} diff --git a/recipes/mbedtls.yaml b/recipes/mbedtls.yaml index d1ac1a51..371d4019 100644 --- a/recipes/mbedtls.yaml +++ b/recipes/mbedtls.yaml @@ -3,7 +3,7 @@ version: "3.6.0" description: "Lightweight TLS/SSL library for embedded systems" license: Apache-2.0 url: https://github.com/Mbed-TLS/mbedtls/releases/download/v3.6.0/mbedtls-3.6.0.tar.bz2 -checksum: sha256:3ecf94fcfdaacafb757786a01b7538a61750ebd85c4b024f56ff8ba1490fcd73 +checksum: sha256:3ecf94fcfdaacafb757786a01b7538a61750ebd85c4b024f56ff8ba1490fcd38 build: cmake configure_args: - -DENABLE_TESTING=OFF diff --git a/tests/ebuild/conftest.py b/tests/ebuild/conftest.py index 38ee8147..ea72fcac 100644 --- a/tests/ebuild/conftest.py +++ b/tests/ebuild/conftest.py @@ -17,8 +17,20 @@ # ── Repo roots (available to all tests via conftest) ───────── # tests/ebuild/conftest.py → parent = tests/ebuild/ → parent = tests/ → parent = repo root EBUILD_ROOT = Path(__file__).resolve().parent.parent.parent -EBOOT_ROOT = EBUILD_ROOT.parent / "eboot" -EOS_ROOT = EBUILD_ROOT.parent / "eos" + + +def _sibling_repo(*names: str) -> Path: + """First existing sibling dir, preferring the first name if none exist.""" + parent = EBUILD_ROOT.parent + for name in names: + candidate = parent / name + if candidate.is_dir(): + return candidate + return parent / names[0] + + +EBOOT_ROOT = _sibling_repo("eboot", "eBoot") +EOS_ROOT = _sibling_repo("eos") def _module_available(name: str) -> bool: diff --git a/tests/ebuild/test_config_validation.py b/tests/ebuild/test_config_validation.py index 3fec61d7..2d338bdb 100644 --- a/tests/ebuild/test_config_validation.py +++ b/tests/ebuild/test_config_validation.py @@ -98,28 +98,67 @@ def test_toolchain_mapping_is_parsed(tmp_path): assert config.toolchain.extra_ldflags == ["--specs=nosys.specs"] -@pytest.mark.parametrize("field_name", ["extra_cflags", "extra_ldflags"]) -def test_toolchain_flag_fields_must_be_lists(tmp_path, field_name): +@pytest.mark.parametrize("invalid_packages", [ + {"name": "zlib", "version": "1.2.13"}, + "zlib", + 42, +]) +def test_packages_must_be_a_list(tmp_path, invalid_packages): + path = write_config( + tmp_path, + {"project": {"name": "demo"}, "packages": invalid_packages}, + ) + + with pytest.raises(ConfigError, match="'packages' must be a list"): + load_config(path) + + +@pytest.mark.parametrize("invalid_item", ["zlib", 42, None]) +def test_package_definition_must_be_mapping(tmp_path, invalid_item): + path = write_config( + tmp_path, + {"project": {"name": "demo"}, "packages": [invalid_item]}, + ) + + with pytest.raises(ConfigError, match="expected a YAML mapping"): + load_config(path) + + +def test_package_definition_requires_name(tmp_path): path = write_config( tmp_path, { "project": {"name": "demo"}, - "toolchain": {field_name: "-O2"}, + "packages": [{"version": "1.2.13"}], }, ) - with pytest.raises(ConfigError, match=rf"field '{field_name}' must be a list"): + with pytest.raises(ConfigError, match="must have a 'name'"): load_config(path) -def test_toolchain_flag_items_must_be_strings(tmp_path): +def test_packages_list_is_parsed(tmp_path): path = write_config( tmp_path, { "project": {"name": "demo"}, - "toolchain": {"extra_cflags": ["-O2", 42]}, + "packages": [ + {"name": "zlib", "version": "1.2.13"}, + {"name": "mbedtls"}, + ], }, ) - with pytest.raises(ConfigError, match="extra_cflags.*must contain only strings"): - load_config(path) + config = load_config(path) + + assert len(config.packages) == 2 + assert config.packages[0].name == "zlib" + assert config.packages[0].version == "1.2.13" + assert config.packages[1].name == "mbedtls" + assert config.packages[1].version is None + + +def test_omitted_packages_is_empty(tmp_path): + path = write_config(tmp_path, {"project": {"name": "demo"}}) + config = load_config(path) + assert config.packages == [] diff --git a/tests/ebuild/test_deps_manager.py b/tests/ebuild/test_deps_manager.py new file mode 100644 index 00000000..16a186bf --- /dev/null +++ b/tests/ebuild/test_deps_manager.py @@ -0,0 +1,78 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""Host tests for DepsManager sibling path resolution.""" + +from pathlib import Path + +import pytest + +from ebuild.deps.manager import DepsManager, SIBLING_DIR_NAMES + + +@pytest.fixture +def isolated_deps(tmp_path, monkeypatch): + """Keep get_repo_path off the real ~/.ebuild cache and config.""" + home = tmp_path / "ebuild-home" + repos = home / "repos" + config = home / "config.yaml" + monkeypatch.setattr("ebuild.deps.manager.ensure_ebuild_home", lambda: home) + monkeypatch.setattr("ebuild.deps.manager.EBUILD_CONFIG_PATH", config) + monkeypatch.setenv("EBUILD_REPOS_DIR", str(repos)) + monkeypatch.delenv("EBUILD_EOS_PATH", raising=False) + monkeypatch.delenv("EBUILD_EBOOT_PATH", raising=False) + home.mkdir() + repos.mkdir() + return tmp_path + + +def _case_sensitive_is_dir(self: Path) -> bool: + """Treat directory names as case-sensitive, as Linux does.""" + try: + names = {child.name for child in self.parent.iterdir()} + except OSError: + return False + return self.name in names + + +def test_eboot_aliases_include_github_casing(): + assert SIBLING_DIR_NAMES["eboot"] == ("eboot", "eBoot") + + +def test_sibling_eboot_resolves_camel_case(isolated_deps, monkeypatch): + workspace = isolated_deps / "ws" + project = workspace / "ebuild" + camel = workspace / "eBoot" + project.mkdir(parents=True) + camel.mkdir() + monkeypatch.setattr(Path, "is_dir", _case_sensitive_is_dir) + + resolved = DepsManager().get_repo_path("eboot", project_dir=project) + + assert resolved is not None + assert resolved.name == "eBoot" + + +def test_sibling_eboot_still_resolves_lowercase(isolated_deps, monkeypatch): + workspace = isolated_deps / "ws" + project = workspace / "ebuild" + lower = workspace / "eboot" + project.mkdir(parents=True) + lower.mkdir() + monkeypatch.setattr(Path, "is_dir", _case_sensitive_is_dir) + + resolved = DepsManager().get_repo_path("eboot", project_dir=project) + + assert resolved is not None + assert resolved.name == "eboot" + + +def test_sibling_eboot_missing_returns_none(isolated_deps, monkeypatch): + workspace = isolated_deps / "ws" + project = workspace / "ebuild" + project.mkdir(parents=True) + monkeypatch.setattr(Path, "is_dir", _case_sensitive_is_dir) + + resolved = DepsManager().get_repo_path("eboot", project_dir=project) + + assert resolved is None diff --git a/tests/ebuild/test_integration_initramfs_security.py b/tests/ebuild/test_integration_initramfs_security.py index 26751633..78cd916b 100644 --- a/tests/ebuild/test_integration_initramfs_security.py +++ b/tests/ebuild/test_integration_initramfs_security.py @@ -21,51 +21,25 @@ """ import gzip -import os -import stat +import shutil +import subprocess import pytest from ebuild.cli.integration import _create_initramfs - -def _newc_members(data): - """Parse enough of ``newc`` to validate names, metadata, and contents.""" - members = {} - offset = 0 - while True: - header = data[offset:offset + 110] - assert len(header) == 110 - assert header[:6] == b"070701" - fields = [int(header[i:i + 8], 16) for i in range(6, 110, 8)] - inode = fields[0] - mode = fields[1] - link_count = fields[4] - file_size = fields[6] - name_size = fields[11] - - offset += 110 - encoded_name = data[offset:offset + name_size] - assert encoded_name.endswith(b"\0") - name = os.fsdecode(encoded_name[:-1]) - offset += name_size - offset += -offset % 4 - - contents = data[offset:offset + file_size] - assert len(contents) == file_size - offset += file_size - offset += -offset % 4 - members[name] = { - "inode": inode, - "mode": mode, - "link_count": link_count, - "contents": contents, - } - - if name == "TRAILER!!!": - return members +# _create_initramfs() drives find(1) and cpio(1) directly. Neither exists on a +# stock Windows runner, so these fail with WinError 2 before reaching anything +# they mean to test. Building a Linux initramfs is not a Windows operation; +# skipping is the honest outcome, matching how test_ninja_backend.py skips when +# no host C compiler is present. +requires_cpio = pytest.mark.skipif( + shutil.which("cpio") is None or shutil.which("find") is None, + reason="find(1)/cpio(1) not available on this host", +) +@requires_cpio def test_create_initramfs_produces_valid_gzip_with_expected_content(tmp_path): """Functional regression: the pipeline must still work correctly.""" rootfs = tmp_path / "rootfs" @@ -94,6 +68,7 @@ def test_create_initramfs_produces_valid_gzip_with_expected_content(tmp_path): assert "TRAILER!!!" in members +@requires_cpio def test_create_initramfs_build_dir_with_shell_metacharacters_is_not_interpreted(tmp_path): """A build_dir name containing shell syntax must be treated as a plain literal path component, never parsed as shell syntax. Pre-fix, a name @@ -122,6 +97,7 @@ def test_create_initramfs_build_dir_with_shell_metacharacters_is_not_interpreted assert not (rootfs / "pwned_marker").exists() +@requires_cpio def test_create_initramfs_rootfs_with_shell_metacharacters_is_not_interpreted(tmp_path): """Same check for the ``rootfs`` argument (the ``cd {rootfs}`` half of the old shell string).""" diff --git a/tests/ebuild/test_ninja_backend.py b/tests/ebuild/test_ninja_backend.py index cca58122..cdbe90f0 100644 --- a/tests/ebuild/test_ninja_backend.py +++ b/tests/ebuild/test_ninja_backend.py @@ -27,7 +27,15 @@ def _shared_library_config(tmp_path, target_cflags=None): ) -def test_shared_library_uses_shared_link_rule(tmp_path): +def test_shared_library_links_with_the_platform_shared_flag(tmp_path): + """A shared_library must link through the compiler driver with the + platform's shared-object flag. + + An earlier revision emitted a dedicated `link_shared` rule hardcoding + `-shared`, which is wrong on macOS (it needs `-dynamiclib`) and skipped the + -L/-l wiring, so the rule was dropped in favour of the generic `link` rule + with the flag pushed into ldflags. + """ config = ProjectConfig( name="shared-example", version="1.0.0", @@ -45,9 +53,15 @@ def test_shared_library_uses_shared_link_rule(tmp_path): NinjaBackend(config, tmp_path / "build", toolchain).generate() ninja_file = (tmp_path / "build" / "build.ninja").read_text(encoding="utf-8") - assert "rule link_shared\n command = $cc -shared" in ninja_file - assert "build " in ninja_file - assert ": link_shared " in ninja_file + shared_flag = "-dynamiclib" if sys.platform == "darwin" else "-shared" + + lib_line = next( + line for line in ninja_file.splitlines() + if line.startswith("build ") and "libexample" in line + ) + assert ": link " in lib_line + assert f"ldflags = {shared_flag}" in ninja_file + assert "link_shared" not in ninja_file def test_cc_rule_emits_and_consumes_a_depfile(tmp_path): diff --git a/tests/ebuild/test_package_fetcher.py b/tests/ebuild/test_package_fetcher.py index cd69f9ad..ea7b82d1 100644 --- a/tests/ebuild/test_package_fetcher.py +++ b/tests/ebuild/test_package_fetcher.py @@ -29,11 +29,29 @@ LWIP_230_URL = "https://example.org/lwip/releases/2.3.0/source.tar.gz" -def make_recipe(name, version="2.9.3", url=None, checksum=""): +#: Placeholder digest for tests that never reach checksum verification (bad +#: URL, unsupported format). Real-looking so it passes recipe validation. +DUMMY_SHA256 = "sha256:" + "a" * 64 + + +def make_recipe(name, version="2.9.3", url=None, checksum=None): + """Build a recipe. + + ``checksum`` defaults to the digest of the archive ``fake_download`` + serves for ``url``, so tests about caching and extraction get past + verification. Pass an explicit value to exercise the checksum paths, or + ``""`` to exercise a recipe with no pin at all. + """ + resolved_url = url if url is not None else LITTLEFS_URL + if checksum is None: + try: + checksum = "sha256:" + sha256_of(targz_bytes(resolved_url)) + except Exception: + checksum = DUMMY_SHA256 return PackageRecipe( name=name, version=version, - url=url if url is not None else LITTLEFS_URL, + url=resolved_url, checksum=checksum, ) @@ -181,12 +199,29 @@ def test_bare_checksum_without_sha256_prefix_is_accepted(tmp_path, fake_download assert marker_in(tmp_path / "src") == LITTLEFS_URL -def test_empty_checksum_skips_verification(tmp_path, fake_download): +def test_recipe_without_a_checksum_is_refused(tmp_path, fake_download): + """A recipe with no checksum used to be fetched and extracted unverified. + + The URL alone is "whatever that host serves today". Refusing is the only + honest outcome: there is nothing to check the download against. + """ fetcher = PackageFetcher(tmp_path / "dl") - fetcher.fetch(make_recipe("littlefs", checksum=""), tmp_path / "src") + with pytest.raises(FetchError, match="no checksum"): + fetcher.fetch(make_recipe("littlefs", checksum=""), tmp_path / "src") - assert marker_in(tmp_path / "src") == LITTLEFS_URL + # And nothing was downloaded or extracted on the way to that refusal. + assert not (tmp_path / "src").exists() + + +def test_plaintext_http_is_refused(tmp_path, fake_download): + """https only: a pin is worth much less over a transport anyone can rewrite.""" + fetcher = PackageFetcher(tmp_path / "dl") + recipe = make_recipe("littlefs", url="http://example.org/lib.tar.gz", + checksum=DUMMY_SHA256) + + with pytest.raises(FetchError, match="https"): + fetcher.fetch(recipe, tmp_path / "src") # ── Extraction ─────────────────────────────────────────────── @@ -207,7 +242,13 @@ def test_unsupported_archive_format_is_rejected(tmp_path, monkeypatch): lambda url, filename: open(filename, "wb").write(b"not an archive"), ) fetcher = PackageFetcher(tmp_path / "dl") - recipe = make_recipe("littlefs", url="https://example.org/littlefs/v2.9.3.rar") + # Checksum of the bytes the patched urlretrieve writes, so the fetch gets + # past verification and reaches the format check this test is about. + recipe = make_recipe( + "littlefs", + url="https://example.org/littlefs/v2.9.3.rar", + checksum="sha256:6bbf954ab0045bc546f16a6db16c95afef820dccd807348411ea924dabb972e9", + ) with pytest.raises(FetchError, match="Unsupported archive format"): fetcher.fetch(recipe, tmp_path / "src") diff --git a/tests/unit/test_add_and_summary.py b/tests/unit/test_add_and_summary.py new file mode 100644 index 00000000..a0e3f157 --- /dev/null +++ b/tests/unit/test_add_and_summary.py @@ -0,0 +1,173 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""`ebuild add` refusing what it cannot resolve, and the build summary. + +The MLP walk is: + + ebuild new temperature-monitor + ebuild configure --board + ebuild add wifi + ebuild add mqtt + ebuild build + EmbeddedOS Build OK toolchain OK board configuration OK dependencies ... + +Two gaps against that. `ebuild add` warned about an unknown package and added +it anyway, trading one clear error now for a confusing one at build time in a +file the developer has since committed. And a successful build printed only +"Build completed successfully", leaving them to infer what was in it. +""" + +from types import SimpleNamespace + +import pytest +import yaml +from click.testing import CliRunner + +from ebuild.cli.commands import _no_recipe_message, cli + + +class _FakeRegistry: + def __init__(self, names): + self._names = names + + def list_packages(self): + return [SimpleNamespace(name=n) for n in self._names] + + +class TestNoRecipeMessage: + def test_it_lists_what_is_available(self): + """"No recipe found" alone leaves the developer guessing at the + spelling, at whether it exists under another name, and at where + recipes come from.""" + msg = _no_recipe_message("wifi", _FakeRegistry(["lwip", "mbedtls"])) + assert "lwip" in msg and "mbedtls" in msg + + def test_a_near_miss_is_suggested(self): + msg = _no_recipe_message("lwipp", _FakeRegistry(["lwip", "zlib"])) + assert "Did you mean" in msg and "lwip" in msg + + def test_an_unrelated_name_gets_no_suggestion(self): + msg = _no_recipe_message("wifi", _FakeRegistry(["lwip", "zlib"])) + assert "Did you mean" not in msg + + def test_it_names_the_escape_hatch(self): + msg = _no_recipe_message("wifi", _FakeRegistry(["lwip"])) + assert "--force" in msg + + def test_an_empty_registry_says_so_rather_than_listing_nothing(self): + msg = _no_recipe_message("wifi", _FakeRegistry([])) + assert "No recipes are visible" in msg + + +@pytest.fixture +def project(tmp_path): + (tmp_path / "build.yaml").write_text(yaml.safe_dump({ + "project": {"name": "p", "version": "0.1.0"}, + "workspace": {"backend": "ninja", "build_dir": "build"}, + "toolchain": {"target": "host"}, + "targets": [{"name": "p", "type": "executable", "sources": ["src/main.c"]}], + }), encoding="utf-8") + recipes = tmp_path / "recipes" + recipes.mkdir() + (recipes / "lwip.yaml").write_text(yaml.safe_dump({ + "name": "lwip", "version": "2.2.0", + "url": "https://example.invalid/lwip-2.2.0.tar.gz", + "checksum": "sha256:" + "0" * 64, + "build": {"type": "cmake"}, + }), encoding="utf-8") + return tmp_path + + +def _packages(path): + return yaml.safe_load((path / "build.yaml").read_text()).get("packages") or [] + + +class TestAddRefusesWhatItCannotResolve: + def test_a_known_package_is_added(self, project): + r = CliRunner().invoke(cli, ["add", "lwip", "--config", + str(project / "build.yaml")]) + assert r.exit_code == 0 + assert [p["name"] for p in _packages(project)] == ["lwip"] + + def test_an_unknown_package_is_refused(self, project): + r = CliRunner().invoke(cli, ["add", "wifi", "--config", + str(project / "build.yaml")]) + assert r.exit_code == 1 + + def test_a_refused_package_is_not_written(self, project): + """The point of refusing: build.yaml must not end up carrying an + entry that can never resolve.""" + CliRunner().invoke(cli, ["add", "wifi", "--config", + str(project / "build.yaml")]) + assert _packages(project) == [] + + def test_force_adds_it_anyway(self, project): + r = CliRunner().invoke(cli, ["add", "wifi", "--force", "--config", + str(project / "build.yaml")]) + assert r.exit_code == 0 + assert [p["name"] for p in _packages(project)] == ["wifi"] + + def test_adding_the_same_package_twice_is_a_no_op(self, project): + cfg = str(project / "build.yaml") + CliRunner().invoke(cli, ["add", "lwip", "--config", cfg]) + CliRunner().invoke(cli, ["add", "lwip", "--config", cfg]) + assert len(_packages(project)) == 1 + + +class TestBuildSummary: + """Rendered through the real logger, so the assertions are on what a + developer actually sees.""" + + def _render(self, cfg, package_paths): + from ebuild.cli.commands import _build_summary + lines = [] + log = SimpleNamespace( + info=lines.append, + warning=lambda m: lines.append("WARN " + m), + verbose=False, + ) + _build_summary(cfg, SimpleNamespace(cc="arm-none-eabi-gcc"), + package_paths, log) + return "\n".join(lines) + + def _cfg(self, packages=()): + return SimpleNamespace( + packages=[SimpleNamespace(name=n) for n in packages], + targets=[SimpleNamespace(name="app", target_type="executable")], + ) + + def test_it_names_the_toolchain_and_the_application(self): + body = self._render(self._cfg(), {}) + assert "arm-none-eabi-gcc" in body + assert "app" in body + + def test_a_resolved_package_is_ok(self): + paths = {"lwip": SimpleNamespace(include_dirs=["/x/include"], + lib_dirs=[], libraries=[])} + body = self._render(self._cfg(["lwip"]), paths) + assert "OK lwip" in body + + def test_a_package_that_resolved_to_nothing_is_flagged(self): + """The interesting case: the build succeeds, the feature is simply + absent, and nothing said so.""" + paths = {"lwip": SimpleNamespace(include_dirs=[], lib_dirs=[], + libraries=[])} + body = self._render(self._cfg(["lwip"]), paths) + assert "MISS lwip" in body + assert "resolved to nothing" in body + + def test_a_package_missing_from_the_map_entirely_is_flagged(self): + body = self._render(self._cfg(["mqtt"]), {}) + assert "MISS mqtt" in body + + def test_no_warning_when_everything_resolved(self): + paths = {"lwip": SimpleNamespace(include_dirs=["/x"], lib_dirs=[], + libraries=[])} + assert "WARN" not in self._render(self._cfg(["lwip"]), paths) + + def test_names_are_column_aligned(self): + body = self._render(self._cfg(["a-very-long-package-name"]), {}) + rows = [l for l in body.splitlines() if l.startswith(" OK") or l.startswith(" MISS")] + assert len({len(l.split()[1]) for l in rows}) >= 1 # renders without error + assert all(l.startswith(" ") for l in rows) diff --git a/tests/unit/test_ninja_backend.py b/tests/unit/test_ninja_backend.py index 66cdf9c9..a3aecba1 100644 --- a/tests/unit/test_ninja_backend.py +++ b/tests/unit/test_ninja_backend.py @@ -9,7 +9,7 @@ from pathlib import Path from types import SimpleNamespace -from ebuild.build.ninja_backend import NinjaBackend +from ebuild.build.ninja_backend import NinjaBackend, _ninja_path from ebuild.core.config import ProjectConfig, TargetConfig @@ -76,5 +76,50 @@ def test_static_library_unaffected(self): self.assertNotIn(": link ", edge) +class TestNinjaPathEscaping(unittest.TestCase): + """Ninja splits build statements on unescaped spaces and colons. + + A Windows absolute path puts a drive-letter colon into the output field, so + Ninja read the statement as a rule separator and every generated file was + rejected with "expected build command name" -- the backend produced no + usable build on Windows at all. Paths in build statements must be escaped; + variable values must not be, or the flags reach the compiler mangled. + """ + + def test_colons_and_spaces_in_paths_are_escaped(self): + self.assertEqual(_ninja_path(r"C:\build\main.o"), r"C$:\build\main.o") + self.assertEqual(_ninja_path("/tmp/my project/main.o"), "/tmp/my$ project/main.o") + + def test_dollar_is_escaped_before_the_escapes_it_introduces(self): + self.assertEqual(_ninja_path("a$b"), "a$$b") + self.assertEqual(_ninja_path("a$b:c"), "a$$b$:c") + + def test_ordinary_posix_paths_are_unchanged(self): + self.assertEqual(_ninja_path("/tmp/build/obj/app/src/main.o"), + "/tmp/build/obj/app/src/main.o") + + +class TestNinjaWindowsStylePaths(unittest.TestCase): + def setUp(self): + self._tmpdir = tempfile.TemporaryDirectory() + self.addCleanup(self._tmpdir.cleanup) + + def test_build_statement_output_is_escaped(self): + build_dir = Path(self._tmpdir.name) / "b" + target = TargetConfig(name="app", target_type="executable", sources=["main.c"]) + config = ProjectConfig(name="proj", version="1.0", targets=[target], + source_dir=build_dir) + NinjaBackend(config, build_dir, _toolchain()).generate() + ninja = (build_dir / "build.ninja").read_text(encoding="utf-8") + + for line in ninja.splitlines(): + if not line.startswith("build "): + continue + # Exactly one unescaped colon per build statement: the one that + # separates outputs from the rule name. + without_escapes = line.replace("$:", "").replace("$$", "") + self.assertEqual(without_escapes.count(":"), 1, line) + + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_package_efw.py b/tests/unit/test_package_efw.py new file mode 100644 index 00000000..7d6bc0b1 --- /dev/null +++ b/tests/unit/test_package_efw.py @@ -0,0 +1,173 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""`ebuild package` — the step §29 puts between eBuild and the device. + + eBuild -> {EoS, eBoot, application} -> eFirmware artifact -> {EoSim, hardware} + +Every piece of that existed except the arrow into eFirmware. The eFirmware +repository implements the image format and ships `efwtool`; nothing in ebuild +referenced it, so a developer had to know the tool existed, build it, and run +it by hand. + +These cover the wiring, not the format. `efwtool` owns the format and has its +own tests; duplicating them here would mean a second definition of the header +to keep in step, which is the failure this repository has spent the week +repairing. +""" + +import os +import stat + +import pytest +import yaml +from click.testing import CliRunner + +from ebuild.build.firmware_image import ( + FirmwareImageError, + find_efwtool, + missing_tool_message, + pack, + verify, +) +from ebuild.cli.commands import cli + + +def _fake_efwtool(tmp_path, body): + """A stand-in for efwtool, so the wiring is testable without building C.""" + tool = tmp_path / "efwtool" + tool.write_text("#!/bin/sh\n" + body) + tool.chmod(tool.stat().st_mode | stat.S_IEXEC) + return tool + + +class TestToolDiscovery: + def test_an_efwtool_on_path_wins(self, tmp_path, monkeypatch): + """A developer with their own build should not have one silently + compiled behind their back.""" + monkeypatch.setattr("ebuild.build.firmware_image.shutil.which", + lambda n: "/usr/local/bin/efwtool" if n == "efwtool" else None) + assert str(find_efwtool(tmp_path)).endswith("efwtool") + + def test_no_checkout_and_no_tool_returns_none(self, tmp_path, monkeypatch): + monkeypatch.setattr("ebuild.build.firmware_image.shutil.which", lambda n: None) + assert find_efwtool(tmp_path) is None + + def test_a_prebuilt_tool_in_the_cache_is_reused(self, tmp_path, monkeypatch): + monkeypatch.setattr("ebuild.build.firmware_image.shutil.which", lambda n: None) + root = tmp_path / "efirmware" + (root / "_ebuild" / "tools").mkdir(parents=True) + (root / "CMakeLists.txt").touch() + tool = root / "_ebuild" / "tools" / "efwtool" + tool.touch() + tool.chmod(tool.stat().st_mode | stat.S_IEXEC) + assert find_efwtool(tmp_path) == tool + + +class TestMissingToolMessage: + def test_it_names_setup_when_the_checkout_is_absent(self, tmp_path): + """"efwtool not found" leaves the developer guessing at what fetches + it.""" + assert "ebuild setup" in missing_tool_message(tmp_path) + + def test_it_gives_the_build_command_when_the_checkout_exists(self, tmp_path): + (tmp_path / "efirmware").mkdir() + msg = missing_tool_message(tmp_path) + assert "cmake" in msg + assert "ebuild setup" not in msg + + +class TestPack: + def test_a_missing_artifact_is_refused_before_the_tool_runs(self, tmp_path): + tool = _fake_efwtool(tmp_path, "exit 0\n") + with pytest.raises(FirmwareImageError, match="no artifact"): + pack(tool, tmp_path / "nope", tmp_path / "out.efw") + + def test_a_tool_that_writes_nothing_is_caught(self, tmp_path): + """efwtool exiting 0 without producing a file would otherwise be + reported as a successful package.""" + tool = _fake_efwtool(tmp_path, "exit 0\n") + payload = tmp_path / "app" + payload.write_bytes(b"\x7fELF") + with pytest.raises(FirmwareImageError, match="wrote no image"): + pack(tool, payload, tmp_path / "out.efw") + + def test_a_failing_tool_surfaces_its_own_message(self, tmp_path): + tool = _fake_efwtool(tmp_path, 'echo "bad magic" >&2\nexit 3\n') + payload = tmp_path / "app" + payload.write_bytes(b"x") + with pytest.raises(FirmwareImageError, match="bad magic"): + pack(tool, payload, tmp_path / "out.efw") + + def test_version_and_addresses_reach_the_tool(self, tmp_path): + tool = _fake_efwtool(tmp_path, 'echo "$@" > "$(dirname "$0")/argv.txt"\ntouch "$3"\n') + payload = tmp_path / "app" + payload.write_bytes(b"x") + pack(tool, payload, tmp_path / "out.efw", version="2.1.0", + load_addr="0x08000000", entry_addr="0x08000100") + argv = (tmp_path / "argv.txt").read_text() + assert "--version 2.1.0" in argv + assert "--load 0x08000000" in argv + assert "--entry 0x08000100" in argv + + def test_addresses_are_omitted_when_not_given(self, tmp_path): + """A host build has no load address, and passing an empty one would + make efwtool reject the call.""" + tool = _fake_efwtool(tmp_path, 'echo "$@" > "$(dirname "$0")/argv.txt"\ntouch "$3"\n') + payload = tmp_path / "app" + payload.write_bytes(b"x") + pack(tool, payload, tmp_path / "out.efw") + argv = (tmp_path / "argv.txt").read_text() + assert "--load" not in argv + assert "--entry" not in argv + + +class TestCommand: + def test_package_is_registered(self): + assert "package" in cli.commands + + def _project(self, tmp_path, built=True): + (tmp_path / "build.yaml").write_text(yaml.safe_dump({ + "project": {"name": "node", "version": "2.1.0"}, + "workspace": {"backend": "ninja", "build_dir": "build"}, + "toolchain": {"target": "host"}, + "targets": [{"name": "node", "type": "executable", + "sources": ["src/main.c"]}], + })) + if built: + (tmp_path / "_build").mkdir() + (tmp_path / "_build" / "node").write_bytes(b"\x7fELF" + b"\x00" * 64) + return tmp_path + + def test_it_refuses_before_a_build(self, tmp_path, monkeypatch): + """Packaging a stale or absent artifact silently is worse than + saying which command to run.""" + monkeypatch.chdir(self._project(tmp_path, built=False)) + result = CliRunner().invoke(cli, ["package"]) + assert result.exit_code == 1 + assert "ebuild build" in result.output + + def test_it_refuses_without_an_executable_target(self, tmp_path, monkeypatch): + (tmp_path / "build.yaml").write_text(yaml.safe_dump({ + "project": {"name": "lib", "version": "1.0"}, + "workspace": {"backend": "ninja", "build_dir": "build"}, + "toolchain": {"target": "host"}, + "targets": [{"name": "lib", "type": "static_library", + "sources": ["a.c"]}], + })) + monkeypatch.chdir(tmp_path) + result = CliRunner().invoke(cli, ["package"]) + assert result.exit_code == 1 + assert "nothing to package" in result.output + + def test_it_says_how_to_get_efwtool(self, tmp_path, monkeypatch): + """Pointing the cache at an empty directory as well as clearing PATH: + this machine has a real efwtool cached, and without both the test + passes by finding it and asserts nothing.""" + monkeypatch.chdir(self._project(tmp_path)) + monkeypatch.setattr("ebuild.build.firmware_image.shutil.which", + lambda n: None) + monkeypatch.setattr("ebuild.deps.EBUILD_REPOS_DIR", tmp_path / "empty") + result = CliRunner().invoke(cli, ["package"]) + assert result.exit_code == 1 + assert "ebuild setup" in result.output diff --git a/tests/unit/test_shipped_recipes.py b/tests/unit/test_shipped_recipes.py new file mode 100644 index 00000000..368b9fb4 --- /dev/null +++ b/tests/unit/test_shipped_recipes.py @@ -0,0 +1,68 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""Every recipe shipped in recipes/ must be a usable pin. + +Four of the five shipped recipes could not be fetched at all: + +- littlefs and lwip carried ``checksum: sha256:placeholder``. That parses, so + nothing rejected it, and then every fetch failed with a checksum mismatch. +- mbedtls carried ``...b1490fcd73`` where the published digest ends + ``...b1490fcd38`` — the last two hex characters transposed. +- freertos carried a digest matching none of the release's assets. + +None of that is visible by reading the file; it only shows up when someone +tries to build the package. These tests make the shape of a recipe checkable +without a network round trip, so a placeholder or a truncated digest cannot be +committed again. They deliberately do not assert the digest *values* — that +needs the network, and pinning a value here would just duplicate the recipe. +""" + +import re +from pathlib import Path + +import pytest +import yaml + +RECIPES_DIR = Path(__file__).resolve().parents[2] / "recipes" +SHA256_RE = re.compile(r"^(?:sha256:)?[0-9a-f]{64}$") + +RECIPE_FILES = sorted(RECIPES_DIR.glob("*.yaml")) + + +def test_there_are_recipes_to_check(): + assert RECIPE_FILES, f"no recipes found under {RECIPES_DIR}" + + +@pytest.mark.parametrize("recipe_path", RECIPE_FILES, ids=lambda p: p.stem) +def test_recipe_pins_a_real_sha256(recipe_path): + raw = yaml.safe_load(recipe_path.read_text(encoding="utf-8")) + checksum = raw.get("checksum", "") + + assert checksum, f"{recipe_path.name}: no checksum, so the download is unverified" + assert SHA256_RE.match(checksum), ( + f"{recipe_path.name}: checksum {checksum!r} is not a sha256 digest. " + f"Expected 64 lowercase hex characters, optionally prefixed 'sha256:'." + ) + + +@pytest.mark.parametrize("recipe_path", RECIPE_FILES, ids=lambda p: p.stem) +def test_recipe_url_is_https(recipe_path): + raw = yaml.safe_load(recipe_path.read_text(encoding="utf-8")) + url = raw.get("url", "") + + assert url, f"{recipe_path.name}: no url" + assert url.startswith("https://"), ( + f"{recipe_path.name}: {url!r} is not https. A pin is worth much less " + f"over a transport anyone on the path can rewrite." + ) + + +@pytest.mark.parametrize("recipe_path", RECIPE_FILES, ids=lambda p: p.stem) +def test_recipe_loads_through_the_real_loader(recipe_path): + """The loader validates too; make sure the shipped files pass it.""" + from ebuild.packages.recipe import load_recipe + + recipe = load_recipe(recipe_path) + assert recipe.name + assert recipe.version