From 72c982ab7be2ac943b031197738ff84e5fb81a8c Mon Sep 17 00:00:00 2001
From: VAN BOSSUYT Nicolas <nivb@odoo.com>
Date: Thu, 30 Nov 2023 13:30:50 +0100
Subject: [PATCH] Added support for nested commands and running cutekit in
 containers.

---
 cutekit/__init__.py                          |  12 +-
 cutekit/builder.py                           | 273 ++++++++++++-------
 cutekit/cli.py                               |  54 +++-
 cutekit/const.py                             |   2 +-
 cutekit/entrypoint.sh                        |  20 ++
 cutekit/graph.py                             |   2 +-
 cutekit/model.py                             |  48 ++--
 cutekit/pods.py                              | 201 ++++++++++++++
 requirements.txt => cutekit/requirements.txt |   1 +
 cutekit/rules.py                             |  41 +--
 cutekit/shell.py                             |  29 +-
 pyproject.toml                               |   4 +-
 tests/test_resolver.py                       |  16 +-
 13 files changed, 506 insertions(+), 197 deletions(-)
 create mode 100755 cutekit/entrypoint.sh
 create mode 100644 cutekit/pods.py
 rename requirements.txt => cutekit/requirements.txt (80%)

diff --git a/cutekit/__init__.py b/cutekit/__init__.py
index 1c9eabc..48ad72c 100644
--- a/cutekit/__init__.py
+++ b/cutekit/__init__.py
@@ -9,6 +9,7 @@ from . import (
     graph,  # noqa: F401 this is imported for side effects
     model,
     plugins,
+    pods,  # noqa: F401 this is imported for side effects
     vt100,
 )
 
@@ -55,10 +56,13 @@ def setupLogger(verbose: bool):
 
 def main() -> int:
     try:
-        a = cli.parse(sys.argv[1:])
-        setupLogger(a.consumeOpt("verbose", False) is True)
-        plugins.loadAll()
-        cli.exec(a)
+        args = cli.parse(sys.argv[1:])
+        setupLogger(args.consumeOpt("verbose", False) is True)
+        safemode = args.consumeOpt("safemode", False) is True
+        if not safemode:
+            plugins.loadAll()
+        pods.reincarnate(args)
+        cli.exec(args)
         print()
         return 0
     except RuntimeError as e:
diff --git a/cutekit/builder.py b/cutekit/builder.py
index b50fbff..61b33d7 100644
--- a/cutekit/builder.py
+++ b/cutekit/builder.py
@@ -3,32 +3,89 @@ import logging
 import dataclasses as dt
 
 from pathlib import Path
-from typing import TextIO, Union
+from typing import Callable, TextIO, Union
 
 from . import shell, rules, model, ninja, const, cli
 
 _logger = logging.getLogger(__name__)
 
 
-def aggregateCincs(target: model.Target, registry: model.Registry) -> set[str]:
+@dt.dataclass
+class TargetScope:
+    registry: model.Registry
+    target: model.Target
+
+    @staticmethod
+    def use(args: cli.Args) -> "TargetScope":
+        registry = model.Registry.use(args)
+        target = model.Target.use(args)
+        return TargetScope(registry, target)
+
+    def openComponentScope(self, c: model.Component):
+        return ComponentScope(self.registry, self.target, c)
+
+
+@dt.dataclass
+class ComponentScope(TargetScope):
+    component: model.Component
+
+    def openComponentScope(self, c: model.Component):
+        return ComponentScope(self.registry, self.target, c)
+
+    def openProductScope(self, path: Path):
+        return ProductScope(self.registry, self.target, self.component, path)
+
+
+@dt.dataclass
+class ProductScope(ComponentScope):
+    path: Path
+
+
+# --- Variables -------------------------------------------------------------- #
+
+Compute = Callable[[TargetScope], str]
+_vars: dict[str, Compute] = {}
+
+
+def var(name: str) -> Callable[[Compute], Compute]:
+    def decorator(func: Compute):
+        _vars[name] = func
+        return func
+
+    return decorator
+
+
+@var("buildir")
+def _computeBuildir(scope: TargetScope) -> str:
+    return scope.target.builddir
+
+
+@var("hashid")
+def _computeHashid(scope: TargetScope) -> str:
+    return scope.target.hashid
+
+
+@var("cincs")
+def _computeCinc(scope: TargetScope) -> str:
     res = set()
 
-    for c in registry.iterEnabled(target):
+    for c in scope.registry.iterEnabled(scope.target):
         if "cpp-root-include" in c.props:
             res.add(c.dirname())
         elif c.type == model.Kind.LIB:
             res.add(str(Path(c.dirname()).parent))
 
-    return set(map(lambda i: f"-I{i}", res))
+    return " ".join(set(map(lambda i: f"-I{i}", res)))
 
 
-def aggregateCdefs(target: model.Target) -> set[str]:
+@var("cdefs")
+def _computeCdef(scope: TargetScope) -> str:
     res = set()
 
     def sanatize(s: str) -> str:
         return s.lower().replace(" ", "_").replace("-", "_").replace(".", "_")
 
-    for k, v in target.props.items():
+    for k, v in scope.target.props.items():
         if isinstance(v, bool):
             if v:
                 res.add(f"-D__ck_{sanatize(k)}__")
@@ -36,35 +93,45 @@ def aggregateCdefs(target: model.Target) -> set[str]:
             res.add(f"-D__ck_{sanatize(k)}_{sanatize(str(v))}__")
             res.add(f"-D__ck_{sanatize(k)}_value={str(v)}")
 
-    return res
+    return " ".join(res)
 
 
-def buildpath(target: model.Target, component: model.Component, path) -> Path:
-    return Path(target.builddir) / component.id / path
+def buildpath(scope: ComponentScope, path) -> Path:
+    return Path(scope.target.builddir) / scope.component.id / path
 
 
 # --- Compilation ------------------------------------------------------------ #
 
 
-def wilcard(component: model.Component, wildcards: list[str]) -> list[str]:
-    dirs = [component.dirname()] + list(
-        map(lambda d: os.path.join(component.dirname(), d), component.subdirs)
-    )
-    return shell.find(dirs, list(wildcards), recusive=False)
+def subdirs(scope: ComponentScope) -> list[str]:
+    registry = scope.registry
+    target = scope.target
+    component = scope.component
+    result = [component.dirname()]
+
+    for subs in component.subdirs:
+        result.append(os.path.join(component.dirname(), subs))
+
+    for inj in component.resolved[target.id].injected:
+        injected = registry.lookup(inj, model.Component)
+        assert injected is not None  # model.Resolver has already checked this
+        result.extend(subdirs(scope))
+
+    return result
+
+
+def wilcard(scope: ComponentScope, wildcards: list[str]) -> list[str]:
+    return shell.find(subdirs(scope), list(wildcards), recusive=False)
 
 
 def compile(
-    w: ninja.Writer,
-    target: model.Target,
-    component: model.Component,
-    rule: str,
-    srcs: list[str],
+    w: ninja.Writer, scope: ComponentScope, rule: str, srcs: list[str]
 ) -> list[str]:
     res: list[str] = []
     for src in srcs:
-        rel = Path(src).relative_to(component.dirname())
-        dest = buildpath(target, component, "obj") / rel.with_suffix(".o")
-        t = target.tools[rule]
+        rel = Path(src).relative_to(scope.component.dirname())
+        dest = buildpath(scope, "obj") / rel.with_suffix(".o")
+        t = scope.target.tools[rule]
         w.build(str(dest), rule, inputs=src, order_only=t.files)
         res.append(str(dest))
     return res
@@ -79,13 +146,12 @@ def listRes(component: model.Component) -> list[str]:
 
 def compileRes(
     w: ninja.Writer,
-    target: model.Target,
-    component: model.Component,
+    scope: ComponentScope,
 ) -> list[str]:
     res: list[str] = []
-    for r in listRes(component):
-        rel = Path(r).relative_to(component.subpath("res"))
-        dest = buildpath(target, component, "res") / rel
+    for r in listRes(scope.component):
+        rel = Path(r).relative_to(scope.component.subpath("res"))
+        dest = buildpath(scope, "res") / rel
         w.build(str(dest), "cp", r)
         res.append(str(dest))
     return res
@@ -94,50 +160,55 @@ def compileRes(
 # --- Linking ---------------------------------------------------------------- #
 
 
-def outfile(target: model.Target, component: model.Component) -> str:
-    if component.type == model.Kind.LIB:
-        return str(buildpath(target, component, f"lib/{component.id}.a"))
+def outfile(scope: ComponentScope) -> str:
+    if scope.component.type == model.Kind.LIB:
+        return str(buildpath(scope, f"lib/{scope.component.id}.a"))
     else:
-        return str(buildpath(target, component, f"bin/{component.id}.out"))
+        return str(buildpath(scope, f"bin/{scope.component.id}.out"))
 
 
 def collectLibs(
-    registry: model.Registry, target: model.Target, component: model.Component
+    scope: ComponentScope,
 ) -> list[str]:
     res: list[str] = []
-    for r in component.resolved[target.id].resolved:
-        req = registry.lookup(r, model.Component)
+    for r in scope.component.resolved[scope.target.id].required:
+        req = scope.registry.lookup(r, model.Component)
         assert req is not None  # model.Resolver has already checked this
 
-        if r == component.id:
+        if r == scope.component.id:
             continue
         if not req.type == model.Kind.LIB:
             raise RuntimeError(f"Component {r} is not a library")
-        res.append(outfile(target, req))
+        res.append(outfile(scope.openComponentScope(req)))
+
     return res
 
 
 def link(
     w: ninja.Writer,
-    registry: model.Registry,
-    target: model.Target,
-    component: model.Component,
+    scope: ComponentScope,
 ) -> str:
     w.newline()
-    out = outfile(target, component)
+    out = outfile(scope)
 
     objs = []
-    objs += compile(w, target, component, "cc", wilcard(component, ["*.c"]))
+    objs += compile(w, scope, "cc", wilcard(scope, ["*.c"]))
     objs += compile(
-        w, target, component, "cxx", wilcard(component, ["*.cpp", "*.cc", "*.cxx"])
+        w,
+        scope,
+        "cxx",
+        wilcard(scope, ["*.cpp", "*.cc", "*.cxx"]),
     )
     objs += compile(
-        w, target, component, "as", wilcard(component, ["*.s", "*.asm", "*.S"])
+        w,
+        scope,
+        "as",
+        wilcard(scope, ["*.s", "*.asm", "*.S"]),
     )
 
-    res = compileRes(w, target, component)
-    libs = collectLibs(registry, target, component)
-    if component.type == model.Kind.LIB:
+    res = compileRes(w, scope)
+    libs = collectLibs(scope)
+    if scope.component.type == model.Kind.LIB:
         w.build(out, "ar", objs, implicit=res)
     else:
         w.build(out, "ld", objs + libs, implicit=res)
@@ -147,32 +218,30 @@ def link(
 # --- Phony ------------------------------------------------------------------ #
 
 
-def all(w: ninja.Writer, registry: model.Registry, target: model.Target) -> list[str]:
+def all(w: ninja.Writer, scope: TargetScope) -> list[str]:
     all: list[str] = []
-    for c in registry.iterEnabled(target):
-        all.append(link(w, registry, target, c))
+    for c in scope.registry.iterEnabled(scope.target):
+        all.append(link(w, scope.openComponentScope(c)))
     w.build("all", "phony", all)
     w.default("all")
     return all
 
 
-def gen(out: TextIO, target: model.Target, registry: model.Registry):
+def gen(out: TextIO, scope: TargetScope):
     w = ninja.Writer(out)
 
     w.comment("File generated by the build system, do not edit")
     w.newline()
 
-    w.variable("builddir", target.builddir)
-    w.variable("hashid", target.hashid)
+    w.separator("Variables")
+    for name, compute in _vars.items():
+        w.variable(name, compute(scope))
+    w.newline()
 
     w.separator("Tools")
 
-    w.variable("cincs", " ".join(aggregateCincs(target, registry)))
-    w.variable("cdefs", " ".join(aggregateCdefs(target)))
-    w.newline()
-
-    for i in target.tools:
-        tool = target.tools[i]
+    for i in scope.target.tools:
+        tool = scope.target.tools[i]
         rule = rules.rules[i]
         w.variable(i, tool.cmd)
         w.variable(i + "flags", " ".join(rule.args + tool.args))
@@ -185,53 +254,40 @@ def gen(out: TextIO, target: model.Target, registry: model.Registry):
 
     w.separator("Build")
 
-    all(w, registry, target)
-
-
-@dt.dataclass
-class Product:
-    path: Path
-    target: model.Target
-    component: model.Component
+    all(w, scope)
 
 
 def build(
-    target: model.Target,
-    registry: model.Registry,
+    scope: TargetScope,
     components: Union[list[model.Component], model.Component, None] = None,
-) -> list[Product]:
+) -> list[ProductScope]:
     all = False
-    shell.mkdir(target.builddir)
-    ninjaPath = os.path.join(target.builddir, "build.ninja")
+    shell.mkdir(scope.target.builddir)
+    ninjaPath = os.path.join(scope.target.builddir, "build.ninja")
 
     if not os.path.exists(ninjaPath):
         with open(ninjaPath, "w") as f:
-            gen(f, target, registry)
+            gen(f, scope)
 
     if components is None:
         all = True
-        components = list(registry.iterEnabled(target))
+        components = list(scope.registry.iterEnabled(scope.target))
 
     if isinstance(components, model.Component):
         components = [components]
 
-    products: list[Product] = []
+    products: list[ProductScope] = []
     for c in components:
-        r = c.resolved[target.id]
+        s = scope.openComponentScope(c)
+        r = c.resolved[scope.target.id]
         if not r.enabled:
             raise RuntimeError(f"Component {c.id} is disabled: {r.reason}")
 
-        products.append(
-            Product(
-                path=Path(outfile(target, c)),
-                target=target,
-                component=c,
-            )
-        )
+        products.append(s.openProductScope(Path(outfile(scope.openComponentScope(c)))))
 
     outs = list(map(lambda p: str(p.path), products))
 
-    shell.exec("ninja", "-f", ninjaPath, *(outs if not all else []))
+    # shell.exec("ninja", "-f", ninjaPath, *(outs if not all else []))
 
     return products
 
@@ -239,60 +295,65 @@ def build(
 # --- Commands --------------------------------------------------------------- #
 
 
-@cli.command("b", "build", "Build a component or all components")
+@cli.command("b", "build", "Build/Run/Clean a component or all components")
 def buildCmd(args: cli.Args):
-    registry = model.Registry.use(args)
-    target = model.Target.use(args)
+    pass
+
+
+@cli.command("b", "build/build", "Build a component or all components")
+def buildBuildCmd(args: cli.Args):
+    scope = TargetScope.use(args)
     componentSpec = args.consumeArg()
     component = None
     if componentSpec is not None:
-        component = registry.lookup(componentSpec, model.Component)
-    build(target, registry, component)[0]
+        component = scope.registry.lookup(componentSpec, model.Component)
+    build(scope, component)[0]
 
 
-@cli.command("r", "run", "Run a component")
-def runCmd(args: cli.Args):
-    registry = model.Registry.use(args)
-    target = model.Target.use(args)
+@cli.command("r", "build/run", "Run a component")
+def buildRunCmd(args: cli.Args):
+    scope = TargetScope.use(args)
     debug = args.consumeOpt("debug", False) is True
 
     componentSpec = args.consumeArg() or "__main__"
-    component = registry.lookup(componentSpec, model.Component, includeProvides=True)
+    component = scope.registry.lookup(
+        componentSpec, model.Component, includeProvides=True
+    )
     if component is None:
         raise RuntimeError(f"Component {componentSpec} not found")
 
-    product = build(target, registry, component)[0]
+    product = build(scope, component)[0]
 
-    os.environ["CK_TARGET"] = target.id
+    os.environ["CK_TARGET"] = product.target.id
+    os.environ["CK_BUILDDIR"] = product.target.builddir
     os.environ["CK_COMPONENT"] = product.component.id
-    os.environ["CK_BUILDDIR"] = target.builddir
 
     shell.exec(*(["lldb", "-o", "run"] if debug else []), str(product.path), *args.args)
 
 
-@cli.command("t", "test", "Run all test targets")
-def testCmd(args: cli.Args):
+@cli.command("t", "build/test", "Run all test targets")
+def buildTestCmd(args: cli.Args):
     # This is just a wrapper around the `run` command that try
     # to run a special hook component named __tests__.
     args.args.insert(0, "__tests__")
-    runCmd(args)
+    buildRunCmd(args)
 
 
-@cli.command("d", "debug", "Debug a component")
-def debugCmd(args: cli.Args):
+@cli.command("d", "build/debug", "Debug a component")
+def buildDebugCmd(args: cli.Args):
     # This is just a wrapper around the `run` command that
     # always enable debug mode.
     args.opts["debug"] = True
-    runCmd(args)
+    buildRunCmd(args)
 
 
-@cli.command("c", "clean", "Clean build files")
-def cleanCmd(args: cli.Args):
+@cli.command("c", "build/clean", "Clean build files")
+def buildCleanCmd(args: cli.Args):
     model.Project.use(args)
     shell.rmrf(const.BUILD_DIR)
 
 
-@cli.command("n", "nuke", "Clean all build files and caches")
-def nukeCmd(args: cli.Args):
+@cli.command("n", "build/nuke", "Clean all build files and caches")
+def buildNukeCmd(args: cli.Args):
     model.Project.use(args)
     shell.rmrf(const.PROJECT_CK_DIR)
diff --git a/cutekit/cli.py b/cutekit/cli.py
index 60a82e0..a291c6f 100644
--- a/cutekit/cli.py
+++ b/cutekit/cli.py
@@ -80,8 +80,10 @@ class Command:
     isPlugin: bool
     callback: Callback
 
+    subcommands: dict[str, "Command"] = dt.field(default_factory=dict)
 
-commands: list[Command] = []
+
+commands: dict[str, Command] = {}
 
 
 def command(shortName: Optional[str], longName: str, helpText: str):
@@ -90,15 +92,18 @@ def command(shortName: Optional[str], longName: str, helpText: str):
 
     def wrap(fn: Callable[[Args], None]):
         _logger.debug(f"Registering command {longName}")
-        commands.append(
-            Command(
-                shortName,
-                longName,
-                helpText,
-                Path(calframe[1].filename).parent != Path(__file__).parent,
-                fn,
-            )
+        path = longName.split("/")
+        parent = commands
+        for p in path[:-1]:
+            parent = parent[p].subcommands
+        parent[path[-1]] = Command(
+            shortName,
+            path[-1],
+            helpText,
+            Path(calframe[1].filename).parent != Path(__file__).parent,
+            fn,
         )
+
         return fn
 
     return wrap
@@ -127,8 +132,8 @@ def helpCmd(args: Args):
 
     print()
     vt100.title("Commands")
-    for cmd in sorted(commands, key=lambda c: c.longName):
-        if cmd.longName.startswith("_"):
+    for cmd in sorted(commands.values(), key=lambda c: c.longName):
+        if cmd.longName.startswith("_") or len(cmd.subcommands) > 0:
             continue
 
         pluginText = ""
@@ -139,6 +144,21 @@ def helpCmd(args: Args):
             f" {vt100.GREEN}{cmd.shortName or ' '}{vt100.RESET}  {cmd.longName} - {cmd.helpText} {pluginText}"
         )
 
+    for cmd in sorted(commands.values(), key=lambda c: c.longName):
+        if cmd.longName.startswith("_") or len(cmd.subcommands) == 0:
+            continue
+
+        print()
+        vt100.title(f"{cmd.longName.capitalize()} - {cmd.helpText}")
+        for subcmd in sorted(cmd.subcommands.values(), key=lambda c: c.longName):
+            pluginText = ""
+            if subcmd.isPlugin:
+                pluginText = f"{vt100.CYAN}(plugin){vt100.RESET}"
+
+            print(
+                f"     {vt100.GREEN}{subcmd.shortName or ' '}{vt100.RESET}  {subcmd.longName} - {subcmd.helpText} {pluginText}"
+            )
+
     print()
     vt100.title("Logging")
     print("    Logs are stored in:")
@@ -151,15 +171,19 @@ def versionCmd(args: Args):
     print(f"CuteKit v{const.VERSION_STR}")
 
 
-def exec(args: Args):
+def exec(args: Args, cmds=commands):
     cmd = args.consumeArg()
 
     if cmd is None:
         raise RuntimeError("No command specified")
 
-    for c in commands:
+    for c in cmds.values():
         if c.shortName == cmd or c.longName == cmd:
-            c.callback(args)
-            return
+            if len(c.subcommands) > 0:
+                exec(args, c.subcommands)
+                return
+            else:
+                c.callback(args)
+                return
 
     raise RuntimeError(f"Unknown command {cmd}")
diff --git a/cutekit/const.py b/cutekit/const.py
index 6124018..1fff81f 100644
--- a/cutekit/const.py
+++ b/cutekit/const.py
@@ -1,7 +1,7 @@
 import os
 import sys
 
-VERSION = (0, 6, 0, "dev")
+VERSION = (0, 7, 0, "dev")
 VERSION_STR = f"{VERSION[0]}.{VERSION[1]}.{VERSION[2]}{'-' + VERSION[3] if len(VERSION) >= 4 else ''}"
 MODULE_DIR = os.path.dirname(os.path.realpath(__file__))
 ARGV0 = os.path.basename(sys.argv[0])
diff --git a/cutekit/entrypoint.sh b/cutekit/entrypoint.sh
new file mode 100755
index 0000000..08f6987
--- /dev/null
+++ b/cutekit/entrypoint.sh
@@ -0,0 +1,20 @@
+#!/bin/env bash
+
+set -e
+
+export PY=python3.11
+
+if [ ! -d "/tools/venv" ]; then
+    echo "Creating virtual environment..."
+
+    $PY -m venv /tools/venv
+    source /tools/venv/bin/activate
+    $PY -m ensurepip
+    $PY -m pip install -r /tools/cutekit/requirements.txt
+    echo "Virtual environment created."
+else
+    source /tools/venv/bin/activate
+fi
+
+export PYTHONPATH=/tools
+$PY -m cutekit "$@"
diff --git a/cutekit/graph.py b/cutekit/graph.py
index 94dee83..22555bb 100644
--- a/cutekit/graph.py
+++ b/cutekit/graph.py
@@ -36,7 +36,7 @@ def view(
         if (
             scopeInstance is not None
             and component.id != scope
-            and component.id not in scopeInstance.resolved[target.id].resolved
+            and component.id not in scopeInstance.resolved[target.id].required
         ):
             continue
 
diff --git a/cutekit/model.py b/cutekit/model.py
index 63647dd..b090a1c 100644
--- a/cutekit/model.py
+++ b/cutekit/model.py
@@ -173,14 +173,19 @@ class Project(Manifest):
         return _project
 
 
-@cli.command("i", "install", "Install required external packages")
-def installCmd(args: cli.Args):
+@cli.command("m", "model", "Manage the model")
+def modelCmd(args: cli.Args):
+    pass
+
+
+@cli.command("i", "model/install", "Install required external packages")
+def modelInstallCmd(args: cli.Args):
     project = Project.use(args)
     Project.fetchs(project.extern)
 
 
-@cli.command("I", "init", "Initialize a new project")
-def initCmd(args: cli.Args):
+@cli.command("I", "model/init", "Initialize a new project")
+def modelInitCmd(args: cli.Args):
     import requests
 
     repo = args.consumeOpt("repo", const.DEFAULT_REPO_TEMPLATES)
@@ -258,9 +263,15 @@ class Target(Manifest):
     tools: Tools = dt.field(default_factory=dict)
     routing: dict[str, str] = dt.field(default_factory=dict)
 
+    _hashid = None
+
     @property
     def hashid(self) -> str:
-        return utils.hash((self.props, [v.to_dict() for k, v in self.tools.items()]))
+        if self._hashid is None:
+            self._hashid = utils.hash(
+                (self.props, [v.to_dict() for k, v in self.tools.items()])
+            )
+        return self._hashid
 
     @property
     def builddir(self) -> str:
@@ -289,7 +300,8 @@ class Target(Manifest):
 @dt.dataclass
 class Resolved:
     reason: Optional[str] = None
-    resolved: list[str] = dt.field(default_factory=list)
+    required: list[str] = dt.field(default_factory=list)
+    injected: list[str] = dt.field(default_factory=list)
 
     @property
     def enabled(self) -> bool:
@@ -436,11 +448,11 @@ class Resolver:
                 self._cache[keep] = Resolved(reason=reqResolved.reason)
                 return self._cache[keep]
 
-            result.extend(reqResolved.resolved)
+            result.extend(reqResolved.required)
 
         stack.pop()
         result.insert(0, keep)
-        self._cache[keep] = Resolved(resolved=utils.uniq(result))
+        self._cache[keep] = Resolved(required=utils.uniq(result))
         return self._cache[keep]
 
 
@@ -570,6 +582,13 @@ class Registry(DataClassJsonMixin):
             target.props |= props
             resolver = Resolver(r, target)
 
+            # Resolve all components
+            for c in r.iter(Component):
+                resolved = resolver.resolve(c.id)
+                if resolved.reason:
+                    _logger.info(f"Component '{c.id}' disabled: {resolved.reason}")
+                c.resolved[target.id] = resolved
+
             # Apply injects
             for c in r.iter(Component):
                 if c.isEnabled(target)[0]:
@@ -577,14 +596,7 @@ class Registry(DataClassJsonMixin):
                         victim = r.lookup(inject, Component)
                         if not victim:
                             raise RuntimeError(f"Cannot find component '{inject}'")
-                        victim.requires += [c.id]
-
-            # Resolve all components
-            for c in r.iter(Component):
-                resolved = resolver.resolve(c.id)
-                if resolved.reason:
-                    _logger.info(f"Component '{c.id}' disabled: {resolved.reason}")
-                c.resolved[target.id] = resolved
+                        victim.resolved[target.id].injected.append(c.id)
 
             # Resolve tooling
             tools: Tools = target.tools
@@ -609,8 +621,8 @@ class Registry(DataClassJsonMixin):
         return r
 
 
-@cli.command("l", "list", "List all components and targets")
-def listCmd(args: cli.Args):
+@cli.command("l", "model/list", "List all components and targets")
+def modelListCmd(args: cli.Args):
     registry = Registry.use(args)
 
     components = list(registry.iter(Component))
diff --git a/cutekit/pods.py b/cutekit/pods.py
new file mode 100644
index 0000000..b4039de
--- /dev/null
+++ b/cutekit/pods.py
@@ -0,0 +1,201 @@
+import sys
+import docker  # type: ignore
+import os
+import dataclasses as dt
+
+from . import cli, model, shell, vt100
+
+
+podPrefix = "CK__"
+projectRoot = "/self"
+toolingRoot = "/tools"
+defaultPodName = f"{podPrefix}default"
+defaultPodImage = "ubuntu"
+
+
+@dt.dataclass
+class Image:
+    id: str
+    image: str
+    init: list[str]
+
+
+@dt.dataclass
+class Pod:
+    name: str
+    image: Image
+
+
+IMAGES: dict[str, Image] = {
+    "ubuntu": Image(
+        "ubuntu",
+        "ubuntu:jammy",
+        [
+            "apt-get update",
+            "apt-get install -y python3.11 python3.11-venv ninja-build",
+        ],
+    ),
+    "debian": Image(
+        "debian",
+        "debian:bookworm",
+        [
+            "apt-get update",
+            "apt-get install -y python3 python3-pip python3-venv ninja-build",
+        ],
+    ),
+    "alpine": Image(
+        "alpine",
+        "alpine:3.18",
+        [
+            "apk update",
+            "apk add python3 python3-dev py3-pip py3-venv build-base linux-headers ninja",
+        ],
+    ),
+    "arch": Image(
+        "arch",
+        "archlinux:latest",
+        [
+            "pacman -Syu --noconfirm",
+            "pacman -S --noconfirm python python-pip python-virtualenv ninja",
+        ],
+    ),
+    "fedora": Image(
+        "fedora",
+        "fedora:39",
+        [
+            "dnf update -y",
+            "dnf install -y python3 python3-pip python3-venv ninja-build",
+        ],
+    ),
+}
+
+
+def reincarnate(args: cli.Args):
+    """
+    Reincarnate cutekit within a docker container, this is
+    useful for cross-compiling
+    """
+    pod = args.consumeOpt("pod", False)
+    if not pod:
+        return
+    if isinstance(pod, str):
+        pod = pod.strip()
+        pod = podPrefix + pod
+    if pod is True:
+        pod = defaultPodName
+    assert isinstance(pod, str)
+    model.Project.ensure()
+    print(f"Reincarnating into pod '{pod[len(podPrefix) :]}'...")
+    try:
+        shell.exec(
+            "docker",
+            "exec",
+            "-w",
+            projectRoot,
+            "-it",
+            pod,
+            "/tools/cutekit/entrypoint.sh",
+            *args.args,
+        )
+        sys.exit(0)
+    except Exception:
+        sys.exit(1)
+
+
+@cli.command("p", "pod", "Manage pods")
+def podCmd(args: cli.Args):
+    pass
+
+
+@cli.command("c", "pod/create", "Create a new pod")
+def podCreateCmd(args: cli.Args):
+    """
+    Create a new development pod with cutekit installed and the current
+    project mounted at /self
+    """
+    project = model.Project.ensure()
+
+    name = str(args.consumeOpt("name", defaultPodName))
+    if not name.startswith(podPrefix):
+        name = f"{podPrefix}{name}"
+    image = IMAGES[str(args.consumeOpt("image", defaultPodImage))]
+
+    client = docker.from_env()
+    try:
+        client.containers.get(name)
+        raise RuntimeError(f"Pod '{name[len(podPrefix):]}' already exists")
+    except docker.errors.NotFound:
+        pass
+
+    print(f"Staring pod '{name[len(podPrefix) :]}'...")
+
+    container = client.containers.run(
+        image.image,
+        "sleep infinity",
+        name=name,
+        volumes={
+            os.path.abspath(os.path.dirname(__file__)): {
+                "bind": toolingRoot + "/cutekit",
+                "mode": "ro",
+            },
+            os.path.abspath(project.dirname()): {"bind": projectRoot, "mode": "rw"},
+        },
+        detach=True,
+    )
+
+    print(f"Initializing pod '{name[len(podPrefix) :]}'...")
+    for cmd in image.init:
+        print(vt100.p(cmd))
+        exitCode, ouput = container.exec_run(f"/bin/bash -c '{cmd}'", demux=True)
+        if exitCode != 0:
+            raise Exception(f"Failed to initialize pod with command '{cmd}'")
+
+    print(f"Created pod '{name[len(podPrefix) :]}' from image '{image.image}'")
+
+
+@cli.command("k", "pod/kill", "Stop and remove a pod")
+def podKillCmd(args: cli.Args):
+    client = docker.from_env()
+    name = str(args.consumeOpt("name", defaultPodName))
+    if not name.startswith(podPrefix):
+        name = f"{podPrefix}{name}"
+
+    try:
+        container = client.containers.get(name)
+        container.stop()
+        container.remove()
+        print(f"Pod '{name[len(podPrefix) :]}' killed")
+    except docker.errors.NotFound:
+        raise RuntimeError(f"Pod '{name[len(podPrefix):]}' does not exist")
+
+
+@cli.command("s", "pod/shell", "Open a shell in a pod")
+def podShellCmd(args: cli.Args):
+    args.args.insert(0, "/bin/bash")
+    podExecCmd(args)
+
+
+@cli.command("l", "pod/list", "List all pods")
+def podListCmd(args: cli.Args):
+    client = docker.from_env()
+    hasPods = False
+    for container in client.containers.list(all=True):
+        if not container.name.startswith(podPrefix):
+            continue
+        print(container.name[len(podPrefix) :], container.status)
+        hasPods = True
+
+    if not hasPods:
+        print(vt100.p("(No pod found)"))
+
+
+@cli.command("e", "pod/exec", "Execute a command in a pod")
+def podExecCmd(args: cli.Args):
+    name = str(args.consumeOpt("name", defaultPodName))
+    if not name.startswith(podPrefix):
+        name = f"{podPrefix}{name}"
+
+    try:
+        shell.exec("docker", "exec", "-it", name, *args.args)
+    except Exception:
+        raise RuntimeError(f"Pod '{name[len(podPrefix):]}' does not exist")
diff --git a/requirements.txt b/cutekit/requirements.txt
similarity index 80%
rename from requirements.txt
rename to cutekit/requirements.txt
index 11d0245..892a2bf 100644
--- a/requirements.txt
+++ b/cutekit/requirements.txt
@@ -1,3 +1,4 @@
 requests ~= 2.31.0
 graphviz ~= 0.20.1
 dataclasses-json ~= 0.6.2
+docker ~= 6.1.3
diff --git a/cutekit/rules.py b/cutekit/rules.py
index f2004db..669e7b5 100644
--- a/cutekit/rules.py
+++ b/cutekit/rules.py
@@ -1,52 +1,39 @@
+import dataclasses as dt
+
 from typing import Optional
 
 
+@dt.dataclass
 class Rule:
     id: str
     fileIn: list[str]
-    fileOut: list[str]
+    fileOut: str
     rule: str
-    args: list[str]
-    deps: Optional[str] = None
-
-    def __init__(
-        self,
-        id: str,
-        fileIn: list[str],
-        fileOut: list[str],
-        rule: str,
-        args: list[str] = [],
-        deps: Optional[str] = None,
-    ):
-        self.id = id
-        self.fileIn = fileIn
-        self.fileOut = fileOut
-        self.rule = rule
-        self.args = args
-        self.deps = deps
+    args: list[str] = dt.field(default_factory=list)
+    deps: list[str] = dt.field(default_factory=list)
 
 
 rules: dict[str, Rule] = {
-    "cp": Rule("cp", ["*"], ["*"], "$in $out"),
+    "cp": Rule("cp", ["*"], "*", "$in $out"),
     "cc": Rule(
         "cc",
         ["*.c"],
-        ["*.o"],
+        "*.o",
         "-c -o $out $in -MD -MF $out.d $flags $cincs $cdefs",
         ["-std=gnu2x", "-Wall", "-Wextra", "-Werror"],
-        "$out.d",
+        ["$out.d"],
     ),
     "cxx": Rule(
         "cxx",
         ["*.cpp", "*.cc", "*.cxx"],
-        ["*.o"],
+        "*.o",
         "-c -o $out $in -MD -MF $out.d $flags $cincs $cdefs",
         ["-std=gnu++2b", "-Wall", "-Wextra", "-Werror", "-fno-exceptions", "-fno-rtti"],
-        "$out.d",
+        ["$out.d"],
     ),
-    "as": Rule("as", ["*.s", "*.asm", "*.S"], ["*.o"], "-o $out $in $flags"),
-    "ar": Rule("ar", ["*.o"], ["*.a"], "$flags $out $in"),
-    "ld": Rule("ld", ["*.o", "*.a"], ["*.out"], "-o $out $in $flags"),
+    "as": Rule("as", ["*.s", "*.asm", "*.S"], "*.o", "-o $out $in $flags"),
+    "ar": Rule("ar", ["*.o"], "*.a", "$flags $out $in"),
+    "ld": Rule("ld", ["*.o", "*.a"], "*.out", "-o $out $in $flags"),
 }
 
 
diff --git a/cutekit/shell.py b/cutekit/shell.py
index a7e4a2e..6af8d09 100644
--- a/cutekit/shell.py
+++ b/cutekit/shell.py
@@ -52,7 +52,7 @@ def sha256sum(path: str) -> str:
 def find(
     path: str | list[str], wildcards: list[str] = [], recusive: bool = True
 ) -> list[str]:
-    _logger.info(f"Looking for files in {path} matching {wildcards}")
+    _logger.debug(f"Looking for files in {path} matching {wildcards}")
 
     result: list[str] = []
 
@@ -88,7 +88,7 @@ def find(
 
 
 def mkdir(path: str) -> str:
-    _logger.info(f"Creating directory {path}")
+    _logger.debug(f"Creating directory {path}")
 
     try:
         os.makedirs(path)
@@ -99,7 +99,7 @@ def mkdir(path: str) -> str:
 
 
 def rmrf(path: str) -> bool:
-    _logger.info(f"Removing directory {path}")
+    _logger.debug(f"Removing directory {path}")
 
     if not os.path.exists(path):
         return False
@@ -118,7 +118,7 @@ def wget(url: str, path: Optional[str] = None) -> str:
     if os.path.exists(path):
         return path
 
-    _logger.info(f"Downloading {url} to {path}")
+    _logger.debug(f"Downloading {url} to {path}")
 
     r = requests.get(url, stream=True)
     r.raise_for_status()
@@ -132,7 +132,7 @@ def wget(url: str, path: Optional[str] = None) -> str:
 
 
 def exec(*args: str, quiet: bool = False) -> bool:
-    _logger.info(f"Executing {args}")
+    _logger.debug(f"Executing {args}")
 
     try:
         proc = subprocess.run(
@@ -142,10 +142,10 @@ def exec(*args: str, quiet: bool = False) -> bool:
         )
 
         if proc.stdout:
-            _logger.info(proc.stdout.decode("utf-8"))
+            _logger.debug(proc.stdout.decode("utf-8"))
 
         if proc.stderr:
-            _logger.error(proc.stderr.decode("utf-8"))
+            _logger.debug(proc.stderr.decode("utf-8"))
 
     except FileNotFoundError:
         raise RuntimeError(f"{args[0]}: Command not found")
@@ -163,7 +163,7 @@ def exec(*args: str, quiet: bool = False) -> bool:
 
 
 def popen(*args: str) -> str:
-    _logger.info(f"Executing {args}")
+    _logger.debug(f"Executing {args}")
 
     try:
         proc = subprocess.run(args, stdout=subprocess.PIPE, stderr=sys.stderr)
@@ -180,7 +180,7 @@ def popen(*args: str) -> str:
 
 
 def readdir(path: str) -> list[str]:
-    _logger.info(f"Reading directory {path}")
+    _logger.debug(f"Reading directory {path}")
 
     try:
         return os.listdir(path)
@@ -189,19 +189,19 @@ def readdir(path: str) -> list[str]:
 
 
 def cp(src: str, dst: str):
-    _logger.info(f"Copying {src} to {dst}")
+    _logger.debug(f"Copying {src} to {dst}")
 
     shutil.copy(src, dst)
 
 
 def mv(src: str, dst: str):
-    _logger.info(f"Moving {src} to {dst}")
+    _logger.debug(f"Moving {src} to {dst}")
 
     shutil.move(src, dst)
 
 
 def cpTree(src: str, dst: str):
-    _logger.info(f"Copying {src} to {dst}")
+    _logger.debug(f"Copying {src} to {dst}")
 
     shutil.copytree(src, dst, dirs_exist_ok=True)
 
@@ -241,10 +241,9 @@ def latest(cmd: str) -> str:
     if cmd in LATEST_CACHE:
         return LATEST_CACHE[cmd]
 
-    _logger.info(f"Finding latest version of {cmd}")
+    _logger.debug(f"Finding latest version of {cmd}")
 
     regex: re.Pattern[str]
-
     if platform.system() == "Windows":
         regex = re.compile(r"^" + re.escape(cmd) + r"(-.[0-9]+)?(\.exe)?$")
     else:
@@ -263,7 +262,7 @@ def latest(cmd: str) -> str:
     versions.sort()
     chosen = versions[-1]
 
-    _logger.info(f"Chosen {chosen} as latest version of {cmd}")
+    _logger.debug(f"Chosen {chosen} as latest version of {cmd}")
 
     LATEST_CACHE[cmd] = chosen
 
diff --git a/pyproject.toml b/pyproject.toml
index 9861be3..ded5e04 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -26,7 +26,7 @@ packages = ["cutekit"]
 
 [tool.setuptools.dynamic]
 version = { attr = "cutekit.const.VERSION" }
-dependencies = { file = ["requirements.txt"] }
+dependencies = { file = ["cutekit/requirements.txt"] }
 
 [tool.setuptools.package-data]
-"cutekit" = ["py.typed"]
+"cutekit" = ["py.typed", "requirements.txt", "pods-entry.sh"]
diff --git a/tests/test_resolver.py b/tests/test_resolver.py
index 7221ce6..7249b99 100644
--- a/tests/test_resolver.py
+++ b/tests/test_resolver.py
@@ -10,7 +10,7 @@ def test_direct_deps():
 
     resolved = res.resolve("myapp")
     assert resolved.reason is None
-    assert resolved.resolved == ["myapp", "mylib"]
+    assert resolved.required == ["myapp", "mylib"]
 
 
 def test_indirect_deps():
@@ -20,7 +20,7 @@ def test_indirect_deps():
     r._append(model.Component("myimpl", provides=["myembed"]))
     t = model.Target("host")
     res = model.Resolver(r, t)
-    assert res.resolve("myapp").resolved == ["myapp", "mylib", "myimpl"]
+    assert res.resolve("myapp").required == ["myapp", "mylib", "myimpl"]
 
 
 def test_deps_routing():
@@ -31,11 +31,11 @@ def test_deps_routing():
     r._append(model.Component("myimplB", provides=["myembed"]))
     t = model.Target("host", routing={"myembed": "myimplB"})
     res = model.Resolver(r, t)
-    assert res.resolve("myapp").resolved == ["myapp", "mylib", "myimplB"]
+    assert res.resolve("myapp").required == ["myapp", "mylib", "myimplB"]
 
     t = model.Target("host", routing={"myembed": "myimplA"})
     res = model.Resolver(r, t)
-    assert res.resolve("myapp").resolved == ["myapp", "mylib", "myimplA"]
+    assert res.resolve("myapp").required == ["myapp", "mylib", "myimplA"]
 
     t = model.Target("host", routing={"myembed": "myimplC"})
     res = model.Resolver(r, t)
@@ -54,11 +54,11 @@ def test_deps_routing_with_props():
     )
     t = model.Target("host", routing={"myembed": "myimplB"}, props={"myprop": "b"})
     res = model.Resolver(r, t)
-    assert res.resolve("myapp").resolved == ["myapp", "mylib", "myimplB"]
+    assert res.resolve("myapp").required == ["myapp", "mylib", "myimplB"]
 
     t = model.Target("host", routing={"myembed": "myimplA"}, props={"myprop": "a"})
     res = model.Resolver(r, t)
-    assert res.resolve("myapp").resolved == ["myapp", "mylib", "myimplA"]
+    assert res.resolve("myapp").required == ["myapp", "mylib", "myimplA"]
 
     t = model.Target("host", routing={"myembed": "myimplC"}, props={"myprop": "c"})
     res = model.Resolver(r, t)
@@ -79,11 +79,11 @@ def test_deps_routing_with_props_and_requires():
     )
     t = model.Target("host", routing={"myembed": "myimplB"}, props={"myprop": "b"})
     res = model.Resolver(r, t)
-    assert res.resolve("myapp").resolved == ["myapp", "mylib", "myimplB"]
+    assert res.resolve("myapp").required == ["myapp", "mylib", "myimplB"]
 
     t = model.Target("host", routing={"myembed": "myimplA"}, props={"myprop": "a"})
     res = model.Resolver(r, t)
-    assert res.resolve("myapp").resolved == ["myapp", "mylib", "myimplA"]
+    assert res.resolve("myapp").required == ["myapp", "mylib", "myimplA"]
 
     t = model.Target("host", routing={"myembed": "myimplC"}, props={"myprop": "c"})
     res = model.Resolver(r, t)