import argparse import os import sys from pathlib import Path from src import ( builder, container, log, plan, profile as profile_mod, recipe as recipe_mod, ) from src.layout import Layout, find_repo_root def _resolve_build_dir(p: str | None) -> Path: if p: return Path(p).resolve() env = os.environ.get("ORCHID_BUILD") if env: return Path(env).resolve() cwd = Path.cwd() if (cwd / "profile").is_symlink() or (cwd / "profile").exists(): return cwd raise SystemExit("error: -C required (or run inside a build dir)") def _layout(build_dir: Path) -> Layout: repo = find_repo_root(build_dir) return Layout(repo=repo, build=build_dir) def _load(layout: Layout): prof = profile_mod.load_profile(layout) rs = recipe_mod.load_recipes(layout, prof) return prof, rs def cmd_init(args) -> int: target = Path(args.build_dir).resolve() repo = find_repo_root(Path.cwd()) profile_mod.init_build_dir(target, repo, args.profile) log.ok(f"initialized {target} (profile: {args.profile})") return 0 def cmd_image(args) -> int: layout = _layout(_resolve_build_dir(args.build_dir)) layout.ensure() prof = profile_mod.load_profile(layout) container.ensure_image( layout.dockerfile, prof["container_image"], layout.image_hash_file ) return 0 def _recipe_version(r) -> str: return f"{r.version}-r{r.revision}" def _print_plan(p: plan.Plan) -> None: out = sys.stdout if not p.order: print( f"{log.tag('info', stream=out)} " f"{log.field('plan', 'nothing to do', stream=out)}" ) return count = len(p.order) suffix = "" if count == 1 else "s" print( f"{log.tag('info', stream=out)} " f"{log.field('plan', f'{count} recipe{suffix}', stream=out)}" ) rows: list[tuple[str, str, str, str]] = [] for i, k in enumerate(p.order, start=1): r = p.recipes[k] rows.append((str(i), k, _recipe_version(r), ", ".join(p.stages.get(k, ())))) widths = [ max(len(row[0]) for row in rows), max(len("recipe"), *(len(row[1]) for row in rows)), max(len("version"), *(len(row[2]) for row in rows)), ] header = ( f" {'#':>{widths[0]}} " f"{'recipe':<{widths[1]}} " f"{'version':<{widths[2]}} " "stages" ) print(log.bold(header, stream=out)) for num, name, version, stages in rows: print( f" {num:>{widths[0]}} " f"{name:<{widths[1]}} " f"{version:<{widths[2]}} " f"{stages}" ) def cmd_plan(args) -> int: layout = _layout(_resolve_build_dir(args.build_dir)) layout.ensure() prof, rs = _load(layout) p = plan.build_plan(rs, layout, args.recipes or None, rebuild=args.rebuild) _print_plan(p) return 0 def cmd_build(args) -> int: layout = _layout(_resolve_build_dir(args.build_dir)) layout.ensure() prof, rs = _load(layout) container.ensure_image( layout.dockerfile, prof["container_image"], layout.image_hash_file ) p = plan.build_plan(rs, layout, args.recipes or None, rebuild=args.rebuild) if args.dry_run: return cmd_plan(args) builder.execute(p, rs, layout, prof) return 0 def cmd_install(args) -> int: layout = _layout(_resolve_build_dir(args.build_dir)) layout.ensure() prof, rs = _load(layout) container.ensure_image( layout.dockerfile, prof["container_image"], layout.image_hash_file ) dest = Path(args.dest).resolve() dest.mkdir(parents=True, exist_ok=True) if args.recipes: pkgs: list[str] = [] for k in args.recipes: r = rs.get(k) if r.kind != "target": raise SystemExit(f"error: cannot install host recipe {k!r}") pkgs.extend(r.outputs) else: pkgs = [r.name for r in rs.target.values() if r.enabled] builder.install_to(layout, prof, dest, pkgs, initdb=args.initdb) log.ok(f"installed {len(pkgs)} package(s) to {dest}") return 0 def cmd_fetch(args) -> int: layout = _layout(_resolve_build_dir(args.build_dir)) layout.ensure() prof, rs = _load(layout) from . import fetch as fetch_mod targets = args.recipes or [r.key for r in rs.all() if r.enabled] for k in targets: r = rs.get(k) for key, src in r.sources.items(): fetch_mod.fetch(layout, src) return 0 def _common(p: argparse.ArgumentParser, with_recipes: bool = True) -> None: p.add_argument( "-C", "--build-dir", help="build directory (defaults to $ORCHID_BUILD or cwd)" ) if with_recipes: p.add_argument( "recipes", nargs="*", help="recipe keys (target name or host:)" ) def make_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="orchid", description="Orchid distribution builder" ) sub = parser.add_subparsers(dest="cmd", required=True) p_init = sub.add_parser("init", help="create a build directory") p_init.add_argument("build_dir") p_init.add_argument("--profile", required=True) p_init.set_defaults(func=cmd_init) p_img = sub.add_parser("image", help="build/refresh the container image") _common(p_img, with_recipes=False) p_img.set_defaults(func=cmd_image) p_plan = sub.add_parser("plan", help="print build plan") _common(p_plan) p_plan.add_argument("--rebuild", action="store_true") p_plan.set_defaults(func=cmd_plan) p_build = sub.add_parser("build", help="build recipes") _common(p_build) p_build.add_argument("--rebuild", action="store_true") p_build.add_argument("-n", "--dry-run", action="store_true") p_build.set_defaults(func=cmd_build) p_inst = sub.add_parser( "install", help="install built packages into a sysroot directory" ) p_inst.add_argument( "-C", "--build-dir", help="build directory (defaults to $ORCHID_BUILD or cwd)" ) p_inst.add_argument("dest", help="destination sysroot directory") p_inst.add_argument( "recipes", nargs="*", help="target recipes to install (defaults to all enabled targets)", ) p_inst.add_argument( "--no-initdb", dest="initdb", action="store_false", help="do not initialize the apk database (use for incremental installs)", ) p_inst.set_defaults(func=cmd_install, initdb=True) p_fetch = sub.add_parser("fetch", help="fetch sources only") _common(p_fetch) p_fetch.set_defaults(func=cmd_fetch) return parser def main(argv: list[str] | None = None) -> int: parser = make_parser() args = parser.parse_args(argv) try: return args.func(args) except (SystemExit, KeyboardInterrupt): raise except Exception as e: log.error(f"{type(e).__name__}: {e}") if os.environ.get("ORCHID_DEBUG"): raise return 1